Initial lookbook implementation

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
This commit is contained in:
soup 2026-01-16 21:14:23 -05:00
commit fc625fb9cf
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
486 changed files with 195373 additions and 0 deletions

View file

@ -0,0 +1,145 @@
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)
}