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}}
+
+

+ {{range .GalleryIDs}}

{{end}}
+
+ {{.ImageCount}}
{{else if .ThumbnailID}}
{{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}}
+
+ {{if .Item.ThumbnailID}}

{{end}}
+ {{range .Item.GalleryIDs}}

{{end}}
+
{{else if .Item.ThumbnailID}}
-

+
{{else if .Item.MediaID}}
-

+
{{end}}
{{else if .Item.MediaID}}
-

+
{{else if .Item.ThumbnailID}}
-

+
{{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 += `
`;
- }
- 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 += `
`;
+ }
+ 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;
}