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
This commit is contained in:
soup 2026-01-17 13:28:20 -05:00
parent e917e67930
commit 007e167707
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
9 changed files with 625 additions and 403 deletions

View file

@ -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
`

View file

@ -26,7 +26,8 @@ type VideoInfo struct {
VideoID string
Title string
Description string
ThumbnailURL 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)
}
@ -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
@ -206,6 +212,7 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
Title: title,
Description: description,
ThumbnailURL: thumbnailURL,
ThumbnailURLs: thumbnailURLs,
VideoURL: videoURL,
EmbedHTML: embedHTML,
}, nil

View file

@ -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" {
} 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)
}
}
}

View file

@ -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

View file

@ -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 {
<blockquote>{{.Description}}</blockquote>
{{if .Title}}<cite> {{.Title}}</cite>{{end}}
</div>
{{else if .GalleryIDs}}
<div class="grid-item-images" data-gallery="true" data-count="{{.ImageCount}}">
<img src="/media/{{.ThumbnailID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy" class="active">
{{range .GalleryIDs}}<img src="/media/{{.}}" alt="Image" loading="lazy">{{end}}
</div>
<div class="gallery-indicator">{{.ImageCount}}</div>
{{else if .ThumbnailID}}
<img src="/media/{{.ThumbnailID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy">
{{if or .HasVideo (eq .ItemType "video")}}<div class="play-indicator"></div>{{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 != "" {

View file

@ -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 {
<span class="play-button">&#9654;</span>
</div>
</div>
{{else if .Item.GalleryIDs}}
<div class="image-gallery">
{{if .Item.ThumbnailID}}<a href="/media/{{.Item.ThumbnailID}}" target="_blank"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>{{end}}
{{range .Item.GalleryIDs}}<a href="/media/{{.}}" target="_blank"><img src="/media/{{.}}" alt="Image"></a>{{end}}
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}">
<a href="/media/{{.Item.ThumbnailID}}" target="_blank"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}"></a>
</div>
{{else if .Item.MediaID}}
<div class="image-container">
<img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}">
<a href="/media/{{.Item.MediaID}}" target="_blank"><img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}"></a>
</div>
{{end}}
{{else if .Item.MediaID}}
<div class="image-container">
<img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}">
<a href="/media/{{.Item.MediaID}}" target="_blank"><img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}">
<a href="/media/{{.Item.ThumbnailID}}" target="_blank"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>
</div>
{{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")

View file

@ -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';

View file

@ -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 {

View file

@ -5,64 +5,70 @@ window.login = async (password) => {
return;
}
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
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...');
console.log(
data.firstTime
? "Password set! Reloading..."
: "Logged in! Reloading...",
);
setTimeout(() => location.reload(), 500);
} else {
console.error(data.error || 'Login failed');
console.error(data.error || "Login failed");
}
} catch (err) {
console.error('Login error:', err);
console.error("Login error:", err);
}
};
window.logout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
console.log('Logged out! Reloading...');
await fetch("/api/auth/logout", { method: "POST" });
console.log("Logged out! Reloading...");
setTimeout(() => location.reload(), 500);
} catch (err) {
console.error('Logout error:', 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', () => {
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');
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');
document
.querySelectorAll(".tab-content")
.forEach((c) => c.classList.remove("active"));
document.getElementById("tab-" + tabId).classList.add("active");
});
});
@ -70,40 +76,81 @@ document.addEventListener('DOMContentLoaded', () => {
const urlInput = document.querySelector('#link-form input[name="url"]');
if (urlInput) {
let debounceTimer;
urlInput.addEventListener('input', (e) => {
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');
const preview = document.getElementById("link-preview");
if (!url) {
preview.classList.remove('active');
preview.innerHTML = '';
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 })
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 = `<div class="preview-error">${data.error || 'Failed to fetch preview'}</div>`;
preview.classList.add('active');
preview.innerHTML = `<div class="preview-error">${data.error || "Failed to fetch preview"}</div>`;
preview.classList.add("active");
return;
}
const data = await res.json();
preview.dataset.preview = JSON.stringify(data);
let html = '';
let html = "";
if (data.imageUrl) {
html += `<img src="${escapeHtml(data.imageUrl)}" alt="Preview">`;
}
@ -117,18 +164,27 @@ async function fetchPreview(url) {
html += `<div class="preview-badge">${escapeHtml(data.provider.toUpperCase())} VIDEO</div>`;
}
preview.innerHTML = html || '<div>No preview available</div>';
preview.classList.add('active');
preview.innerHTML = html || "<div>No preview available</div>";
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 = '<div class="preview-error">Failed to fetch preview</div>';
preview.classList.add('active');
console.error("Preview error:", err);
preview.innerHTML =
'<div class="preview-error">Failed to fetch preview</div>';
preview.classList.add("active");
}
}
@ -137,11 +193,18 @@ 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) : [];
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) : {};
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;
@ -162,21 +225,21 @@ async function submitLink(event) {
body.embedHtml = previewData.embedHtml;
}
const res = await fetch('/api/items/from-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
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');
alert(data.error || "Failed to add item");
}
} catch (err) {
console.error('Submit error:', err);
alert('Failed to add item');
console.error("Submit error:", err);
alert("Failed to add item");
}
return false;
@ -189,20 +252,20 @@ async function submitUpload(event) {
const formData = new FormData(form);
try {
const res = await fetch('/api/items/upload', {
method: 'POST',
body: formData
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');
alert(data.error || "Failed to upload");
}
} catch (err) {
console.error('Upload error:', err);
alert('Failed to upload');
console.error("Upload error:", err);
alert("Failed to upload");
}
return false;
@ -215,24 +278,29 @@ async function submitQuote(event) {
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) : [];
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 })
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');
alert(data.error || "Failed to add quote");
}
} catch (err) {
console.error('Submit error:', err);
alert('Failed to add quote');
console.error("Submit error:", err);
alert("Failed to add quote");
}
return false;
@ -251,24 +319,29 @@ async function submitEdit(event) {
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) : [];
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 })
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');
alert(data.error || "Failed to update");
}
} catch (err) {
console.error('Update error:', err);
alert('Failed to update');
console.error("Update error:", err);
alert("Failed to update");
}
return false;
@ -280,56 +353,63 @@ async function refreshMetadata(id) {
// Fetch current item data
const itemRes = await fetch(`/api/items/${id}`);
if (!itemRes.ok) {
alert('Failed to fetch item');
alert("Failed to fetch item");
return;
}
const item = await itemRes.json();
if (!item.linkUrl) {
alert('Item has no link URL');
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 })
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');
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;
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';
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?';
msg += "\n\nContinue?";
if (!confirm(msg)) return;
}
// Proceed with refresh
const res = await fetch(`/api/items/${id}/refresh`, { method: 'POST' });
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');
alert(data.error || "Failed to refresh");
}
} catch (err) {
console.error('Refresh error:', err);
alert('Failed to refresh');
console.error("Refresh error:", err);
alert("Failed to refresh");
}
}
@ -341,19 +421,19 @@ async function submitReplaceMedia(event, id) {
try {
const res = await fetch(`/api/items/${id}/media`, {
method: 'POST',
body: formData
method: "POST",
body: formData,
});
if (res.ok) {
location.reload();
} else {
const data = await res.json();
alert(data.error || 'Failed to replace media');
alert(data.error || "Failed to replace media");
}
} catch (err) {
console.error('Replace media error:', err);
alert('Failed to replace media');
console.error("Replace media error:", err);
alert("Failed to replace media");
}
return false;
@ -361,39 +441,39 @@ async function submitReplaceMedia(event, id) {
// Delete item
async function deleteItem(id) {
if (!confirm('Delete this item?')) return;
if (!confirm("Delete this item?")) return;
try {
const res = await fetch(`/api/items/${id}`, { method: 'DELETE' });
const res = await fetch(`/api/items/${id}`, { method: "DELETE" });
if (res.ok) {
location.href = '/';
location.href = "/";
} else {
const data = await res.json();
alert(data.error || 'Failed to delete');
alert(data.error || "Failed to delete");
}
} catch (err) {
console.error('Delete error:', err);
alert('Failed to delete');
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');
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);
video.addEventListener("mouseenter", () => (video.controls = true));
video.addEventListener("mouseleave", () => (video.controls = false));
}
}
// Utility
function escapeHtml(str) {
const div = document.createElement('div');
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}