// ── Assets (images, audio, PDFs) ── import { $, esc, api, getCsrf } from './util.js'; const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i; const PDF_EXT = /\.pdf$/i; const IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i; function fileType(url) { if (IMAGE_EXT.test(url)) return 'image'; if (AUDIO_EXT.test(url)) return 'audio'; if (PDF_EXT.test(url)) return 'pdf'; return 'other'; } function isAudio(url) { return AUDIO_EXT.test(url); } function mediaTile(url, extraClass = '') { const type = fileType(url); if (type === 'audio') { return `
`; } if (type === 'pdf') { const name = url.split('/').pop(); return `
${esc(name)}
`; } return `
`; } let activeFilter = 'all'; import { state } from './state.js'; import { showDialog } from './dialog.js'; // Cards with full bodies — fetched fresh by openGallery so we don't // depend on state.cards which may have body=null during the two-phase // lazy load (loadCards loads /api/cards/meta first, then batches of 25 // with bodies). Without this, most cards look body-less and every file // appears orphaned until the last batch completes. let galleryCards = []; function getReferencedUrls() { const urls = new Set(); for (const card of galleryCards) { const body = card.body || ''; let m; // Markdown images: ![alt](url) const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g; while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]); // HTML images const htmlRe = /]+src=["']([^"']+)["'][^>]*>/gi; while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]); // Slide-image blocks const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g; while ((m = slideRe.exec(body)) !== null) { const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m); if (srcMatch) urls.add(srcMatch[1].trim()); } // Markdown links to uploads (PDFs, other assets): [text](uploads/...) const linkRe = /\[[^\]]*\]\((uploads\/[^)]+)\)/g; while ((m = linkRe.exec(body)) !== null) urls.add(m[1]); // Plain URL mentions (catches any format we missed above) const plainRe = /uploads\/[a-f0-9]{32}\.[a-z0-9]+/gi; while ((m = plainRe.exec(body)) !== null) urls.add(m[0]); } return urls; } function findCardForUrl(url) { for (const card of galleryCards) { const body = card.body || ''; if (body.includes(url)) return card; } return null; } export async function openGallery() { const el = $('gallery'); if (!el) return; // Fetch both file list and full card bodies in parallel. Using // api('api/cards') (not state.cards) guarantees bodies are present // — state.cards may still be in the two-phase lazy-load with // body=null on most entries. let files = [], cards = []; try { [files, cards] = await Promise.all([ api('api/uploads'), api('api/cards'), ]); } catch {} files = Array.isArray(files) ? files : []; galleryCards = Array.isArray(cards) ? cards : []; const referenced = getReferencedUrls(); const grid = $('gallery-grid'); const count = $('gallery-count'); const linked = []; const orphaned = []; for (const f of files) { const card = findCardForUrl(f.url); if (card) { linked.push({ ...f, cardId: card.id, cardTitle: card.title, cardColor: card.color }); } else { orphaned.push(f); } } const total = linked.length + orphaned.length; if (count) count.textContent = `${total} asset${total !== 1 ? 's' : ''}${orphaned.length ? ` · ${orphaned.length} orphaned` : ''}`; // Tab filter const allFiles = [...orphaned, ...linked]; const applyFilter = (filter) => { activeFilter = filter; document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => { t.classList.toggle('active', t.dataset.filter === filter); }); let visibleCount = 0; grid.querySelectorAll('.gallery-item').forEach(item => { const url = item.dataset.url || ''; const type = fileType(url); const show = filter === 'all' || type === filter; item.style.display = show ? '' : 'none'; if (show) visibleCount++; }); // Hide section headers when no items in that section are visible grid.querySelectorAll('[data-section]:not(.gallery-item)').forEach(hdr => { const section = hdr.dataset.section; const hasVisible = !!grid.querySelector(`.gallery-item[data-section="${section}"]:not([style*="display: none"])`); hdr.style.display = hasVisible ? '' : 'none'; }); // Show/hide the empty-filter placeholder let placeholder = grid.querySelector('.gallery-filter-empty'); if (visibleCount === 0 && total > 0) { if (!placeholder) { placeholder = document.createElement('div'); placeholder.className = 'gallery-filter-empty col-span-full text-center py-16'; const icons = { image: 'ph-image', audio: 'ph-music-note', pdf: 'ph-file-pdf' }; const icon = icons[filter] || 'ph-folder-open'; placeholder.innerHTML = `

no ${filter} assets

