Pinterest-style visual bookmarking app with: - URL metadata extraction (OG/Twitter meta, oEmbed fallback) - Image caching in Postgres with 480px thumbnails - Multi-tag filtering with Ctrl/Cmd for OR mode - Fuzzy tag suggestions and inline tag editing - Browser console auth() with first-use password setup - Brutalist UI with Commit Mono font and Pico CSS - Light/dark mode via browser preference
145 lines
3.4 KiB
Go
145 lines
3.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.soup.land/soup/lookbook/internal/components"
|
|
"git.soup.land/soup/lookbook/internal/data/image"
|
|
"git.soup.land/soup/lookbook/internal/data/item"
|
|
"git.soup.land/soup/lookbook/internal/data/tag"
|
|
"git.soup.land/soup/sxgo/ssr"
|
|
)
|
|
|
|
type galleryPageData struct {
|
|
Items []galleryItem
|
|
Tags []tag.Row
|
|
HasAuth bool
|
|
TagNames []string
|
|
}
|
|
|
|
type galleryItem struct {
|
|
Item item.Row
|
|
Thumb *image.Ref
|
|
TagNames []string
|
|
TagListText string
|
|
}
|
|
|
|
func (g galleryPageData) Render(sw *ssr.Writer) error {
|
|
return sw.Tmpl(g, `
|
|
<section class="hero">
|
|
<form method="POST" action="/items" data-auth-required {{if not .HasAuth}}hidden{{end}}>
|
|
<input type="url" name="url" placeholder="Paste a link" required>
|
|
<button type="submit">Add</button>
|
|
</form>
|
|
<div class="notice" data-auth-status>Read only</div>
|
|
</section>
|
|
|
|
<section class="filters">
|
|
{{range .Tags}}
|
|
<button type="button" class="filter-pill" data-tag-filter="{{.Name}}">{{.Name}}</button>
|
|
{{end}}
|
|
<div class="tag-suggestions" data-tag-suggestions data-tags='{{json .TagNames}}'></div>
|
|
</section>
|
|
|
|
<section class="gallery">
|
|
{{range .Items}}
|
|
<div class="card" data-item-tags="{{.TagListText}}">
|
|
<a href="/items/{{.Item.ID}}">
|
|
{{if .Thumb}}
|
|
<img src="/images/{{.Thumb.ID}}" alt="{{.Item.Title}}">
|
|
{{else}}
|
|
<div class="placeholder">{{if .Item.Title}}{{.Item.Title}}{{else}}{{.Item.SourceURL}}{{end}}</div>
|
|
{{end}}
|
|
<div class="overlay">
|
|
<div class="title">{{if .Item.Title}}{{.Item.Title}}{{else}}{{.Item.SourceURL}}{{end}}</div>
|
|
<div class="tags">{{range $i, $tag := .TagNames}}{{if $i}} · {{end}}{{$tag}}{{end}}</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
{{end}}
|
|
</section>
|
|
`)
|
|
}
|
|
|
|
func HandleGetGallery(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
|
items, err := item.QList(r.Context(), rc.DB)
|
|
if err != nil {
|
|
return fmt.Errorf("list items: %w", err)
|
|
}
|
|
|
|
var itemIDs []int64
|
|
for _, row := range items {
|
|
itemIDs = append(itemIDs, row.ID)
|
|
}
|
|
|
|
thumbs, err := image.QListPrimaryRefsByItems(r.Context(), rc.DB, itemIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("list thumbs: %w", err)
|
|
}
|
|
|
|
entries, err := tag.QListItemTags(r.Context(), rc.DB)
|
|
if err != nil {
|
|
return fmt.Errorf("list tags: %w", err)
|
|
}
|
|
|
|
itemTagNames := make(map[int64][]string)
|
|
for _, entry := range entries {
|
|
itemTagNames[entry.ItemID] = append(itemTagNames[entry.ItemID], entry.Name)
|
|
}
|
|
|
|
var cards []galleryItem
|
|
for _, row := range items {
|
|
tagNames := itemTagNames[row.ID]
|
|
cards = append(cards, galleryItem{
|
|
Item: row,
|
|
Thumb: refOrNil(thumbs[row.ID]),
|
|
TagNames: tagNames,
|
|
TagListText: strings.Join(tagNames, ","),
|
|
})
|
|
}
|
|
|
|
tags, err := tag.QList(r.Context(), rc.DB)
|
|
if err != nil {
|
|
return fmt.Errorf("list tags: %w", err)
|
|
}
|
|
|
|
authed, err := isAuthenticated(r, rc)
|
|
if err != nil {
|
|
return fmt.Errorf("auth status: %w", err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
sw := ssr.NewWriter(w, rc.TmplCache)
|
|
|
|
var tagNames []string
|
|
for _, row := range tags {
|
|
tagNames = append(tagNames, row.Name)
|
|
}
|
|
|
|
data := galleryPageData{
|
|
Items: cards,
|
|
Tags: tags,
|
|
HasAuth: authed,
|
|
TagNames: tagNames,
|
|
}
|
|
return components.Page{
|
|
Title: "Gallery",
|
|
Content: data,
|
|
ShowNav: true,
|
|
HasAuth: authed,
|
|
}.Render(sw)
|
|
}
|
|
|
|
func refOrNil(ref image.Ref) *image.Ref {
|
|
if ref.ID == 0 {
|
|
return nil
|
|
}
|
|
return &ref
|
|
}
|
|
|
|
func parseID(value string) (int64, error) {
|
|
return strconv.ParseInt(value, 10, 64)
|
|
}
|