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("/subreddits/add", routes.PageSubredditsAdd)
|
||||||
r.Get("/config", routes.PageConfig)
|
r.Get("/config", routes.PageConfig)
|
||||||
r.Get("/devices", routes.PageDevices)
|
r.Get("/devices", routes.PageDevices)
|
||||||
|
r.Get("/devices/details/{slug}", routes.PageDeviceDetails)
|
||||||
r.Get("/schedules", routes.PageScheduleHistory)
|
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 {
|
} else {
|
||||||
<h1>Devices</h1>
|
<h1>Devices</h1>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
@filter(data)
|
||||||
<h2>{ strconv.FormatInt(data.Total, 10) } Devices</h2>
|
<h2>{ strconv.FormatInt(data.Total, 10) } Devices</h2>
|
||||||
@devicesList(data)
|
@devicesList(data)
|
||||||
}
|
}
|
||||||
|
@ -55,6 +56,7 @@ templ devicesList(data Data) {
|
||||||
<span class="text-sm self-end italic font-normal">{ device.Slug }</span>
|
<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>
|
<p class="text-xs my-auto text-end">{ fmt.Sprintf("%.0f \u00d7 %.0f", device.ResolutionX, device.ResolutionY) } px</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="divider">Filter</div>
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
if device.NSFW == 1 {
|
if device.NSFW == 1 {
|
||||||
<div class="badge badge-accent">NSFW</div>
|
<div class="badge badge-accent">NSFW</div>
|
|
@ -33,8 +33,6 @@ type Subreddit struct {
|
||||||
func NewRecentlyAddedImages(images models.ImageSlice) RecentlyAddedImages {
|
func NewRecentlyAddedImages(images models.ImageSlice) RecentlyAddedImages {
|
||||||
r := make(RecentlyAddedImages, 0, len(images))
|
r := make(RecentlyAddedImages, 0, len(images))
|
||||||
|
|
||||||
var count int
|
|
||||||
|
|
||||||
for _, image := range images {
|
for _, image := range images {
|
||||||
if image.R.Device == nil || image.R.Subreddit == nil {
|
if image.R.Device == nil || image.R.Subreddit == nil {
|
||||||
continue
|
continue
|
||||||
|
@ -48,7 +46,6 @@ func NewRecentlyAddedImages(images models.ImageSlice) RecentlyAddedImages {
|
||||||
if subreddit.Subreddit.Name == image.R.Subreddit.Name {
|
if subreddit.Subreddit.Name == image.R.Subreddit.Name {
|
||||||
subredditFound = true
|
subredditFound = true
|
||||||
r[i].Subreddits[j].Images = append(r[i].Subreddits[j].Images, image)
|
r[i].Subreddits[j].Images = append(r[i].Subreddits[j].Images, image)
|
||||||
count++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !subredditFound {
|
if !subredditFound {
|
||||||
|
@ -56,12 +53,10 @@ func NewRecentlyAddedImages(images models.ImageSlice) RecentlyAddedImages {
|
||||||
Subreddit: image.R.Subreddit,
|
Subreddit: image.R.Subreddit,
|
||||||
Images: models.ImageSlice{image},
|
Images: models.ImageSlice{image},
|
||||||
})
|
})
|
||||||
count++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !deviceFound {
|
if !deviceFound {
|
||||||
count++
|
|
||||||
r = append(r, RecentlyAddedImage{
|
r = append(r, RecentlyAddedImage{
|
||||||
Device: image.R.Device,
|
Device: image.R.Device,
|
||||||
Subreddits: []Subreddit{
|
Subreddits: []Subreddit{
|
||||||
|
|
Loading…
Reference in a new issue