- Use FxTwitter API for full note tweet text (with syndication API fallback) - Save Twitter posts based on media content: - Videos → embed type (proxied video) - Images → image type (gallery) - Text-only → quote type - Add granular preview badges: 'X VIDEO', 'X GALLERY', 'X POST' - Preserve formatting/spacing with white-space: pre-wrap for quotes and descriptions - Rename VideoInfo to EmbedInfo for better semantic clarity
511 lines
13 KiB
JavaScript
511 lines
13 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);
|
|
}
|
|
};
|
|
|
|
window.changePassword = async (currentPassword, newPassword) => {
|
|
if (!currentPassword || !newPassword) {
|
|
console.error('Usage: changePassword("current-password", "new-password")');
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch("/api/auth/password", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ currentPassword, newPassword }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
console.log("Password changed successfully!");
|
|
} else {
|
|
console.error(data.error || "Failed to change password");
|
|
}
|
|
} catch (err) {
|
|
console.error("Password change 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) {
|
|
let badge = data.provider.toUpperCase() + ' VIDEO';
|
|
if (data.provider === 'twitter') {
|
|
if (data.mediaType === 'video') {
|
|
badge = 'X VIDEO';
|
|
} else if (data.mediaType === 'images') {
|
|
badge = 'X GALLERY';
|
|
} else {
|
|
badge = 'X POST';
|
|
}
|
|
}
|
|
html += `<div class="preview-badge">${escapeHtml(badge)}</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;
|
|
}
|