PDF upload support: * index.php: application/pdf added to $mimeMap + $extMap in the /api/upload handler. PDFs are stored in uploads/ like images/audio. * editor.js uploadFile(): detects PDF via MIME or .pdf extension. Inserts a markdown link [📄 filename](uploads/...) instead of an image embed. Paste + drop handlers updated to accept PDFs. * index.php: new toolbar button #md-upload-pdf with ph-file-pdf icon next to the extract button. Triggers a hidden file input (accept=".pdf"). Wired in editor.js. Gallery → Assets rename: * index.php footer button: "Images" → "Assets", icon ph-folder-open. * index.php gallery overlay header: "images" → "assets", icon ph-folder-open, upload input accept extended to include .pdf. * Empty state text: "no images" → "no assets". File-type tabs in the assets overlay: * index.php: new #gallery-tabs div with 4 buttons (All / Images / Audio / PDF), each with data-filter attribute. * gallery.js: fileType(url) helper returns 'image'|'audio'|'pdf'| 'other'. Tab clicks toggle .active class and filter gallery-items by data-url + fileType. activeFilter persists across re-opens. * CSS: .gallery-tab styling with hover + .active accent fill. PDF rendering in the assets grid: * gallery.js mediaTile(): PDFs render as ph-file-pdf icon + filename instead of a thumbnail. Orphaned PDFs show the same icon tile. * Click on an orphaned PDF opens it in a new tab (window.open). * "New card" from an orphaned PDF inserts a markdown link (not image embed). Orphan detection fix (gallery.js): * Root cause: loadCards() in app.js uses two-phase lazy loading — first /api/cards/meta (body=null on all cards), then batches of 25 with full bodies. Opening the gallery before the last batch completes meant most cards had body=null, getReferencedUrls() found no URLs, and every file appeared orphaned. * Fix: openGallery() now fetches its own card list with full bodies via api('api/cards') in parallel with api('api/uploads'), stored in a module-local galleryCards array. Both getReferencedUrls() and findCardForUrl() read from galleryCards instead of state.cards. * Bonus: added a fallback regex uploads/[hex]{32}.[ext] that catches references in any format the specific patterns might miss (HTML attributes, custom blocks, inline text). chore: bump version to 0.9.18 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
328 lines
16 KiB
JavaScript
328 lines
16 KiB
JavaScript
// ── 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 `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-2">
|
|
<i class="ph ph-music-note text-2xl text-t-dim"></i>
|
|
<audio controls preload="metadata" src="${url}" class="w-full" style="height:28px"></audio>
|
|
</div>`;
|
|
}
|
|
if (type === 'pdf') {
|
|
const name = url.split('/').pop();
|
|
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-3">
|
|
<i class="ph ph-file-pdf text-3xl text-t-danger"></i>
|
|
<span class="font-pixel text-[8px] text-t-muted truncate w-full text-center">${esc(name)}</span>
|
|
</div>`;
|
|
}
|
|
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden">
|
|
<img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
|
|
</div>`;
|
|
}
|
|
|
|
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 = /<img[^>]+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.
|
|
const [files, cards] = await Promise.all([
|
|
api('api/uploads'),
|
|
api('api/cards'),
|
|
]);
|
|
galleryCards = 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);
|
|
});
|
|
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';
|
|
});
|
|
};
|
|
document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => {
|
|
t.onclick = () => applyFilter(t.dataset.filter);
|
|
});
|
|
|
|
if (!total) {
|
|
grid.innerHTML = `<div class="col-span-full text-center py-16">
|
|
<i class="ph ph-folder-open text-4xl text-t-dim mb-2 block"></i>
|
|
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no assets</p></div>`;
|
|
} else {
|
|
let html = '';
|
|
|
|
if (orphaned.length) {
|
|
html += `<div class="col-span-full font-pixel text-[10px] text-t-danger flex items-center gap-1.5 pt-2"><i class="ph ph-warning text-xs"></i>orphaned (not used by any card)</div>`;
|
|
html += orphaned.map(f => `
|
|
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}" data-url="${esc(f.url)}">
|
|
<div class="relative aspect-square bg-t-bg border border-t-danger/30 rounded overflow-visible">
|
|
${fileType(f.url) === 'pdf'
|
|
? `<div class="w-full h-full flex flex-col items-center justify-center gap-1 opacity-60"><i class="ph ph-file-pdf text-3xl text-t-danger"></i><span class="font-pixel text-[8px] text-t-dim">${esc(f.name)}</span></div>`
|
|
: fileType(f.url) === 'audio'
|
|
? `<div class="w-full h-full flex items-center justify-center opacity-60"><i class="ph ph-music-note text-3xl text-t-dim"></i></div>`
|
|
: `<img src="${f.url}" class="w-full h-full object-cover opacity-60 rounded" loading="lazy">`}
|
|
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button>
|
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" title="new slide (background)"><i class="ph ph-image-square text-sm"></i></button>
|
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" title="delete"><i class="ph ph-trash text-sm"></i></button>
|
|
</div>
|
|
</div>
|
|
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
if (linked.length) {
|
|
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
|
|
html += linked.map(img => `
|
|
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}" data-gallery-origin="${encodeURIComponent(img.url)}" data-url="${esc(img.url)}">
|
|
${mediaTile(img.url, 'border-t-border')}
|
|
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded flex flex-col justify-end p-2">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="w-2 h-2 rounded-full shrink-0" style="background:${img.cardColor}"></span>
|
|
<span class="font-pixel text-[9px] text-white truncate">${esc(img.cardTitle)}</span>
|
|
</div>
|
|
</div>
|
|
</div>`).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 = `
|
|
<img src="${file.url}" style="max-width:min(92vw,1400px);max-height:85vh;object-fit:contain;border:1px solid var(--t-border);box-shadow:0 16px 60px rgba(0,0,0,0.55);">
|
|
<div style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;gap:8px;">
|
|
<button class="orphan-lb-create flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
|
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">New card</span>
|
|
</button>
|
|
<button class="orphan-lb-slide flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
|
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">New slide</span>
|
|
</button>
|
|
<button class="orphan-lb-delete flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" style="border-radius:4px;">
|
|
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete</span>
|
|
</button>
|
|
</div>
|
|
<button style="position:fixed;top:12px;right:12px;color:rgba(255,255,255,0.6);font-size:20px;background:none;border:none;cursor:pointer;" class="orphan-lb-close">
|
|
<i class="ph ph-x"></i>
|
|
</button>
|
|
`;
|
|
|
|
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();
|
|
});
|