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

245
internal/handlers/item.go Normal file
View file

@ -0,0 +1,245 @@
package handlers
import (
"fmt"
"net/http"
"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/lookbook/internal/services"
"git.soup.land/soup/sxgo/ssr"
)
type itemPageData struct {
Item item.Row
Images []image.Row
TagNames []string
AllTagNames []string
HasAuth bool
}
func (d itemPageData) Render(sw *ssr.Writer) error {
return sw.Tmpl(d, `
<section class="detail">
<div>
{{if .Images}}
<img src="/images/{{(index .Images 0).ID}}" alt="{{.Item.Title}}">
{{else}}
<div class="notice">No preview image cached.</div>
{{end}}
</div>
<div class="meta">
<h1>{{if .Item.Title}}{{.Item.Title}}{{else}}{{.Item.SourceURL}}{{end}}</h1>
{{if .Item.SiteName}}<div>{{.Item.SiteName}}</div>{{end}}
{{if .Item.Description}}<p>{{.Item.Description}}</p>{{end}}
<a href="{{.Item.SourceURL}}" target="_blank" rel="noreferrer">{{.Item.SourceURL}}</a>
<div>
<strong>Tags</strong>
<div class="tag-list" data-tag-list>
{{range .TagNames}}
<span class="tag-chip">{{.}}</span>
{{else}}
<span class="tag-chip">No tags</span>
{{end}}
</div>
<button type="button" class="ghost" data-tag-toggle data-auth-required {{if not .HasAuth}}hidden{{end}}>Edit tags</button>
</div>
<div class="tag-editor" data-tag-editor data-auth-required hidden {{if not .HasAuth}}hidden{{end}}>
<form method="POST" action="/items/{{.Item.ID}}/tags" data-tag-form>
<input type="text" name="tags" value="{{range $i, $tag := .TagNames}}{{if $i}}, {{end}}{{$tag}}{{end}}" data-tag-input>
<button type="submit">Save tags</button>
</form>
<div class="tag-suggestions" data-tag-suggestions data-tags='{{json .AllTagNames}}'></div>
</div>
<div class="actions" data-auth-required {{if not .HasAuth}}hidden{{end}}>
<form method="POST" action="/items/{{.Item.ID}}/refresh">
<button type="submit">Refresh metadata</button>
</form>
<form method="POST" action="/items/{{.Item.ID}}/delete">
<button type="submit">Delete</button>
</form>
</div>
</div>
</section>
`)
}
func HandleGetItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
id, err := parseID(r.PathValue("id"))
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return nil
}
row, err := item.QFindByID(r.Context(), rc.DB, id)
if err != nil {
return fmt.Errorf("find item: %w", err)
}
if row == nil {
http.NotFound(w, r)
return nil
}
images, err := image.QListByItem(r.Context(), rc.DB, id)
if err != nil {
return fmt.Errorf("list images: %w", err)
}
tags, err := tag.QListByItem(r.Context(), rc.DB, id)
if err != nil {
return fmt.Errorf("list item tags: %w", err)
}
allTags, err := tag.QList(r.Context(), rc.DB)
if err != nil {
return fmt.Errorf("list tags: %w", err)
}
var tagNames []string
for _, row := range tags {
tagNames = append(tagNames, row.Name)
}
var allTagNames []string
for _, row := range allTags {
allTagNames = append(allTagNames, row.Name)
}
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)
data := itemPageData{
Item: *row,
Images: images,
TagNames: tagNames,
AllTagNames: allTagNames,
HasAuth: authed,
}
return components.Page{
Title: "Item",
Content: data,
ShowNav: true,
HasAuth: authed,
}.Render(sw)
}
func HandlePostItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !requireAuth(w, r, rc) {
return nil
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return nil
}
sourceURL := strings.TrimSpace(r.FormValue("url"))
if sourceURL == "" {
http.Error(w, "URL required", http.StatusBadRequest)
return nil
}
_, err := services.CreateItemFromURL(r.Context(), rc.DB, sourceURL)
if err != nil {
return fmt.Errorf("create item: %w", err)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
func HandlePostItemTags(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !requireAuth(w, r, rc) {
return nil
}
id, err := parseID(r.PathValue("id"))
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return nil
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return nil
}
tags := parseTags(r.FormValue("tags"))
if err := tag.QReplaceItemTags(r.Context(), rc.DB, id, tags); err != nil {
return fmt.Errorf("update tags: %w", err)
}
w.WriteHeader(http.StatusNoContent)
return nil
}
func HandleDeleteItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !requireAuth(w, r, rc) {
return nil
}
id, err := parseID(r.PathValue("id"))
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return nil
}
if err := item.QSoftDelete(r.Context(), rc.DB, id); err != nil {
return fmt.Errorf("delete item: %w", err)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
func HandleRefreshItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !requireAuth(w, r, rc) {
return nil
}
id, err := parseID(r.PathValue("id"))
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return nil
}
row, err := item.QFindByID(r.Context(), rc.DB, id)
if err != nil {
return fmt.Errorf("find item: %w", err)
}
if row == nil {
http.NotFound(w, r)
return nil
}
if err := services.RefreshItemFromURL(r.Context(), rc.DB, *row); err != nil {
return fmt.Errorf("refresh item: %w", err)
}
http.Redirect(w, r, fmt.Sprintf("/items/%d", id), http.StatusSeeOther)
return nil
}
func parseTags(value string) []string {
var tags []string
for _, tagValue := range strings.Split(value, ",") {
clean := strings.TrimSpace(tagValue)
if clean == "" {
continue
}
tags = append(tags, clean)
}
return tags
}