Initial commit: Lookbook personal collection app

Pinterest-like app for saving images, videos, quotes, and embeds.

Features:
- Go backend with PostgreSQL, SSR templates
- Console-based admin auth (login/logout via browser console)
- Item types: images, videos (ffmpeg transcoding), quotes, embeds
- Media stored as BLOBs in PostgreSQL
- OpenGraph metadata extraction for links
- Embed detection for YouTube, Vimeo, Twitter/X
- Masonry grid layout, item detail pages
- Tag system with filtering
- Refresh metadata endpoint with change warnings
- Replace media endpoint for updating item images/videos
This commit is contained in:
soup 2026-01-17 01:09:23 -05:00
commit cdcc5b5293
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
45 changed files with 4634 additions and 0 deletions

191
cmd/server/main.go Normal file
View file

@ -0,0 +1,191 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"git.soup.land/soup/sxgo/ssr"
"lookbook/internal/handlers"
"lookbook/internal/middleware"
"lookbook/internal/migrations"
"lookbook/internal/static"
)
const defaultAddr = ":8080"
func main() {
migrate := flag.Bool("migrate", false, "run database migrations and exit")
rollback := flag.Bool("rollback", false, "roll back migrations and exit (one step by default)")
rollbackTarget := flag.Int64("to", -1, "target version for rollback when using -rollback")
dbURLFlag := flag.String("db-url", "", "database connection URL")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
dbURL := *dbURLFlag
if dbURL == "" {
dbURL = os.Getenv("DATABASE_URL")
}
if dbURL == "" {
dbURL = migrations.DefaultURL
}
switch {
case *migrate && *rollback:
logger.Error("choose either -migrate or -rollback, not both")
os.Exit(1)
case *migrate:
if err := migrations.Up(context.Background(), dbURL, logger); err != nil {
logger.Error("migration failed", slog.Any("err", err))
os.Exit(1)
}
return
case *rollback:
if err := migrations.Down(context.Background(), dbURL, *rollbackTarget, logger); err != nil {
logger.Error("rollback failed", slog.Any("err", err))
os.Exit(1)
}
return
}
// Check for subcommand
args := flag.Args()
if len(args) < 1 {
printUsage()
os.Exit(1)
}
mode := args[0]
switch mode {
case "web":
runWebServer(dbURL, logger)
default:
logger.Error("unknown mode", slog.String("mode", mode))
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " lookbook web - Run web server")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Flags:")
fmt.Fprintln(os.Stderr, " -migrate Run database migrations")
fmt.Fprintln(os.Stderr, " -rollback Roll back migrations")
fmt.Fprintln(os.Stderr, " -to <version> Target version for rollback")
fmt.Fprintln(os.Stderr, " -db-url <url> Database connection URL")
}
func runWebServer(dbURL string, logger *slog.Logger) {
// Check for pending migrations
pending, err := migrations.CheckPending(context.Background(), dbURL, logger)
if err != nil {
logger.Warn("could not check migration status", slog.Any("err", err))
} else if pending > 0 {
logger.Warn("database has pending migrations",
slog.Int("pending", pending),
slog.String("hint", "run 'make migrate' to apply"))
}
db, err := sql.Open("pgx", dbURL)
if err != nil {
logger.Error("failed to open database", slog.Any("err", err))
os.Exit(1)
}
defer db.Close()
if err := db.Ping(); err != nil {
logger.Error("failed to ping database", slog.Any("err", err))
os.Exit(1)
}
rc := &handlers.RequestContext{
DB: db,
Logger: logger,
TmplCache: ssr.NewTmplCache(handlers.TemplateFuncs),
}
router := handlers.NewRouter(rc)
// Pages
router.Handle("GET /", handlers.HandleHome)
router.Handle("GET /item/{id}", handlers.HandleItemPage)
// Static files
router.HandleStd("GET /static/{version}/", static.Handler())
// Media
router.Handle("GET /media/{id}", handlers.HandleGetMedia)
// Auth API
router.Handle("POST /api/auth/login", handlers.HandleLogin)
router.Handle("POST /api/auth/logout", handlers.HandleLogout)
router.Handle("GET /api/auth/status", handlers.HandleAuthStatus)
// Items API
router.Handle("GET /api/items", handlers.HandleListItems)
router.Handle("GET /api/items/{id}", handlers.HandleGetItem)
router.Handle("POST /api/items", handlers.HandleCreateItem)
router.Handle("PUT /api/items/{id}", handlers.HandleUpdateItem)
router.Handle("DELETE /api/items/{id}", handlers.HandleDeleteItem)
// Item creation endpoints
router.Handle("POST /api/preview", handlers.HandlePreviewLink)
router.Handle("POST /api/items/from-link", handlers.HandleCreateFromLink)
router.Handle("POST /api/items/upload", handlers.HandleUpload)
router.Handle("POST /api/items/quote", handlers.HandleCreateQuote)
router.Handle("POST /api/items/{id}/refresh", handlers.HandleRefreshMetadata)
router.Handle("POST /api/items/{id}/media", handlers.HandleReplaceMedia)
// Tags API
router.Handle("GET /api/tags", handlers.HandleListTags)
router.Handle("GET /api/tags/suggest", handlers.HandleSuggestTags)
addr := defaultAddr
if envAddr := os.Getenv("ADDR"); envAddr != "" {
addr = envAddr
}
server := &http.Server{
Addr: addr,
Handler: middleware.Logging(logger)(router),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("listening", slog.String("addr", addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("http server error", slog.Any("err", err))
os.Exit(1)
}
}()
waitForShutdown(server, logger)
}
func waitForShutdown(server *http.Server, logger *slog.Logger) {
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
logger.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", slog.Any("err", err))
}
}