`; grid.appendChild(placeholder); } placeholder.style.display = ''; } else if (placeholder) { placeholder.style.display = 'none'; } }; document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => { t.onclick = () => applyFilter(t.dataset.filter); }); if (!total) { grid.innerHTML = `
`; } else { let html = ''; if (orphaned.length) { html += `
orphaned (not used by any card)
`; html += orphaned.map(f => ` `).join(''); } if (linked.length) { if (orphaned.length) html += `
linked to cards
`; html += linked.map(img => ` `).join(''); } grid.innerHTML = html; // Linked image clicks → open card grid.querySelectorAll('[data-card-id]').forEach(item => { item.addEventListener('click', () => { window._openDetail(item.dataset.cardId, { fromGallery: true, galleryOrigin: item.dataset.galleryOrigin || null, }); }); }); // Orphan: click to enlarge (images) or open in new tab (PDFs) grid.querySelectorAll('[data-orphan]').forEach(item => { item.addEventListener('click', e => { if (e.target.closest('button')) return; const name = item.dataset.orphan; const f = orphaned.find(o => o.name === name); if (!f) return; const type = fileType(f.url); if (type === 'pdf') window.open(f.url, '_blank'); else if (type === 'image') openOrphanLightbox(f); }); }); // Orphan: inline new card button grid.querySelectorAll('[data-new-orphan]').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const name = btn.dataset.newOrphan; const label = name.replace(/\.[^.]+$/, ''); closeGallery(); const type = fileType('uploads/' + name); const body = type === 'pdf' ? `[📄 ${name}](uploads/${name})` : `![${label}](uploads/${name})`; window._openModal({ id: null, title: label, body, color: '#38bdf8', bucket_id: null }); }); }); // Orphan: inline new slide button (creates image slide with slide-image block) grid.querySelectorAll('[data-new-slide]').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const name = btn.dataset.newSlide; const label = name.replace(/\.[^.]+$/, ''); closeGallery(); const body = '```slide-image\nsrc: uploads/' + name + '\n```'; window._openModal({ id: null, title: label, body, color: '#38bdf8', bucket_id: null }); }); }); // Orphan: inline delete button grid.querySelectorAll('[data-del-orphan]').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); const name = btn.dataset.delOrphan; if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { const ok = await showDialog({ title: 'delete_image', message: `Delete "${name}"?`, danger: true, okLabel: 'Delete' }); if (!ok) return; } await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } }); openGallery(); }); }); } // Apply the active filter (persists across re-opens within a session) applyFilter(activeFilter); el.classList.add('open'); } function openOrphanLightbox(file) { let overlay = document.querySelector('.orphan-lightbox'); if (overlay) overlay.remove(); overlay = document.createElement('div'); overlay.className = 'orphan-lightbox'; overlay.style.cssText = 'position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;background:rgba(2,6,23,0.92);'; overlay.innerHTML = `
`; const close = () => overlay.remove(); overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); overlay.querySelector('.orphan-lb-close').addEventListener('click', close); overlay.querySelector('.orphan-lb-create').addEventListener('click', async () => { const name = file.url.split('/').pop().replace(/\.[^.]+$/, ''); close(); closeGallery(); window._openModal({ id: null, title: name, body: `![${name}](${file.url})`, color: '#38bdf8', bucket_id: null }); }); overlay.querySelector('.orphan-lb-slide').addEventListener('click', async () => { const name = file.url.split('/').pop().replace(/\.[^.]+$/, ''); close(); closeGallery(); const body = '```slide-image\nsrc: ' + file.url + '\n```'; window._openModal({ id: null, title: name, body, color: '#38bdf8', bucket_id: null }); }); overlay.querySelector('.orphan-lb-delete').addEventListener('click', async () => { const ok = await showDialog({ title: 'delete_image', message: `Delete "${file.name}"?`, danger: true, okLabel: 'Delete' }); if (!ok) return; await fetch(`api/uploads/${file.name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } }); close(); openGallery(); }); document.body.appendChild(overlay); } export function closeGallery() { $('gallery')?.classList.remove('open'); } $('gallery-close')?.addEventListener('click', closeGallery); $('gallery')?.addEventListener('click', e => { if (e.target === $('gallery')) closeGallery(); }); $('gallery-btn')?.addEventListener('click', openGallery); // Upload button $('gallery-upload')?.addEventListener('click', () => $('gallery-upload-file')?.click()); $('gallery-upload-file')?.addEventListener('change', async e => { const files = Array.from(e.target.files || []); if (!files.length) return; for (const file of files) { const form = new FormData(); form.append('file', file, file.name); try { await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() }, }); } catch {} } e.target.value = ''; await openGallery(); });