devices: enhanced more displays

This commit is contained in:
Tigor Hutasuhut 2024-05-08 19:32:14 +07:00
parent 1dc3617df3
commit 3c8a1b1fd6
15 changed files with 302 additions and 50 deletions

18
api/devices_exist.go Normal file
View file

@ -0,0 +1,18 @@
package api
import (
"github.com/tigorlazuardi/redmage/models"
"github.com/tigorlazuardi/redmage/pkg/errs"
"golang.org/x/net/context"
)
func (api *API) DevicesExist(ctx context.Context, slug string) (exist bool, err error) {
ctx, span := tracer.Start(ctx, "API.DevicesExist")
defer span.End()
exist, err = models.Devices.Query(ctx, api.db, models.SelectWhere.Devices.Slug.EQ(slug)).Exists()
if err != nil {
return false, errs.Wrapw(err, "failed to check device existence", "slug", slug)
}
return exist, nil
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/aarondl/opt/omit"
@ -11,6 +12,7 @@ import (
"github.com/tigorlazuardi/redmage/models"
"github.com/tigorlazuardi/redmage/pkg/errs"
"github.com/tigorlazuardi/redmage/pkg/log"
"github.com/tigorlazuardi/redmage/views/components"
)
type deviceUpdate struct {
@ -53,6 +55,7 @@ func (routes *Routes) APIDeviceUpdate(rw http.ResponseWriter, r *http.Request) {
device, err := routes.API.DevicesUpdate(ctx, slug, &models.DeviceSetter{
Name: omit.FromCond(body.Name, body.Name != ""),
Enable: omit.FromCond(body.Enable, body.Enable == 1 || body.Enable == 0),
ResolutionX: omit.FromCond(body.ResolutionX, body.ResolutionX != 0),
ResolutionY: omit.FromCond(body.ResolutionY, body.ResolutionY != 0),
AspectRatioTolerance: omit.FromPtr(body.AspectRatioTolerance),
@ -61,7 +64,6 @@ func (routes *Routes) APIDeviceUpdate(rw http.ResponseWriter, r *http.Request) {
MaxX: omit.FromPtr(body.MaxX),
MaxY: omit.FromPtr(body.MaxY),
NSFW: omit.FromPtr(body.NSFW),
WindowsWallpaperMode: omit.FromPtr(body.WindowsWallpaperMode),
UpdatedAt: omit.From(time.Now().Unix()),
})
if err != nil {
@ -74,3 +76,106 @@ func (routes *Routes) APIDeviceUpdate(rw http.ResponseWriter, r *http.Request) {
_ = enc.Encode(device)
}
func (routes *Routes) DevicesUpdateHTMX(rw http.ResponseWriter, req *http.Request) {
ctx, span := tracer.Start(req.Context(), "*Routes.DevicesUpdateHTMX")
defer span.End()
slug := chi.URLParam(req, "slug")
exist, err := routes.API.DevicesExist(ctx, slug)
if err != nil {
log.New(ctx).Err(err).Error("failed to check device slug existence")
code, message := errs.HTTPMessage(err)
rw.WriteHeader(code)
if err := components.ErrorToast(message).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render error notification")
}
return
}
if !exist {
rw.WriteHeader(http.StatusNotFound)
if err := components.ErrorToast("Device with slug identifier '%s' does not exist", slug).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render error notification")
}
return
}
device, err := routes.API.DevicesUpdate(ctx, slug, deviceSetterFromRequest(req))
if err != nil {
log.New(ctx).Err(err).Error("failed to update device")
code, message := errs.HTTPMessage(err)
rw.WriteHeader(code)
if err := components.ErrorToast(message).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render error notification")
}
return
}
rw.Header().Set("HX-Redirect", fmt.Sprintf("/devices/details/%s", slug))
if err := components.SuccessToast("Device %q has been updated", device.Name).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render success notification")
}
}
func deviceSetterFromRequest(req *http.Request) *models.DeviceSetter {
setter := &models.DeviceSetter{
UpdatedAt: omit.From(time.Now().Unix()),
}
name := req.FormValue("name")
setter.Name = omit.FromCond(name, name != "")
enable, err := strconv.Atoi(req.FormValue("enable"))
if err == nil {
if enable > 1 {
enable = 1
} else if enable < 0 {
enable = 0
}
setter.Enable = omit.From(int32(enable))
}
resx, _ := strconv.Atoi(req.FormValue("resolution_x"))
setter.ResolutionX = omit.FromCond(float64(resx), resx != 0)
resy, _ := strconv.Atoi(req.FormValue("resolution_y"))
setter.ResolutionY = omit.FromCond(float64(resy), resy != 0)
art, err := strconv.ParseFloat(req.FormValue("aspect_ratio_tolerance"), 64)
if err == nil {
setter.AspectRatioTolerance = omit.FromCond(art, art >= 0)
}
minX, err := strconv.Atoi(req.FormValue("min_x"))
if err == nil {
setter.MinX = omit.FromCond(int32(minX), minX >= 0)
}
minY, err := strconv.Atoi(req.FormValue("min_y"))
if err == nil {
setter.MinY = omit.FromCond(int32(minY), minY >= 0)
}
maxX, err := strconv.Atoi(req.FormValue("max_x"))
if err == nil {
setter.MaxX = omit.FromCond(int32(maxX), maxX >= 0)
}
maxY, err := strconv.Atoi(req.FormValue("max_y"))
if err == nil {
setter.MaxY = omit.FromCond(int32(maxY), maxY >= 0)
}
nsfw, err := strconv.Atoi(req.FormValue("nsfw"))
if err == nil {
if nsfw > 1 {
nsfw = 1
} else if nsfw < 0 {
nsfw = 0
}
setter.NSFW = omit.From(int32(nsfw))
}
return setter
}

View file

@ -0,0 +1,81 @@
package routes
import (
"fmt"
"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/components"
"github.com/tigorlazuardi/redmage/views/devices/put"
)
func (routes *Routes) PageDevicesEdit(rw http.ResponseWriter, req *http.Request) {
ctx, span := tracer.Start(req.Context(), "*Routes.PageDevicesEdit")
defer span.End()
c := views.NewContext(routes.Config, req)
slug := chi.URLParam(req, "slug")
device, err := routes.API.DeviceBySlug(ctx, slug)
if err != nil {
code, message := errs.HTTPMessage(err)
if code >= 500 {
log.New(ctx).Err(err).Error("failed to get device by slug")
}
rw.WriteHeader(code)
msg := fmt.Sprintf("%d: %s", code, message)
if err := components.PageError(c, msg).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render device edit page")
}
return
}
data := put.Data{
PageTitle: fmt.Sprintf("Edit Device %q", device.Name),
PostAction: fmt.Sprintf("/devices/edit/%s", device.Slug),
EditMode: true,
NameInput: put.NameInputData{
Value: device.Name,
EditMode: true,
},
SlugInput: put.SlugInputData{
Value: device.Slug,
},
ResolutionX: put.ResolutionData{
Value: int(device.ResolutionX),
},
ResolutionY: put.ResolutionData{
Value: int(device.ResolutionY),
},
AspectRatioTolerance: put.AspectRatioToleranceData{
Value: device.AspectRatioTolerance,
},
NSFWCheckbox: put.NSFWCheckboxData{
Checked: device.NSFW == 1,
EditMode: true,
},
WindowsWallpaperCheckbox: put.WindowsWallpaperCheckboxData{
Checked: device.WindowsWallpaperMode == 1,
},
MinImageResolutionXInput: put.ResolutionData{
Value: int(device.MinX),
},
MinImageResolutionYInput: put.ResolutionData{
Value: int(device.MinY),
},
MaxImageResolutionXInput: put.ResolutionData{
Value: int(device.MaxX),
},
MaxImageResolutionYInput: put.ResolutionData{
Value: int(device.MaxY),
},
}
if err := put.View(c, data).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render device edit page")
}
}

