- Fetch all images from Twitter syndication API (photos array) - Store images with unified 'image' media type (first = thumbnail by ID order) - Display multi-image tweets in grid layout on detail page - Add hover cycling through images on home grid (2s interval) - Show image count indicator on multi-image items - Extract shared downloadAndStoreImages() helper for create/refresh - Add migration to convert existing thumbnail/gallery types to image - Make images clickable to open in new tab on detail page
276 lines
7.2 KiB
Go
276 lines
7.2 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"`
|
|
GalleryIDs []int64 `json:"galleryIds,omitempty"` // Additional images (for multi-image tweets)
|
|
}
|
|
|
|
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
|
|
// Media is ordered by ID, so first "image" is the thumbnail, rest are gallery
|
|
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
|
|
if err != nil {
|
|
return itemResponse{}, err
|
|
}
|
|
firstImage := true
|
|
for _, m := range mediaList {
|
|
if m.MediaType == "original" {
|
|
resp.MediaID = &m.ID
|
|
} else if m.MediaType == "image" {
|
|
if firstImage {
|
|
resp.ThumbnailID = &m.ID
|
|
resp.ThumbnailSourceURL = m.SourceURL
|
|
firstImage = false
|
|
} else {
|
|
resp.GalleryIDs = append(resp.GalleryIDs, m.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return resp, nil
|
|
}
|