subreddit: implemented add page

This commit is contained in:
Tigor Hutasuhut 2024-05-03 19:44:25 +07:00
parent f4399555b4
commit 0d68bbead0
10 changed files with 366 additions and 99 deletions

View file

@ -49,6 +49,17 @@ func (reddit *Reddit) CheckSubreddit(ctx context.Context, params CheckSubredditP
return actual, errs.Wrapw(err, msg, "url", url, "params", params).Code(http.StatusNotFound) return actual, errs.Wrapw(err, msg, "url", url, "params", params).Code(http.StatusNotFound)
} }
if resp.StatusCode == http.StatusForbidden {
var msg string
if params.SubredditType == SubredditTypeUser {
msg = "user has set their profile to private"
}
if params.SubredditType == SubredditTypeSub {
msg = "subreddit is private"
}
return actual, errs.Wrapw(err, msg, "url", url, "params", params).Code(http.StatusForbidden)
}
if params.SubredditType == SubredditTypeUser && resp.StatusCode == http.StatusOK { if params.SubredditType == SubredditTypeUser && resp.StatusCode == http.StatusOK {
return params.Subreddit, nil return params.Subreddit, nil
} }

View file

@ -58,6 +58,7 @@ func (routes *Routes) registerHTMXRoutes(router chi.Router) {
router.Use(chimiddleware.RequestLogger(middleware.ChiLogger{})) router.Use(chimiddleware.RequestLogger(middleware.ChiLogger{}))
router.Use(chimiddleware.SetHeader("Content-Type", "text/html; charset=utf-8")) router.Use(chimiddleware.SetHeader("Content-Type", "text/html; charset=utf-8"))
router.Post("/subreddits/add", routes.SubredditsCreateHTMX)
router.Post("/subreddits/start", routes.SubredditStartDownloadHTMX) router.Post("/subreddits/start", routes.SubredditStartDownloadHTMX)
router.Post("/subreddits/check", routes.SubredditCheckHTMX) router.Post("/subreddits/check", routes.SubredditCheckHTMX)
router.Get("/subreddits/validate/schedule", routes.SubredditValidateScheduleHTMX) router.Get("/subreddits/validate/schedule", routes.SubredditValidateScheduleHTMX)

View file

@ -5,13 +5,17 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"github.com/a-h/templ"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
"github.com/tigorlazuardi/redmage/api" "github.com/tigorlazuardi/redmage/api"
"github.com/tigorlazuardi/redmage/api/reddit" "github.com/tigorlazuardi/redmage/api/reddit"
"github.com/tigorlazuardi/redmage/models" "github.com/tigorlazuardi/redmage/models"
"github.com/tigorlazuardi/redmage/pkg/errs" "github.com/tigorlazuardi/redmage/pkg/errs"
"github.com/tigorlazuardi/redmage/pkg/log" "github.com/tigorlazuardi/redmage/pkg/log"
"github.com/tigorlazuardi/redmage/views/components"
"github.com/tigorlazuardi/redmage/views/subredditsview/addview"
) )
func (routes *Routes) SubredditsCreateAPI(rw http.ResponseWriter, req *http.Request) { func (routes *Routes) SubredditsCreateAPI(rw http.ResponseWriter, req *http.Request) {
@ -64,6 +68,118 @@ func (routes *Routes) SubredditsCreateAPI(rw http.ResponseWriter, req *http.Requ
} }
} }
func (routes *Routes) SubredditsCreateHTMX(rw http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "*Routes.SubredditsCreateHTMX")
defer span.End()
sub, errComponents := subredditsDataFromRequest(r)
if len(errComponents) > 0 {
rw.WriteHeader(http.StatusBadRequest)
for _, err := range errComponents {
if e := err.Render(ctx, rw); e != nil {
log.New(ctx).Err(e).Error("failed to render error")
}
}
return
}
actual, err := routes.API.SubredditCheck(ctx, api.SubredditCheckParam{
Subreddit: sub.Name,
SubredditType: reddit.SubredditType(sub.Subtype),
})
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
log.New(ctx).Err(err).Error("subreddit check returns error")
renderer := addview.SubredditInputForm(addview.SubredditInputData{
Value: sub.Name,
Error: err.Error(),
Type: reddit.SubredditType(sub.Subtype),
HXSwapOOB: "true",
})
if err := renderer.Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render error")
}
return
}
sub.Name = actual
_, err = routes.API.SubredditsCreate(ctx, sub)
if err != nil {
log.New(ctx).Err(err).Error("failed to create subreddit")
code, message := errs.HTTPMessage(err)
rw.Header().Set("HX-Retarget", components.NotificationContainerID)
rw.WriteHeader(code)
if err := components.ErrorNotication(message).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render error")
}
return
}
rw.Header().Set("HX-Redirect", "/subreddits")
rw.WriteHeader(http.StatusCreated)
_, _ = rw.Write([]byte("Subreddit created"))
}
func subredditsDataFromRequest(r *http.Request) (sub *models.Subreddit, errs []templ.Component) {
sub = &models.Subreddit{}
var t reddit.SubredditType
err := t.Parse(r.FormValue("type"))
if err != nil {
errs = append(errs, addview.SubredditTypeInput(addview.SubredditTypeData{
Value: strconv.Itoa(int(t)),
Error: err.Error(),
HXSwapOOB: "true",
}))
return nil, errs
}
sub.Subtype = int32(t)
sub.Name = r.FormValue("name")
if sub.Name == "" {
errs = append(errs, addview.SubredditInputForm(addview.SubredditInputData{
Value: sub.Name,
Error: "name is required",
Type: t,
HXSwapOOB: "true",
}))
return nil, errs
}
enableSchedule, _ := strconv.Atoi(r.FormValue("enable_schedule"))
sub.EnableSchedule = int32(enableSchedule)
if sub.EnableSchedule > 1 {
sub.EnableSchedule = 1
} else if sub.EnableSchedule < 0 {
sub.EnableSchedule = 0
}
if sub.EnableSchedule == 0 {
sub.Schedule = "@daily"
}
if sub.EnableSchedule == 1 {
sub.Schedule = r.FormValue("schedule")
_, err = cronParser.Parse(sub.Schedule)
if err != nil {
errs = append(errs, addview.ScheduleInput(addview.ScheduleInputData{
Value: sub.Schedule,
Error: fmt.Sprintf("invalid cron schedule: %s", err),
HXSwapOOB: "true",
}))
}
}
countback, _ := strconv.Atoi(r.FormValue("countback"))
sub.Countback = int32(countback)
if sub.Countback < 1 {
errs = append(errs, addview.CountbackInput(addview.CountbackInputData{
Value: int64(sub.Countback),
Error: "countback must be 1 or higher",
}))
}
return sub, errs
}
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
func validateSubredditsCreate(body *models.Subreddit) error { func validateSubredditsCreate(body *models.Subreddit) error {

View file

@ -45,7 +45,7 @@ func (routes *Routes) SubredditValidateScheduleHTMX(rw http.ResponseWriter, r *h
next := scheduler.Next(time.Now()) next := scheduler.Next(time.Now())
data.Valid = fmt.Sprintf("Schedule is valid. Next run at: %s", next.Format("Monday, _2 January 2006 15:04:05 MST")) data.Valid = fmt.Sprintf("Syntax is valid. Next run at: %s", next.Format("Monday, _2 January 2006 15:04 MST"))
if err := addview.ScheduleInput(data).Render(ctx, rw); err != nil { if err := addview.ScheduleInput(data).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("failed to render schedule input") log.New(ctx).Err(err).Error("failed to render schedule input")

View file

@ -19,19 +19,24 @@ templ AddviewContent(c *views.Context) {
<h1>Add Subreddit</h1> <h1>Add Subreddit</h1>
<div class="divider"></div> <div class="divider"></div>
<form <form
action="/htmx/subreddits/add"
method="POST"
onkeydown="return event.key !== 'Enter'" onkeydown="return event.key !== 'Enter'"
hx-post="/htmx/subreddit/add" hx-post="/htmx/subreddits/add"
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
> >
<label id="subreddit-input" class="form-control w-full"> <div
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
@SubredditInputForm(SubredditInputData{}) @SubredditInputForm(SubredditInputData{})
</label>
<label id="subreddit-type-input" class="form-control w-full">
@SubredditTypeInput(SubredditTypeData{}) @SubredditTypeInput(SubredditTypeData{})
</label> <div class="sm:col-span-2">
<div class="flex gap-4 content-center"> @scheduleInputContainer()
@scheduleInputContainer() </div>
<div class="sm:col-span-2">
@CountbackInput(CountbackInputData{})
</div>
</div> </div>
<button type="submit" class="block btn btn-primary mx-auto w-full max-w-xs mt-8 text-primary-content">Add</button>
</form> </form>
} }
</main> </main>

View file

@ -0,0 +1,73 @@
package addview
import "strconv"
import "github.com/tigorlazuardi/redmage/views/utils"
type CountbackInputData struct {
Value int64
Error string
HXSwapOOB string
}
func (c *CountbackInputData) GetValue() string {
if c.Value < 1 {
return "100"
}
return strconv.FormatInt(c.Value, 10)
}
templ CountbackInput(data CountbackInputData) {
<label
id="countback-input"
class="form-control w-full sm:col-span-2"
hx-swap-oob={ data.HXSwapOOB }
>
<div class="label">
<span
class={ utils.CX(map[string]bool{
"label-text": true,
"text-error": data.Error != "",
"text-base": true,
}) }
>Countback</span>
<span
class={ utils.CX(map[string]bool{
"label-text-alt": true,
"text-error": data.Error != "",
}) }
>NOTE: Non image posts are also counted in the countback!</span>
</div>
<input
name="countback"
type="number"
class={ utils.CX(map[string]bool{
"input": true,
"input-bordered": true,
"input-error": data.Error != "",
"text-error": data.Error != "",
}) }
value={ data.GetValue() }
min="1"
required
data-error={ data.Error }
hx-on::load="this.setCustomValidity(this.dataset.error)"
onchange="this.setCustomValidity('')"
/>
<div class="label">
<span
class={ utils.CX(map[string]bool{
"label-text": true,
"text-error": data.Error != "",
}) }
>
if data.Error != "" {
{ data.Error }
} else {
Number of posts to lookup for whenever the scheduler runs this task or triggered manually by you.
The bigger the number, the longer it takes to finish the task.
You should adjust this number based on how active the subreddit is, how often the scheduler runs this task (if enabled), and your internet speed.
}
</span>
</div>
</label>
}

View file

@ -1,64 +1,44 @@
package addview package addview
import "github.com/tigorlazuardi/redmage/views/components"
import "github.com/tigorlazuardi/redmage/views/utils" import "github.com/tigorlazuardi/redmage/views/utils"
import "github.com/tigorlazuardi/redmage/api/reddit" import "github.com/tigorlazuardi/redmage/api/reddit"
type SubredditInputData struct { type SubredditInputData struct {
Value string Value string
Error string Error string
Valid bool Valid bool
Type reddit.SubredditType Type reddit.SubredditType
HXSwapOOB string
} }
templ SubredditInputForm(data SubredditInputData) { templ SubredditInputForm(data SubredditInputData) {
<div class="label"> <label
<span id="subreddit-input"
class={ utils.CX(map[string]bool{ class="form-control w-full"
hx-post="/htmx/subreddits/check"
hx-target-error="this"
hx-trigger="input delay:1s, on-demand"
hx-include="[name='type']"
hx-swap="outerHTML"
hx-swap-oob={ data.HXSwapOOB }
>
<div class="label">
<span
class={ utils.CX(map[string]bool{
"label-text": true, "label-text": true,
"text-error": data.Error != "", "text-error": data.Error != "",
"text-success": data.Valid, "text-success": data.Valid,
"text-base": true, "text-base": true,
}) } }) }
>Subreddit Name</span> >Subreddit Name</span>
</div> </div>
@subredditInputField("/htmx/subreddits/check", data) <input
<div class="label"> type="text"
<span id="name"
name="name"
value={ data.Value }
placeholder="e.g. 'wallpaper' or 'EarthPorn'"
class={ utils.CX(map[string]bool{ class={ utils.CX(map[string]bool{
"label-text": true,
"text-error": data.Error != "",
"text-success": data.Valid,
"min-h-[1rem]": true,
}) }
>
if data.Valid {
if data.Type == reddit.SubredditTypeUser {
Username target is valid
} else {
Subreddit is valid
}
} else {
{ data.Error }
}
</span>
</div>
}
templ subredditInputField(target string, data SubredditInputData) {
<input
type="text"
id="name"
name="name"
hx-post={ target }
hx-target="#subreddit-input"
hx-target-4xx="#subreddit-input"
hx-trigger="keyup changed delay:1s, on-demand"
hx-include="[name='type']"
hx-target-5x={ components.NotificationContainerID }
value={ data.Value }
placeholder="e.g. 'wallpaper' or 'EarthPorn'"
class={ utils.CX(map[string]bool{
"input": true, "input": true,
"input-bordered": true, "input-bordered": true,
"input-error": data.Error != "", "input-error": data.Error != "",
@ -66,8 +46,29 @@ templ subredditInputField(target string, data SubredditInputData) {
"input-success": data.Valid, "input-success": data.Valid,
"text-success": data.Valid, "text-success": data.Valid,
}) } }) }
required required
data-error={ data.Error } data-error={ data.Error }
hx-on::load="this.setCustomValidity(this.getAttribute('data-error'))" hx-on::load="this.setCustomValidity(this.getAttribute('data-error'))"
/> />
<div class="label">
<span
class={ utils.CX(map[string]bool{
"label-text": true,
"text-error": data.Error != "",
"text-success": data.Valid,
"min-h-[1rem]": true,
}) }
>
if data.Valid {
if data.Type == reddit.SubredditTypeUser {
Username target is valid
} else {
Subreddit is valid
}
} else {
{ data.Error }
}
</span>
</div>
</label>
} }

View file

@ -1,16 +1,37 @@
package addview package addview
import "github.com/tigorlazuardi/redmage/views/utils" import "github.com/tigorlazuardi/redmage/views/utils"
import "fmt"
import "strconv"
type ScheduleInputData struct { type ScheduleInputData struct {
Value string Value string
Error string Error string
Valid string Valid string
Disabled bool Disabled bool
HXSwapOOB string
} }
templ scheduleInputContainer() { templ scheduleInputContainer() {
@ScheduleInput(ScheduleInputData{}) @ScheduleInput(ScheduleInputData{})
<datalist id="cron-templates">
<option value="@hourly">Every hour</option>
<option value="@daily">Every day at midnight</option>
<option value="@weekly">Every Sunday at midnight</option>
<option value="@monthly">Every start of month</option>
<option value="@yearly">Every start of year</option>
<option value="@annually">Every start of year</option>
<option value="0 0 * * MON">Every Monday at midnight</option>
<option value="0 0 * * TUE">Every Tuesday at midnight</option>
<option value="0 0 * * WED">Every Wednesday at midnight</option>
<option value="0 0 * * THU">Every Thursday at midnight</option>
<option value="0 0 * * FRI">Every Friday at midnight</option>
<option value="0 0 * * SAT">Every Saturday at midnight</option>
<option value="0 0 * * SUN">Every Sunday at midnight</option>
for i := 1; i < 24; i++ {
<option value={ fmt.Sprintf("0 %d * * *", i) }>Every day at { strconv.Itoa(i) } o'clock</option>
}
</datalist>
<script> <script>
document.addEventListener('DOMContentLoaded', () => htmx.trigger('#schedule-input-group', 'change')) document.addEventListener('DOMContentLoaded', () => htmx.trigger('#schedule-input-group', 'change'))
</script> </script>
@ -19,14 +40,14 @@ templ scheduleInputContainer() {
templ ScheduleInput(data ScheduleInputData) { templ ScheduleInput(data ScheduleInputData) {
<div <div
id="schedule-input-group" id="schedule-input-group"
class="form-control w-full my-auto" class="form-control w-full"
hx-get="/htmx/subreddits/validate/schedule" hx-get="/htmx/subreddits/validate/schedule"
hx-trigger="change, input delay:1s" hx-trigger="change"
hx-include="this" hx-include="this"
hx-target="this"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-swap-oob={ data.HXSwapOOB }
> >
<label class="label"> <label for="schedule" class="label">
<span <span
class={ utils.CX(map[string]bool{ class={ utils.CX(map[string]bool{
"label-text": true, "label-text": true,
@ -35,18 +56,26 @@ templ ScheduleInput(data ScheduleInputData) {
"text-base": true, "text-base": true,
}) } }) }
>Schedule</span> >Schedule</span>
<div class="tooltip" data-tip="Whether to enable scheduler or not"> <div class="tooltip tooltip-left" data-tip="Whether to enable the scheduler or not">
if data.Disabled { if data.Disabled {
<input type="checkbox" name="enable_schedule" value="1" class="toggle toggle-primary"/> <input type="checkbox" name="enable_schedule" value="1" class="toggle toggle-primary my-auto"/>
} else { } else {
<input type="checkbox" name="enable_schedule" value="1" class="toggle toggle-primary" checked/> <input type="checkbox" name="enable_schedule" value="1" class="toggle toggle-primary my-auto" checked/>
} }
</div> </div>
</label> </label>
if data.Disabled { if data.Disabled {
<input name="schedule" type="text" placeholder="e.g. '@daily' or '0 0 * * MON'" class="input input-bordered" disabled/> <input
id="schedule"
name="schedule"
type="text"
placeholder="e.g. '@daily' or '0 0 * * MON'"
class="input input-bordered"
disabled
/>
} else { } else {
<input <input
id="schedule"
name="schedule" name="schedule"
type="text" type="text"
placeholder="e.g. '@daily' or '0 0 * * MON'" placeholder="e.g. '@daily' or '0 0 * * MON'"
@ -59,6 +88,10 @@ templ ScheduleInput(data ScheduleInputData) {
"input-success": data.Valid != "", "input-success": data.Valid != "",
"text-success": data.Valid != "", "text-success": data.Valid != "",
}) } }) }
data-error={ data.Error }
hx-on::load="this.setCustomValidity(this.getAttribute('data-error'))"
list="cron-templates"
required
/> />
} }
<div class="label"> <div class="label">
@ -72,8 +105,10 @@ templ ScheduleInput(data ScheduleInputData) {
> >
if data.Valid != "" { if data.Valid != "" {
{ data.Valid } { data.Valid }
} else { } else if data.Error != "" {
{ data.Error } { data.Error }
} else if !data.Disabled {
Uses cron syntax. Tip: Start by typing 'every' to get suggestions or search custom expressions via Google like 'cron every 6 hours'.
} }
</span> </span>
</div> </div>

View file

@ -1,27 +0,0 @@
package addview
type SubredditTypeData struct {
Value string
}
templ SubredditTypeInput(data SubredditTypeData) {
<div class="label">
<span class="label-text text-base">Subreddit Type</span>
</div>
<select
onchange="htmx.trigger('#name', 'on-demand')"
name="type"
value={ data.Value }
class="select select-bordered"
>
if data.Value == "1" {
<option value="0">Subreddit</option>
<option selected value="1">User</option>
} else {
<option selected value="0">Subreddit</option>
<option value="1">User</option>
}
</select>
<div class="min-h-4"></div>
<script>document.addEventListener('DOMContentLoaded', () => htmx.trigger('#name', 'on-demand')) </script>
}

View file

@ -0,0 +1,52 @@
package addview
import "github.com/tigorlazuardi/redmage/views/utils"
type SubredditTypeData struct {
Value string
Error string
HXSwapOOB string
}
templ SubredditTypeInput(data SubredditTypeData) {
<label
id="subreddit-type-input"
class="form-control w-full"
hx-swap-oob={ data.HXSwapOOB }
>
<div class="label">
<span
class={ utils.CX(map[string]bool{
"label-text": true,
"text-error": data.Error != "",
"text-base": true,
}) }
>Subreddit Type</span>
</div>
<select
onchange="
htmx.trigger('#name', 'on-demand');
this.setCustomValidity('');
"
name="type"
value={ data.Value }
class="select select-bordered"
class={ utils.CX(map[string]bool{
"select": true,
"select-bordered": true,
"select-error": data.Error != "",
}) }
data-error={ data.Error }
hx-on::load="this.setCustomValidity(this.dataset.error)"
>
if data.Value == "1" {
<option value="0">Subreddit</option>
<option selected value="1">User</option>
} else {
<option selected value="0">Subreddit</option>
<option value="1">User</option>
}
</select>
<script>document.addEventListener('DOMContentLoaded', () => htmx.trigger('#name', 'on-demand')) </script>
</label>
}