From 03f57cbaee59fc2160c45cb17b1e56e0a4dce153 Mon Sep 17 00:00:00 2001 From: th Date: Tue, 7 Apr 2026 08:20:48 +0200 Subject: [PATCH] bak --- Dockerfile | 3 ++ public/css/style.css | 22 ++++++++- public/index.php | 17 +++++-- public/js/modules/board.js | 17 ++++++- public/js/modules/buckets.js | 9 ++-- public/js/modules/editor.js | 87 ++++++++++++++++++++++++----------- public/js/modules/gallery.js | 23 ++++++--- public/js/modules/markdown.js | 4 +- 8 files changed, 136 insertions(+), 46 deletions(-) diff --git a/Dockerfile b/Dockerfile index 59cdc3d..be1c795 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,9 @@ RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ /etc/apache2/sites-available/*.conf \ /etc/apache2/apache2.conf +# PHP upload limits +RUN echo "upload_max_filesize = 20M\npost_max_size = 22M" > /usr/local/etc/php/conf.d/uploads.ini + # Allow .htaccess overrides RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' \ /etc/apache2/apache2.conf diff --git a/public/css/style.css b/public/css/style.css index 4b23c5e..87fbafe 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -111,8 +111,22 @@ body::after { border: 2px solid var(--t-border); box-shadow: 0 2px 8px rgba(0,0,0,0.3); z-index: 2; + transition: width 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), height 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), margin 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.2s ease, box-shadow 0.25s ease; +} +.card-thumb { + transform-origin: center center; +} +.card-thumb:hover { + width: 80px; + height: 80px; + margin-top: -18px; + margin-right: -18px; + border-color: var(--t-muted); + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + z-index: 10; + background-size: cover; + background-repeat: no-repeat; } -.group:hover .card-thumb { border-color: var(--t-muted); } .card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); } .card-dragging { opacity: 0.15; transform: scale(0.95); } @@ -444,6 +458,12 @@ body.detail-image-fs-open { overflow: hidden; } } .prose li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.5em; } +.prose audio { + width: 100%; + max-width: 400px; + margin: 0.8em 0; + border-radius: 6px; +} .prose img { max-width: 100%; border-radius: 4px; diff --git a/public/index.php b/public/index.php index 7d14145..472fa9c 100644 --- a/public/index.php +++ b/public/index.php @@ -204,7 +204,7 @@ if (str_starts_with($route, '/api/')) { $dir = __DIR__ . '/uploads'; $files = []; if (is_dir($dir)) { - foreach (glob($dir . '/*.{png,jpg,gif,webp}', GLOB_BRACE) as $f) { + foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) { $name = basename($f); $files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)]; } @@ -245,14 +245,20 @@ if (str_starts_with($route, '/api/')) { exit; } - // Check real MIME via finfo, not client-supplied type - $allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp']; + // 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']; + $extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a']; $finfo = new finfo(FILEINFO_MIME_TYPE); $realMime = $finfo->file($file['tmp_name']); - $ext = $allowed[$realMime] ?? null; + $ext = $mimeMap[$realMime] ?? null; + // Fallback: trust extension for audio when finfo returns octet-stream + if (!$ext && ($realMime === 'application/octet-stream' || str_starts_with($realMime, 'audio/'))) { + $origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + $ext = $extMap[$origExt] ?? null; + } if (!$ext) { http_response_code(400); - echo json_encode(['error' => 'invalid image type']); + echo json_encode(['error' => 'invalid file type', 'detected' => $realMime]); exit; } @@ -470,6 +476,7 @@ if (str_starts_with($route, '/api/')) { +
© hyprcrd
diff --git a/public/js/modules/board.js b/public/js/modules/board.js index 72d663b..bf6d5a3 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -51,7 +51,16 @@ export function renderBoard() { cards = parent ? [parent, ...getChildren(parent.id)] : []; } else { cards = state.cards.filter(c => !c.parent_id); - if (state.activeBucket === 'none') cards = cards.filter(c => !c.bucket_id); + if (state.activeBucket === 'current') { + const recent = getRecent(); + const now = Date.now(); + const freshMs = 48 * 60 * 60 * 1000; // 48h + cards = cards.filter(c => { + if (recent.includes(String(c.id))) return true; + const updated = new Date(c.updated_at || c.created_at || 0).getTime(); + return (now - updated) < freshMs; + }); + } else if (state.activeBucket === 'none') cards = cards.filter(c => !c.bucket_id); else if (state.activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == state.activeBucket); } cards = sortedCards(cards); @@ -64,6 +73,8 @@ export function renderBoard() { board.innerHTML = `
`; + const fc = $('footer-count'); + if (fc) fc.textContent = `0 cards · ${state.cards.length} total`; return; } @@ -105,6 +116,10 @@ export function renderBoard() { }).join(''); bindCardEvents(); + + // Footer count + const fc = $('footer-count'); + if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`; } // ── Group bar ── diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js index 4280136..d108531 100644 --- a/public/js/modules/buckets.js +++ b/public/js/modules/buckets.js @@ -148,6 +148,7 @@ function renderBar() { bar.innerHTML = ` + ${state.buckets.map(b => { const c = b.color || '#64748b'; @@ -164,7 +165,7 @@ function renderBar() { const bid = t.dataset.bucket; const isUser = bid !== 'all' && bid !== 'none'; - t.addEventListener('click', () => { state.activeBucket = bid; renderBar(); window._renderBoard(); }); + t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); }); if (isUser) { t.addEventListener('contextmenu', e => { @@ -225,9 +226,9 @@ function renderBar() { // Card drop on bucket const cardId = e.dataTransfer.getData('text/plain'); if (!cardId) return; - const bucketId = bid === 'none' ? null : (bid === 'all' ? undefined : bid); + const bucketId = bid === 'none' ? null : ((bid === 'all' || bid === 'current') ? undefined : bid); if (bucketId === undefined) return; - await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId } }); + await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } }); await window._reloadAll(); }); }); @@ -258,7 +259,7 @@ function renderBar() { const card = state.cards.find(c => String(c.id) === String(cardId)); const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } }); if (!created?.id) return; - await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id } }); + await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } }); await loadBuckets(); await window._reloadAll(); }); diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index 6ffb436..6738830 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -207,38 +207,71 @@ body.addEventListener('keydown', e => { body.addEventListener('blur', () => setTimeout(hideLinkAc, 150)); -// ── Clipboard image paste ── +// ── File upload (paste + drag-drop) ── -body.addEventListener('paste', async e => { +async function uploadFile(file) { + const isAudio = file.type.startsWith('audio/'); + const isImage = file.type.startsWith('image/'); + if (!isAudio && !isImage) return; + + const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin'; + const placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()'; + const pos = body.selectionStart; + body.setRangeText(placeholder, pos, body.selectionEnd, 'end'); + updatePreview(); + + const form = new FormData(); + form.append('file', file, (file.name || 'file.' + ext)); + try { + const res = await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); + const text = await res.text(); + console.log('Upload response:', res.status, text.substring(0, 300)); + const data = JSON.parse(text); + if (data.error) throw new Error(data.error); + if (data.url) { + const md = isAudio + ? `` + : `![${data.name}](${data.url})`; + const idx = body.value.indexOf(placeholder); + if (idx !== -1) { + body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length); + body.selectionStart = body.selectionEnd = idx + md.length; + } + } + } catch (err) { + console.error('Upload failed:', err); + const idx = body.value.indexOf(placeholder); + if (idx !== -1) { + body.value = body.value.substring(0, idx) + body.value.substring(idx + placeholder.length); + body.selectionStart = body.selectionEnd = idx; + } + } + updatePreview(); +} + +body.addEventListener('paste', e => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { - if (!item.type.startsWith('image/')) continue; - e.preventDefault(); - const file = item.getAsFile(); - if (!file) return; - const placeholder = '![uploading...]()'; - body.setRangeText(placeholder, body.selectionStart, body.selectionEnd, 'end'); - updatePreview(); - const form = new FormData(); - form.append('file', file, 'paste.' + (file.type.split('/')[1] || 'png')); - try { - const res = await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); - const text = await res.text(); - const data = JSON.parse(text); - if (data.error) throw new Error(data.error); - if (data.url) { - const md = `![${data.name}](${data.url})`; - const idx = body.value.indexOf(placeholder); - if (idx !== -1) { body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length); body.selectionStart = body.selectionEnd = idx + md.length; } - } - } catch (err) { - console.error('Upload failed:', err); - const idx = body.value.indexOf(placeholder); - if (idx !== -1) { body.value = body.value.substring(0, idx) + body.value.substring(idx + placeholder.length); body.selectionStart = body.selectionEnd = idx; } + if (item.type.startsWith('image/') || item.type.startsWith('audio/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) uploadFile(file); + return; + } + } +}); + +body.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); +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/')) { + e.preventDefault(); + uploadFile(file); + return; } - updatePreview(); - return; } }); diff --git a/public/js/modules/gallery.js b/public/js/modules/gallery.js index 1e9489e..cac8673 100644 --- a/public/js/modules/gallery.js +++ b/public/js/modules/gallery.js @@ -1,6 +1,21 @@ // ── 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 `
+ + +
`; + } + return `
+ +
`; +} import { state } from './state.js'; import { showDialog } from './dialog.js'; @@ -59,9 +74,7 @@ export async function openGallery() { html += `
orphaned (not used by any card)
`; html += orphaned.map(f => `