You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
RedditVideoMakerBot/GUI/index.html

391 lines
18 KiB

{% extends "layout.html" %}
{% block main %}
<div class="bg-[#F2EFEB] min-h-[calc(100vh-6.5rem)] py-8">
<div class="container mx-auto px-4">
<!-- Header & Search -->
<div class="flex flex-col md:flex-row justify-between items-stretch md:items-center gap-4 mb-8">
<h1 class="text-3xl font-display font-black uppercase tracking-tighter text-[#111111]">Video Library</h1>
<!-- Bulk-action bar (visible in select mode only) -->
<div id="bulk-bar" class="hidden w-full md:w-auto items-center justify-between md:justify-end gap-2 border border-[#111111] bg-white p-2">
<button type="button" onclick="selectAll()"
class="btn-ghost-neo h-10">
<i data-lucide="check-square" class="w-4 h-4 mr-1"></i>
<span id="select-all-label">Select All</span>
</button>
<button type="button" onclick="cancelSelectMode()"
class="btn-ghost-neo h-10">
Cancel
</button>
<button id="bulk-delete-btn" type="button" onclick="confirmBulkDelete()"
class="btn-danger-neo text-sm h-10">
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
Delete (<span id="selection-count">0</span>)
</button>
</div>
<!-- Normal toolbar (hidden in select mode) -->
<div id="normal-toolbar" class="flex items-center gap-2 w-full md:w-auto">
{% if not public_demo_mode %}
<button type="button" onclick="toggleSelectMode()"
class="btn-ghost-neo shrink-0" style="height: 3rem; min-height: 3rem;">
<i data-lucide="check-square" class="w-4 h-4 mr-1"></i>
Select
</button>
{% endif %}
<div class="relative w-full md:w-72">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#111111]/40"></i>
<input type="text"
class="searchFilter input-neo w-full pl-10"
style="height: 3rem;"
placeholder="Search videos..."
onkeyup="searchFilter()">
</div>
</div>
</div>
<!-- Video Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" id="videos">
<!-- Videos will be injected here -->
</div>
<!-- Empty State -->
<div id="empty-state" class="hidden flex flex-col items-center justify-center py-20 text-[#111111]/30">
<i data-lucide="video-off" class="w-16 h-16 mb-4"></i>
<p class="text-lg font-mono uppercase tracking-wider">No videos found</p>
</div>
</div>
</div>
<!-- Video Player Modal -->
<dialog id="player_modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box bg-white border border-[#111111] max-w-2xl p-0 overflow-hidden">
<div class="flex justify-between items-center px-4 py-3 border-b border-[#111111]">
<h3 id="player_title" class="font-mono text-sm text-[#111111] truncate pr-4 uppercase"></h3>
<button type="button" onclick="closePlayer()" class="btn-ghost-neo p-1">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<video id="player_video" class="w-full bg-black" controls playsinline></video>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Delete Confirmation Modal -->
<dialog id="delete_modal" class="modal">
<div class="modal-box bg-white border border-[#111111]">
<div class="flex items-center gap-3 mb-3">
<i data-lucide="triangle-alert" class="w-5 h-5 text-[#DE6C56] shrink-0"></i>
<h3 class="font-display font-black uppercase tracking-tighter text-lg text-[#111111]">Delete Video?</h3>
</div>
<p id="delete-modal-msg" class="text-[#111111]/60 mb-6 text-sm font-mono"></p>
<div class="modal-action mt-0">
<form method="dialog">
<button class="btn-ghost-neo">Cancel</button>
</form>
<button type="button" class="btn-danger-neo text-sm" onclick="executeDelete()">
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
Delete
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<script>
const intervals = [
{ label: 'year', seconds: 31536000 },
{ label: 'month', seconds: 2592000 },
{ label: 'day', seconds: 86400 },
{ label: 'hour', seconds: 3600 },
{ label: 'minute', seconds: 60 },
{ label: 'second', seconds: 1 }
];
function timeSince(date) {
const seconds = Math.floor((Date.now() / 1000 - date));
const interval = intervals.find(i => i.seconds <= seconds) || intervals[intervals.length - 1];
const count = Math.floor(seconds / interval.seconds);
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
}
function categoryLabel(subreddit) {
if (!subreddit) return "";
if (subreddit === "threads") return "Threads";
return `r/${subreddit}`;
}
function sourceUrl(subreddit, id) {
if (subreddit === "threads") {
return `https://www.threads.net/post/${id}`;
}
return `https://www.reddit.com/r/${subreddit}/comments/${id}/`;
}
function checkTitle(reddit_title, filename) {
const file = filename.slice(0, -4);
return reddit_title === file ? reddit_title : file;
}
// Escape arbitrary strings for safe embedding inside HTML attributes
function h(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
async function loadVideos() {
try {
const response = await fetch("videos.json");
const data = await response.json();
data.sort((b, a) => a['time'] - b['time']);
const container = document.getElementById('videos');
container.innerHTML = data.map(v => {
const title = checkTitle(v.reddit_title, v.filename);
return `
<div class="video-card group bg-white border border-[#111111] relative hover:border-[#DE6C56] transition-colors duration-200 flex flex-col min-h-[23.25rem]"
data-video-id="${h(v.id)}">
<!-- Checkbox overlay (shown in select mode) -->
<div class="select-overlay hidden absolute top-3 right-3 z-10 pointer-events-none">
<input type="checkbox" class="card-checkbox w-5 h-5 pointer-events-auto accent-[#DE6C56]" />
</div>
<button type="button"
class="play-btn aspect-video w-full bg-[#111111]/5 flex items-center justify-center relative overflow-hidden cursor-pointer border-b border-[#111111]"
data-video-id="${h(v.id)}"
data-video-title="${h(title)}">
<i data-lucide="play-circle" class="w-12 h-12 text-[#111111]/30 group-hover:text-[#DE6C56] transition-colors"></i>
<div class="absolute top-3 left-3">
<span class="inline-block bg-white border border-[#111111] text-[#111111] font-mono text-[10px] uppercase tracking-wider px-2 py-0.5">
${h(categoryLabel(v.subreddit))}
</span>
</div>
</button>
<div class="p-4 flex flex-col flex-1">
<h3 class="text-[#111111] font-mono text-sm font-medium line-clamp-2 break-words mb-4 min-h-10" title="${h(title)}">
${h(title)}
</h3>
<div class="flex items-center justify-between gap-2 mt-auto">
<div class="flex gap-1">
<a href="${h(sourceUrl(v.subreddit, v.id))}" target="_blank"
class="btn-ghost-neo p-2"
title="View Source">
<i data-lucide="external-link" class="w-4 h-4"></i>
</a>
<a href="${window.appPath('/video/' + encodeURIComponent(v.id))}?download=1" download
class="btn-ghost-neo p-2"
title="Download">
<i data-lucide="download" class="w-4 h-4"></i>
</a>
</div>
<div class="flex gap-1">
<button class="btn-ghost-neo p-2 copy-btn"
data-copy="${h(sourceUrl(v.subreddit, v.id))}"
title="Copy Link">
<i data-lucide="link" class="w-4 h-4"></i>
</button>
${window.PUBLIC_DEMO_MODE ? '' : `<button class="btn-ghost-neo p-2 hover:text-[#DE6C56] delete-btn"
data-video-id="${h(v.id)}"
title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>`}
</div>
</div>
<div class="mt-4 pt-4 border-t border-[#111111]/20 flex justify-between items-center">
<span class="text-[10px] uppercase tracking-wider text-[#111111]/40 font-mono">
${timeSince(v.time)}
</span>
</div>
</div>
</div>`;
}).join('');
// Wire play buttons — in select mode, toggle checkbox instead of playing
container.querySelectorAll('.play-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (selectMode) {
const card = btn.closest('.video-card');
const cb = card.querySelector('.card-checkbox');
cb.checked = !cb.checked;
updateSelectionCount();
} else {
openPlayer(window.appPath(`/video/${encodeURIComponent(btn.dataset.videoId)}`), btn.dataset.videoTitle);
}
});
});
// Wire copy buttons
container.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
navigator.clipboard.writeText(btn.dataset.copy).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i data-lucide="check" class="w-4 h-4 text-[#4ADE80]"></i>';
lucide.createIcons();
setTimeout(() => { btn.innerHTML = orig; lucide.createIcons(); }, 2000);
});
});
});
// Wire single-delete buttons
container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', () => confirmSingleDelete(btn.dataset.videoId));
});
// Wire checkboxes to update the bulk-delete counter
container.querySelectorAll('.card-checkbox').forEach(cb => {
cb.addEventListener('change', updateSelectionCount);
});
// Re-init icons
lucide.createIcons();
} catch (error) {
console.error("Error loading videos:", error);
}
}
// ── Select mode ────────────────────────────────────────────────────────────
let selectMode = false;
let pendingDeleteIds = [];
function toggleSelectMode() {
selectMode = true;
document.getElementById('bulk-bar').classList.remove('hidden');
document.getElementById('bulk-bar').classList.add('flex');
document.getElementById('normal-toolbar').classList.add('hidden');
document.querySelectorAll('.select-overlay').forEach(el => el.classList.remove('hidden'));
document.querySelectorAll('.card-checkbox').forEach(cb => cb.checked = false);
updateSelectionCount();
lucide.createIcons();
}
function cancelSelectMode() {
selectMode = false;
document.getElementById('bulk-bar').classList.add('hidden');
document.getElementById('bulk-bar').classList.remove('flex');
document.getElementById('normal-toolbar').classList.remove('hidden');
document.querySelectorAll('.select-overlay').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.card-checkbox').forEach(cb => cb.checked = false);
updateSelectionCount();
}
function selectAll() {
const checkboxes = document.querySelectorAll('.card-checkbox');
const allChecked = [...checkboxes].every(cb => cb.checked);
checkboxes.forEach(cb => cb.checked = !allChecked);
document.getElementById('select-all-label').textContent = allChecked ? 'Select All' : 'Deselect All';
updateSelectionCount();
}
function updateSelectionCount() {
const count = document.querySelectorAll('.card-checkbox:checked').length;
document.getElementById('selection-count').textContent = count;
document.getElementById('bulk-delete-btn').disabled = count === 0;
}
function getSelectedIds() {
return [...document.querySelectorAll('.card-checkbox:checked')]
.map(cb => cb.closest('.video-card').dataset.videoId);
}
// ── Delete confirmation ─────────────────────────────────────────────────
function confirmBulkDelete() {
pendingDeleteIds = getSelectedIds();
if (!pendingDeleteIds.length) return;
const n = pendingDeleteIds.length;
document.getElementById('delete-modal-msg').textContent =
`Are you sure you want to delete ${n} video${n !== 1 ? 's' : ''}? This cannot be undone.`;
document.getElementById('delete_modal').showModal();
}
function confirmSingleDelete(videoId) {
pendingDeleteIds = [videoId];
document.getElementById('delete-modal-msg').textContent =
'Are you sure you want to delete this video? This cannot be undone.';
document.getElementById('delete_modal').showModal();
}
async function executeDelete() {
document.getElementById('delete_modal').close();
if (!pendingDeleteIds.length) return;
const ids = [...pendingDeleteIds];
pendingDeleteIds = [];
try {
await fetch(window.appPath('/videos/delete'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
} catch (err) {
console.error('Delete request failed:', err);
}
// Remove cards from DOM regardless (optimistic UI)
ids.forEach(id => {
const card = document.querySelector(`.video-card[data-video-id="${CSS.escape(id)}"]`);
if (card) card.remove();
});
// Show empty state if nothing remains
const remaining = document.querySelectorAll('.video-card:not(.hidden)').length;
document.getElementById('empty-state').classList.toggle('hidden', remaining > 0);
if (selectMode) cancelSelectMode();
}
function searchFilter() {
const query = document.querySelector(".searchFilter") ? document.querySelector(".searchFilter").value.toLowerCase() : '';
const cards = document.querySelectorAll(".video-card");
let visibleCount = 0;
cards.forEach(card => {
const text = card.textContent.toLowerCase();
const matches = text.includes(query);
card.classList.toggle('hidden', !matches);
if (matches) visibleCount++;
});
document.getElementById('empty-state').classList.toggle('hidden', visibleCount > 0);
}
function openPlayer(src, title) {
const modal = document.getElementById('player_modal');
const video = document.getElementById('player_video');
const titleEl = document.getElementById('player_title');
titleEl.textContent = title || '';
video.src = src;
modal.showModal();
video.play().catch(() => {});
}
function closePlayer() {
const modal = document.getElementById('player_modal');
const video = document.getElementById('player_video');
video.pause();
video.removeAttribute('src');
video.load();
modal.close();
}
document.getElementById('player_modal').addEventListener('close', () => {
const video = document.getElementById('player_video');
video.pause();
video.removeAttribute('src');
video.load();
});
document.addEventListener('DOMContentLoaded', loadVideos);
</script>
{% endblock %}