views: implemented subreddit details page
This commit is contained in:
parent
82d5ba23fc
commit
387988139e
|
@ -26,7 +26,7 @@ log = "build-errors.log"
|
||||||
poll = false
|
poll = false
|
||||||
poll_interval = 0
|
poll_interval = 0
|
||||||
post_cmd = []
|
post_cmd = []
|
||||||
pre_cmd = ["make gen"]
|
pre_cmd = ["make prepare"]
|
||||||
rerun = false
|
rerun = false
|
||||||
rerun_delay = 500
|
rerun_delay = 500
|
||||||
send_interrupt = true
|
send_interrupt = true
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -23,6 +23,7 @@ go.work
|
||||||
node_modules
|
node_modules
|
||||||
*.env
|
*.env
|
||||||
*_templ.go
|
*_templ.go
|
||||||
|
*_templ.txt
|
||||||
public/style.css
|
public/style.css
|
||||||
public/htmx*.js
|
public/htmx*.js
|
||||||
.direnv
|
.direnv
|
||||||
|
|
187
api/subreddits_get_by_name.go
Normal file
187
api/subreddits_get_by_name.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/stephenafamo/bob"
|
"github.com/stephenafamo/bob"
|
||||||
|
"github.com/stephenafamo/bob/dialect/sqlite"
|
||||||
"github.com/stephenafamo/bob/dialect/sqlite/dialect"
|
"github.com/stephenafamo/bob/dialect/sqlite/dialect"
|
||||||
"github.com/stephenafamo/bob/dialect/sqlite/sm"
|
"github.com/stephenafamo/bob/dialect/sqlite/sm"
|
||||||
"github.com/tigorlazuardi/redmage/models"
|
"github.com/tigorlazuardi/redmage/models"
|
||||||
|
@ -50,9 +51,9 @@ func (l ListSubredditsParams) Query() (expr []bob.Mod[*dialect.SelectQuery]) {
|
||||||
}
|
}
|
||||||
if l.OrderBy != "" {
|
if l.OrderBy != "" {
|
||||||
if l.Sort == "desc" {
|
if l.Sort == "desc" {
|
||||||
expr = append(expr, sm.OrderBy(l.OrderBy).Desc())
|
expr = append(expr, sm.OrderBy(sqlite.Quote(l.OrderBy)).Desc())
|
||||||
} else {
|
} else {
|
||||||
expr = append(expr, sm.OrderBy(l.OrderBy).Asc())
|
expr = append(expr, sm.OrderBy(sqlite.Quote(l.OrderBy)).Asc())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
expr = append(expr, sm.OrderBy(models.SubredditColumns.Name).Asc())
|
expr = append(expr, sm.OrderBy(models.SubredditColumns.Name).Asc())
|
||||||
|
|
47
server/routes/page_subreddits_details.go
Normal file
47
server/routes/page_subreddits_details.go
Normal file
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -65,6 +65,7 @@ func (routes *Routes) registerWWWRoutes(router chi.Router) {
|
||||||
r.Use(chimiddleware.SetHeader("Content-Type", "text/html; charset=utf-8"))
|
r.Use(chimiddleware.SetHeader("Content-Type", "text/html; charset=utf-8"))
|
||||||
r.Get("/", routes.PageHome)
|
r.Get("/", routes.PageHome)
|
||||||
r.Get("/subreddits", routes.PageSubreddits)
|
r.Get("/subreddits", routes.PageSubreddits)
|
||||||
|
r.Get("/subreddits/details/{name}", routes.PageSubredditsDetails)
|
||||||
r.Get("/config", routes.PageConfig)
|
r.Get("/config", routes.PageConfig)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package homeview
|
package components
|
||||||
|
|
||||||
import "github.com/tigorlazuardi/redmage/models"
|
import "github.com/tigorlazuardi/redmage/models"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
@ -12,13 +12,12 @@ func (o ImageCardOption) Has(opt ImageCardOption) bool {
|
||||||
|
|
||||||
const (
|
const (
|
||||||
HideTitle ImageCardOption = 1 << iota
|
HideTitle ImageCardOption = 1 << iota
|
||||||
HideDescription
|
|
||||||
HideSubreddit
|
HideSubreddit
|
||||||
HidePoster
|
HidePoster
|
||||||
)
|
)
|
||||||
|
|
||||||
templ RecentlyAddedImageCard(data *models.Image, opts ImageCardOption) {
|
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] rounded-xl top-0 hover:-top-1 hover:drop-shadow-2xl transition-all">
|
<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">
|
||||||
<figure>
|
<figure>
|
||||||
<a
|
<a
|
||||||
href={ templ.URL(fmt.Sprintf("/img/%s", data.ImageRelativePath)) }
|
href={ templ.URL(fmt.Sprintf("/img/%s", data.ImageRelativePath)) }
|
||||||
|
@ -67,16 +66,8 @@ func formatByteSize(size int64) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateTitle(title string) string {
|
func truncateTitle(title string) string {
|
||||||
if len(title) > 50 {
|
if len(title) > 52 {
|
||||||
return title[:50] + "..."
|
return title[:50] + "..."
|
||||||
}
|
}
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
templ RecentlyAddedImageList(images models.ImageSlice, opts ImageCardOption) {
|
|
||||||
<div class="overflow-x-auto flex gap-4 p-6 shadow-inner bg-base-300 rounded-2xl w-[85vw] md:w-full">
|
|
||||||
for _, data := range images {
|
|
||||||
@RecentlyAddedImageCard(data, opts)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
|
@ -3,6 +3,7 @@ package homeview
|
||||||
import "github.com/tigorlazuardi/redmage/views/components"
|
import "github.com/tigorlazuardi/redmage/views/components"
|
||||||
import "github.com/tigorlazuardi/redmage/views"
|
import "github.com/tigorlazuardi/redmage/views"
|
||||||
import "github.com/tigorlazuardi/redmage/views/utils"
|
import "github.com/tigorlazuardi/redmage/views/utils"
|
||||||
|
import "github.com/tigorlazuardi/redmage/models"
|
||||||
import "strconv"
|
import "strconv"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
|
@ -120,3 +121,11 @@ templ nsfwToggle(c *views.Context, data Data) {
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ RecentlyAddedImageList(images models.ImageSlice, opts components.ImageCardOption) {
|
||||||
|
<div class="overflow-x-auto flex gap-4 p-6 shadow-inner bg-base-300 rounded-2xl w-[85vw] md:w-full">
|
||||||
|
for _, data := range images {
|
||||||
|
@components.ImageCard(data, 0)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
66
views/subredditsview/detailsview/detailsview.templ
Normal file
66
views/subredditsview/detailsview/detailsview.templ
Normal file
|
@ -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) {
|
||||||
|
<main class="prose min-w-full">
|
||||||
|
@components.Container() {
|
||||||
|
if data.Error != "" {
|
||||||
|
<h1>Error: { data.Error }</h1>
|
||||||
|
} else {
|
||||||
|
<h1>Subreddit { data.Subreddit.Name }</h1>
|
||||||
|
<h2>
|
||||||
|
Total Images:
|
||||||
|
{ strconv.FormatInt(data.TotalImages, 10) }
|
||||||
|
</h2>
|
||||||
|
<div class="divider"></div>
|
||||||
|
@paginationButtons(c, data)
|
||||||
|
<div class="flex flex-wrap gap-4 mt-8" hx-boost="true">
|
||||||
|
for _, image := range data.Images {
|
||||||
|
@components.ImageCard(image, 0)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ paginationButtons(_ *views.Context, data Data) {
|
||||||
|
<div class="flex justify-center join">
|
||||||
|
for i, count := 1, int64(0); count < data.TotalImages; i, count = i+1, count+data.Params.Limit {
|
||||||
|
if data.Params.Offset <= count && data.Params.Offset > count-data.Params.Limit {
|
||||||
|
<a href={ buildPaginationURL(data.Subreddit.Name, data.Params, "offset", strconv.FormatInt(count, 10)) } class="join-item btn btn-active no-underline">{ strconv.Itoa(i) }</a>
|
||||||
|
} else {
|
||||||
|
<a href={ buildPaginationURL(data.Subreddit.Name, data.Params, "offset", strconv.FormatInt(count, 10)) } class="join-item btn no-underline">{ strconv.Itoa(i) }</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
|
@ -26,7 +26,7 @@ templ SubredditContent(c *views.Context, data Data) {
|
||||||
} else {
|
} else {
|
||||||
<h2>{ strconv.FormatInt(data.Subreddits.Total, 10) } Subreddits Registered</h2>
|
<h2>{ strconv.FormatInt(data.Subreddits.Total, 10) } Subreddits Registered</h2>
|
||||||
}
|
}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1" hx-boost="true">
|
||||||
for _, subreddit := range data.Subreddits.Data {
|
for _, subreddit := range data.Subreddits.Data {
|
||||||
@SubredditCard(c, subreddit)
|
@SubredditCard(c, subreddit)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue