lookbook/internal/handlers/api_items.go
soup 2887d9c430
Add Twitter video support and use sql.Null[T] for nullable columns
- Store Twitter video URLs in embed_video_url column instead of downloading
- Play videos directly from Twitter's CDN in <video> element
- Fix Twitter API parsing (content_type/url fields)
- Strip t.co URLs from tweet text descriptions
- Use sql.Null[T] generic type for nullable DB columns
- Add Nullable[T] and Ptr[T] helper functions
- Add play indicator overlay for video items in grid
- Add migration for embed_video_url column
2026-01-17 01:23:32 -05:00

268 lines
6.9 KiB
Go

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: item.Nullable(req.Title),
Description: item.Nullable(req.Description),
LinkURL: item.Nullable(req.LinkURL),
ItemType: req.ItemType,
EmbedProvider: item.Nullable(req.EmbedProvider),
EmbedVideoID: item.Nullable(req.EmbedVideoID),
EmbedHTML: item.Nullable(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: item.Nullable(req.Title),
Description: item.Nullable(req.Description),
LinkURL: item.Nullable(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: item.Ptr(it.Title),
Description: item.Ptr(it.Description),
LinkURL: item.Ptr(it.LinkURL),
ItemType: it.ItemType,
EmbedHTML: item.Ptr(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
}