View file

@ -88,6 +88,8 @@ func (routes *Routes) registerWWWRoutes(router chi.Router) {
r.Get("/devices/add", routes.PageDevicesAdd)
r.Post("/devices/add", routes.DevicesCreateHTMX)
r.Get("/devices/details/{slug}", routes.PageDeviceDetails)
r.Get("/devices/edit/{slug}", routes.PageDevicesEdit)
r.Post("/devices/edit/{slug}", routes.DevicesUpdateHTMX)
r.Get("/schedules", routes.PageScheduleHistory)
})
}

View file

@ -2,7 +2,7 @@ package components
import "github.com/tigorlazuardi/redmage/models"
import "fmt"
import "github.com/tigorlazuardi/redmage/views/utils"
import "time"
type ImageCardOption uint
@ -26,7 +26,18 @@ const (
)
templ ImageCard(data *models.Image, opts ImageCardOption) {
<div class="not-prose card card-bordered bg-base-100 hover:bg-base-200 shadow-xl min-w-[16rem] max-w-[16rem] rounded-xl top-0 hover:-top-1 hover:drop-shadow-2xl transition-all">
<div
x-data={ fmt.Sprintf(`{
time: %d,
get timeTooltip() {
return dayjs.unix(this.time).tz(dayjs.tz.guess()).format('ddd, D MMM YYYY HH:mm:ss Z')
},
get relativeTime() {
return dayjs.unix(this.time).tz(dayjs.tz.guess()).fromNow()
},
}`, data.CreatedAt) }
class="not-prose card card-bordered bg-base-100 hover:bg-base-200 shadow-xl min-w-[16rem] max-w-[16rem] rounded-xl top-0 hover:-top-1 hover:drop-shadow-2xl transition-all"
>
<figure>
<a
href={ templ.URL(fmt.Sprintf("/img/%s", data.ImageRelativePath)) }
@ -51,7 +62,9 @@ templ ImageCard(data *models.Image, opts ImageCardOption) {
<a class="text-primary text-sm underline" href={ templ.URL(data.PostAuthorURL) }>{ data.PostAuthor }</a>
<div class="flex-1"></div>
<div class="flex">
@utils.RelativeTimeNode(fmt.Sprintf("relative-time-%s", data.PostName), data.CreatedAt, "text-sm")
<div class="tooltip" :data-tip="timeTooltip">
<span class="text-xs" :class="{ 'text-xs': false }" x-text="relativeTime">{ time.Unix(data.CreatedAt, 0).Format("Mon, _2 Jan 2006 15:04:05 MST") } </span>
</div>
</div>
<div class="grid grid-cols-2">
<p class="text-xs">{ fmt.Sprintf("%d \u00d7 %d", data.ImageWidth, data.ImageHeight) } px</p>

View file

@ -2,7 +2,7 @@ package components
import "github.com/tigorlazuardi/redmage/views"
templ Page404(c *views.Context, text string) {
templ PageError(c *views.Context, text string) {
@Doctype() {
@Head(c, HeadTitle(text))
@Body(c) {

View file

@ -1,8 +1,9 @@
package details
import "fmt"
import "github.com/tigorlazuardi/redmage/views"
templ filter(data Data) {
templ filter(c *views.Context, data Data) {
<div
id="filter-bar"
hx-get={ fmt.Sprintf("/devices/details/%s", data.Device.Slug) }
@ -12,9 +13,9 @@ templ filter(data Data) {
hx-select="main"
hx-swap="outerHTML"
hx-push-url="true"
class="grid sm:grid-cols-2 md:grid-cols-4 gap-4"
class="grid md:grid-cols-2 gap-4 items-center"
>
<label class="input input-bordered flex items-center gap-2 sm:col-span-2 md:col-auto">
<label class="input input-bordered flex items-center gap-2">
<input
id="search"
type="text"
@ -31,5 +32,32 @@ templ filter(data Data) {
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] gap-4 items-center">
<label for="limit">Limit</label>
<select id="limit" name="limit" class="select select-bordered w-full">
<option value="25" selected?={ data.Params.Limit == 25 }>25</option>
<option value="50" selected?={ data.Params.Limit == 50 }>50</option>
<option value="75" selected?={ data.Params.Limit == 75 }>75</option>
<option value="100" selected?={ data.Params.Limit == 100 }>100</option>
</select>
<label for="range">Range</label>
<select id="range" name="created_at" class="select select-bordered w-full">
@rangeOption(c, "-10800", "3 Hours")
@rangeOption(c, "-21600", "6 Hours")
@rangeOption(c, "-43200", "12 Hours")
<option
value="-86400"
selected?={ c.Request.URL.Query().Get("created_at") == "" || c.Request.URL.Query().Get("created_at") == "-86400" }
>1 Day</option>
@rangeOption(c, "-172800", "2 Days")
@rangeOption(c, "-259200", "3 Days")
@rangeOption(c, "-604800", "7 Days")
@rangeOption(c, "-2592000", "30 Days")
</select>
</div>
</div>
}
templ rangeOption(c *views.Context, value, display string) {
<option value={ value } selected?={ c.Request.URL.Query().Get("created_at") == value }>{ display }</option>
}

View file

@ -24,9 +24,15 @@ templ Content(c *views.Context, data Data) {
if data.Error != "" {
@components.ErrorToast(data.Error)
} else {
<h1>{ data.Device.Name }</h1>
<div class="flex justify-between items-center">
<h1 class="my-auto">{ data.Device.Name }</h1>
<a
href={ templ.SafeURL(fmt.Sprintf("/devices/edit/%s", data.Device.Slug)) }
class="btn btn-primary no-underline sm:w-24"
>Edit</a>
</div>
<div class="divider"></div>
@filter(data)
@filter(c, data)
for _, group := range data.splitImages() {
<h2>{ group.Subreddit }</h2>
@imageList(group.Images)

View file

@ -69,7 +69,7 @@ templ MaxImageResolutionYInput(data ResolutionData) {
<input
id="max-image-height-field"
x-data={ fmt.Sprintf(`{ init() { $el.setCustomValidity(%q) } }`, data.Error) }
name="min_y"
name="max_y"
type="number"
min="0"
@change="$el.setCustomValidity(''); this.error = false"

View file

@ -4,13 +4,13 @@ import "github.com/tigorlazuardi/redmage/views/utils"
import "fmt"
type NameInputData struct {
Error string
Value string
DisableValidation bool
Error string
Value string
EditMode bool
}
templ NameInput(data NameInputData) {
<label id="name-input-form" class="form-control">
<label id="name-input-form" class={ utils.CXX("form-control", true, "col-span-2", data.EditMode) }>
<div class="label">
<span
class={ utils.CXX("label-text", true, "text-error", data.Error != "") }
@ -18,7 +18,7 @@ templ NameInput(data NameInputData) {
</div>
<input
id="name-input-field"
if !data.DisableValidation {
if !data.EditMode {
required
x-data={ fmt.Sprintf(`{ init() { $el.setCustomValidity(%q) } }`, data.Error) }
hx-get="/htmx/devices/add/validate/name"

View file

@ -1,15 +1,17 @@
package put
import "fmt"
import "github.com/tigorlazuardi/redmage/views/utils"
type NSFWCheckboxData struct {
Checked bool
Checked bool
EditMode bool
}
templ NSFWCheckbox(data NSFWCheckboxData) {
<div
x-data={ fmt.Sprintf(`{checked: %t}`, data.Checked) }
class="form-control"
class={ utils.CXX("form-control", true, "col-span-2", data.EditMode) }
>
<label
class="label cursor-pointer border input input-bordered"

View file

@ -4,12 +4,10 @@ import "github.com/tigorlazuardi/redmage/views/utils"
import "fmt"
type SlugInputData struct {
Error string
Value string
Valid string
HXSwapOOB bool
Disabled bool
DisabledText string
Error string
Value string
Valid string
HXSwapOOB bool
}
templ SlugInput(data SlugInputData) {
@ -28,17 +26,14 @@ templ SlugInput(data SlugInputData) {
"text-success", data.Valid != "",
) }
>Slug Identifier</span>
<span class="label-text-alt italic font-bold text-primary">NOTE: Slug Identifier cannot be changed after creation</span>
</div>
<input
id="slug-input-field"
if !data.Disabled {
x-data={ fmt.Sprintf(`{ init() { $el.setCustomValidity(%q) } }`, data.Error) }
}
x-data={ fmt.Sprintf(`{ init() { $el.setCustomValidity(%q) } }`, data.Error) }
name="slug"
type="text"
if !data.Disabled {
@change="$el.setCustomValidity('')"
}
@change="$el.setCustomValidity('')"
class={ utils.CXX(
"input input-bordered", true,
"text-error", data.Error != "",
@ -46,18 +41,14 @@ templ SlugInput(data SlugInputData) {
"input-error", data.Error != "",
"input-success", data.Valid != "",
) }
if data.Disabled {
disabled
} else {
hx-get="/htmx/devices/add/validate/slug"
hx-trigger="change, input delay:2s"
hx-target="#slug-input-form"
hx-target-error="#slug-input-form"
hx-swap="outerHTML"
placeholder="my-awesome-device"
title="Url Friendly Characters Only"
required
}
hx-get="/htmx/devices/add/validate/slug"
hx-trigger="change, input delay:2s"
hx-target="#slug-input-form"
hx-target-error="#slug-input-form"
hx-swap="outerHTML"
placeholder="my-awesome-device"
title="Url Friendly Characters Only"
required
value={ data.Value }
/>
<div class="label">
@ -66,8 +57,6 @@ templ SlugInput(data SlugInputData) {
{ data.Valid }
} else if data.Error != "" {
{ data.Error }
} else if data.DisabledText != "" {
{ data.DisabledText }
} else {
URL friendly Unique identifier for the device.
Value must be lowercase english alphabet and supported separator is only 'dash' (-) and 'underscores' (_).

View file

@ -6,6 +6,7 @@ import "github.com/tigorlazuardi/redmage/views/components"
type Data struct {
PageTitle string
PostAction string
EditMode bool
NameInput NameInputData
SlugInput SlugInputData
@ -38,24 +39,28 @@ templ Content(c *views.Context, data Data) {
method="post"
hx-post={ data.PostAction }
action={ templ.SafeURL(data.PostAction) }
class="grid sm:grid-cols-2 gap-4"
class="grid sm:grid-cols-2 gap-4 sm:gap-y-8"
hx-target={ components.NotificationContainerID }
hx-target-error={ components.NotificationContainerID }
hx-swap="afterbegin"
>
@NameInput(data.NameInput)
@SlugInput(data.SlugInput)
if !data.EditMode {
@SlugInput(data.SlugInput)
}
@ResolutionXInput(data.ResolutionX)
@ResolutionYInput(data.ResolutionY)
<div class="divider my-auto sm:col-span-2"><h3 class="m-0 p-0">Filter</h3></div>
<div class="divider my-auto sm:col-span-2 sm:my-8"><h3 class="m-0 p-0">Filter</h3></div>
@AspectRatioToleranceInput(data.AspectRatioTolerance)
@NSFWCheckbox(data.NSFWCheckbox)
@WindowsWallpaperCheckbox(data.WindowsWallpaperCheckbox)
if !data.EditMode {
@WindowsWallpaperCheckbox(data.WindowsWallpaperCheckbox)
}
@MinImageResolutionXInput(data.MinImageResolutionXInput)
@MinImageResolutionYInput(data.MinImageResolutionYInput)
@MaxImageResolutionXInput(data.MaxImageResolutionXInput)
@MaxImageResolutionYInput(data.MaxImageResolutionYInput)
<button type="submit" class="btn btn-primary sm:col-span-2">Add</button>
<button type="submit" class="btn btn-primary sm:col-span-2">Save</button>
</form>
}
</main>

View file

@ -35,6 +35,7 @@ templ WindowsWallpaperCheckbox(data WindowsWallpaperCheckboxData) {
>
Windows Wallpaper Mode puts images for this device under one folder instead of split by subreddits.
This allows the user to target Windows Wallpaper to the whole image collections.
<span class="text-primary italic font-bold">Windows wallpaper mode cannot be changed after creation.</span>
</span>
</div>
}

View file

@ -36,7 +36,9 @@ templ DetailsContent(c *views.Context, data Data) {
if data.Error != "" {
<h1>Error: { data.Error }</h1>
} else {
<h1>Subreddit { data.Subreddit.Name }</h1>
<div class="flex items-center justify-between">
<h1>Subreddit { data.Subreddit.Name }</h1>
</div>
<div class="flex flex-wrap justify-between content-center">
<h2 class="my-auto">
Total Images: