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.
389 lines
17 KiB
389 lines
17 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">
|
|
<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>
|
|
<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, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
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="/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>
|
|
<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(`/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('/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 %}
|