-
-
images
+
+
assets
+
+
+
+
+
+
-
+
@@ -2140,6 +2146,8 @@ if (str_starts_with($route, '/api/')) {
+
+
= PluginManager::instance()->slot('md-toolbar') ?>
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index 334323e..6b70bae 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -994,10 +994,11 @@ body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hi
async function uploadFile(file) {
const isAudio = file.type.startsWith('audio/');
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 placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()';
+ const placeholder = isPdf ? '[uploading pdf...]' : isAudio ? '[uploading audio...]' : '![uploading...]()';
const pos = body.selectionStart;
body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
updatePreview();
@@ -1010,9 +1011,11 @@ async function uploadFile(file) {
const data = JSON.parse(text);
if (data.error) throw new Error(data.error);
if (data.url) {
- const md = isAudio
- ? `
`
- : ``;
+ const md = isPdf
+ ? `[๐ ${file.name || data.name}](${data.url})`
+ : isAudio
+ ? `
`
+ : ``;
const idx = body.value.indexOf(placeholder);
if (idx !== -1) {
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;
if (!items) return;
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();
const file = item.getAsFile();
if (file) uploadFile(file);
@@ -1047,7 +1050,7 @@ body.addEventListener('drop', e => {
const files = e.dataTransfer?.files;
if (!files?.length) return;
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();
uploadFile(file);
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 โโ
document.addEventListener('mousedown', e => {
diff --git a/public/js/modules/gallery.js b/public/js/modules/gallery.js
index 3334e51..d9aa251 100644
--- a/public/js/modules/gallery.js
+++ b/public/js/modules/gallery.js
@@ -1,44 +1,79 @@
-// โโ Image gallery โโ
+// โโ 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 = '') {
- if (isAudio(url)) {
+ 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 state.cards) {
+ 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 state.cards) {
+ for (const card of galleryCards) {
const body = card.body || '';
if (body.includes(url)) return card;
}
@@ -49,7 +84,15 @@ export async function openGallery() {
const el = $('gallery');
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 grid = $('gallery-grid');
const count = $('gallery-count');
@@ -66,21 +109,43 @@ export async function openGallery() {
}
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) {
grid.innerHTML = `
`;
+
+
no assets
`;
} else {
let html = '';
if (orphaned.length) {
html += `