151 lines
6.5 KiB
JavaScript
151 lines
6.5 KiB
JavaScript
// ── Image gallery ──
|
|
|
|
import { $, esc, api, getCsrf } from './util.js';
|
|
|
|
const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
|
function isAudio(url) { return AUDIO_EXT.test(url); }
|
|
|
|
function mediaTile(url, extraClass = '') {
|
|
if (isAudio(url)) {
|
|
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>`;
|
|
}
|
|
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>`;
|
|
}
|
|
import { state } from './state.js';
|
|
import { showDialog } from './dialog.js';
|
|
|
|
function getReferencedUrls() {
|
|
const urls = new Set();
|
|
for (const card of state.cards) {
|
|
const body = card.body || '';
|
|
let m;
|
|
const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
|
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
|
|
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
|
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
function findCardForUrl(url) {
|
|
for (const card of state.cards) {
|
|
const body = card.body || '';
|
|
if (body.includes(url)) return card;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function openGallery() {
|
|
const el = $('gallery');
|
|
if (!el) return;
|
|
|
|
const files = await api('api/uploads');
|
|
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} image${total !== 1 ? 's' : ''}${orphaned.length ? ` · ${orphaned.length} orphaned` : ''}`;
|
|
|
|
if (!total) {
|
|
grid.innerHTML = `<div class="col-span-full text-center py-16">
|
|
<i class="ph ph-image text-4xl text-t-dim mb-2 block"></i>
|
|
<p class="font-pixel text-[10px] text-t-dim">no images</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 relative" data-orphan="${f.name}">
|
|
${mediaTile(f.url, 'border-t-danger/30')}
|
|
<div class="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button class="w-6 h-6 rounded bg-t-accent text-t-bg flex items-center justify-center text-xs" data-create="${f.url}" title="create card">
|
|
<i class="ph ph-plus"></i>
|
|
</button>
|
|
<button class="w-6 h-6 rounded bg-t-danger text-t-bg flex items-center justify-center text-xs" data-del="${f.name}" title="delete">
|
|
<i class="ph ph-trash"></i>
|
|
</button>
|
|
</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)}">
|
|
${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: create card from image
|
|
grid.querySelectorAll('[data-create]').forEach(btn => {
|
|
btn.addEventListener('click', async e => {
|
|
e.stopPropagation();
|
|
const url = btn.dataset.create;
|
|
const name = url.split('/').pop().replace(/\.[^.]+$/, '');
|
|
await api('api/cards', { method: 'POST', body: { title: name, body: ``, color: '#38bdf8', position: -1 } });
|
|
await window._reloadAll();
|
|
openGallery(); // refresh
|
|
});
|
|
});
|
|
|
|
// Orphan: delete
|
|
grid.querySelectorAll('[data-del]').forEach(btn => {
|
|
btn.addEventListener('click', async e => {
|
|
e.stopPropagation();
|
|
const name = btn.dataset.del;
|
|
const ok = await showDialog({ title: 'delete_image', message: `"${name}" loeschen?`, danger: true, okLabel: 'delete' });
|
|
if (!ok) return;
|
|
await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
|
openGallery(); // refresh
|
|
});
|
|
});
|
|
}
|
|
|
|
el.classList.add('open');
|
|
}
|
|
|
|
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);
|