From 387988139e357b79dd21c8d072d64aa99b4fecc9 Mon Sep 17 00:00:00 2001 From: Tigor Hutasuhut Date: Thu, 2 May 2024 15:42:10 +0700 Subject: [PATCH] views: implemented subreddit details page --- .air.toml | 2 +- .gitignore | 1 + api/subreddits_get_by_name.go | 187 ++++++++++++++++++ api/subreddits_list.go | 5 +- server/routes/page_subreddits_details.go | 47 +++++ server/routes/routes.go | 1 + .../image_card.templ} | 17 +- views/homeview/homeview.templ | 9 + .../detailsview/detailsview.templ | 66 +++++++ views/subredditsview/subredditsview.templ | 2 +- 10 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 api/subreddits_get_by_name.go create mode 100644 server/routes/page_subreddits_details.go rename views/{homeview/recently_added_image.templ => components/image_card.templ} (78%) create mode 100644 views/subredditsview/detailsview/detailsview.templ diff --git a/.air.toml b/.air.toml index fe69a81..a771eff 100644 --- a/.air.toml +++ b/.air.toml @@ -26,7 +26,7 @@ log = "build-errors.log" poll = false poll_interval = 0 post_cmd = [] -pre_cmd = ["make gen"] +pre_cmd = ["make prepare"] rerun = false rerun_delay = 500 send_interrupt = true diff --git a/.gitignore b/.gitignore index 746eff1..a790c69 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ go.work node_modules *.env *_templ.go +*_templ.txt public/style.css public/htmx*.js .direnv diff --git a/api/subreddits_get_by_name.go b/api/subreddits_get_by_name.go new file mode 100644 index 0000000..10d67ad --- /dev/null +++ b/api/subreddits_get_by_name.go @@ -0,0 +1,187 @@ +package api + +import ( + "context" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/stephenafamo/bob" + "github.com/stephenafamo/bob/dialect/sqlite" + "github.com/stephenafamo/bob/dialect/sqlite/dialect" + "github.com/stephenafamo/bob/dialect/sqlite/sm" + "github.com/tigorlazuardi/redmage/models" + "github.com/tigorlazuardi/redmage/pkg/errs" +) + +func (api *API) SubredditsGetByName(ctx context.Context, name string) (subreddit *models.Subreddit, err error) { + ctx, span := tracer.Start(ctx, "*API.SubredditsGetByName") + defer span.End() + + subreddit, err = models.FindSubreddit(ctx, api.db, name) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, errs.Wrapw(err, "subreddit not found", "name", name).Code(http.StatusNotFound) + } + return nil, errs.Wrapw(err, "failed to get subreddit by name") + } + + return subreddit, nil +} + +type SubredditGetByNameImageParams struct { + Q string + Limit int64 + Offset int64 + OrderBy string + Sort string + SFW int + After time.Time +} + +func (sgb SubredditGetByNameImageParams) IntoQuery() url.Values { + queries := make(url.Values) + + if sgb.Q != "" { + queries.Set("q", sgb.Q) + } + if sgb.Limit > 0 { + queries.Set("limit", strconv.FormatInt(sgb.Limit, 10)) + } + if sgb.Offset > 0 { + queries.Set("offset", strconv.FormatInt(sgb.Offset, 10)) + } + if sgb.OrderBy != "" { + queries.Set("order_by", sgb.OrderBy) + } + if sgb.Sort != "" { + queries.Set("sort", sgb.Sort) + } + if !sgb.After.IsZero() { + queries.Set("after", strconv.FormatInt(sgb.After.Unix(), 10)) + } + + return queries +} + +func (sgb SubredditGetByNameImageParams) IntoQueryWith(keyValue ...string) url.Values { + queries := sgb.IntoQuery() + for i := 0; i < len(keyValue); i += 2 { + queries.Set(keyValue[i], keyValue[i+1]) + } + return queries +} + +func (sgb *SubredditGetByNameImageParams) FillFromQuery(query Queryable) { + sgb.Q = query.Get("q") + sgb.Limit, _ = strconv.ParseInt(query.Get("limit"), 10, 64) + if sgb.Limit < 1 { + sgb.Limit = 25 + } else if sgb.Limit > 100 { + sgb.Limit = 100 + } + + sgb.Offset, _ = strconv.ParseInt(query.Get("offset"), 10, 64) + if sgb.Offset < 0 { + sgb.Offset = 0 + } + + sgb.OrderBy = query.Get("order_by") + sgb.Sort = strings.ToLower(query.Get("sort")) + + afterint, _ := strconv.ParseInt(query.Get("after"), 10, 64) + if afterint > 0 { + sgb.After = time.Unix(afterint, 0) + } else if afterint < 0 { + sgb.After = time.Now().Add(time.Duration(afterint) * time.Second) + } + + sgb.SFW, _ = strconv.Atoi(query.Get("sfw")) + if sgb.SFW < 0 { + sgb.SFW = 0 + } else if sgb.SFW > 1 { + sgb.SFW = 1 + } +} + +func (sgb *SubredditGetByNameImageParams) CountQuery() (expr []bob.Mod[*dialect.SelectQuery]) { + if sgb.Q != "" { + arg := sqlite.Arg("%" + sgb.Q + "%") + expr = append(expr, + sm.Where( + models.ImageColumns.PostTitle.Like(arg). + Or(models.ImageColumns.PostURL.Like(arg). + Or(models.ImageColumns.ImageRelativePath.Like(arg)), + ), + )) + } + + if !sgb.After.IsZero() { + expr = append(expr, models.SelectWhere.Images.CreatedAt.GTE(sgb.After.Unix())) + } + + if sgb.SFW == 1 { + expr = append(expr, models.SelectWhere.Images.NSFW.EQ(0)) + } + + return expr +} + +func (sgb *SubredditGetByNameImageParams) Query() (expr []bob.Mod[*dialect.SelectQuery]) { + expr = append(expr, sgb.CountQuery()...) + + if sgb.Limit > 0 { + expr = append(expr, sm.Limit(sgb.Limit)) + } + + if sgb.Offset > 0 { + expr = append(expr, sm.Offset(sgb.Offset)) + } + + if sgb.OrderBy != "" { + order := sm.OrderBy(sqlite.Quote(sgb.OrderBy)) + if sgb.Sort == "desc" { + expr = append(expr, order.Desc()) + } else { + expr = append(expr, order.Asc()) + } + } else { + expr = append(expr, sm.OrderBy(models.ImageColumns.CreatedAt).Desc()) + } + + return expr +} + +type SubredditGetByNameImageResult struct { + Subreddit *models.Subreddit + Images models.ImageSlice + Total int64 +} + +func (api *API) SubredditGetByNameWithImages(ctx context.Context, name string, imageParams SubredditGetByNameImageParams) (result SubredditGetByNameImageResult, err error) { + ctx, span := tracer.Start(ctx, "*API.SubredditsGetByNameWithImages") + defer span.End() + + result.Subreddit, err = api.SubredditsGetByName(ctx, name) + if err != nil { + return result, err + } + + result.Images, err = models.Images. + Query(ctx, api.db, append(imageParams.Query(), models.SelectWhere.Images.Subreddit.EQ(result.Subreddit.Name))...). + All() + if err != nil { + return result, errs.Wrapw(err, "failed to get images by subreddit", "subreddit", result.Subreddit.Name, "params", imageParams) + } + + result.Total, err = models.Images. + Query(ctx, api.db, append(imageParams.CountQuery(), models.SelectWhere.Images.Subreddit.EQ(result.Subreddit.Name))...). + Count() + if err != nil { + return result, errs.Wrapw(err, "failed to count images by subreddit", "subreddit", result.Subreddit.Name, "params", imageParams) + } + + return result, nil +} diff --git a/api/subreddits_list.go b/api/subreddits_list.go index 7a35c09..3a9c686 100644 --- a/api/subreddits_list.go +++ b/api/subreddits_list.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/stephenafamo/bob" + "github.com/stephenafamo/bob/dialect/sqlite" "github.com/stephenafamo/bob/dialect/sqlite/dialect" "github.com/stephenafamo/bob/dialect/sqlite/sm" "github.com/tigorlazuardi/redmage/models" @@ -50,9 +51,9 @@ func (l ListSubredditsParams) Query() (expr []bob.Mod[*dialect.SelectQuery]) { } if l.OrderBy != "" { if l.Sort == "desc" { - expr = append(expr, sm.OrderBy(l.OrderBy).Desc()) + expr = append(expr, sm.OrderBy(sqlite.Quote(l.OrderBy)).Desc()) } else { - expr = append(expr, sm.OrderBy(l.OrderBy).Asc()) + expr = append(expr, sm.OrderBy(sqlite.Quote(l.OrderBy)).Asc()) } } else { expr = append(expr, sm.OrderBy(models.SubredditColumns.Name).Asc()) diff --git a/server/routes/page_subreddits_details.go b/server/routes/page_subreddits_details.go new file mode 100644 index 0000000..8915952 --- /dev/null +++ b/server/routes/page_subreddits_details.go @@ -0,0 +1,47 @@ +package routes + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/tigorlazuardi/redmage/api" + "github.com/tigorlazuardi/redmage/pkg/errs" + "github.com/tigorlazuardi/redmage/pkg/log" + "github.com/tigorlazuardi/redmage/views" + "github.com/tigorlazuardi/redmage/views/subredditsview/detailsview" +) + +func (routes *Routes) PageSubredditsDetails(rw http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(r.Context(), "*Routes.PageSubredditsDetails") + defer span.End() + + name := chi.URLParam(r, "name") + + var params api.SubredditGetByNameImageParams + params.FillFromQuery(r.URL.Query()) + + var data detailsview.Data + var err error + data.Params = params + + c := views.NewContext(routes.Config, r) + + result, err := routes.API.SubredditGetByNameWithImages(ctx, name, params) + if err != nil { + log.New(ctx).Err(err).Error("failed to get subreddit by name") + code, message := errs.HTTPMessage(err) + rw.WriteHeader(code) + data.Error = message + if err := detailsview.Detailsview(c, data).Render(ctx, rw); err != nil { + log.New(ctx).Err(err).Error("failed to render subreddit details page") + } + return + } + data.Subreddit = result.Subreddit + data.Images = result.Images + data.TotalImages = result.Total + + if err := detailsview.Detailsview(c, data).Render(ctx, rw); err != nil { + log.New(ctx).Err(err).Error("failed to render subreddit details page") + } +} diff --git a/server/routes/routes.go b/server/routes/routes.go index 5c5e15b..ac487a4 100644 --- a/server/routes/routes.go +++ b/server/routes/routes.go @@ -65,6 +65,7 @@ func (routes *Routes) registerWWWRoutes(router chi.Router) { r.Use(chimiddleware.SetHeader("Content-Type", "text/html; charset=utf-8")) r.Get("/", routes.PageHome) r.Get("/subreddits", routes.PageSubreddits) + r.Get("/subreddits/details/{name}", routes.PageSubredditsDetails) r.Get("/config", routes.PageConfig) }) } diff --git a/views/homeview/recently_added_image.templ b/views/components/image_card.templ similarity index 78% rename from views/homeview/recently_added_image.templ rename to views/components/image_card.templ index 82fc820..8f86a4a 100644 --- a/views/homeview/recently_added_image.templ +++ b/views/components/image_card.templ @@ -1,4 +1,4 @@ -package homeview +package components import "github.com/tigorlazuardi/redmage/models" import "fmt" @@ -12,13 +12,12 @@ func (o ImageCardOption) Has(opt ImageCardOption) bool { const ( HideTitle ImageCardOption = 1 << iota - HideDescription HideSubreddit HidePoster ) -templ RecentlyAddedImageCard(data *models.Image, opts ImageCardOption) { -
+templ ImageCard(data *models.Image, opts ImageCardOption) { +
50 { + if len(title) > 52 { return title[:50] + "..." } return title } - -templ RecentlyAddedImageList(images models.ImageSlice, opts ImageCardOption) { -
- for _, data := range images { - @RecentlyAddedImageCard(data, opts) - } -
-} diff --git a/views/homeview/homeview.templ b/views/homeview/homeview.templ index 8fe9e4e..60a041c 100644 --- a/views/homeview/homeview.templ +++ b/views/homeview/homeview.templ @@ -3,6 +3,7 @@ package homeview import "github.com/tigorlazuardi/redmage/views/components" import "github.com/tigorlazuardi/redmage/views" import "github.com/tigorlazuardi/redmage/views/utils" +import "github.com/tigorlazuardi/redmage/models" import "strconv" import "fmt" @@ -120,3 +121,11 @@ templ nsfwToggle(c *views.Context, data Data) { } } + +templ RecentlyAddedImageList(images models.ImageSlice, opts components.ImageCardOption) { +
+ for _, data := range images { + @components.ImageCard(data, 0) + } +
+} diff --git a/views/subredditsview/detailsview/detailsview.templ b/views/subredditsview/detailsview/detailsview.templ new file mode 100644 index 0000000..afae935 --- /dev/null +++ b/views/subredditsview/detailsview/detailsview.templ @@ -0,0 +1,66 @@ +package detailsview + +import "github.com/tigorlazuardi/redmage/views" +import "github.com/tigorlazuardi/redmage/models" +import "github.com/tigorlazuardi/redmage/views/components" +import "strconv" +import "github.com/tigorlazuardi/redmage/api" +import "fmt" + +type Data struct { + Subreddit *models.Subreddit + Images models.ImageSlice + TotalImages int64 + Error string + Params api.SubredditGetByNameImageParams +} + +templ Detailsview(c *views.Context, data Data) { + @components.Doctype() { + @components.Head(c, components.HeadTitle("Redmage - Subreddits")) + @components.Body(c) { + @DetailsContent(c, data) + } + } +} + +templ DetailsContent(c *views.Context, data Data) { +
+ @components.Container() { + if data.Error != "" { +

Error: { data.Error }

+ } else { +

Subreddit { data.Subreddit.Name }

+

+ Total Images: + { strconv.FormatInt(data.TotalImages, 10) } +

+
+ @paginationButtons(c, data) +
+ for _, image := range data.Images { + @components.ImageCard(image, 0) + } +
+ } + } +
+} + +templ paginationButtons(_ *views.Context, data Data) { +
+} + +func buildPaginationURL(subreddit string, params api.SubredditGetByNameImageParams, extraQueries ...string) templ.SafeURL { + queries := params.IntoQueryWith(extraQueries...) + + return templ.SafeURL(fmt.Sprintf("/subreddits/details/%s?%s", subreddit, queries.Encode())) +} diff --git a/views/subredditsview/subredditsview.templ b/views/subredditsview/subredditsview.templ index 976e4ca..bd4bb9f 100644 --- a/views/subredditsview/subredditsview.templ +++ b/views/subredditsview/subredditsview.templ @@ -26,7 +26,7 @@ templ SubredditContent(c *views.Context, data Data) { } else {

{ strconv.FormatInt(data.Subreddits.Total, 10) } Subreddits Registered

} -
+
for _, subreddit := range data.Subreddits.Data { @SubredditCard(c, subreddit) }