PDF upload, gallery → assets with type tabs, orphan detection fix

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>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-12 11:39:50 +02:00
1 parent 195b0e343f
commit bf81d046e6
5 files changed
+128 -29

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '0.9.17'); define('APP_VERSION', '0.9.18');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
define('DB_PATH', __DIR__ . '/data/hyprcrd.db'); define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
+5
View File
@@ -832,6 +832,11 @@ body.anim-slow .board-card.card-pulse-local {
/* Shortcut help table — table-fixed prevents column wobble when content /* Shortcut help table — table-fixed prevents column wobble when content
length varies between sections. kbd styling matches typical keyboard-cap length varies between sections. kbd styling matches typical keyboard-cap
look (surface bg, border, mono font, tiny padding). */ look (surface bg, border, mono font, tiny padding). */
/* Asset tab filters in the gallery/assets overlay header */
.gallery-tab { border-radius: var(--ui-radius-button, 0); }
.gallery-tab:hover { color: var(--t-accent); border-color: var(--t-accent); }
.gallery-tab.active { background: var(--t-accent); color: var(--t-bg); border-color: var(--t-accent); }
.shortcut-table { width:100%; table-layout:fixed; font-size:12px; border-collapse:collapse; } .shortcut-table { width:100%; table-layout:fixed; font-size:12px; border-collapse:collapse; }
.shortcut-table td { padding:4px 0; vertical-align:middle; color:var(--t-text); } .shortcut-table td { padding:4px 0; vertical-align:middle; color:var(--t-text); }
.shortcut-table td:first-child { width:48%; color:var(--t-dim); white-space:nowrap; font-size:11px; } .shortcut-table td:first-child { width:48%; color:var(--t-dim); white-space:nowrap; font-size:11px; }
+15 -7
View File
@@ -454,8 +454,8 @@ if (str_starts_with($route, '/api/')) {
} }
// Check MIME — finfo first, fallback to extension for audio // Check MIME — finfo first, fallback to extension for audio
$mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg']; $mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg', 'application/pdf' => 'pdf'];
$extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a']; $extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a', 'pdf' => 'pdf'];
$finfo = new finfo(FILEINFO_MIME_TYPE); $finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file['tmp_name']); $realMime = $finfo->file($file['tmp_name']);
$ext = $mimeMap[$realMime] ?? null; $ext = $mimeMap[$realMime] ?? null;
@@ -1236,7 +1236,7 @@ if (str_starts_with($route, '/api/')) {
<i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span> <i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span>
</button> </button>
<button id="gallery-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"> <button id="gallery-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">Images</span> <i class="ph ph-folder-open text-sm"></i><span class="font-pixel text-[10px]">Assets</span>
</button> </button>
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors"> <button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">Tasks</span> <i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">Tasks</span>
@@ -2027,18 +2027,24 @@ if (str_starts_with($route, '/api/')) {
</div> </div>
<!-- Gallery --> <!-- Assets (was Gallery) -->
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery"> <div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;"> <div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0"> <div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
<i class="ph ph-images text-t-accent text-base"></i> <i class="ph ph-folder-open text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">images</h2> <h2 class="font-pixel text-xs text-t-accent">assets</h2>
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span> <span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
<div id="gallery-tabs" class="flex items-center gap-1 ml-3">
<button class="gallery-tab active font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="all">All</button>
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="image">Images</button>
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="audio">Audio</button>
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="pdf">PDF</button>
</div>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="gallery-upload" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"> <button id="gallery-upload" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">Upload</span> <i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">Upload</span>
</button> </button>
<input type="file" id="gallery-upload-file" accept="image/*,audio/*" multiple class="hidden"> <input type="file" id="gallery-upload-file" accept="image/*,audio/*,.pdf,application/pdf" multiple class="hidden">
<button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"> <button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span> <i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
</button> </button>
@@ -2140,6 +2146,8 @@ if (str_starts_with($route, '/api/')) {
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button> <button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button> <button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
<button id="md-upload-pdf" title="Upload & link PDF" class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
<?= PluginManager::instance()->slot('md-toolbar') ?> <?= PluginManager::instance()->slot('md-toolbar') ?>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button> <button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
+18 -7
View File
@@ -994,10 +994,11 @@ body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hi
async function uploadFile(file) { async function uploadFile(file) {
const isAudio = file.type.startsWith('audio/'); const isAudio = file.type.startsWith('audio/');
const isImage = file.type.startsWith('image/'); const isImage = file.type.startsWith('image/');
if (!isAudio && !isImage) return; const isPdf = file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf');
if (!isAudio && !isImage && !isPdf) return;
const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin'; const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin';
const placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()'; const placeholder = isPdf ? '[uploading pdf...]' : isAudio ? '[uploading audio...]' : '![uploading...]()';
const pos = body.selectionStart; const pos = body.selectionStart;
body.setRangeText(placeholder, pos, body.selectionEnd, 'end'); body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
updatePreview(); updatePreview();
@@ -1010,9 +1011,11 @@ async function uploadFile(file) {
const data = JSON.parse(text); const data = JSON.parse(text);
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
if (data.url) { if (data.url) {
const md = isAudio const md = isPdf
? `<audio controls preload="metadata" src="${data.url}"></audio>` ? `[📄 ${file.name || data.name}](${data.url})`
: `![${data.name}](${data.url})`; : isAudio
? `<audio controls preload="metadata" src="${data.url}"></audio>`
: `![${data.name}](${data.url})`;
const idx = body.value.indexOf(placeholder); const idx = body.value.indexOf(placeholder);
if (idx !== -1) { if (idx !== -1) {
body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length); body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length);
@@ -1033,7 +1036,7 @@ body.addEventListener('paste', e => {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
if (!items) return; if (!items) return;
for (const item of items) { for (const item of items) {
if (item.type.startsWith('image/') || item.type.startsWith('audio/')) { if (item.type.startsWith('image/') || item.type.startsWith('audio/') || item.type === 'application/pdf') {
e.preventDefault(); e.preventDefault();
const file = item.getAsFile(); const file = item.getAsFile();
if (file) uploadFile(file); if (file) uploadFile(file);
@@ -1047,7 +1050,7 @@ body.addEventListener('drop', e => {
const files = e.dataTransfer?.files; const files = e.dataTransfer?.files;
if (!files?.length) return; if (!files?.length) return;
for (const file of files) { for (const file of files) {
if (file.type.startsWith('image/') || file.type.startsWith('audio/')) { if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type === 'application/pdf') {
e.preventDefault(); e.preventDefault();
uploadFile(file); uploadFile(file);
return; return;
@@ -1055,6 +1058,14 @@ body.addEventListener('drop', e => {
} }
}); });
// PDF upload toolbar button
$('md-upload-pdf')?.addEventListener('click', () => $('md-upload-pdf-file')?.click());
$('md-upload-pdf-file')?.addEventListener('change', e => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
e.target.value = '';
});
// ── Image resize ── // ── Image resize ──
document.addEventListener('mousedown', e => { document.addEventListener('mousedown', e => {
+89 -14
View File
@@ -1,44 +1,79 @@
// ── Image gallery ── // ── Assets (images, audio, PDFs) ──
import { $, esc, api, getCsrf } from './util.js'; import { $, esc, api, getCsrf } from './util.js';
const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i; 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 isAudio(url) { return AUDIO_EXT.test(url); }
function mediaTile(url, extraClass = '') { function mediaTile(url, extraClass = '') {
if (isAudio(url)) { 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"> 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> <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> <audio controls preload="metadata" src="${url}" class="w-full" style="height:28px"></audio>
</div>`; </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"> 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"> <img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
</div>`; </div>`;
} }
let activeFilter = 'all';
import { state } from './state.js'; import { state } from './state.js';
import { showDialog } from './dialog.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() { function getReferencedUrls() {
const urls = new Set(); const urls = new Set();
for (const card of state.cards) { for (const card of galleryCards) {
const body = card.body || ''; const body = card.body || '';
let m; let m;
// Markdown images: ![alt](url)
const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g; const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g;
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]); while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
// HTML images
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi; const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]); while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
// Slide-image blocks
const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g; const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g;
while ((m = slideRe.exec(body)) !== null) { while ((m = slideRe.exec(body)) !== null) {
const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m); const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m);
if (srcMatch) urls.add(srcMatch[1].trim()); 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; return urls;
} }
function findCardForUrl(url) { function findCardForUrl(url) {
for (const card of state.cards) { for (const card of galleryCards) {
const body = card.body || ''; const body = card.body || '';
if (body.includes(url)) return card; if (body.includes(url)) return card;
} }
@@ -49,7 +84,15 @@ export async function openGallery() {
const el = $('gallery'); const el = $('gallery');
if (!el) return; if (!el) return;
const files = await api('api/uploads'); // 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 referenced = getReferencedUrls();
const grid = $('gallery-grid'); const grid = $('gallery-grid');
const count = $('gallery-count'); const count = $('gallery-count');
@@ -66,21 +109,43 @@ export async function openGallery() {
} }
const total = linked.length + orphaned.length; const total = linked.length + orphaned.length;
if (count) count.textContent = `${total} image${total !== 1 ? 's' : ''}${orphaned.length ? ` · ${orphaned.length} orphaned` : ''}`; 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) { if (!total) {
grid.innerHTML = `<div class="col-span-full text-center py-16"> 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> <i class="ph ph-folder-open text-4xl text-t-dim mb-2 block"></i>
<p class="font-pixel text-[10px] text-t-dim">no images</p></div>`; <p class="font-pixel text-[10px] text-t-dim cursor-blink">no assets</p></div>`;
} else { } else {
let html = ''; let html = '';
if (orphaned.length) { 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 += `<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 => ` html += orphaned.map(f => `
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}"> <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"> <div class="relative aspect-square bg-t-bg border border-t-danger/30 rounded overflow-visible">
<img src="${f.url}" class="w-full h-full object-cover opacity-60 rounded" loading="lazy"> ${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"> <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-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-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>
@@ -94,7 +159,7 @@ export async function openGallery() {
if (linked.length) { 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>`; 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 => ` html += linked.map(img => `
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}" data-gallery-origin="${encodeURIComponent(img.url)}"> <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')} ${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="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"> <div class="flex items-center gap-1.5">
@@ -117,13 +182,16 @@ export async function openGallery() {
}); });
}); });
// Orphan: click to enlarge // Orphan: click to enlarge (images) or open in new tab (PDFs)
grid.querySelectorAll('[data-orphan]').forEach(item => { grid.querySelectorAll('[data-orphan]').forEach(item => {
item.addEventListener('click', e => { item.addEventListener('click', e => {
if (e.target.closest('button')) return; if (e.target.closest('button')) return;
const name = item.dataset.orphan; const name = item.dataset.orphan;
const f = orphaned.find(o => o.name === name); const f = orphaned.find(o => o.name === name);
if (f && !isAudio(f.url)) openOrphanLightbox(f); if (!f) return;
const type = fileType(f.url);
if (type === 'pdf') window.open(f.url, '_blank');
else if (type === 'image') openOrphanLightbox(f);
}); });
}); });
@@ -134,7 +202,11 @@ export async function openGallery() {
const name = btn.dataset.newOrphan; const name = btn.dataset.newOrphan;
const label = name.replace(/\.[^.]+$/, ''); const label = name.replace(/\.[^.]+$/, '');
closeGallery(); closeGallery();
window._openModal({ id: null, title: label, body: `![${label}](uploads/${name})`, color: '#38bdf8', bucket_id: null }); 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 });
}); });
}); });
@@ -165,6 +237,9 @@ export async function openGallery() {
}); });
} }
// Apply the active filter (persists across re-opens within a session)
applyFilter(activeFilter);
el.classList.add('open'); el.classList.add('open');
} }