From 007e1677079b714e2b5c4c03ebf49d438ecb2081 Mon Sep 17 00:00:00 2001 From: soup Date: Sat, 17 Jan 2026 13:28:20 -0500 Subject: [PATCH] Add support for multiple images from Twitter links - 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 --- internal/data/media/queries.go | 11 +- internal/embed/detect.go | 35 +- internal/handlers/api_items.go | 14 +- internal/handlers/api_upload.go | 100 ++- internal/handlers/home.go | 25 +- internal/handlers/item_page.go | 29 +- .../sql/0004_unify_image_media_type.sql | 8 + internal/static/css/app.css | 60 ++ internal/static/js/app.js | 746 ++++++++++-------- 9 files changed, 625 insertions(+), 403 deletions(-) create mode 100644 internal/migrations/sql/0004_unify_image_media_type.sql diff --git a/internal/data/media/queries.go b/internal/data/media/queries.go index c48de2e..557e0e9 100644 --- a/internal/data/media/queries.go +++ b/internal/data/media/queries.go @@ -9,7 +9,7 @@ import ( type Row struct { ID int64 ItemID int64 - MediaType string // 'original', 'thumbnail' + MediaType string // 'original' (user uploads), 'image' (fetched from URLs) ContentType string // MIME type Data []byte Width *int @@ -68,13 +68,13 @@ func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) { return &row, nil } -// QFindByItemID finds all media for an item. +// QFindByItemID finds all media for an item, ordered by ID. func QFindByItemID(ctx context.Context, db *sql.DB, itemID int64) ([]Row, error) { query := ` SELECT id, item_id, media_type, content_type, data, width, height, source_url, created_at FROM media WHERE item_id = $1 - ORDER BY media_type ASC + ORDER BY id ASC ` rows, err := db.QueryContext(ctx, query, itemID) @@ -97,12 +97,13 @@ func QFindByItemID(ctx context.Context, db *sql.DB, itemID int64) ([]Row, error) return media, rows.Err() } -// QFindThumbnailByItemID finds the thumbnail for an item. +// QFindThumbnailByItemID finds the first image for an item (used as thumbnail). func QFindThumbnailByItemID(ctx context.Context, db *sql.DB, itemID int64) (*Row, error) { query := ` SELECT id, item_id, media_type, content_type, data, width, height, source_url, created_at FROM media - WHERE item_id = $1 AND media_type = 'thumbnail' + WHERE item_id = $1 AND media_type = 'image' + ORDER BY id ASC LIMIT 1 ` diff --git a/internal/embed/detect.go b/internal/embed/detect.go index 1faa6cc..7f6f2bd 100644 --- a/internal/embed/detect.go +++ b/internal/embed/detect.go @@ -22,13 +22,14 @@ const ( // VideoInfo contains information about an embedded video. type VideoInfo struct { - Provider Provider - VideoID string - Title string - Description string - ThumbnailURL string - EmbedHTML string - VideoURL string // Direct video URL (for Twitter videos) + Provider Provider + VideoID string + Title string + Description string + ThumbnailURL string // First/primary thumbnail (for backward compatibility) + ThumbnailURLs []string // All thumbnail URLs (for multi-image tweets) + EmbedHTML string + VideoURL string // Direct video URL (for Twitter videos) } var ( @@ -164,8 +165,13 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid // Find thumbnail and video URL from media var thumbnailURL, videoURL string + var thumbnailURLs []string if len(tweet.Photos) > 0 { thumbnailURL = tweet.Photos[0].URL + // Collect all photo URLs for multi-image tweets + for _, photo := range tweet.Photos { + thumbnailURLs = append(thumbnailURLs, photo.URL) + } } else if len(tweet.MediaDetails) > 0 { media := tweet.MediaDetails[0] thumbnailURL = media.MediaURLHTTPS @@ -201,13 +207,14 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid description = strings.TrimSpace(description) return &VideoInfo{ - Provider: ProviderTwitter, - VideoID: tweetID, - Title: title, - Description: description, - ThumbnailURL: thumbnailURL, - VideoURL: videoURL, - EmbedHTML: embedHTML, + Provider: ProviderTwitter, + VideoID: tweetID, + Title: title, + Description: description, + ThumbnailURL: thumbnailURL, + ThumbnailURLs: thumbnailURLs, + VideoURL: videoURL, + EmbedHTML: embedHTML, }, nil } diff --git a/internal/handlers/api_items.go b/internal/handlers/api_items.go index 47e7f8b..4885c76 100644 --- a/internal/handlers/api_items.go +++ b/internal/handlers/api_items.go @@ -23,6 +23,7 @@ type itemResponse struct { 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 { @@ -251,16 +252,23 @@ func buildItemResponse(ctx context.Context, rc *RequestContext, it item.Row) (it } // 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 == "thumbnail" { - resp.ThumbnailID = &m.ID - resp.ThumbnailSourceURL = m.SourceURL + } 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) + } } } diff --git a/internal/handlers/api_upload.go b/internal/handlers/api_upload.go index c114526..960a5a3 100644 --- a/internal/handlers/api_upload.go +++ b/internal/handlers/api_upload.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "strings" "time" @@ -34,10 +35,61 @@ func nullStr(s string) sql.Null[string] { return sql.Null[string]{V: s, Valid: true} } +// downloadAndStoreImages downloads images from URLs and stores them for an item. +// All images are stored with media_type "image". The first image (by ID order) serves as the thumbnail. +// If imageURLs is empty but imageURL is set, stores that single image. +func downloadAndStoreImages( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, + itemID int64, + imageURL string, + imageURLs []string, +) { + if len(imageURLs) > 0 { + // Multi-image (e.g., Twitter with multiple photos) + for i, imgURL := range imageURLs { + imgData, contentType, err := opengraph.DownloadImage(ctx, imgURL) + if err != nil { + logger.Warn("failed to download image", "url", imgURL, "index", i, "error", err) + continue + } + _, err = media.QCreate(ctx, db, media.CreateParams{ + ItemID: itemID, + MediaType: "image", + ContentType: contentType, + Data: imgData, + SourceURL: &imgURL, + }) + if err != nil { + logger.Warn("failed to store image", "index", i, "error", err) + } + } + } else if imageURL != "" { + // Single image + imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL) + if err != nil { + logger.Warn("failed to download image", "url", imageURL, "error", err) + return + } + _, err = media.QCreate(ctx, db, media.CreateParams{ + ItemID: itemID, + MediaType: "image", + ContentType: contentType, + Data: imgData, + SourceURL: &imageURL, + }) + if err != nil { + logger.Warn("failed to store image", "error", err) + } + } +} + type urlMetadata struct { Title string Description string ImageURL string + ImageURLs []string // All image URLs (for multi-image tweets) SiteName string IsEmbed bool Provider string @@ -54,6 +106,7 @@ func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) { Title: videoInfo.Title, Description: videoInfo.Description, ImageURL: videoInfo.ThumbnailURL, + ImageURLs: videoInfo.ThumbnailURLs, IsEmbed: true, Provider: string(videoInfo.Provider), VideoID: videoInfo.VideoID, @@ -169,14 +222,16 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req itemType = "link" } - // For embeds, fetch thumbnail and video URL + // For embeds, fetch thumbnail(s) and video URL var imageURL, videoURL string + var imageURLs []string // For multi-image tweets if req.ImageURL != nil { imageURL = *req.ImageURL } if itemType == "embed" && embedProvider != nil { if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil { imageURL = videoInfo.ThumbnailURL + imageURLs = videoInfo.ThumbnailURLs videoURL = videoInfo.VideoURL } } @@ -196,24 +251,8 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req return err } - // Download and store thumbnail - 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) - } - } - } + // Download and store images + downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, imageURL, imageURLs) // Set tags if len(req.Tags) > 0 { @@ -547,26 +586,11 @@ func HandleRefreshMetadata(rc *RequestContext, w http.ResponseWriter, r *http.Re item.QUpdateVideoURL(ctx, rc.DB, it.ID, meta.VideoURL) } - // Download and replace thumbnail - if meta.ImageURL != "" { - // Delete existing thumbnails + // Download and replace images + if len(meta.ImageURLs) > 0 || meta.ImageURL != "" { + // Delete existing media (thumbnails and gallery) 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) - } - } + downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, meta.ImageURL, meta.ImageURLs) } // Refetch and return updated item diff --git a/internal/handlers/home.go b/internal/handlers/home.go index 0612d3f..6de90fe 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -31,6 +31,8 @@ type homeItem struct { ThumbnailID *int64 MediaID *int64 HasVideo bool + GalleryIDs []int64 // Additional images for multi-image embeds + ImageCount int // Total image count (1 + len(GalleryIDs)) } func (h homeContent) Render(sw *ssr.Writer) error { @@ -57,6 +59,12 @@ func (h homeContent) Render(sw *ssr.Writer) error {
{{.Description}}
{{if .Title}}— {{.Title}}{{end}} + {{else if .GalleryIDs}} +
+ {{if .Title}}{{.Title}}{{else}}Image{{end}} + {{range .GalleryIDs}}Image{{end}} +
+ {{else if .ThumbnailID}} {{if .Title}}{{.Title}}{{else}}Image{{end}} {{if or .HasVideo (eq .ItemType "video")}}
{{end}} @@ -189,20 +197,31 @@ func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) erro } // Get media + // 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 err } + firstImage := true for _, m := range mediaList { - if m.MediaType == "thumbnail" { - hi.ThumbnailID = &m.ID - } else if m.MediaType == "original" { + if m.MediaType == "original" { hi.MediaID = &m.ID if strings.HasPrefix(m.ContentType, "video/") { hi.HasVideo = true } + } else if m.MediaType == "image" { + if firstImage { + hi.ThumbnailID = &m.ID + firstImage = false + } else { + hi.GalleryIDs = append(hi.GalleryIDs, m.ID) + } } } + // Calculate total image count (thumbnail + gallery images) + if len(hi.GalleryIDs) > 0 { + hi.ImageCount = 1 + len(hi.GalleryIDs) + } // Also check for embed video URL if it.EmbedVideoURL.Valid && it.EmbedVideoURL.V != "" { diff --git a/internal/handlers/item_page.go b/internal/handlers/item_page.go index c66e246..a336107 100644 --- a/internal/handlers/item_page.go +++ b/internal/handlers/item_page.go @@ -31,6 +31,7 @@ type itemPageData struct { ThumbnailID *int64 MediaID *int64 MediaIsVideo bool + GalleryIDs []int64 // Additional images for multi-image embeds } func (c itemPageContent) Render(sw *ssr.Writer) error { @@ -60,22 +61,27 @@ func (c itemPageContent) Render(sw *ssr.Writer) error { + {{else if .Item.GalleryIDs}} + {{else if .Item.ThumbnailID}}
- {{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}} + {{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}
{{else if .Item.MediaID}}
- {{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}} + {{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}
{{end}} {{else if .Item.MediaID}}
- {{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}} + {{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}
{{else if .Item.ThumbnailID}}
- {{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}} + {{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}
{{end}} @@ -173,18 +179,26 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request) } // Get media + // Media is ordered by ID, so first "image" is the thumbnail, rest are gallery var thumbnailID, mediaID *int64 var mediaIsVideo bool + var galleryIDs []int64 mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID) if err != nil { return err } + firstImage := true for _, m := range mediaList { - if m.MediaType == "thumbnail" { - thumbnailID = &m.ID - } else if m.MediaType == "original" { + if m.MediaType == "original" { mediaID = &m.ID mediaIsVideo = strings.HasPrefix(m.ContentType, "video/") + } else if m.MediaType == "image" { + if firstImage { + thumbnailID = &m.ID + firstImage = false + } else { + galleryIDs = append(galleryIDs, m.ID) + } } } @@ -201,6 +215,7 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request) ThumbnailID: thumbnailID, MediaID: mediaID, MediaIsVideo: mediaIsVideo, + GalleryIDs: galleryIDs, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/migrations/sql/0004_unify_image_media_type.sql b/internal/migrations/sql/0004_unify_image_media_type.sql new file mode 100644 index 0000000..aa526ce --- /dev/null +++ b/internal/migrations/sql/0004_unify_image_media_type.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Consolidate 'thumbnail' and 'gallery' media types into unified 'image' type +UPDATE media SET media_type = 'image' WHERE media_type IN ('thumbnail', 'gallery'); + +-- +goose Down +-- Note: Cannot perfectly reverse since we lose the distinction between thumbnail and gallery +-- This sets all 'image' back to 'thumbnail' as a fallback +UPDATE media SET media_type = 'thumbnail' WHERE media_type = 'image'; diff --git a/internal/static/css/app.css b/internal/static/css/app.css index 2be45b6..c74d5ca 100644 --- a/internal/static/css/app.css +++ b/internal/static/css/app.css @@ -599,6 +599,66 @@ input[type="file"] { color: var(--gray-3); } +/* Image Gallery (detail page) */ +.image-gallery { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2px; +} + +.image-gallery img { + width: 100%; + height: auto; + display: block; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.image-gallery img:hover { + opacity: 0.9; +} + +/* Single image when odd count - make last one full width */ +.image-gallery img:last-child:nth-child(odd) { + grid-column: span 2; +} + +/* Gallery Cycling (home grid) */ +.grid-item-images { + position: relative; + width: 100%; +} + +.grid-item-images img { + width: 100%; + display: block; + position: absolute; + top: 0; + left: 0; + opacity: 0; + transition: opacity 0.4s ease; +} + +.grid-item-images img:first-child { + position: relative; +} + +.grid-item-images img.active { + opacity: 1; +} + +/* Gallery Indicator */ +.gallery-indicator { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 0.7rem; + padding: 0.2rem 0.4rem; + pointer-events: none; +} + /* Responsive */ @media (max-width: 1200px) { .grid { diff --git a/internal/static/js/app.js b/internal/static/js/app.js index deb1287..f11bc49 100644 --- a/internal/static/js/app.js +++ b/internal/static/js/app.js @@ -1,399 +1,479 @@ // Console-based authentication window.login = async (password) => { - if (!password) { - console.error('Usage: login("your-password")'); - return; - } - try { - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }) - }); - const data = await res.json(); - if (res.ok) { - console.log(data.firstTime ? 'Password set! Reloading...' : 'Logged in! Reloading...'); - setTimeout(() => location.reload(), 500); - } else { - console.error(data.error || 'Login failed'); - } - } catch (err) { - console.error('Login error:', err); - } + if (!password) { + console.error('Usage: login("your-password")'); + return; + } + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (res.ok) { + console.log( + data.firstTime + ? "Password set! Reloading..." + : "Logged in! Reloading...", + ); + setTimeout(() => location.reload(), 500); + } else { + console.error(data.error || "Login failed"); + } + } catch (err) { + console.error("Login error:", err); + } }; window.logout = async () => { - try { - await fetch('/api/auth/logout', { method: 'POST' }); - console.log('Logged out! Reloading...'); - setTimeout(() => location.reload(), 500); - } catch (err) { - console.error('Logout error:', err); - } + try { + await fetch("/api/auth/logout", { method: "POST" }); + console.log("Logged out! Reloading..."); + setTimeout(() => location.reload(), 500); + } catch (err) { + console.error("Logout error:", err); + } }; // Modal functions function showAddModal() { - document.getElementById('add-modal').classList.add('active'); + document.getElementById("add-modal").classList.add("active"); } function hideAddModal() { - document.getElementById('add-modal').classList.remove('active'); + document.getElementById("add-modal").classList.remove("active"); } function showEditModal() { - document.getElementById('edit-modal').classList.add('active'); + document.getElementById("edit-modal").classList.add("active"); } function hideEditModal() { - document.getElementById('edit-modal').classList.remove('active'); + document.getElementById("edit-modal").classList.remove("active"); } // Tab switching -document.addEventListener('DOMContentLoaded', () => { - const tabs = document.querySelectorAll('.modal-tabs .tab'); - tabs.forEach(tab => { - tab.addEventListener('click', () => { - const tabId = tab.dataset.tab; - - // Update tab buttons - tabs.forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - - // Update tab content - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - document.getElementById('tab-' + tabId).classList.add('active'); - }); - }); - - // URL preview on input - const urlInput = document.querySelector('#link-form input[name="url"]'); - if (urlInput) { - let debounceTimer; - urlInput.addEventListener('input', (e) => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => fetchPreview(e.target.value), 500); - }); - } +document.addEventListener("DOMContentLoaded", () => { + const tabs = document.querySelectorAll(".modal-tabs .tab"); + tabs.forEach((tab) => { + tab.addEventListener("click", () => { + const tabId = tab.dataset.tab; + + // Update tab buttons + tabs.forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); + + // Update tab content + document + .querySelectorAll(".tab-content") + .forEach((c) => c.classList.remove("active")); + document.getElementById("tab-" + tabId).classList.add("active"); + }); + }); + + // URL preview on input + const urlInput = document.querySelector('#link-form input[name="url"]'); + if (urlInput) { + let debounceTimer; + urlInput.addEventListener("input", (e) => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => fetchPreview(e.target.value), 500); + }); + } + + // Gallery hover cycling + const galleries = document.querySelectorAll('.grid-item-images[data-gallery="true"]'); + galleries.forEach((gallery) => { + const images = gallery.querySelectorAll('img'); + const indicator = gallery.parentElement.querySelector('.gallery-indicator'); + if (images.length < 2) return; + + let currentIndex = 0; + let cycleInterval = null; + + const showImage = (index) => { + images.forEach((img, i) => { + img.classList.toggle('active', i === index); + }); + if (indicator) { + indicator.textContent = `${index + 1}/${images.length}`; + } + }; + + const startCycling = () => { + if (cycleInterval) return; + cycleInterval = setInterval(() => { + currentIndex = (currentIndex + 1) % images.length; + showImage(currentIndex); + }, 2000); + }; + + const stopCycling = () => { + if (cycleInterval) { + clearInterval(cycleInterval); + cycleInterval = null; + } + // Reset to first image + currentIndex = 0; + showImage(0); + }; + + gallery.parentElement.addEventListener('mouseenter', startCycling); + gallery.parentElement.addEventListener('mouseleave', stopCycling); + }); }); // Fetch URL preview async function fetchPreview(url) { - const preview = document.getElementById('link-preview'); - if (!url) { - preview.classList.remove('active'); - preview.innerHTML = ''; - return; - } - - try { - const res = await fetch('/api/preview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }) - }); - - if (!res.ok) { - const data = await res.json(); - preview.innerHTML = `
${data.error || 'Failed to fetch preview'}
`; - preview.classList.add('active'); - return; - } - - const data = await res.json(); - preview.dataset.preview = JSON.stringify(data); - - let html = ''; - if (data.imageUrl) { - html += `Preview`; - } - if (data.title) { - html += `
${escapeHtml(data.title)}
`; - } - if (data.description) { - html += `
${escapeHtml(data.description)}
`; - } - if (data.isEmbed) { - html += `
${escapeHtml(data.provider.toUpperCase())} VIDEO
`; - } - - preview.innerHTML = html || '
No preview available
'; - preview.classList.add('active'); - - // Auto-fill title if empty - const titleInput = document.querySelector('#link-form input[name="title"]'); - if (titleInput && !titleInput.value && data.title) { - titleInput.value = data.title; - } - } catch (err) { - console.error('Preview error:', err); - preview.innerHTML = '
Failed to fetch preview
'; - preview.classList.add('active'); - } + const preview = document.getElementById("link-preview"); + if (!url) { + preview.classList.remove("active"); + preview.innerHTML = ""; + return; + } + + try { + const res = await fetch("/api/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + + if (!res.ok) { + const data = await res.json(); + preview.innerHTML = `
${data.error || "Failed to fetch preview"}
`; + preview.classList.add("active"); + return; + } + + const data = await res.json(); + preview.dataset.preview = JSON.stringify(data); + + let html = ""; + if (data.imageUrl) { + html += `Preview`; + } + if (data.title) { + html += `
${escapeHtml(data.title)}
`; + } + if (data.description) { + html += `
${escapeHtml(data.description)}
`; + } + if (data.isEmbed) { + html += `
${escapeHtml(data.provider.toUpperCase())} VIDEO
`; + } + + preview.innerHTML = html || "
No preview available
"; + preview.classList.add("active"); + + // Auto-fill title if empty + const titleInput = document.querySelector('#link-form input[name="title"]'); + if (titleInput && !titleInput.value && data.title) { + titleInput.value = data.title; + } + + // Auto-fill description if empty + const descriptionInput = document.querySelector( + '#link-form textarea[name="description"]', + ); + if (descriptionInput && !descriptionInput.value && data.description) { + descriptionInput.value = data.description; + } + } catch (err) { + console.error("Preview error:", err); + preview.innerHTML = + '
Failed to fetch preview
'; + preview.classList.add("active"); + } } // Submit link form async function submitLink(event) { - event.preventDefault(); - const form = event.target; - const url = form.url.value; - const tags = form.tags.value ? form.tags.value.split(',').map(t => t.trim()).filter(Boolean) : []; - - // Get preview data - const preview = document.getElementById('link-preview'); - const previewData = preview.dataset.preview ? JSON.parse(preview.dataset.preview) : {}; - - // Use form values, falling back to preview data - const title = form.title.value || previewData.title || null; - const description = form.description.value || previewData.description || null; - - try { - const body = { - url, - title, - description, - tags, - imageUrl: previewData.imageUrl || null, - }; - - if (previewData.isEmbed) { - body.provider = previewData.provider; - body.videoId = previewData.videoId; - body.embedHtml = previewData.embedHtml; - } - - const res = await fetch('/api/items/from-link', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - - if (res.ok) { - location.reload(); - } else { - const data = await res.json(); - alert(data.error || 'Failed to add item'); - } - } catch (err) { - console.error('Submit error:', err); - alert('Failed to add item'); - } - - return false; + event.preventDefault(); + const form = event.target; + const url = form.url.value; + const tags = form.tags.value + ? form.tags.value + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : []; + + // Get preview data + const preview = document.getElementById("link-preview"); + const previewData = preview.dataset.preview + ? JSON.parse(preview.dataset.preview) + : {}; + + // Use form values, falling back to preview data + const title = form.title.value || previewData.title || null; + const description = form.description.value || previewData.description || null; + + try { + const body = { + url, + title, + description, + tags, + imageUrl: previewData.imageUrl || null, + }; + + if (previewData.isEmbed) { + body.provider = previewData.provider; + body.videoId = previewData.videoId; + body.embedHtml = previewData.embedHtml; + } + + const res = await fetch("/api/items/from-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (res.ok) { + location.reload(); + } else { + const data = await res.json(); + alert(data.error || "Failed to add item"); + } + } catch (err) { + console.error("Submit error:", err); + alert("Failed to add item"); + } + + return false; } // Submit upload form async function submitUpload(event) { - event.preventDefault(); - const form = event.target; - const formData = new FormData(form); - - try { - const res = await fetch('/api/items/upload', { - method: 'POST', - body: formData - }); - - if (res.ok) { - location.reload(); - } else { - const data = await res.json(); - alert(data.error || 'Failed to upload'); - } - } catch (err) { - console.error('Upload error:', err); - alert('Failed to upload'); - } - - return false; + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + const res = await fetch("/api/items/upload", { + method: "POST", + body: formData, + }); + + if (res.ok) { + location.reload(); + } else { + const data = await res.json(); + alert(data.error || "Failed to upload"); + } + } catch (err) { + console.error("Upload error:", err); + alert("Failed to upload"); + } + + return false; } // Submit quote form async function submitQuote(event) { - event.preventDefault(); - const form = event.target; - const text = form.text.value; - const source = form.source.value || null; - const sourceUrl = form.sourceUrl.value || null; - const tags = form.tags.value ? form.tags.value.split(',').map(t => t.trim()).filter(Boolean) : []; - - try { - const res = await fetch('/api/items/quote', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, source, sourceUrl, tags }) - }); - - if (res.ok) { - location.reload(); - } else { - const data = await res.json(); - alert(data.error || 'Failed to add quote'); - } - } catch (err) { - console.error('Submit error:', err); - alert('Failed to add quote'); - } - - return false; + event.preventDefault(); + const form = event.target; + const text = form.text.value; + const source = form.source.value || null; + const sourceUrl = form.sourceUrl.value || null; + const tags = form.tags.value + ? form.tags.value + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : []; + + try { + const res = await fetch("/api/items/quote", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, source, sourceUrl, tags }), + }); + + if (res.ok) { + location.reload(); + } else { + const data = await res.json(); + alert(data.error || "Failed to add quote"); + } + } catch (err) { + console.error("Submit error:", err); + alert("Failed to add quote"); + } + + return false; } // Edit item function editItem(id) { - showEditModal(); + showEditModal(); } // Submit edit form async function submitEdit(event) { - event.preventDefault(); - const form = event.target; - const id = form.id.value; - const title = form.title.value || null; - const description = form.description.value || null; - const linkUrl = form.linkUrl.value || null; - const tags = form.tags.value ? form.tags.value.split(',').map(t => t.trim()).filter(Boolean) : []; - - try { - const res = await fetch(`/api/items/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, description, linkUrl, tags }) - }); - - if (res.ok) { - location.reload(); - } else { - const data = await res.json(); - alert(data.error || 'Failed to update'); - } - } catch (err) { - console.error('Update error:', err); - alert('Failed to update'); - } - - return false; + event.preventDefault(); + const form = event.target; + const id = form.id.value; + const title = form.title.value || null; + const description = form.description.value || null; + const linkUrl = form.linkUrl.value || null; + const tags = form.tags.value + ? form.tags.value + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : []; + + try { + const res = await fetch(`/api/items/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, description, linkUrl, tags }), + }); + + if (res.ok) { + location.reload(); + } else { + const data = await res.json(); + alert(data.error || "Failed to update"); + } + } catch (err) { + console.error("Update error:", err); + alert("Failed to update"); + } + + return false; } // Refresh metadata async function refreshMetadata(id) { - try { - // Fetch current item data - const itemRes = await fetch(`/api/items/${id}`); - if (!itemRes.ok) { - alert('Failed to fetch item'); - return; - } - const item = await itemRes.json(); - - if (!item.linkUrl) { - alert('Item has no link URL'); - return; - } - - // Fetch fresh metadata - const previewRes = await fetch('/api/preview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: item.linkUrl }) - }); - if (!previewRes.ok) { - const data = await previewRes.json(); - alert(data.error || 'Failed to fetch metadata'); - return; - } - const preview = await previewRes.json(); - - // Check if user has made manual edits - const titleChanged = item.title && preview.title && item.title !== preview.title; - const descChanged = item.description && preview.description && item.description !== preview.description; - const imageChanged = item.thumbnailSourceUrl && preview.imageUrl && item.thumbnailSourceUrl !== preview.imageUrl; - - if (titleChanged || descChanged || imageChanged) { - let msg = 'This will overwrite your changes:\n'; - if (titleChanged) msg += `\nTitle: "${item.title}" → "${preview.title}"`; - if (descChanged) msg += `\nDescription will be replaced`; - if (imageChanged) msg += `\nImage will be replaced`; - msg += '\n\nContinue?'; - - if (!confirm(msg)) return; - } - - // Proceed with refresh - const res = await fetch(`/api/items/${id}/refresh`, { method: 'POST' }); - - if (res.ok) { - location.reload(); - } else { - const data = await res.json(); - alert(data.error || 'Failed to refresh'); - } - } catch (err) { - console.error('Refresh error:', err); - alert('Failed to refresh'); - } + try { + // Fetch current item data + const itemRes = await fetch(`/api/items/${id}`); + if (!itemRes.ok) { + alert("Failed to fetch item"); + return; + } + const item = await itemRes.json(); + + if (!item.linkUrl) { + alert("Item has no link URL"); + return; + } + + // Fetch fresh metadata + const previewRes = await fetch("/api/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: item.linkUrl }), + }); + if (!previewRes.ok) { + const data = await previewRes.json(); + alert(data.error || "Failed to fetch metadata"); + return; + } + const preview = await previewRes.json(); + + // Check if user has made manual edits + const titleChanged = + item.title && preview.title && item.title !== preview.title; + const descChanged = + item.description && + preview.description && + item.description !== preview.description; + const imageChanged = + item.thumbnailSourceUrl && + preview.imageUrl && + item.thumbnailSourceUrl !== preview.imageUrl; + + if (titleChanged || descChanged || imageChanged) { + let msg = "This will overwrite your changes:\n"; + if (titleChanged) msg += `\nTitle: "${item.title}" → "${preview.title}"`; + if (descChanged) msg += `\nDescription will be replaced`; + if (imageChanged) msg += `\nImage will be replaced`; + msg += "\n\nContinue?"; + + if (!confirm(msg)) return; + } + + // Proceed with refresh + const res = await fetch(`/api/items/${id}/refresh`, { method: "POST" }); + + if (res.ok) { + location.reload(); + } else { + const data = await res.json(); + alert(data.error || "Failed to refresh"); + } + } catch (err) { + console.error("Refresh error:", err); + alert("Failed to refresh"); + } } // Replace media async function submitReplaceMedia(event, id) { - event.preventDefault(); - const form = event.target; - const formData = new FormData(form); - - try { - const res = await fetch(`/api/items/${id}/media`, { - method: 'POST', - body: formData - }); - - if (res.ok) { - location.reload(); - } else { - const data = await res.json(); - alert(data.error || 'Failed to replace media'); - } - } catch (err) { - console.error('Replace media error:', err); - alert('Failed to replace media'); - } - - return false; + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + const res = await fetch(`/api/items/${id}/media`, { + method: "POST", + body: formData, + }); + + if (res.ok) { + location.reload(); + } else { + const data = await res.json(); + alert(data.error || "Failed to replace media"); + } + } catch (err) { + console.error("Replace media error:", err); + alert("Failed to replace media"); + } + + return false; } // Delete item async function deleteItem(id) { - if (!confirm('Delete this item?')) return; - - try { - const res = await fetch(`/api/items/${id}`, { method: 'DELETE' }); - - if (res.ok) { - location.href = '/'; - } else { - const data = await res.json(); - alert(data.error || 'Failed to delete'); - } - } catch (err) { - console.error('Delete error:', err); - alert('Failed to delete'); - } + if (!confirm("Delete this item?")) return; + + try { + const res = await fetch(`/api/items/${id}`, { method: "DELETE" }); + + if (res.ok) { + location.href = "/"; + } else { + const data = await res.json(); + alert(data.error || "Failed to delete"); + } + } catch (err) { + console.error("Delete error:", err); + alert("Failed to delete"); + } } // Play video (initial click to start) function playVideo(overlay) { - const container = overlay.parentElement; - const video = container.querySelector('video'); - if (video) { - video.play(); - overlay.remove(); - // Show controls on hover - video.addEventListener('mouseenter', () => video.controls = true); - video.addEventListener('mouseleave', () => video.controls = false); - } + const container = overlay.parentElement; + const video = container.querySelector("video"); + if (video) { + video.play(); + overlay.remove(); + // Show controls on hover + video.addEventListener("mouseenter", () => (video.controls = true)); + video.addEventListener("mouseleave", () => (video.controls = false)); + } } // Utility function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; }