devices: enhanced device list and details page
This commit is contained in:
parent
b33b672cb6
commit
ab810c4b45
25
api/devices_by_slug.go
Normal file
25
api/devices_by_slug.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tigorlazuardi/redmage/models"
|
||||
"github.com/tigorlazuardi/redmage/pkg/errs"
|
||||
)
|
||||
|
||||
func (api *API) DeviceBySlug(ctx context.Context, slug string) (device *models.Device, err error) {
|
||||
ctx, span := tracer.Start(ctx, "*API.DeviceByName")
|
||||
defer span.End()
|
||||
|
||||
device, err = models.FindDevice(ctx, api.db, slug)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return device, errs.Wrapw(err, "device not found", "device", device).Code(http.StatusNotFound)
|
||||
}
|
||||
|
||||
return device, errs.Wrapw(err, "failed to find device", "device", device)
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
58
server/routes/page_devices_details.go
Normal file
58
server/routes/page_devices_details.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tigorlazuardi/redmage/pkg/errs"
|
||||
"github.com/tigorlazuardi/redmage/pkg/log"
|
||||
"github.com/tigorlazuardi/redmage/views"
|
||||
"github.com/tigorlazuardi/redmage/views/devicesview/devicedetails"
|
||||
)
|
||||
|
||||
func (routes *Routes) PageDeviceDetails(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, span := tracer.Start(req.Context(), "*Routees.PageDeviceDetails")
|
||||
defer span.End()
|
||||
|
||||
c := views.NewContext(routes.Config, req)
|
||||
|
||||
slug := chi.URLParam(req, "slug")
|
||||
|
||||
var data devicedetails.Data
|
||||
data.Params.FillFromQuery(req.URL.Query())
|
||||
|
||||
var err error
|
||||
|
||||
data.Device, err = routes.API.DeviceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
log.New(ctx).Err(err).Error("failed to get device by slug")
|
||||
code, message := errs.HTTPMessage(err)
|
||||
rw.WriteHeader(code)
|
||||
data.Error = message
|
||||
if err := devicedetails.View(c, data).Render(ctx, rw); err != nil {
|
||||
log.New(ctx).Err(err).Error("failed to render device details page")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data.Params.Device = data.Device.Slug
|
||||
|
||||
result, err := routes.API.ImagesList(ctx, data.Params)
|
||||
if err != nil {
|
||||
log.New(ctx).Err(err).Error("failed to get image by device")
|
||||
code, message := errs.HTTPMessage(err)
|
||||
rw.WriteHeader(code)
|
||||
data.Error = message
|
||||
if err := devicedetails.View(c, data).Render(ctx, rw); err != nil {
|
||||
log.New(ctx).Err(err).Error("failed to render device details page")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data.Images = result.Images
|
||||
data.TotalImages = result.Total
|
||||
|
||||
if err := devicedetails.View(c, data).Render(ctx, rw); err != nil {
|
||||
log.New(ctx).Err(err).Error("failed to render device details page")
|
||||
}
|
||||
}
|
|
@ -82,6 +82,7 @@ func (routes *Routes) registerWWWRoutes(router chi.Router) {
|
|||
r.Get("/subreddits/add", routes.PageSubredditsAdd)
|
||||
r.Get("/config", routes.PageConfig)
|
||||
r.Get("/devices", routes.PageDevices)
|
||||
r.Get("/devices/details/{slug}", routes.PageDeviceDetails)
|
||||
r.Get("/schedules", routes.PageScheduleHistory)
|
||||
})
|
||||
}
|
||||
|
|
46
views/devicesview/devicedetails/data.go
Normal file
46
views/devicesview/devicedetails/data.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package devicedetails
|
||||
|
||||
import (
|
||||
"github.com/tigorlazuardi/redmage/api"
|
||||
"github.com/tigorlazuardi/redmage/models"
|
||||
)
|
||||
|
||||
type Data struct {
|
||||
Error string
|
||||
Device *models.Device
|
||||
Images models.ImageSlice
|
||||
TotalImages int64
|
||||
Params api.ImageListParams
|
||||
}
|
||||
|
||||
type splitBySubredditImages struct {
|
||||
Subreddit string
|
||||
Images models.ImageSlice
|
||||
}
|
||||
|
||||
func (data Data) splitImages() []*splitBySubredditImages {
|
||||
var out []*splitBySubredditImages
|
||||
|
||||
for _, image := range data.Images {
|
||||
var found bool
|
||||
|
||||
inner:
|
||||
for _, o := range out {
|
||||
if o.Subreddit == image.Subreddit {
|
||||
found = true
|
||||
o.Images = append(o.Images, image)
|
||||
break inner
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
out = append(out, &splitBySubredditImages{
|
||||
Subreddit: image.Subreddit,
|
||||
Images: models.ImageSlice{image},
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
35
views/devicesview/devicedetails/filter.templ
Normal file
35
views/devicesview/devicedetails/filter.templ
Normal file
|
@ -0,0 +1,35 @@
|
|||
package devicedetails
|
||||
|
||||
import "fmt"
|
||||
|
||||
templ filter(data Data) {
|
||||
<div
|
||||
id="filter-bar"
|
||||
hx-get={ fmt.Sprintf("/devices/details/%s", data.Device.Slug) }
|
||||
hx-include="this"
|
||||
hx-trigger="change, input delay:500ms"
|
||||
hx-target="main"
|
||||
hx-select="main"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="grid sm:grid-cols-2 md:grid-cols-4 gap-4"
|
||||
>
|
||||
<label class="input input-bordered flex items-center gap-2 sm:col-span-2 md:col-auto">
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search"
|
||||
name="q"
|
||||
value={ data.Params.Q }
|
||||
/>
|
||||
<svg
|
||||
onclick="htmx.trigger('#filter-bar', 'change')"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4 opacity-70"
|
||||
><path fill-rule="evenodd" d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z" clip-rule="evenodd"></path></svg>
|
||||
</label>
|
||||
</div>
|
||||
}
|
45
views/devicesview/devicedetails/view.templ
Normal file
45
views/devicesview/devicedetails/view.templ
Normal file
|
@ -0,0 +1,45 @@
|
|||
package devicedetails
|
||||
|
||||
import "github.com/tigorlazuardi/redmage/views"
|
||||
import "github.com/tigorlazuardi/redmage/views/components"
|
||||
import "fmt"
|
||||
import "github.com/tigorlazuardi/redmage/models"
|
||||
|
||||
templ View(c *views.Context, data Data) {
|
||||
@components.Doctype() {
|
||||
if data.Device == nil {
|
||||
@components.Head(c, components.HeadTitle("Redmage - Device Not Found"))
|
||||
} else {
|
||||
@components.Head(c, components.HeadTitle(fmt.Sprintf("Redmage - %s", data.Device.Name)))
|
||||
}
|
||||
@components.Body(c) {
|
||||
@Content(c, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ Content(c *views.Context, data Data) {
|
||||
<main class="prose min-w-full">
|
||||
@components.Container() {
|
||||
if data.Error != "" {
|
||||
@components.ErrorToast(data.Error)
|
||||
} else {
|
||||
<h1>{ data.Device.Name }</h1>
|
||||
<div class="divider"></div>
|
||||
@filter(data)
|
||||
for _, group := range data.splitImages() {
|
||||
<h2>{ group.Subreddit }</h2>
|
||||
@imageList(group.Images)
|
||||
}
|
||||
}
|
||||
}
|
||||
</main>
|
||||
}
|
||||
|
||||
templ imageList(images models.ImageSlice) {
|
||||
<div class="overflow-x-auto flex gap-4 p-6 shadow-inner bg-base-300 rounded-2xl w-[85vw] md:w-full scrollbar-track-base-100 scrollbar-thumb-primary scrollbar-thin hover:scrollbar-thumb-base-300">
|
||||
for _, data := range images {
|
||||
@components.ImageCard(data, 0)
|
||||
}
|
||||
</div>
|
||||
}
|
67
views/devicesview/filter.templ
Normal file
67
views/devicesview/filter.templ
Normal file
|
@ -0,0 +1,67 @@
|
|||
package devicesview
|
||||
|
||||
import "github.com/tigorlazuardi/redmage/api"
|
||||
import "strconv"
|
||||
|
||||
templ filter(data Data) {
|
||||
<div
|
||||
id="filter-bar"
|
||||
hx-get="/devices"
|
||||
hx-include="this"
|
||||
hx-trigger="change, input delay:500ms"
|
||||
hx-target="main"
|
||||
hx-select="main"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="grid md:grid-cols-2 gap-4"
|
||||
>
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search"
|
||||
name="q"
|
||||
value={ data.Params.Q }
|
||||
/>
|
||||
<svg
|
||||
onclick="htmx.trigger('#filter-bar', 'change')"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4 opacity-70"
|
||||
><path fill-rule="evenodd" d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z" clip-rule="evenodd"></path></svg>
|
||||
</label>
|
||||
<div class="grid grid-cols-[1fr,3fr] sm:grid-cols-[1fr,3fr,1fr,3fr] items-center gap-4">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status" class="select select-bordered w-full">
|
||||
<option value="">*No Filter</option>
|
||||
if data.Params.Status == 2 {
|
||||
<option selected value="2">Enabled</option>
|
||||
} else {
|
||||
<option value="2">Enabled</option>
|
||||
}
|
||||
if data.Params.Status == 1 {
|
||||
<option selected value="1">Disabled</option>
|
||||
} else {
|
||||
<option value="1">Disabled</option>
|
||||
}
|
||||
</select>
|
||||
<label for="limit">Limit</label>
|
||||
<select id="limit" name="limit" class="select select-bordered w-full">
|
||||
@limitOption(data.Params, 25)
|
||||
@limitOption(data.Params, 50)
|
||||
@limitOption(data.Params, 75)
|
||||
@limitOption(data.Params, 100)
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ limitOption(params api.DevicesListParams, value int) {
|
||||
if int(params.Limit) == value {
|
||||
<option selected>{ strconv.Itoa(value) }</option>
|
||||
} else {
|
||||
<option>{ strconv.Itoa(value) }</option>
|
||||
}
|
||||
}
|
|
@ -32,6 +32,7 @@ templ DevicesContent(c *views.Context, data Data) {
|
|||
} else {
|
||||
<h1>Devices</h1>
|
||||
<div class="divider"></div>
|
||||
@filter(data)
|
||||
<h2>{ strconv.FormatInt(data.Total, 10) } Devices</h2>
|
||||
@devicesList(data)
|
||||
}
|
||||
|
@ -55,6 +56,7 @@ templ devicesList(data Data) {
|
|||
<span class="text-sm self-end italic font-normal">{ device.Slug }</span>
|
||||
<p class="text-xs my-auto text-end">{ fmt.Sprintf("%.0f \u00d7 %.0f", device.ResolutionX, device.ResolutionY) } px</p>
|
||||
</div>
|
||||
<div class="divider">Filter</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
if device.NSFW == 1 {
|
||||
<div class="badge badge-accent">NSFW</div>
|
|
@ -33,8 +33,6 @@ type Subreddit struct {
|
|||
func NewRecentlyAddedImages(images models.ImageSlice) RecentlyAddedImages {
|
||||
r := make(RecentlyAddedImages, 0, len(images))
|
||||
|
||||
var count int
|
||||
|
||||
for _, image := range images {
|
||||
if image.R.Device == nil || image.R.Subreddit == nil {
|
||||
continue
|
||||
|
@ -48,7 +46,6 @@ func NewRecentlyAddedImages(images models.ImageSlice) RecentlyAddedImages {
|
|||
if subreddit.Subreddit.Name == image.R.Subreddit.Name {
|
||||
subredditFound = true
|
||||
r[i].Subreddits[j].Images = append(r[i].Subreddits[j].Images, image)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if !subredditFound {
|
||||
|
@ -56,12 +53,10 @@ func NewRecentlyAddedImages(images models.ImageSlice) RecentlyAddedImages {
|
|||
Subreddit: image.R.Subreddit,
|
||||
Images: models.ImageSlice{image},
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
if !deviceFound {
|
||||
count++
|
||||
r = append(r, RecentlyAddedImage{
|
||||
Device: image.R.Device,
|
||||
Subreddits: []Subreddit{
|
||||
|
|
Loading…
Reference in a new issue