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,51 @@
package components
import (
"git.soup.land/soup/sxgo/ssr"
)
type Page struct {
Title string
Content ssr.Renderable
ShowNav bool
HasAuth bool
}
func (p Page) Render(sw *ssr.Writer) error {
sw.Tmpl(p, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<meta name="darkreader-lock">
<title>{{.Title}} - Lookbook</title>
<link rel="stylesheet" href="{{staticURL "css/pico.min.css"}}">
<link rel="stylesheet" href="{{staticURL "css/styles.css"}}">
</head>
<body>
<header class="site-header">
<div class="title">Lookbook</div>
{{if .ShowNav}}
<nav class="site-nav">
<a href="/">All</a>
<button type="button" class="ghost" onclick="auth()">Auth</button>
<button type="button" class="ghost" onclick="logout()" {{if not .HasAuth}}hidden{{end}}>Logout</button>
</nav>
{{end}}
</header>
<main class="container">
`)
p.Content.Render(sw)
return sw.Tmpl(p, `
</main>
<script>
window.LOOKBOOK_AUTH = {{if .HasAuth}}true{{else}}false{{end}};
</script>
<script src="{{staticURL "js/app.js"}}"></script>
</body>
</html>
`)
}

View file

@ -0,0 +1,45 @@
package auth
import (
"context"
"database/sql"
"time"
)
type Row struct {
ID int64
PasswordHash []byte
CreatedAt time.Time
}
func QGet(ctx context.Context, db *sql.DB) (*Row, error) {
var row Row
err := db.QueryRowContext(ctx, `
SELECT id, password_hash, created_at
FROM auth
ORDER BY id ASC
LIMIT 1
`).Scan(&row.ID, &row.PasswordHash, &row.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func QCreate(ctx context.Context, db *sql.DB, hash []byte) (Row, error) {
var row Row
err := db.QueryRowContext(ctx, `
INSERT INTO auth (password_hash)
VALUES ($1)
RETURNING id, password_hash, created_at
`, hash).Scan(&row.ID, &row.PasswordHash, &row.CreatedAt)
return row, err
}
func QUpdate(ctx context.Context, db *sql.DB, id int64, hash []byte) error {
_, err := db.ExecContext(ctx, `UPDATE auth SET password_hash = $2 WHERE id = $1`, id, hash)
return err
}

View file

@ -0,0 +1,119 @@
package image
import (
"context"
"database/sql"
"time"
)
type Row struct {
ID int64
ItemID int64
OriginalURL string
ContentType string
Bytes []byte
Width int
Height int
IsThumb bool
CreatedAt time.Time
}
type Ref struct {
ID int64
ItemID int64
IsThumb bool
}
func QListByItem(ctx context.Context, db *sql.DB, itemID int64) ([]Row, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, item_id, original_url, content_type, bytes, width, height, is_thumb, created_at
FROM image
WHERE item_id = $1
ORDER BY is_thumb DESC, id ASC
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Row
for rows.Next() {
var row Row
if err := rows.Scan(&row.ID, &row.ItemID, &row.OriginalURL, &row.ContentType, &row.Bytes, &row.Width, &row.Height, &row.IsThumb, &row.CreatedAt); err != nil {
return nil, err
}
items = append(items, row)
}
return items, rows.Err()
}
func QListPrimaryRefsByItems(ctx context.Context, db *sql.DB, itemIDs []int64) (map[int64]Ref, error) {
if len(itemIDs) == 0 {
return map[int64]Ref{}, nil
}
rows, err := db.QueryContext(ctx, `
SELECT id, item_id, is_thumb
FROM image
WHERE item_id = ANY($1)
ORDER BY item_id ASC, is_thumb DESC, id ASC
`, itemIDs)
if err != nil {
return nil, err
}
defer rows.Close()
results := make(map[int64]Ref)
for rows.Next() {
var row Ref
if err := rows.Scan(&row.ID, &row.ItemID, &row.IsThumb); err != nil {
return nil, err
}
if _, exists := results[row.ItemID]; !exists {
results[row.ItemID] = row
}
}
return results, rows.Err()
}
func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) {
var row Row
err := db.QueryRowContext(ctx, `
SELECT id, item_id, original_url, content_type, bytes, width, height, is_thumb, created_at
FROM image
WHERE id = $1
LIMIT 1
`, id).Scan(&row.ID, &row.ItemID, &row.OriginalURL, &row.ContentType, &row.Bytes, &row.Width, &row.Height, &row.IsThumb, &row.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func QCreate(ctx context.Context, db *sql.DB, itemID int64, originalURL, contentType string, bytes []byte, width, height int, isThumb bool) (Row, error) {
var row Row
err := db.QueryRowContext(ctx, `
INSERT INTO image (item_id, original_url, content_type, bytes, width, height, is_thumb)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, item_id, original_url, content_type, bytes, width, height, is_thumb, created_at
`, itemID, originalURL, contentType, bytes, width, height, isThumb).Scan(
&row.ID,
&row.ItemID,
&row.OriginalURL,
&row.ContentType,
&row.Bytes,
&row.Width,
&row.Height,
&row.IsThumb,
&row.CreatedAt,
)
return row, err
}
func QDeleteByItem(ctx context.Context, db *sql.DB, itemID int64) error {
_, err := db.ExecContext(ctx, `DELETE FROM image WHERE item_id = $1`, itemID)
return err
}

View file

@ -0,0 +1,100 @@
package item
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
type Row struct {
ID int64
PubID uuid.UUID
SourceURL string
Title string
Description string
SiteName string
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt sql.Null[time.Time]
}
func QList(ctx context.Context, db *sql.DB) ([]Row, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, pub_id, source_url, title, description, site_name, created_at, updated_at, deleted_at
FROM item
WHERE deleted_at IS NULL
ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Row
for rows.Next() {
var row Row
if err := rows.Scan(&row.ID, &row.PubID, &row.SourceURL, &row.Title, &row.Description, &row.SiteName, &row.CreatedAt, &row.UpdatedAt, &row.DeletedAt); err != nil {
return nil, err
}
items = append(items, row)
}
return items, rows.Err()
}
func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) {
var row Row
err := db.QueryRowContext(ctx, `
SELECT id, pub_id, source_url, title, description, site_name, created_at, updated_at, deleted_at
FROM item
WHERE id = $1
LIMIT 1
`, id).Scan(&row.ID, &row.PubID, &row.SourceURL, &row.Title, &row.Description, &row.SiteName, &row.CreatedAt, &row.UpdatedAt, &row.DeletedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func QCreate(ctx context.Context, db *sql.DB, sourceURL, title, description, siteName string) (Row, error) {
var row Row
err := db.QueryRowContext(ctx, `
INSERT INTO item (source_url, title, description, site_name)
VALUES ($1, $2, $3, $4)
RETURNING id, pub_id, source_url, title, description, site_name, created_at, updated_at, deleted_at
`, sourceURL, title, description, siteName).Scan(
&row.ID,
&row.PubID,
&row.SourceURL,
&row.Title,
&row.Description,
&row.SiteName,
&row.CreatedAt,
&row.UpdatedAt,
&row.DeletedAt,
)
return row, err
}
func QUpdateMeta(ctx context.Context, db *sql.DB, id int64, title, description, siteName string) error {
_, err := db.ExecContext(ctx, `
UPDATE item
SET title = $2, description = $3, site_name = $4, updated_at = NOW()
WHERE id = $1
`, id, title, description, siteName)
return err
}
func QSoftDelete(ctx context.Context, db *sql.DB, id int64) error {
_, err := db.ExecContext(ctx, `UPDATE item SET deleted_at = NOW() WHERE id = $1`, id)
return err
}
func QRestore(ctx context.Context, db *sql.DB, id int64) error {
_, err := db.ExecContext(ctx, `UPDATE item SET deleted_at = NULL WHERE id = $1`, id)
return err
}

View file

@ -0,0 +1,51 @@
package session
import (
"context"
"database/sql"
"time"
)
type Row struct {
ID int64
Token string
CreatedAt time.Time
ExpiresAt time.Time
}
func QCreate(ctx context.Context, db *sql.DB, token string, expiresAt time.Time) (Row, error) {
var row Row
err := db.QueryRowContext(ctx, `
INSERT INTO session (token, expires_at)
VALUES ($1, $2)
RETURNING id, token, created_at, expires_at
`, token, expiresAt).Scan(&row.ID, &row.Token, &row.CreatedAt, &row.ExpiresAt)
return row, err
}
func QFindByToken(ctx context.Context, db *sql.DB, token string) (*Row, error) {
var row Row
err := db.QueryRowContext(ctx, `
SELECT id, token, created_at, expires_at
FROM session
WHERE token = $1
LIMIT 1
`, token).Scan(&row.ID, &row.Token, &row.CreatedAt, &row.ExpiresAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func QDelete(ctx context.Context, db *sql.DB, token string) error {
_, err := db.ExecContext(ctx, `DELETE FROM session WHERE token = $1`, token)
return err
}
func QDeleteExpired(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `DELETE FROM session WHERE expires_at < NOW()`)
return err
}

View file

@ -0,0 +1,141 @@
package tag
import (
"context"
"database/sql"
"strings"
"time"
)
type Row struct {
ID int64
Name string
CreatedAt time.Time
}
type ItemTag struct {
ItemID int64
TagID int64
Name string
}
func QList(ctx context.Context, db *sql.DB) ([]Row, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, name, created_at
FROM tag
ORDER BY name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []Row
for rows.Next() {
var row Row
if err := rows.Scan(&row.ID, &row.Name, &row.CreatedAt); err != nil {
return nil, err
}
tags = append(tags, row)
}
return tags, rows.Err()
}
func QListByItem(ctx context.Context, db *sql.DB, itemID int64) ([]Row, error) {
rows, err := db.QueryContext(ctx, `
SELECT t.id, t.name, t.created_at
FROM tag t
JOIN item_tag it ON it.tag_id = t.id
WHERE it.item_id = $1
ORDER BY t.name ASC
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []Row
for rows.Next() {
var row Row
if err := rows.Scan(&row.ID, &row.Name, &row.CreatedAt); err != nil {
return nil, err
}
tags = append(tags, row)
}
return tags, rows.Err()
}
func QListItemTags(ctx context.Context, db *sql.DB) ([]ItemTag, error) {
rows, err := db.QueryContext(ctx, `
SELECT it.item_id, t.id, t.name
FROM item_tag it
JOIN tag t ON t.id = it.tag_id
ORDER BY t.name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ItemTag
for rows.Next() {
var row ItemTag
if err := rows.Scan(&row.ItemID, &row.TagID, &row.Name); err != nil {
return nil, err
}
entries = append(entries, row)
}
return entries, rows.Err()
}
func QUpsert(ctx context.Context, db *sql.DB, name string) (Row, error) {
cleaned := strings.TrimSpace(strings.ToLower(name))
var row Row
err := db.QueryRowContext(ctx, `
INSERT INTO tag (name)
VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name, created_at
`, cleaned).Scan(&row.ID, &row.Name, &row.CreatedAt)
return row, err
}
func QReplaceItemTags(ctx context.Context, db *sql.DB, itemID int64, names []string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `DELETE FROM item_tag WHERE item_id = $1`, itemID)
if err != nil {
return err
}
for _, name := range names {
if strings.TrimSpace(name) == "" {
continue
}
var tagID int64
err = tx.QueryRowContext(ctx, `
INSERT INTO tag (name)
VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id
`, strings.TrimSpace(strings.ToLower(name))).Scan(&tagID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO item_tag (item_id, tag_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, itemID, tagID)
if err != nil {
return err
}
}
return tx.Commit()
}

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

View file

@ -0,0 +1,44 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
// Logging emits structured request logs.
func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(ww, r)
logger.LogAttrs(r.Context(), slog.LevelInfo, "request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.status),
slog.Int("bytes", ww.bytes),
slog.Duration("latency", time.Since(start)),
)
})
}
}
type responseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *responseWriter) WriteHeader(statusCode int) {
w.status = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *responseWriter) Write(b []byte) (int, error) {
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}

View file

@ -0,0 +1,130 @@
package migrations
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
"os"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)
//go:embed sql/*.sql
var FS embed.FS
const DefaultURL = "postgres://postgres:postgres@localhost:5432/lookbook?sslmode=disable"
// Up applies all available migrations using the provided database URL.
func Up(ctx context.Context, dbURL string, logger *slog.Logger) error {
url := dbURL
if url == "" {
url = DefaultURL
}
db, err := openDB(url, logger)
if err != nil {
return err
}
defer db.Close()
if err := goose.UpContext(ctx, db, "sql"); err != nil {
return fmt.Errorf("apply migrations: %w", err)
}
logger.Info("database migrated")
return nil
}
// Down rolls back migrations. If targetVersion < 0, it steps back one migration; otherwise it migrates down to the target version.
func Down(ctx context.Context, dbURL string, targetVersion int64, logger *slog.Logger) error {
url := dbURL
if url == "" {
url = DefaultURL
}
db, err := openDB(url, logger)
if err != nil {
return err
}
defer db.Close()
if targetVersion < 0 {
if err := goose.DownContext(ctx, db, "sql"); err != nil {
return fmt.Errorf("rollback one: %w", err)
}
logger.Info("rolled back one migration")
return nil
}
if err := goose.DownToContext(ctx, db, "sql", targetVersion); err != nil {
return fmt.Errorf("rollback to version %d: %w", targetVersion, err)
}
logger.Info("rolled back to version", slog.Int64("version", targetVersion))
return nil
}
func CheckPending(ctx context.Context, dbURL string, logger *slog.Logger) (int, error) {
url := dbURL
if url == "" {
url = DefaultURL
}
db, err := openDB(url, logger)
if err != nil {
return 0, err
}
defer db.Close()
current, err := goose.GetDBVersionContext(ctx, db)
if err != nil {
return 0, fmt.Errorf("get db version: %w", err)
}
migrations, err := goose.CollectMigrations("sql", 0, goose.MaxVersion)
if err != nil {
return 0, fmt.Errorf("collect migrations: %w", err)
}
pending := 0
for _, m := range migrations {
if m.Version > current {
pending++
}
}
return pending, nil
}
// slogLogger adapts slog to goose's minimal logging interface.
type slogLogger struct {
logger *slog.Logger
}
func (l slogLogger) Printf(format string, v ...any) {
l.logger.Info(fmt.Sprintf(format, v...))
}
func (l slogLogger) Fatalf(format string, v ...any) {
l.logger.Error(fmt.Sprintf(format, v...))
os.Exit(1)
}
func openDB(url string, logger *slog.Logger) (*sql.DB, error) {
goose.SetBaseFS(FS)
goose.SetLogger(slogLogger{logger: logger})
db, err := sql.Open("pgx", url)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
return db, nil
}

View file

@ -0,0 +1,73 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE item (
id BIGSERIAL PRIMARY KEY,
pub_id UUID NOT NULL DEFAULT gen_random_uuid(),
source_url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
site_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE (pub_id)
);
CREATE INDEX idx_item_created_at ON item(created_at DESC);
CREATE INDEX idx_item_deleted_at ON item(deleted_at);
CREATE TABLE image (
id BIGSERIAL PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES item(id) ON DELETE CASCADE,
original_url TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT '',
bytes BYTEA NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
is_thumb BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_image_item ON image(item_id);
CREATE INDEX idx_image_thumb ON image(item_id, is_thumb);
CREATE TABLE tag (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE item_tag (
item_id BIGINT NOT NULL REFERENCES item(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
PRIMARY KEY (item_id, tag_id)
);
CREATE INDEX idx_item_tag_item ON item_tag(item_id);
CREATE INDEX idx_item_tag_tag ON item_tag(tag_id);
CREATE TABLE auth (
id BIGSERIAL PRIMARY KEY,
password_hash BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE session (
id BIGSERIAL PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_session_token ON session(token);
CREATE INDEX idx_session_expires_at ON session(expires_at);
-- +goose Down
DROP TABLE IF EXISTS session;
DROP TABLE IF EXISTS auth;
DROP TABLE IF EXISTS item_tag;
DROP TABLE IF EXISTS tag;
DROP TABLE IF EXISTS image;
DROP TABLE IF EXISTS item;
DROP EXTENSION IF EXISTS pgcrypto;

View file

@ -0,0 +1,288 @@
package services
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/disintegration/imaging"
"golang.org/x/net/html"
"git.soup.land/soup/lookbook/internal/data/image"
"git.soup.land/soup/lookbook/internal/data/item"
)
const thumbWidth = 480
func CreateItemFromURL(ctx context.Context, db *sql.DB, sourceURL string) (item.Row, error) {
meta, err := FetchMetadata(ctx, sourceURL)
if err != nil {
return item.Row{}, err
}
row, err := item.QCreate(ctx, db, sourceURL, meta.Title, meta.Description, meta.SiteName)
if err != nil {
return item.Row{}, err
}
if err := storeImages(ctx, db, row.ID, meta); err != nil {
return row, err
}
return row, nil
}
func RefreshItemFromURL(ctx context.Context, db *sql.DB, row item.Row) error {
meta, err := FetchMetadata(ctx, row.SourceURL)
if err != nil {
return err
}
if err := item.QUpdateMeta(ctx, db, row.ID, meta.Title, meta.Description, meta.SiteName); err != nil {
return err
}
if err := image.QDeleteByItem(ctx, db, row.ID); err != nil {
return err
}
return storeImages(ctx, db, row.ID, meta)
}
type Metadata struct {
Title string
Description string
SiteName string
ImageURL string
}
func FetchMetadata(ctx context.Context, sourceURL string) (Metadata, error) {
resp, err := fetchURL(ctx, sourceURL)
if err != nil {
return Metadata{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return Metadata{}, err
}
meta := Metadata{}
contentType := resp.Header.Get("Content-Type")
meta.ImageURL = extractImageURL(resp.Request.URL, contentType)
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
if meta.Title == "" {
meta.Title = path.Base(resp.Request.URL.Path)
}
if meta.SiteName == "" {
meta.SiteName = resp.Request.URL.Hostname()
}
return meta, nil
}
doc, err := html.Parse(bytes.NewReader(body))
if err != nil {
return meta, nil
}
extractMeta(doc, &meta)
if meta.Title == "" {
meta.Title = titleFromDoc(doc)
}
if meta.ImageURL == "" {
if oembed, err := fetchOEmbed(ctx, sourceURL); err == nil {
if meta.Title == "" {
meta.Title = oembed.Title
}
if meta.Description == "" {
meta.Description = oembed.Description
}
if meta.ImageURL == "" {
meta.ImageURL = oembed.ThumbnailURL
}
if meta.SiteName == "" {
meta.SiteName = oembed.ProviderName
}
}
}
return meta, nil
}
type oEmbedResponse struct {
Title string `json:"title"`
Description string `json:"description"`
ThumbnailURL string `json:"thumbnail_url"`
ProviderName string `json:"provider_name"`
}
func fetchOEmbed(ctx context.Context, sourceURL string) (oEmbedResponse, error) {
oembedURL := fmt.Sprintf("https://noembed.com/embed?url=%s", url.QueryEscape(sourceURL))
resp, err := fetchURL(ctx, oembedURL)
if err != nil {
return oEmbedResponse{}, err
}
defer resp.Body.Close()
var payload oEmbedResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return oEmbedResponse{}, err
}
return payload, nil
}
func fetchURL(ctx context.Context, rawURL string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "lookbook/1.0")
client := &http.Client{Timeout: 12 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
resp.Body.Close()
return nil, fmt.Errorf("fetch %s: status %d", rawURL, resp.StatusCode)
}
return resp, nil
}
func extractMeta(n *html.Node, meta *Metadata) {
if n.Type == html.ElementNode && n.Data == "meta" {
var property, content, name string
for _, attr := range n.Attr {
switch strings.ToLower(attr.Key) {
case "property":
property = strings.ToLower(attr.Val)
case "content":
content = strings.TrimSpace(attr.Val)
case "name":
name = strings.ToLower(attr.Val)
}
}
if content != "" {
switch property {
case "og:title", "twitter:title":
if meta.Title == "" {
meta.Title = content
}
case "og:description", "twitter:description":
if meta.Description == "" {
meta.Description = content
}
case "og:site_name":
if meta.SiteName == "" {
meta.SiteName = content
}
case "og:image", "twitter:image":
if meta.ImageURL == "" {
meta.ImageURL = content
}
}
if meta.Description == "" && name == "description" {
meta.Description = content
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
extractMeta(c, meta)
}
}
func titleFromDoc(n *html.Node) string {
if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil {
return strings.TrimSpace(n.FirstChild.Data)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if title := titleFromDoc(c); title != "" {
return title
}
}
return ""
}
func extractImageURL(baseURL *url.URL, contentType string) string {
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
return baseURL.String()
}
return ""
}
func storeImages(ctx context.Context, db *sql.DB, itemID int64, meta Metadata) error {
if meta.ImageURL == "" {
return nil
}
resp, err := fetchURL(ctx, meta.ImageURL)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = mime.TypeByExtension(strings.ToLower(path.Ext(resp.Request.URL.Path)))
}
width, height, thumbBytes, thumbHeight, err := createThumb(payload)
if err != nil {
return err
}
_, err = image.QCreate(ctx, db, itemID, meta.ImageURL, contentType, payload, width, height, false)
if err != nil {
return err
}
if thumbBytes != nil {
_, err = image.QCreate(ctx, db, itemID, meta.ImageURL, thumbContentType(contentType), thumbBytes, thumbWidth, thumbHeight, true)
if err != nil {
return err
}
}
return nil
}
func createThumb(payload []byte) (int, int, []byte, int, error) {
img, err := imaging.Decode(bytes.NewReader(payload))
if err != nil {
return 0, 0, nil, 0, nil
}
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= thumbWidth {
return width, height, payload, height, nil
}
thumb := imaging.Resize(img, thumbWidth, 0, imaging.Lanczos)
buf := new(bytes.Buffer)
if err := imaging.Encode(buf, thumb, imaging.JPEG); err != nil {
return width, height, nil, 0, err
}
return width, height, buf.Bytes(), thumb.Bounds().Dy(), nil
}
func thumbContentType(_ string) string {
return "image/jpeg"
}

4
internal/static/css/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,331 @@
/* Commit Mono */
@font-face {
font-family: "CommitMono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/CommitMono-400-Regular.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("../fonts/CommitMono-400-Italic.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono-Light";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/CommitMono-450-Regular.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono-Light";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("../fonts/CommitMono-450-Italic.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/CommitMono-700-Regular.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("../fonts/CommitMono-700-Italic.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono-Light";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/CommitMono-700-Regular.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono-Light";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("../fonts/CommitMono-700-Italic.woff2") format("woff2");
}
:root {
color-scheme: light dark;
font-family:
"CommitMono-Light",
"CommitMono",
monospace;
font-size: 13px;
}
@media (prefers-color-scheme: dark) {
:root {
font-family:
"CommitMono",
monospace;
}
}
body {
margin: 0;
background: var(--pico-background-color);
color: var(--pico-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
border-bottom: 2px solid currentColor;
}
.site-header .title {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.site-nav {
display: flex;
gap: 0.75rem;
align-items: center;
}
.site-nav a,
.site-nav button {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid currentColor;
padding: 0.35rem 0.75rem;
background: transparent;
color: inherit;
}
.site-nav button.ghost {
background: transparent;
}
button.ghost {
border: 1px solid currentColor;
padding: 0.35rem 0.75rem;
background: transparent;
color: inherit;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.hero form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
}
.hero input[type="url"] {
border-radius: 0;
border: 2px solid currentColor;
background: transparent;
padding: 0.6rem 0.75rem;
}
.hero button {
border-radius: 0;
border: 2px solid currentColor;
background: currentColor;
color: var(--pico-background-color);
padding: 0.6rem 1rem;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 2rem;
}
.filter-pill {
border: 1px solid currentColor;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.filter-pill.active {
background: currentColor;
color: var(--pico-background-color);
}
.gallery {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.card {
position: relative;
border: 2px solid currentColor;
background: transparent;
overflow: hidden;
min-height: 160px;
}
.card img,
.card video {
display: block;
width: 100%;
height: auto;
}
.card a {
color: inherit;
text-decoration: none;
}
.card .placeholder {
padding: 1rem;
font-size: 0.9rem;
}
.card .overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
color: #fff;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.8rem;
opacity: 0;
transition: opacity 0.15s ease;
}
.card:hover .overlay {
opacity: 1;
}
.card .overlay .title {
font-weight: 600;
font-size: 0.85rem;
}
.card .overlay .tags {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.detail {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 2rem;
}
.detail img {
width: 100%;
border: 2px solid currentColor;
}
.detail .meta {
display: grid;
gap: 0.75rem;
}
.detail .meta h1 {
margin: 0;
font-size: 1.5rem;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-chip {
border: 1px solid currentColor;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tag-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-editor input {
border-radius: 0;
border: 2px solid currentColor;
padding: 0.45rem 0.6rem;
background: transparent;
}
.tag-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-suggestions button {
border-radius: 0;
border: 1px solid currentColor;
padding: 0.25rem 0.5rem;
background: transparent;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.actions button,
.actions form button {
border-radius: 0;
border: 1px solid currentColor;
padding: 0.45rem 0.8rem;
background: transparent;
}
.notice {
border: 2px dashed currentColor;
padding: 1rem;
font-size: 0.85rem;
}
@media (max-width: 900px) {
.detail {
grid-template-columns: 1fr;
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,39 @@
Copyright (c) 2023 Eigil Nikolajsen (eigi0088@gmail.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole

181
internal/static/js/app.js Normal file
View file

@ -0,0 +1,181 @@
(function() {
const logoutButton = document.querySelector('.site-nav button[onclick="logout()"]');
const authStatusEl = document.querySelector('[data-auth-status]');
window.auth = async function() {
const password = window.prompt('Enter password');
if (!password) return;
const resp = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (resp.ok) {
await refreshAuth();
} else {
alert('Auth failed');
}
};
window.logout = async function() {
await fetch('/auth/logout', { method: 'POST' });
await refreshAuth();
};
async function refreshAuth() {
const resp = await fetch('/auth/status');
if (!resp.ok) return;
const data = await resp.json();
window.LOOKBOOK_AUTH = data.authenticated;
if (logoutButton) {
logoutButton.hidden = !data.authenticated;
}
if (authStatusEl) {
authStatusEl.textContent = data.authenticated ? 'Authed' : 'Read only';
}
const gated = document.querySelectorAll('[data-auth-required]');
gated.forEach((el) => {
el.toggleAttribute('hidden', !data.authenticated);
});
}
const tagInput = document.querySelector('[data-tag-input]');
const tagSuggestions = document.querySelector('[data-tag-suggestions]');
const tagForm = document.querySelector('[data-tag-form]');
const tagEditor = document.querySelector('[data-tag-editor]');
const tagToggle = document.querySelector('[data-tag-toggle]');
const tagList = document.querySelector('[data-tag-list]');
const tagData = tagSuggestions ? JSON.parse(tagSuggestions.getAttribute('data-tags') || '[]') : [];
if (tagToggle && tagEditor) {
tagToggle.addEventListener('click', () => {
const isHidden = tagEditor.hasAttribute('hidden');
tagEditor.toggleAttribute('hidden', !isHidden);
});
}
if (tagList && tagEditor) {
tagList.addEventListener('click', () => {
if (!window.LOOKBOOK_AUTH) return;
const isHidden = tagEditor.hasAttribute('hidden');
tagEditor.toggleAttribute('hidden', !isHidden);
});
}
if (tagSuggestions) {
if (tagInput) {
tagInput.addEventListener('input', () => renderSuggestions(tagInput.value));
renderSuggestions(tagInput.value);
} else {
renderSuggestions('');
}
}
function renderSuggestions(value) {
if (!tagSuggestions) return;
const query = (value || '').trim().toLowerCase();
tagSuggestions.innerHTML = '';
const matches = fuzzyMatchTags(query, tagData).slice(0, 10);
matches.forEach((tag) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = tag;
button.addEventListener('click', () => {
if (tagInput) {
addTag(tag);
return;
}
const target = Array.from(filterButtons).find((btn) => btn.dataset.tagFilter === tag);
if (target) target.click();
});
tagSuggestions.appendChild(button);
});
}
function addTag(tag) {
if (!tagInput) return;
const tags = parseTags(tagInput.value);
if (!tags.includes(tag)) tags.push(tag);
tagInput.value = tags.join(', ');
renderSuggestions(tagInput.value);
}
function parseTags(value) {
return value
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
}
function fuzzyMatchTags(query, tags) {
if (!query) return tags;
const scores = tags.map((tag) => ({ tag, score: fuzzyScore(tag, query) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || a.tag.localeCompare(b.tag));
return scores.map((entry) => entry.tag);
}
function fuzzyScore(tag, query) {
let score = 0;
let ti = 0;
for (const qc of query) {
const idx = tag.indexOf(qc, ti);
if (idx === -1) return 0;
score += idx === ti ? 3 : 1;
ti = idx + 1;
}
return score;
}
const filterButtons = document.querySelectorAll('[data-tag-filter]');
const gridItems = document.querySelectorAll('[data-item-tags]');
const selectedTags = new Set();
filterButtons.forEach((button) => {
button.addEventListener('click', (event) => {
const tag = button.dataset.tagFilter;
const multi = event.ctrlKey || event.metaKey;
if (!multi) {
selectedTags.clear();
filterButtons.forEach((b) => b.classList.remove('active'));
}
if (selectedTags.has(tag)) {
selectedTags.delete(tag);
button.classList.remove('active');
} else {
selectedTags.add(tag);
button.classList.add('active');
}
applyFilters();
});
});
function applyFilters() {
const selected = Array.from(selectedTags);
gridItems.forEach((item) => {
const tags = (item.getAttribute('data-item-tags') || '').split(',').map((t) => t.trim()).filter(Boolean);
if (selected.length === 0) {
item.removeAttribute('hidden');
return;
}
const matches = selected.some((tag) => tags.includes(tag));
item.toggleAttribute('hidden', !matches);
});
}
if (tagForm) {
tagForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(tagForm);
const resp = await fetch(tagForm.action, {
method: 'POST',
body: formData
});
if (resp.ok) {
window.location.reload();
}
});
}
refreshAuth();
})();

42
internal/static/static.go Normal file
View file

@ -0,0 +1,42 @@
package static
import (
"crypto/sha256"
"embed"
"encoding/hex"
"net/http"
"strings"
"time"
)
//go:embed css/* js/* fonts/*
var staticFS embed.FS
// Version is set via -ldflags in production
var Version string
func init() {
if Version == "" {
h := sha256.Sum256([]byte(time.Now().String()))
Version = hex.EncodeToString(h[:4])
}
}
func VersionedPath(path string) string {
return "/static/" + Version + "/" + path
}
func Handler() http.Handler {
fileServer := http.FileServer(http.FS(staticFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
path = strings.TrimPrefix(path, "/static/")
if idx := strings.Index(path, "/"); idx != -1 {
path = path[idx+1:]
}
r.URL.Path = "/" + path
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
fileServer.ServeHTTP(w, r)
})
}