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

167
internal/handlers/auth.go Normal file
View file

@ -0,0 +1,167 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
"git.soup.land/soup/lookbook/internal/data/auth"
"git.soup.land/soup/lookbook/internal/data/session"
)
const sessionCookieName = "lookbook_session"
const sessionDuration = 30 * 24 * time.Hour
type authRequest struct {
Password string `json:"password"`
}
type authStatus struct {
Authenticated bool `json:"authenticated"`
HasPassword bool `json:"has_password"`
}
func HandlePostAuthLogin(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return nil
}
if req.Password == "" {
http.Error(w, "Password required", http.StatusBadRequest)
return nil
}
existing, err := auth.QGet(r.Context(), rc.DB)
if err != nil {
return fmt.Errorf("get auth: %w", err)
}
if existing == nil {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if _, err := auth.QCreate(r.Context(), rc.DB, hash); err != nil {
return fmt.Errorf("create password: %w", err)
}
} else {
if err := bcrypt.CompareHashAndPassword(existing.PasswordHash, []byte(req.Password)); err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return nil
}
}
token, err := newToken(32)
if err != nil {
return fmt.Errorf("token: %w", err)
}
expiresAt := time.Now().Add(sessionDuration)
if _, err := session.QCreate(r.Context(), rc.DB, token, expiresAt); err != nil {
return fmt.Errorf("create session: %w", err)
}
setSessionCookie(w, r, token, expiresAt)
w.WriteHeader(http.StatusNoContent)
return nil
}
func HandlePostAuthLogout(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
cookie, err := r.Cookie(sessionCookieName)
if err == nil {
_ = session.QDelete(r.Context(), rc.DB, cookie.Value)
}
clearSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent)
return nil
}
func HandleGetAuthStatus(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
status := authStatus{}
existing, err := auth.QGet(r.Context(), rc.DB)
if err != nil {
return fmt.Errorf("get auth: %w", err)
}
status.HasPassword = existing != nil
authed, err := isAuthenticated(r, rc)
if err != nil {
return fmt.Errorf("auth status: %w", err)
}
status.Authenticated = authed
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(status)
}
func isAuthenticated(r *http.Request, rc *RequestContext) (bool, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return false, nil
}
row, err := session.QFindByToken(r.Context(), rc.DB, cookie.Value)
if err != nil {
return false, err
}
if row == nil {
return false, nil
}
if time.Now().After(row.ExpiresAt) {
_ = session.QDelete(r.Context(), rc.DB, cookie.Value)
return false, nil
}
return true, nil
}
func requireAuth(w http.ResponseWriter, r *http.Request, rc *RequestContext) bool {
authed, err := isAuthenticated(r, rc)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
}
if !authed {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
}
return true
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
Expires: expiresAt,
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
})
}
func newToken(length int) (string, error) {
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View file

@ -0,0 +1,15 @@
package handlers
import (
"database/sql"
"log/slog"
"git.soup.land/soup/sxgo/ssr"
)
// RequestContext holds dependencies that are injected into every request handler.
type RequestContext struct {
DB *sql.DB
Logger *slog.Logger
TmplCache *ssr.TmplCache
}

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)
}

View file

@ -0,0 +1,49 @@
package handlers
import (
"log/slog"
"net/http"
)
// Handler is a function that handles HTTP requests and can return an error.
// It receives a RequestContext with injected dependencies (DB, Logger).
type Handler func(rc *RequestContext, w http.ResponseWriter, r *http.Request) error
// WithErrorHandling wraps a Handler to automatically handle errors.
func WithErrorHandling(rc *RequestContext, h Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
wrapper := &responseWrapper{
ResponseWriter: w,
written: false,
}
err := h(rc, wrapper, r)
if err != nil {
rc.Logger.Error("handler error",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Any("error", err),
)
if !wrapper.written {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
}
// responseWrapper tracks if response was written
type responseWrapper struct {
http.ResponseWriter
written bool
}
func (w *responseWrapper) Write(b []byte) (int, error) {
w.written = true
return w.ResponseWriter.Write(b)
}
func (w *responseWrapper) WriteHeader(statusCode int) {
w.written = true
w.ResponseWriter.WriteHeader(statusCode)
}

View file

@ -0,0 +1,32 @@
package handlers
import (
"fmt"
"net/http"
"git.soup.land/soup/lookbook/internal/data/image"
)
func HandleGetImage(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 := image.QFindByID(r.Context(), rc.DB, id)
if err != nil {
return fmt.Errorf("find image: %w", err)
}
if row == nil {
http.NotFound(w, r)
return nil
}
if row.ContentType != "" {
w.Header().Set("Content-Type", row.ContentType)
}
w.Header().Set("Cache-Control", "public, max-age=604800")
_, _ = w.Write(row.Bytes)
return nil
}

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
}

View file

@ -0,0 +1,42 @@
package handlers
import (
"net/http"
)
// Router wraps http.ServeMux and automatically injects RequestContext into handlers.
type Router struct {
mux *http.ServeMux
rc *RequestContext
}
func NewRouter(rc *RequestContext) *Router {
return &Router{
mux: http.NewServeMux(),
rc: rc,
}
}
// Handle registers a handler that returns an error.
// The RequestContext is automatically injected and error handling is applied.
func (rt *Router) Handle(pattern string, h Handler) {
rt.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
rc := &RequestContext{
DB: rt.rc.DB,
Logger: rt.rc.Logger,
TmplCache: rt.rc.TmplCache,
}
handler := WithErrorHandling(rc, h)
handler(w, r)
})
}
// HandleStd registers a standard http.Handler (for static files, etc.)
func (rt *Router) HandleStd(pattern string, h http.Handler) {
rt.mux.Handle(pattern, h)
}
func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rt.mux.ServeHTTP(w, r)
}

View file

@ -0,0 +1,23 @@
package handlers
import (
"encoding/json"
"html/template"
"strings"
"git.soup.land/soup/lookbook/internal/static"
)
var TemplateFuncs = template.FuncMap{
"staticURL": static.VersionedPath,
"json": jsonTemplate,
}
func jsonTemplate(v any) template.JS {
payload, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
safe := strings.ReplaceAll(string(payload), "</", "<\\/")
return template.JS(safe)
}