devices: enhanced device list and details page

This commit is contained in:
Tigor Hutasuhut 2024-05-06 21:09:52 +07:00
parent b33b672cb6
commit ab810c4b45
9 changed files with 279 additions and 5 deletions

25
api/devices_by_slug.go Normal file
View 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
}

View 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")
}
}

View file

@ -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)
}) })
} }

View 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
}

View 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>
}

View 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>
}

View 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>
}
}

View file

@ -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>

View file

@ -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{