lookbook/internal/static/js/app.js
soup 007e167707
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
2026-01-17 13:28:20 -05:00

479 lines
12 KiB
JavaScript

// 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);
}
};
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);
}
};
// Modal functions
function showAddModal() {
document.getElementById("add-modal").classList.add("active");
}
function hideAddModal() {
document.getElementById("add-modal").classList.remove("active");
}
function showEditModal() {
document.getElementById("edit-modal").classList.add("active");
}
function hideEditModal() {
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);
});
}
// 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 = `<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 = "";
if (data.imageUrl) {
html += `<img src="${escapeHtml(data.imageUrl)}" alt="Preview">`;
}
if (data.title) {
html += `<div class="preview-title">${escapeHtml(data.title)}</div>`;
}
if (data.description) {
html += `<div class="preview-description">${escapeHtml(data.description)}</div>`;
}
if (data.isEmbed) {
html += `<div class="preview-badge">${escapeHtml(data.provider.toUpperCase())} VIDEO</div>`;
}
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");
}
}
// 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;
}
// 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;
}
// 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;
}
// Edit item
function editItem(id) {
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;
}
// 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");
}
}
// 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;
}
// 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");
}
}
// 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));
}
}
// Utility
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}