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:
commit
cdcc5b5293
45 changed files with 4634 additions and 0 deletions
141
internal/handlers/api_auth.go
Normal file
141
internal/handlers/api_auth.go
Normal 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)
|
||||
}
|
||||
268
internal/handlers/api_items.go
Normal file
268
internal/handlers/api_items.go
Normal 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
|
||||
}
|
||||
47
internal/handlers/api_tags.go
Normal file
47
internal/handlers/api_tags.go
Normal 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)
|
||||
}
|
||||
577
internal/handlers/api_upload.go
Normal file
577
internal/handlers/api_upload.go
Normal 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)
|
||||
}
|
||||
27
internal/handlers/context.go
Normal file
27
internal/handlers/context.go
Normal 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
213
internal/handlers/home.go
Normal file
213
internal/handlers/home.go
Normal 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()">×</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)
|
||||
}
|
||||
205
internal/handlers/item_page.go
Normal file
205
internal/handlers/item_page.go
Normal 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">← 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()">×</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)
|
||||
}
|
||||
50
internal/handlers/media.go
Normal file
50
internal/handlers/media.go
Normal 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
|
||||
}
|
||||
75
internal/handlers/router.go
Normal file
75
internal/handlers/router.go
Normal 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)
|
||||
}
|
||||
17
internal/handlers/templates.go
Normal file
17
internal/handlers/templates.go
Normal 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)
|
||||
},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue