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

View file

@ -0,0 +1,141 @@
package handlers
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
"lookbook/internal/data/admin"
"lookbook/internal/data/session"
)
const sessionDuration = 30 * 24 * time.Hour // 30 days
type loginRequest struct {
Password string `json:"password"`
}
type loginResponse struct {
FirstTime bool `json:"firstTime,omitempty"`
Error string `json:"error,omitempty"`
}
// HandleLogin handles POST /api/auth/login
// If no password is set, it sets the password. Otherwise, it verifies the password.
func HandleLogin(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return writeJSON(w, http.StatusBadRequest, loginResponse{Error: "invalid request"})
}
if req.Password == "" {
return writeJSON(w, http.StatusBadRequest, loginResponse{Error: "password required"})
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
adm, err := admin.QGet(ctx, rc.DB)
if err != nil {
return err
}
firstTime := adm.PasswordHash == nil
if firstTime {
// First login: set the password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := admin.QSetPassword(ctx, rc.DB, hash); err != nil {
return err
}
} else {
// Verify password
if err := bcrypt.CompareHashAndPassword(adm.PasswordHash, []byte(req.Password)); err != nil {
return writeJSON(w, http.StatusUnauthorized, loginResponse{Error: "invalid password"})
}
}
// Create session
sessionID, err := generateSessionID()
if err != nil {
return err
}
expiresAt := time.Now().Add(sessionDuration)
if _, err := session.QCreate(ctx, rc.DB, sessionID, expiresAt); err != nil {
return err
}
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
})
return writeJSON(w, http.StatusOK, loginResponse{FirstTime: firstTime})
}
// HandleLogout handles POST /api/auth/logout
func HandleLogout(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
cookie, err := r.Cookie("session_id")
if err == nil {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
session.QDelete(ctx, rc.DB, cookie.Value)
}
// Clear cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// HandleAuthStatus handles GET /api/auth/status
func HandleAuthStatus(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
hasPassword, err := admin.QHasPassword(ctx, rc.DB)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, map[string]any{
"isAdmin": rc.IsAdmin,
"passwordSet": hasPassword,
})
}
func generateSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func writeJSON(w http.ResponseWriter, status int, v any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(v)
}

View file

@ -0,0 +1,268 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"time"
"lookbook/internal/data/item"
"lookbook/internal/data/media"
"lookbook/internal/data/tag"
)
type itemResponse struct {
ID string `json:"id"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
LinkURL *string `json:"linkUrl,omitempty"`
ItemType string `json:"itemType"`
EmbedHTML *string `json:"embedHtml,omitempty"`
Tags []string `json:"tags"`
CreatedAt string `json:"createdAt"`
MediaID *int64 `json:"mediaId,omitempty"`
ThumbnailID *int64 `json:"thumbnailId,omitempty"`
ThumbnailSourceURL *string `json:"thumbnailSourceUrl,omitempty"`
}
type createItemRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
LinkURL *string `json:"linkUrl"`
ItemType string `json:"itemType"` // 'image', 'video', 'quote', 'embed'
EmbedProvider *string `json:"embedProvider"`
EmbedVideoID *string `json:"embedVideoId"`
EmbedHTML *string `json:"embedHtml"`
Tags []string `json:"tags"`
}
type updateItemRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
LinkURL *string `json:"linkUrl"`
Tags []string `json:"tags"`
}
// HandleListItems handles GET /api/items
func HandleListItems(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tagFilter := r.URL.Query().Get("tag")
var items []item.Row
var err error
if tagFilter != "" {
items, err = item.QListByTag(ctx, rc.DB, tagFilter)
} else {
items, err = item.QList(ctx, rc.DB)
}
if err != nil {
return err
}
response := make([]itemResponse, 0, len(items))
for _, it := range items {
resp, err := buildItemResponse(ctx, rc, it)
if err != nil {
return err
}
response = append(response, resp)
}
return writeJSON(w, http.StatusOK, response)
}
// HandleGetItem handles GET /api/items/{id}
func HandleGetItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
pubID := r.PathValue("id")
if pubID == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
if err != nil {
return err
}
if it == nil {
return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
resp, err := buildItemResponse(ctx, rc, *it)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, resp)
}
// HandleCreateItem handles POST /api/items
func HandleCreateItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
var req createItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.ItemType == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "itemType required"})
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
Title: req.Title,
Description: req.Description,
LinkURL: req.LinkURL,
ItemType: req.ItemType,
EmbedProvider: req.EmbedProvider,
EmbedVideoID: req.EmbedVideoID,
EmbedHTML: req.EmbedHTML,
})
if err != nil {
return err
}
// Set tags
if len(req.Tags) > 0 {
if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, req.Tags); err != nil {
return err
}
}
resp, err := buildItemResponse(ctx, rc, it)
if err != nil {
return err
}
return writeJSON(w, http.StatusCreated, resp)
}
// HandleUpdateItem handles PUT /api/items/{id}
func HandleUpdateItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
pubID := r.PathValue("id")
if pubID == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
}
var req updateItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
if err != nil {
return err
}
if it == nil {
return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
if err := item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{
Title: req.Title,
Description: req.Description,
LinkURL: req.LinkURL,
}); err != nil {
return err
}
// Update tags
if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, req.Tags); err != nil {
return err
}
// Refetch to return updated item
it, err = item.QFindByID(ctx, rc.DB, it.ID)
if err != nil {
return err
}
resp, err := buildItemResponse(ctx, rc, *it)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, resp)
}
// HandleDeleteItem handles DELETE /api/items/{id}
func HandleDeleteItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
pubID := r.PathValue("id")
if pubID == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
if err != nil {
return err
}
if it == nil {
return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
if err := item.QSoftDelete(ctx, rc.DB, it.ID); err != nil {
return err
}
return writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func buildItemResponse(ctx context.Context, rc *RequestContext, it item.Row) (itemResponse, error) {
tags, err := tag.QTagsForItem(ctx, rc.DB, it.ID)
if err != nil {
return itemResponse{}, err
}
tagNames := make([]string, len(tags))
for i, t := range tags {
tagNames[i] = t.Name
}
resp := itemResponse{
ID: it.PubID,
Title: it.Title,
Description: it.Description,
LinkURL: it.LinkURL,
ItemType: it.ItemType,
EmbedHTML: it.EmbedHTML,
Tags: tagNames,
CreatedAt: it.CreatedAt.Format(time.RFC3339),
}
// Get media IDs
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil {
return itemResponse{}, err
}
for _, m := range mediaList {
if m.MediaType == "original" {
resp.MediaID = &m.ID
} else if m.MediaType == "thumbnail" {
resp.ThumbnailID = &m.ID
resp.ThumbnailSourceURL = m.SourceURL
}
}
return resp, nil
}

View file

@ -0,0 +1,47 @@
package handlers
import (
"context"
"net/http"
"time"
"lookbook/internal/data/tag"
)
// HandleListTags handles GET /api/tags
func HandleListTags(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
tags, err := tag.QList(ctx, rc.DB)
if err != nil {
return err
}
names := make([]string, len(tags))
for i, t := range tags {
names[i] = t.Name
}
return writeJSON(w, http.StatusOK, names)
}
// HandleSuggestTags handles GET /api/tags/suggest?q=...
func HandleSuggestTags(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
prefix := r.URL.Query().Get("q")
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
tags, err := tag.QSuggest(ctx, rc.DB, prefix, 10)
if err != nil {
return err
}
names := make([]string, len(tags))
for i, t := range tags {
names[i] = t.Name
}
return writeJSON(w, http.StatusOK, names)
}

View file

@ -0,0 +1,577 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"lookbook/internal/data/item"
"lookbook/internal/data/media"
"lookbook/internal/data/tag"
"lookbook/internal/embed"
"lookbook/internal/opengraph"
"lookbook/internal/video"
)
type urlMetadata struct {
Title string
Description string
ImageURL string
SiteName string
IsEmbed bool
Provider string
VideoID string
EmbedHTML string
}
// fetchURLMetadata fetches metadata for a URL, trying embed detection first, then OpenGraph
func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) {
// Check if it's a YouTube/Vimeo/Twitter embed
videoInfo, err := embed.Detect(ctx, url)
if err == nil && videoInfo != nil {
return &urlMetadata{
Title: videoInfo.Title,
Description: videoInfo.Description,
ImageURL: videoInfo.ThumbnailURL,
IsEmbed: true,
Provider: string(videoInfo.Provider),
VideoID: videoInfo.VideoID,
EmbedHTML: videoInfo.EmbedHTML,
}, nil
}
// Fetch OpenGraph metadata
meta, err := opengraph.Fetch(ctx, url)
if err != nil {
return nil, err
}
return &urlMetadata{
Title: meta.Title,
Description: meta.Description,
ImageURL: meta.ImageURL,
SiteName: meta.SiteName,
IsEmbed: false,
}, nil
}
type previewResponse struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
SiteName string `json:"siteName,omitempty"`
IsEmbed bool `json:"isEmbed"`
Provider string `json:"provider,omitempty"`
VideoID string `json:"videoId,omitempty"`
EmbedHTML string `json:"embedHtml,omitempty"`
}
// HandlePreviewLink handles POST /api/preview - fetches metadata for a URL
func HandlePreviewLink(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.URL == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "url required"})
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
meta, err := fetchURLMetadata(ctx, req.URL)
if err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)})
}
return writeJSON(w, http.StatusOK, previewResponse{
Title: meta.Title,
Description: meta.Description,
ImageURL: meta.ImageURL,
SiteName: meta.SiteName,
IsEmbed: meta.IsEmbed,
Provider: meta.Provider,
VideoID: meta.VideoID,
EmbedHTML: meta.EmbedHTML,
})
}
type createFromLinkRequest struct {
URL string `json:"url"`
Title *string `json:"title"`
Description *string `json:"description"`
Tags []string `json:"tags"`
// For embeds:
Provider *string `json:"provider"`
VideoID *string `json:"videoId"`
EmbedHTML *string `json:"embedHtml"`
// For downloading hero image:
ImageURL *string `json:"imageUrl"`
}
// HandleCreateFromLink handles POST /api/items/from-link
func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
var req createFromLinkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
var itemType string
var embedProvider, embedVideoID, embedHTML *string
if req.Provider != nil && *req.Provider != "" {
// It's an embed
itemType = "embed"
embedProvider = req.Provider
embedVideoID = req.VideoID
embedHTML = req.EmbedHTML
} else if req.ImageURL != nil && *req.ImageURL != "" {
// It's a link with an image
itemType = "image"
} else {
// Just a link (will be shown as a card)
itemType = "link"
}
// Create the item
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
Title: req.Title,
Description: req.Description,
LinkURL: &req.URL,
ItemType: itemType,
EmbedProvider: embedProvider,
EmbedVideoID: embedVideoID,
EmbedHTML: embedHTML,
})
if err != nil {
return err
}
// Download and store image if available
var imageURL string
if req.ImageURL != nil {
imageURL = *req.ImageURL
}
// For embeds, fetch thumbnail
if itemType == "embed" && embedProvider != nil {
if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil {
imageURL = videoInfo.ThumbnailURL
}
}
if imageURL != "" {
imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL)
if err != nil {
rc.Logger.Warn("failed to download image", "url", imageURL, "error", err)
} else {
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "thumbnail",
ContentType: contentType,
Data: imgData,
SourceURL: &imageURL,
})
if err != nil {
rc.Logger.Warn("failed to store image", "error", err)
}
}
}
// Set tags
if len(req.Tags) > 0 {
if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, req.Tags); err != nil {
return err
}
}
resp, err := buildItemResponse(ctx, rc, it)
if err != nil {
return err
}
return writeJSON(w, http.StatusCreated, resp)
}
// HandleUpload handles POST /api/items/upload - multipart file upload
func HandleUpload(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
// Parse multipart form (max 500MB)
if err := r.ParseMultipartForm(500 << 20); err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse form"})
}
file, header, err := r.FormFile("file")
if err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"})
}
defer file.Close()
title := r.FormValue("title")
description := r.FormValue("description")
tagsStr := r.FormValue("tags")
var tags []string
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
for i := range tags {
tags[i] = strings.TrimSpace(tags[i])
}
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
// Read file data
data, err := io.ReadAll(file)
if err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read file"})
}
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(data)
}
var itemType string
var originalData, thumbnailData []byte
var originalContentType string
if video.IsVideo(contentType) {
itemType = "video"
// Process video: transcode and extract thumbnail
transcoded, thumbnail, err := video.ProcessVideo(ctx, data, contentType)
if err != nil {
return writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("video processing failed: %v", err)})
}
originalData = transcoded
originalContentType = "video/mp4"
thumbnailData = thumbnail
} else if video.IsImage(contentType) {
itemType = "image"
originalData = data
originalContentType = contentType
} else {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported file type"})
}
// Create item
var titlePtr, descPtr *string
if title != "" {
titlePtr = &title
}
if description != "" {
descPtr = &description
}
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
Title: titlePtr,
Description: descPtr,
ItemType: itemType,
})
if err != nil {
return err
}
// Store original media
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "original",
ContentType: originalContentType,
Data: originalData,
})
if err != nil {
return err
}
// Store thumbnail for videos
if len(thumbnailData) > 0 {
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "thumbnail",
ContentType: "image/jpeg",
Data: thumbnailData,
})
if err != nil {
rc.Logger.Warn("failed to store thumbnail", "error", err)
}
}
// Set tags
if len(tags) > 0 {
if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, tags); err != nil {
return err
}
}
resp, err := buildItemResponse(ctx, rc, it)
if err != nil {
return err
}
return writeJSON(w, http.StatusCreated, resp)
}
type createQuoteRequest struct {
Text string `json:"text"`
Source *string `json:"source"` // Optional attribution
SourceURL *string `json:"sourceUrl"`
Tags []string `json:"tags"`
}
// HandleCreateQuote handles POST /api/items/quote
func HandleCreateQuote(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
var req createQuoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.Text == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text required"})
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
Title: req.Source,
Description: &req.Text,
LinkURL: req.SourceURL,
ItemType: "quote",
})
if err != nil {
return err
}
// Set tags
if len(req.Tags) > 0 {
if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, req.Tags); err != nil {
return err
}
}
resp, err := buildItemResponse(ctx, rc, it)
if err != nil {
return err
}
return writeJSON(w, http.StatusCreated, resp)
}
// HandleReplaceMedia handles POST /api/items/{id}/media - replaces media for an item
func HandleReplaceMedia(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
pubID := r.PathValue("id")
if pubID == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
}
// Parse multipart form (max 500MB)
if err := r.ParseMultipartForm(500 << 20); err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse form"})
}
file, header, err := r.FormFile("file")
if err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"})
}
defer file.Close()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
if err != nil {
return err
}
if it == nil {
return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
// Read file data
data, err := io.ReadAll(file)
if err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read file"})
}
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(data)
}
var originalData, thumbnailData []byte
var originalContentType string
var newItemType string
if video.IsVideo(contentType) {
newItemType = "video"
transcoded, thumbnail, err := video.ProcessVideo(ctx, data, contentType)
if err != nil {
return writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("video processing failed: %v", err)})
}
originalData = transcoded
originalContentType = "video/mp4"
thumbnailData = thumbnail
} else if video.IsImage(contentType) {
newItemType = "image"
originalData = data
originalContentType = contentType
} else {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported file type"})
}
// Delete existing media
media.QDeleteByItemID(ctx, rc.DB, it.ID)
// Store new original media
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "original",
ContentType: originalContentType,
Data: originalData,
})
if err != nil {
return err
}
// Store thumbnail for videos
if len(thumbnailData) > 0 {
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "thumbnail",
ContentType: "image/jpeg",
Data: thumbnailData,
})
if err != nil {
rc.Logger.Warn("failed to store thumbnail", "error", err)
}
}
// Update item type if it changed (e.g., embed -> image)
if it.ItemType != newItemType && (it.ItemType == "embed" || it.ItemType == "link") {
item.QUpdateType(ctx, rc.DB, it.ID, newItemType)
}
// Refetch and return updated item
it, err = item.QFindByID(ctx, rc.DB, it.ID)
if err != nil {
return err
}
resp, err := buildItemResponse(ctx, rc, *it)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, resp)
}
// HandleRefreshMetadata handles POST /api/items/{id}/refresh
// Re-fetches metadata and thumbnail for an item with a link URL
func HandleRefreshMetadata(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
if !rc.RequireAdmin(w) {
return nil
}
pubID := r.PathValue("id")
if pubID == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
if err != nil {
return err
}
if it == nil {
return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
if it.LinkURL == nil || *it.LinkURL == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "item has no link URL"})
}
meta, err := fetchURLMetadata(ctx, *it.LinkURL)
if err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)})
}
// Update title and description
var titlePtr, descPtr *string
if meta.Title != "" {
titlePtr = &meta.Title
}
if meta.Description != "" {
descPtr = &meta.Description
}
item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{
Title: titlePtr,
Description: descPtr,
LinkURL: it.LinkURL,
})
// Download and replace thumbnail
if meta.ImageURL != "" {
// Delete existing media for this item
media.QDeleteByItemID(ctx, rc.DB, it.ID)
imgData, contentType, err := opengraph.DownloadImage(ctx, meta.ImageURL)
if err != nil {
rc.Logger.Warn("failed to download image during refresh", "url", meta.ImageURL, "error", err)
} else {
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "thumbnail",
ContentType: contentType,
Data: imgData,
SourceURL: &meta.ImageURL,
})
if err != nil {
rc.Logger.Warn("failed to store refreshed image", "error", err)
}
}
}
// Refetch and return updated item
it, err = item.QFindByID(ctx, rc.DB, it.ID)
if err != nil {
return err
}
resp, err := buildItemResponse(ctx, rc, *it)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, resp)
}

View file

@ -0,0 +1,27 @@
package handlers
import (
"database/sql"
"log/slog"
"net/http"
"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
IsAdmin bool // true if authenticated as admin
}
// RequireAdmin checks authentication and returns 401 if not admin.
// Returns true if authenticated, false if 401 was sent.
func (rc *RequestContext) RequireAdmin(w http.ResponseWriter) bool {
if !rc.IsAdmin {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
}
return true
}

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

213
internal/handlers/home.go Normal file
View file

@ -0,0 +1,213 @@
package handlers
import (
"context"
"net/http"
"time"
"git.soup.land/soup/sxgo/ssr"
"lookbook/internal/components"
"lookbook/internal/data/item"
"lookbook/internal/data/media"
"lookbook/internal/data/tag"
)
type homeContent struct {
Items []homeItem
Tags []string
ActiveTag string
IsAdmin bool
}
type homeItem struct {
ID string
Title *string
Description *string
LinkURL *string
ItemType string
EmbedHTML *string
Tags []string
ThumbnailID *int64
MediaID *int64
}
func (h homeContent) Render(sw *ssr.Writer) error {
return sw.Tmpl(h, `
<div class="home">
{{if .IsAdmin}}
<div class="admin-bar">
<button class="btn" onclick="showAddModal()">+ ADD ITEM</button>
</div>
{{end}}
<div class="tags-bar">
<a href="/" class="tag {{if not .ActiveTag}}active{{end}}">ALL</a>
{{range .Tags}}
<a href="/?tag={{.}}" class="tag {{if eq $.ActiveTag .}}active{{end}}">{{.}}</a>
{{end}}
</div>
<div class="grid">
{{range .Items}}
<a href="/item/{{.ID}}" class="grid-item" data-type="{{.ItemType}}">
{{if eq .ItemType "quote"}}
<div class="quote-card">
<blockquote>{{.Description}}</blockquote>
{{if .Title}}<cite> {{.Title}}</cite>{{end}}
</div>
{{else if .ThumbnailID}}
<img src="/media/{{.ThumbnailID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy">
{{else if .MediaID}}
<img src="/media/{{.MediaID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy">
{{else if eq .ItemType "embed"}}
<div class="embed-placeholder">
<span></span>
</div>
{{else}}
<div class="link-card">
{{if .Title}}<div class="link-title">{{.Title}}</div>{{end}}
{{if .LinkURL}}<div class="link-url">{{.LinkURL}}</div>{{end}}
</div>
{{end}}
{{if .Tags}}
<div class="item-tags">
{{range .Tags}}<span class="item-tag">{{.}}</span>{{end}}
</div>
{{end}}
</a>
{{end}}
</div>
</div>
{{if .IsAdmin}}
<div id="add-modal" class="modal" onclick="if(event.target===this)hideAddModal()">
<div class="modal-content">
<div class="modal-header">
<h2>ADD ITEM</h2>
<button class="btn-close" onclick="hideAddModal()">&times;</button>
</div>
<div class="modal-tabs">
<button class="tab active" data-tab="link">LINK</button>
<button class="tab" data-tab="upload">UPLOAD</button>
<button class="tab" data-tab="quote">QUOTE</button>
</div>
<div class="modal-body">
<div id="tab-link" class="tab-content active">
<form id="link-form" onsubmit="return submitLink(event)">
<input type="url" name="url" placeholder="Paste URL..." required>
<div id="link-preview" class="preview"></div>
<input type="text" name="title" placeholder="Title (optional)">
<textarea name="description" placeholder="Description (optional)"></textarea>
<input type="text" name="tags" placeholder="Tags (comma-separated)">
<button type="submit" class="btn">ADD</button>
</form>
</div>
<div id="tab-upload" class="tab-content">
<form id="upload-form" onsubmit="return submitUpload(event)">
<input type="file" name="file" accept="image/*,video/*" required>
<input type="text" name="title" placeholder="Title (optional)">
<textarea name="description" placeholder="Description (optional)"></textarea>
<input type="text" name="tags" placeholder="Tags (comma-separated)">
<button type="submit" class="btn">UPLOAD</button>
</form>
</div>
<div id="tab-quote" class="tab-content">
<form id="quote-form" onsubmit="return submitQuote(event)">
<textarea name="text" placeholder="Quote text..." required rows="4"></textarea>
<input type="text" name="source" placeholder="Source / Attribution (optional)">
<input type="url" name="sourceUrl" placeholder="Source URL (optional)">
<input type="text" name="tags" placeholder="Tags (comma-separated)">
<button type="submit" class="btn">ADD QUOTE</button>
</form>
</div>
</div>
</div>
</div>
{{end}}
`)
}
// HandleHome handles GET /
func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tagFilter := r.URL.Query().Get("tag")
var items []item.Row
var err error
if tagFilter != "" {
items, err = item.QListByTag(ctx, rc.DB, tagFilter)
} else {
items, err = item.QList(ctx, rc.DB)
}
if err != nil {
return err
}
// Get all tags
allTags, err := tag.QList(ctx, rc.DB)
if err != nil {
return err
}
tagNames := make([]string, len(allTags))
for i, t := range allTags {
tagNames[i] = t.Name
}
// Build home items
homeItems := make([]homeItem, 0, len(items))
for _, it := range items {
hi := homeItem{
ID: it.PubID,
Title: it.Title,
Description: it.Description,
LinkURL: it.LinkURL,
ItemType: it.ItemType,
EmbedHTML: it.EmbedHTML,
}
// Get tags
itemTags, err := tag.QTagsForItem(ctx, rc.DB, it.ID)
if err != nil {
return err
}
hi.Tags = make([]string, len(itemTags))
for i, t := range itemTags {
hi.Tags[i] = t.Name
}
// Get media
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil {
return err
}
for _, m := range mediaList {
if m.MediaType == "thumbnail" {
hi.ThumbnailID = &m.ID
} else if m.MediaType == "original" {
hi.MediaID = &m.ID
}
}
homeItems = append(homeItems, hi)
}
content := homeContent{
Items: homeItems,
Tags: tagNames,
ActiveTag: tagFilter,
IsAdmin: rc.IsAdmin,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
sw := ssr.NewWriter(w, rc.TmplCache)
page := components.Page{
Title: "",
IsAdmin: rc.IsAdmin,
Content: content,
}
return page.Render(sw)
}

View file

@ -0,0 +1,205 @@
package handlers
import (
"context"
"net/http"
"time"
"git.soup.land/soup/sxgo/ssr"
"lookbook/internal/components"
"lookbook/internal/data/item"
"lookbook/internal/data/media"
"lookbook/internal/data/tag"
)
type itemPageContent struct {
Item itemPageData
IsAdmin bool
}
type itemPageData struct {
ID string
Title *string
Description *string
LinkURL *string
ItemType string
EmbedHTML *string
Tags []string
CreatedAt string
ThumbnailID *int64
MediaID *int64
}
func (c itemPageContent) Render(sw *ssr.Writer) error {
return sw.Tmpl(c, `
<div class="item-page">
<a href="/" class="back-link">&larr; BACK</a>
<article class="item-detail">
{{if eq .Item.ItemType "quote"}}
<div class="quote-detail">
<blockquote>{{.Item.Description}}</blockquote>
{{if .Item.Title}}<cite> {{.Item.Title}}</cite>{{end}}
</div>
{{else if eq .Item.ItemType "video"}}
<div class="video-container">
<video controls>
<source src="/media/{{.Item.MediaID}}" type="video/mp4">
</video>
</div>
{{else if eq .Item.ItemType "embed"}}
{{if .Item.ThumbnailID}}
<div class="image-container">
<img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}">
</div>
{{else if .Item.MediaID}}
<div class="image-container">
<img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}">
</div>
{{end}}
{{else if .Item.MediaID}}
<div class="image-container">
<img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}">
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}">
</div>
{{end}}
<div class="item-meta">
{{if .Item.Title}}
<h1>{{.Item.Title}}</h1>
{{end}}
{{if and .Item.Description (ne .Item.ItemType "quote")}}
<p class="description">{{.Item.Description}}</p>
{{end}}
{{if .Item.LinkURL}}
<a href="{{.Item.LinkURL}}" target="_blank" rel="noopener" class="source-link">{{.Item.LinkURL}}</a>
{{end}}
{{if .Item.Tags}}
<div class="item-tags">
{{range .Item.Tags}}
<a href="/?tag={{.}}" class="item-tag">{{.}}</a>
{{end}}
</div>
{{end}}
<time class="timestamp">{{.Item.CreatedAt}}</time>
</div>
{{if .IsAdmin}}
<div class="item-actions">
<button class="btn" onclick="editItem('{{.Item.ID}}')">EDIT</button>
{{if .Item.LinkURL}}<button class="btn" onclick="refreshMetadata('{{.Item.ID}}')">REFRESH</button>{{end}}
<button class="btn btn-danger" onclick="deleteItem('{{.Item.ID}}')">DELETE</button>
</div>
{{end}}
</article>
</div>
{{if .IsAdmin}}
<div id="edit-modal" class="modal" onclick="if(event.target===this)hideEditModal()">
<div class="modal-content">
<div class="modal-header">
<h2>EDIT ITEM</h2>
<button class="btn-close" onclick="hideEditModal()">&times;</button>
</div>
<div class="modal-body">
<form id="edit-form" onsubmit="return submitEdit(event)">
<input type="hidden" name="id" value="{{.Item.ID}}">
<input type="text" name="title" placeholder="Title" value="{{if .Item.Title}}{{.Item.Title}}{{end}}">
<textarea name="description" placeholder="Description">{{if .Item.Description}}{{.Item.Description}}{{end}}</textarea>
<input type="url" name="linkUrl" placeholder="Link URL" value="{{if .Item.LinkURL}}{{.Item.LinkURL}}{{end}}">
<input type="text" name="tags" placeholder="Tags (comma-separated)" value="{{range $i, $t := .Item.Tags}}{{if $i}}, {{end}}{{$t}}{{end}}">
<button type="submit" class="btn">SAVE</button>
</form>
<hr style="margin: 1.5rem 0; border: none; border-top: 1px solid var(--border);">
<form id="replace-media-form" onsubmit="return submitReplaceMedia(event, '{{.Item.ID}}')">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem;">REPLACE IMAGE/VIDEO</label>
<input type="file" name="file" accept="image/*,video/*" required>
<button type="submit" class="btn" style="margin-top: 0.5rem;">REPLACE</button>
</form>
</div>
</div>
</div>
{{end}}
`)
}
// HandleItemPage handles GET /item/{id}
func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
pubID := r.PathValue("id")
if pubID == "" {
http.NotFound(w, r)
return nil
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
if err != nil {
return err
}
if it == nil {
http.NotFound(w, r)
return nil
}
// Get tags
itemTags, err := tag.QTagsForItem(ctx, rc.DB, it.ID)
if err != nil {
return err
}
tagNames := make([]string, len(itemTags))
for i, t := range itemTags {
tagNames[i] = t.Name
}
// Get media
var thumbnailID, mediaID *int64
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil {
return err
}
for _, m := range mediaList {
if m.MediaType == "thumbnail" {
thumbnailID = &m.ID
} else if m.MediaType == "original" {
mediaID = &m.ID
}
}
data := itemPageData{
ID: it.PubID,
Title: it.Title,
Description: it.Description,
LinkURL: it.LinkURL,
ItemType: it.ItemType,
EmbedHTML: it.EmbedHTML,
Tags: tagNames,
CreatedAt: it.CreatedAt.Format("Jan 2, 2006"),
ThumbnailID: thumbnailID,
MediaID: mediaID,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
sw := ssr.NewWriter(w, rc.TmplCache)
var title string
if it.Title != nil {
title = *it.Title
}
page := components.Page{
Title: title,
IsAdmin: rc.IsAdmin,
Content: itemPageContent{Item: data, IsAdmin: rc.IsAdmin},
}
return page.Render(sw)
}

View file

@ -0,0 +1,50 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"lookbook/internal/data/media"
)
// HandleGetMedia handles GET /media/{id}
func HandleGetMedia(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return nil
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
m, err := media.QFindByID(ctx, rc.DB, id)
if err != nil {
return err
}
if m == nil {
http.NotFound(w, r)
return nil
}
// Set caching headers (media is immutable)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Type", m.ContentType)
w.Header().Set("Content-Length", strconv.Itoa(len(m.Data)))
// Add ETag for conditional requests
etag := fmt.Sprintf(`"%d"`, m.ID)
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return nil
}
w.Write(m.Data)
return nil
}

View file

@ -0,0 +1,75 @@
package handlers
import (
"context"
"log/slog"
"net/http"
"time"
"lookbook/internal/data/session"
)
// 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,
IsAdmin: rt.loadAuth(r),
}
handler := WithErrorHandling(rc, h)
handler(w, r)
})
}
// loadAuth checks if the request has a valid session cookie.
func (rt *Router) loadAuth(r *http.Request) bool {
cookie, err := r.Cookie("session_id")
if err != nil {
return false
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
sess, err := session.QFindBySessionID(ctx, rt.rc.DB, cookie.Value)
if err != nil {
rt.rc.Logger.Error("failed to find session", slog.Any("err", err))
return false
}
if sess == nil {
return false
}
if time.Now().After(sess.ExpiresAt) {
rt.rc.Logger.Info("session expired", slog.String("session_id", cookie.Value))
return false
}
return true
}
// 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,17 @@
package handlers
import (
"html/template"
"lookbook/internal/static"
)
var TemplateFuncs = template.FuncMap{
"staticURL": static.VersionedPath,
"safeHTML": func(s *string) template.HTML {
if s == nil {
return ""
}
return template.HTML(*s)
},
}