// ── 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 ``;
}
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: 
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 => `
${mediaTile(f.url, 'border-t-danger/30 opacity-60')}
${esc(f.name)}
`).join('');
}
if (linked.length) {
if (orphaned.length) html += `linked to cards
`;
html += linked.map(img => `
${mediaTile(img.url, 'border-t-border')}
`).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})`
: ``;
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: ``, 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();
});