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:
commit
fc625fb9cf
486 changed files with 195373 additions and 0 deletions
51
internal/components/page.go
Normal file
51
internal/components/page.go
Normal 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>
|
||||
`)
|
||||
}
|
||||
45
internal/data/auth/queries.go
Normal file
45
internal/data/auth/queries.go
Normal 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
|
||||
}
|
||||
119
internal/data/image/queries.go
Normal file
119
internal/data/image/queries.go
Normal 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
|
||||
}
|
||||
100
internal/data/item/queries.go
Normal file
100
internal/data/item/queries.go
Normal 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
|
||||
}
|
||||
51
internal/data/session/queries.go
Normal file
51
internal/data/session/queries.go
Normal 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
|
||||
}
|
||||
141
internal/data/tag/queries.go
Normal file
141
internal/data/tag/queries.go
Normal 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
167
internal/handlers/auth.go
Normal 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
|
||||
}
|
||||
15
internal/handlers/context.go
Normal file
15
internal/handlers/context.go
Normal 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
|
||||
}
|
||||
145
internal/handlers/gallery.go
Normal file
145
internal/handlers/gallery.go
Normal 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)
|
||||
}
|
||||
49
internal/handlers/handler.go
Normal file
49
internal/handlers/handler.go
Normal 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)
|
||||
}
|
||||
32
internal/handlers/image.go
Normal file
32
internal/handlers/image.go
Normal 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
245
internal/handlers/item.go
Normal 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
|
||||
}
|
||||
42
internal/handlers/router.go
Normal file
42
internal/handlers/router.go
Normal 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)
|
||||
}
|
||||
23
internal/handlers/templates.go
Normal file
23
internal/handlers/templates.go
Normal 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)
|
||||
}
|
||||
44
internal/middleware/logger.go
Normal file
44
internal/middleware/logger.go
Normal 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
|
||||
}
|
||||
130
internal/migrations/migrations.go
Normal file
130
internal/migrations/migrations.go
Normal 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
|
||||
}
|
||||
73
internal/migrations/sql/0001_init.sql
Normal file
73
internal/migrations/sql/0001_init.sql
Normal 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;
|
||||
288
internal/services/metadata.go
Normal file
288
internal/services/metadata.go
Normal 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
4
internal/static/css/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
331
internal/static/css/styles.css
Normal file
331
internal/static/css/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
internal/static/fonts/CommitMono-400-Italic.woff2
Normal file
BIN
internal/static/fonts/CommitMono-400-Italic.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-400-Regular.woff2
Normal file
BIN
internal/static/fonts/CommitMono-400-Regular.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-450-Italic.woff2
Normal file
BIN
internal/static/fonts/CommitMono-450-Italic.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-450-Regular.woff2
Normal file
BIN
internal/static/fonts/CommitMono-450-Regular.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-700-Italic.woff2
Normal file
BIN
internal/static/fonts/CommitMono-700-Italic.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-700-Regular.woff2
Normal file
BIN
internal/static/fonts/CommitMono-700-Regular.woff2
Normal file
Binary file not shown.
39
internal/static/fonts/OFL.txt
Normal file
39
internal/static/fonts/OFL.txt
Normal 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
181
internal/static/js/app.js
Normal 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
42
internal/static/static.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue