From 262b853c5b5d4be2ccdf54a204ec024748348e9e Mon Sep 17 00:00:00 2001 From: th Date: Sat, 11 Apr 2026 01:22:52 +0200 Subject: [PATCH] slideshow --- public/css/style.css | 23 + public/index.php | 4 + public/js/app.js | 10 +- public/js/modules/board.js | 6 +- public/js/modules/buckets.js | 4 +- public/js/modules/context-menu.js | 4 +- public/js/modules/detail.js | 2 +- public/js/modules/editor.js | 7 +- public/js/modules/gallery.js | 53 +- public/js/modules/markdown.js | 19 +- public/plugins/MINDMAP.md | 32 +- public/plugins/chat/chat.css | 7 + public/plugins/slideshow/plugin.json | 10 + public/plugins/slideshow/slideshow.css | 285 +++++++++ public/plugins/slideshow/slideshow.html | 94 +++ public/plugins/slideshow/slideshow.js | 764 ++++++++++++++++++++++++ public/plugins/tests/relay.test.js | 5 +- public/plugins/tests/snippets.test.js | 6 +- 18 files changed, 1305 insertions(+), 30 deletions(-) create mode 100644 public/plugins/slideshow/plugin.json create mode 100644 public/plugins/slideshow/slideshow.css create mode 100644 public/plugins/slideshow/slideshow.html create mode 100644 public/plugins/slideshow/slideshow.js diff --git a/public/css/style.css b/public/css/style.css index 5d24de1..3565f3d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -660,12 +660,35 @@ body.detail-image-fs-open { overflow: hidden; } } .prose img { max-width: 100%; + height: auto; border-radius: 4px; border: 1px solid var(--t-border); margin: 0.8em 0; cursor: nwse-resize; } .prose img.resizing { outline: 2px solid var(--t-accent); } + +/* Preview panes: ensure the prose content never overflows horizontally so + images can resolve their `max-width: 100%` against a constrained container. + Without this, images with explicit width attrs (e.g. ) + push the content box wider than the flex pane. */ +#editor-preview, +#board-preview-body { + overflow-x: hidden; + min-width: 0; +} +#preview-content, +#board-preview-body > * { + max-width: 100%; + min-width: 0; +} +#editor-preview .prose img, +#board-preview-body.prose img, +#board-preview-body .prose img { + max-width: 100%; + width: auto; + height: auto; +} .prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; } .prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; } .prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; } diff --git a/public/index.php b/public/index.php index 83edf2c..ddb4ce7 100644 --- a/public/index.php +++ b/public/index.php @@ -1877,6 +1877,10 @@ if (str_starts_with($route, '/api/')) {

images

+ + diff --git a/public/js/app.js b/public/js/app.js index a7752b9..a0a1583 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -126,10 +126,10 @@ importFile?.addEventListener('change', async e => { // Check if it's a selective export (merge) or ask for full replace const form = new FormData(); form.append('file', file); - const mode = await showDialog({ title: 'import_data', message: 'Wie importieren?', okLabel: 'merge (hinzufuegen)', cancelLabel: 'replace (alles ersetzen)' }); + const mode = await showDialog({ title: 'import_data', message: 'How do you want to import?', okLabel: 'merge (add to existing)', cancelLabel: 'replace (wipe all)' }); // mode: true = merge, null = replace if (mode === null) { - const sure = await showDialog({ title: 'replace_all', message: 'Alle bestehenden Daten werden ersetzt!', danger: true, okLabel: 'replace' }); + const sure = await showDialog({ title: 'replace_all', message: 'All existing data will be replaced!', danger: true, okLabel: 'replace' }); if (!sure) { e.target.value = ''; return; } } try { @@ -161,7 +161,7 @@ importLogseq?.addEventListener('change', async e => { // ── Auth ── document.getElementById('logout-btn')?.addEventListener('click', async () => { - const ok = await showDialog({ title: 'logout', message: 'Abmelden?', okLabel: 'logout', danger: true }); + const ok = await showDialog({ title: 'logout', message: 'Log out?', okLabel: 'logout', danger: true }); if (!ok) return; await fetch('api/auth/logout', { method: 'POST' }); location.reload(); @@ -175,7 +175,7 @@ async function checkAuth() { if (data.setup) { let pw = null; while (!pw || pw.length < 4) { - pw = await showDialog({ title: 'setup', message: 'Willkommen bei hyprcrd. Setze ein Passwort:', input: 'password', placeholder: '> passwort (min. 4 zeichen)...', okLabel: 'set', cancelLabel: '' }); + pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'set', cancelLabel: '' }); if (pw && pw.length < 4) pw = null; } const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); @@ -185,7 +185,7 @@ async function checkAuth() { } else if (!data.logged_in) { let ok = false; while (!ok) { - const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> passwort...', okLabel: 'login', cancelLabel: '' }); + const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> password...', okLabel: 'login', cancelLabel: '' }); if (!pw) continue; const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); const d = await r.json(); diff --git a/public/js/modules/board.js b/public/js/modules/board.js index c501555..89cdd4f 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -78,7 +78,7 @@ document.addEventListener('keydown', async e => { e.preventDefault(); if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { const { showDialog } = await import('./dialog.js'); - const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' }); + const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'delete' }); if (!ok) return; } for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' }); @@ -215,7 +215,7 @@ $('select-delete')?.addEventListener('click', async () => { if (!selectedIds.size) return; if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { const { showDialog } = await import('./dialog.js'); - const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' }); + const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'delete' }); if (!ok) return; } for (const id of selectedIds) { @@ -771,6 +771,7 @@ function renderGroupBar(cards) { if (state.activeGroup) { const parent = state.cards.find(c => c.id == state.activeGroup); bar.classList.remove('hidden'); + bar.dataset.groupId = String(state.activeGroup); bar.innerHTML = ` + @@ -132,13 +138,25 @@ export async function openGallery() { }); }); + // 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: `"${name}" loeschen?`, danger: true, okLabel: 'delete' }); + 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() } }); @@ -163,6 +181,9 @@ function openOrphanLightbox(file) { + @@ -183,8 +204,16 @@ function openOrphanLightbox(file) { window._openModal({ id: null, title: name, body: `![${name}](${file.url})`, 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: `"${file.name}" loeschen?`, danger: true, okLabel: 'delete' }); + 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(); @@ -201,3 +230,23 @@ export function closeGallery() { $('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(); +}); diff --git a/public/js/modules/markdown.js b/public/js/modules/markdown.js index 9c19718..6b24cb6 100644 --- a/public/js/modules/markdown.js +++ b/public/js/modules/markdown.js @@ -243,12 +243,23 @@ export function getTodos(src) { return { total, done: (s.match(/- \[x\]/g) || []).length }; } +// Matches the `src:` line inside a ```slide-image fenced block (slideshow plugin format). +const SLIDE_IMAGE_RE = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/; +function slideImageSrc(block) { + if (!block) return null; + const m = block.match(/^\s*src\s*:\s*([^\n]+)/m); + return m ? m[1].trim() : null; +} + export function firstImage(src) { const s = src || ''; const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/); if (md) return md[1]; const html = s.match(/]+src="([^"]+)"/i); - return html ? html[1] : null; + if (html) return html[1]; + const slide = s.match(SLIDE_IMAGE_RE); + if (slide) return slideImageSrc(slide[1]); + return null; } export function leadingImage(src) { @@ -257,5 +268,9 @@ export function leadingImage(src) { const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/); if (md) return md[1].split(/\s+/)[0].replace(/^<(.+)>$/, '$1') || null; const html = s.match(/^]+src=(['"])(.*?)\1/i); - return html ? html[2] : null; + if (html) return html[2]; + // A card whose body *starts* with a slide-image block → use its image as the cover. + const slide = s.match(/^```slide-image\s*\n([\s\S]*?)(?:\n```|$)/); + if (slide) return slideImageSrc(slide[1]); + return null; } diff --git a/public/plugins/MINDMAP.md b/public/plugins/MINDMAP.md index 8484ed1..8d07b50 100644 --- a/public/plugins/MINDMAP.md +++ b/public/plugins/MINDMAP.md @@ -162,15 +162,29 @@ hyprcrd plugin system │ │ ├── loader.js conditionally imports tests.js on ?test or Ctrl+Shift+T │ │ ├── Test files import from '../../js/modules/...' (NOT '../modules/...') │ │ └── Both static AND dynamic imports use that path — sed both when moving files -│ └── email/ (~1300 lines, biggest) -│ ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes) -│ ├── Schema: email_accounts table, passwords AES-256-CBC with vault key -│ ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}} -│ ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'}) -│ │ └── onShow rebuilds form on first show, refreshes accounts list every show -│ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email') -│ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn -│ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll() +│ ├── email/ (~1300 lines, biggest) +│ │ ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes) +│ │ ├── Schema: email_accounts table, passwords AES-256-CBC with vault key +│ │ ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}} +│ │ ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'}) +│ │ │ └── onShow rebuilds form on first show, refreshes accounts list every show +│ │ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email') +│ │ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn +│ │ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll() +│ └── slideshow/ (~500 lines) +│ ├── No backend — data stored as ```slide-image fenced block in card body +│ ├── Slideshow = parent card (start slide) + children (in position order) +│ ├── Text slides: normal markdown rendered fullscreen via hyprcrd.markdown.render +│ ├── Image slides: fenced block with `src:` + `x,y,w,h -> target` lines (coords in %) +│ │ └── Targets: card:, slide:next, slide:prev, slide:first, slide: +│ ├── Viewer: fullscreen overlay, ArrowLeft/Right + Esc, HUD with counter +│ │ ├── Image slides use object-fit:contain; hotspots positioned relative to img box +│ │ ├── Arrow keys always work as fallback, even when hotspots are present +│ │ └── 'h' key toggles debug-hotspots class to visualize rects during presentation +│ ├── Hotspot editor: drag on image to create rect, picker assigns target, dbl-click rect to re-pick +│ ├── Group-bar button: mutation-observed injection; reads parent id from #group-bar[data-group-id] +│ │ └── Requires board.js renderGroupBar to write bar.dataset.groupId — small core patch +│ └── Toolbar button "slideHotspots" injected into #md-toolbar, detects existing block at cursor │ ├── Gotchas / lessons learned │ ├── statusHeight vs scrollHeight — textarea.scrollHeight includes padding, mind it diff --git a/public/plugins/chat/chat.css b/public/plugins/chat/chat.css index bbe64b4..6fc12f3 100644 --- a/public/plugins/chat/chat.css +++ b/public/plugins/chat/chat.css @@ -44,6 +44,13 @@ overflow-y: auto; } +/* When empty, collapse so the `.chat-empty` placeholder can fill and center + properly instead of being pushed into the lower half of the view. */ +.chat-list:empty { + flex: 0 0 0; + display: none; +} + .chat-list-row { display: flex; align-items: center; diff --git a/public/plugins/slideshow/plugin.json b/public/plugins/slideshow/plugin.json new file mode 100644 index 0000000..165321c --- /dev/null +++ b/public/plugins/slideshow/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "slideshow", + "version": "1.0.0", + "description": "Presentation plugin — render a card group as a slideshow. Image slides with invisible clickable hotspot rects that navigate between slides.", + "frontend": { + "js": ["slideshow.js"], + "css": ["slideshow.css"], + "html": ["slideshow.html"] + } +} diff --git a/public/plugins/slideshow/slideshow.css b/public/plugins/slideshow/slideshow.css new file mode 100644 index 0000000..d470ce0 --- /dev/null +++ b/public/plugins/slideshow/slideshow.css @@ -0,0 +1,285 @@ +/* ── Slideshow Plugin ── */ + +/* ── Fenced block preview (in normal card views) ── */ +/* `inline-block + max-width:100%` doesn't reliably constrain the container when the + img's intrinsic size is larger than the parent, so we use block + fit-content instead. + That way the container shrinks to the img while still respecting the parent width cap. */ +.slide-image-preview { + position: relative; + display: block; + width: fit-content; + max-width: 100%; + box-sizing: border-box; + line-height: 0; + border: 1px solid var(--t-border); + background: var(--t-bg); + margin: 0.8em 0; +} +.slide-image-preview img, +.prose .slide-image-preview img { + display: block; + max-width: 100%; + width: auto; + height: auto; + pointer-events: none; + margin: 0; + border: 0; + border-radius: 0; + cursor: default; +} +.slide-image-preview .slide-hotspot { + position: absolute; + border: 1px dashed var(--t-accent); + background: color-mix(in srgb, var(--t-accent) 12%, transparent); + pointer-events: auto; + cursor: help; + box-sizing: border-box; + transition: background 0.15s, border-color 0.15s; +} +.slide-image-preview .slide-hotspot:hover { + background: color-mix(in srgb, var(--t-accent) 25%, transparent); + border-color: var(--t-bright); +} +/* Hover tooltip: shows the resolved target label (usually the card title) */ +.slide-image-preview .slide-hotspot[data-label]::after { + content: attr(data-label); + position: absolute; + left: 50%; + top: 100%; + margin-top: 4px; + transform: translateX(-50%); + padding: 3px 6px; + background: var(--t-surface); + border: 1px solid var(--t-accent); + color: var(--t-bright); + font-family: var(--t-font-pixel, monospace); + font-size: 10px; + line-height: 1.3; + white-space: nowrap; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + z-index: 10; +} +.slide-image-preview .slide-hotspot[data-label]:hover::after { + opacity: 1; +} + +/* ── Viewer ── */ +#slideshow-viewer.hidden { display: none; } + +#slideshow-slide .slide-text { + width: 100%; + height: 100%; + padding: 6vh 8vw; + overflow: auto; + color: var(--t-text); + background: var(--t-bg); + display: flex; + flex-direction: column; + gap: 2vh; +} +#slideshow-slide .slide-text .slide-title { + font-family: var(--t-font-pixel, monospace); + font-size: clamp(18px, 3.2vw, 42px); + color: var(--t-accent); + margin: 0; +} +#slideshow-slide .slide-text .slide-prose { + font-size: clamp(14px, 1.8vw, 22px); + line-height: 1.55; + max-width: 80ch; +} +#slideshow-slide .slide-text .slide-prose h1, +#slideshow-slide .slide-text .slide-prose h2, +#slideshow-slide .slide-text .slide-prose h3 { color: var(--t-bright); margin-top: 0.8em; } +#slideshow-slide .slide-text .slide-prose p { margin: 0.5em 0; } +#slideshow-slide .slide-text .slide-prose img { max-width: 100%; height: auto; } +#slideshow-slide .slide-text .slide-prose pre { + background: var(--t-surface); + padding: 0.8em 1em; + border: 1px solid var(--t-border); + overflow: auto; + font-size: 0.75em; +} +#slideshow-slide .slide-text .slide-prose code { font-family: var(--t-font-mono, monospace); } + +#slideshow-slide .slide-image-wrap { + position: relative; + display: inline-block; + line-height: 0; + max-width: 100vw; + max-height: 100vh; +} +#slideshow-slide .slide-image-wrap > img { + display: block; + max-width: 100vw; + max-height: 100vh; + width: auto; + height: auto; + object-fit: contain; + user-select: none; + pointer-events: none; + -webkit-user-drag: none; +} +#slideshow-slide .slide-hotspot-live { + position: absolute; + box-sizing: border-box; + cursor: pointer; + /* Nearly-invisible fill: transparent divs sometimes don't register hits reliably, this forces them to. */ + background: rgba(0, 0, 0, 0.001); + border: 0; + pointer-events: auto; + z-index: 5; + transition: background 0.15s, border 0.15s; +} +#slideshow-viewer.debug-hotspots .slide-hotspot-live { + background: color-mix(in srgb, var(--t-accent) 18%, transparent); + border: 1px dashed var(--t-accent); +} +#slideshow-slide .slide-hotspot-live:hover { + background: color-mix(in srgb, var(--t-accent) 15%, transparent); + border: 1px solid var(--t-accent); +} + +/* ── Group-bar button ── */ +#group-bar .slideshow-present-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-family: var(--t-font-pixel, monospace); + font-size: 10px; + color: var(--t-muted); + border: 1px solid var(--t-border); + background: transparent; + cursor: pointer; + transition: all 0.15s; +} +#group-bar .slideshow-present-btn:hover { + color: var(--t-accent); + border-color: var(--t-accent); +} + +/* ── Hotspot editor ── */ +#slideshow-editor.hidden { display: none; } +#slideshow-editor[disabled], #slideshow-editor [disabled] { opacity: 0.4; pointer-events: none; } +#slideshow-ed-stage { cursor: crosshair; } +#slideshow-ed-layer { pointer-events: auto; } +#slideshow-ed-layer .ed-rect { + position: absolute; + box-sizing: border-box; + border: 1px solid var(--t-accent); + background: color-mix(in srgb, var(--t-accent) 10%, transparent); + cursor: pointer; +} +#slideshow-ed-layer .ed-rect.selected { + border: 2px solid var(--t-bright); + background: color-mix(in srgb, var(--t-bright) 18%, transparent); + cursor: move; +} +#slideshow-ed-layer .ed-rect .ed-label { + position: absolute; + left: 0; + bottom: 100%; + margin-bottom: 2px; + font-family: var(--t-font-pixel, monospace); + font-size: 9px; + padding: 1px 3px; + background: var(--t-accent); + color: var(--t-bg); + white-space: nowrap; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} +#slideshow-ed-layer .ed-handle { + position: absolute; + width: 10px; + height: 10px; + background: var(--t-bright); + border: 1px solid var(--t-bg); + box-sizing: border-box; + pointer-events: auto; + z-index: 2; +} +#slideshow-ed-layer .ed-handle[data-handle="nw"] { left: -5px; top: -5px; cursor: nwse-resize; } +#slideshow-ed-layer .ed-handle[data-handle="n"] { left: 50%; top: -5px; cursor: ns-resize; transform: translateX(-50%); } +#slideshow-ed-layer .ed-handle[data-handle="ne"] { right: -5px; top: -5px; cursor: nesw-resize; } +#slideshow-ed-layer .ed-handle[data-handle="e"] { right: -5px; top: 50%; cursor: ew-resize; transform: translateY(-50%); } +#slideshow-ed-layer .ed-handle[data-handle="se"] { right: -5px; bottom: -5px; cursor: nwse-resize; } +#slideshow-ed-layer .ed-handle[data-handle="s"] { left: 50%; bottom: -5px; cursor: ns-resize; transform: translateX(-50%); } +#slideshow-ed-layer .ed-handle[data-handle="sw"] { left: -5px; bottom: -5px; cursor: nesw-resize; } +#slideshow-ed-layer .ed-handle[data-handle="w"] { left: -5px; top: 50%; cursor: ew-resize; transform: translateY(-50%); } +#slideshow-ed-layer .ed-rect-draft { + position: absolute; + box-sizing: border-box; + border: 1px dashed var(--t-bright); + background: color-mix(in srgb, var(--t-bright) 8%, transparent); + pointer-events: none; +} + +/* ── Gallery picker ── */ +#slideshow-gallery-picker.hidden { display: none; } +.slideshow-gp-item { + position: relative; + aspect-ratio: 1; + background: var(--t-bg); + border: 1px solid var(--t-border); + cursor: pointer; + overflow: hidden; + transition: border 0.15s; +} +.slideshow-gp-item:hover { border-color: var(--t-accent); } +.slideshow-gp-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + pointer-events: none; +} +.slideshow-gp-item .slideshow-gp-name { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 3px 5px; + background: rgba(0, 0, 0, 0.7); + color: var(--t-text); + font-family: var(--t-font-pixel, monospace); + font-size: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Target picker ── */ +#slideshow-picker.hidden { display: none; } +.slideshow-pick-row { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + font-family: var(--t-font-pixel, monospace); + font-size: 10px; + color: var(--t-text); + border: 1px solid var(--t-border); + background: var(--t-bg); + text-align: left; + cursor: pointer; + transition: all 0.15s; +} +.slideshow-pick-row:hover { + border-color: var(--t-accent); + color: var(--t-accent); +} +#slideshow-pick-list .slideshow-pick-row { + border: 1px solid transparent; +} +#slideshow-pick-list .slideshow-pick-row:hover { + border-color: var(--t-accent); +} diff --git a/public/plugins/slideshow/slideshow.html b/public/plugins/slideshow/slideshow.html new file mode 100644 index 0000000..d38f79b --- /dev/null +++ b/public/plugins/slideshow/slideshow.html @@ -0,0 +1,94 @@ + + + + + + + + + + + + + diff --git a/public/plugins/slideshow/slideshow.js b/public/plugins/slideshow/slideshow.js new file mode 100644 index 0000000..8ab046a --- /dev/null +++ b/public/plugins/slideshow/slideshow.js @@ -0,0 +1,764 @@ +// ── Slideshow Plugin ── +// +// Stellt eine Karten-Gruppe (Parent + Children) als Präsentation dar. +// - Text-Folien: normaler Markdown-Content wird fullscreen gerendert. +// - Bild-Folien: Card-Body enthält einen ```slide-image block mit Bild + Hotspot-Rects. +// Rects sind unsichtbar, aber klickbar und springen zu anderen Folien. +// +// Datenformat (fenced block ```slide-image```): +// +// src: /uploads/foo.png +// 10,20,30,15 -> card:42 +// 50,60,20,10 -> slide:next +// +// Koordinaten in Prozent. Targets: +// card: → springt zu Karte mit (wenn in der Gruppe) +// slide:next → nächste Folie +// slide:prev → vorherige Folie +// slide:first → Startfolie +// slide: → Folie mit Index n (0-basiert) + +const hyprcrd = window.hyprcrd; +if (!hyprcrd) throw new Error('[slideshow] window.hyprcrd not found'); + +const $ = (id) => document.getElementById(id); +const escHtml = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + +// ── Fenced block parser/serializer ─────────────────────────────── + +function parseSlideBlock(content) { + const lines = String(content || '').split('\n'); + let src = ''; + const hotspots = []; + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + if (/^src\s*:/i.test(line)) { + src = line.replace(/^src\s*:\s*/i, '').trim(); + continue; + } + const m = line.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*(?:->|→)\s*(.+)$/); + if (m) hotspots.push({ x: +m[1], y: +m[2], w: +m[3], h: +m[4], target: m[5].trim() }); + } + return { src, hotspots }; +} + +function serializeSlideBlock({ src, hotspots }) { + const r = (n) => Math.round(Number(n) * 100) / 100; + const out = [`src: ${src || ''}`]; + for (const h of hotspots) { + out.push(`${r(h.x)},${r(h.y)},${r(h.w)},${r(h.h)} -> ${h.target}`); + } + return out.join('\n'); +} + +// Find an existing ```slide-image``` block that contains the cursor position +// (or returns the first one if no cursor given). Returns { start, end, content } or null. +function findSlideBlock(value, cursor) { + const re = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g; + let m; + let first = null; + while ((m = re.exec(value)) !== null) { + const start = m.index; + const end = m.index + m[0].length; + const entry = { start, end, content: m[1] }; + if (first === null) first = entry; + if (cursor != null && cursor >= start && cursor <= end) return entry; + } + // Fall back to the first block in the body so the user can edit it from anywhere. + return first; +} + +// Detect if a card body is a "pure image slide" — body's rendered slide block is the primary content. +function extractCardSlide(body) { + const b = String(body || ''); + const m = b.match(/```slide-image\s*\n([\s\S]*?)(?:\n```|$)/); + if (!m) return null; + return parseSlideBlock(m[1]); +} + +// ── Card title cache (for resolving hotspot targets to human labels) ─── + +const cardTitleCache = new Map(); // id → title + +async function refreshCardTitles() { + try { + const cards = await hyprcrd.api('api/cards/meta'); + if (Array.isArray(cards)) { + cardTitleCache.clear(); + for (const c of cards) cardTitleCache.set(String(c.id), c.title || ''); + } + } catch {} +} + +function resolveTargetLabel(target) { + const t = String(target || '').trim(); + if (!t) return ''; + if (t === 'slide:next') return 'next slide'; + if (t === 'slide:prev') return 'previous slide'; + if (t === 'slide:first') return 'first slide'; + const slideMatch = t.match(/^slide:(\d+)$/); + if (slideMatch) return `slide ${slideMatch[1]}`; + const cardMatch = t.match(/^card:(.+)$/); + if (cardMatch) { + const id = cardMatch[1]; + return cardTitleCache.get(String(id)) || ''; + } + return t; +} + +// Fire off initial cache load (non-blocking) +refreshCardTitles(); + +// ── Markdown fenced block renderer (preview in normal card views) ── + +hyprcrd.markdown.registerFencedBlock('slide-image', (content, { esc }) => { + const { src, hotspots } = parseSlideBlock(content); + if (!src) return `
slide-image: missing src
`; + const rects = hotspots.map(h => { + const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`; + const label = resolveTargetLabel(h.target); + const labelAttr = label ? ` data-label="${esc(label)}"` : ''; + return `
`; + }).join(''); + return `
${rects}
`; +}); + +// ── Viewer ───────────────────────────────────────────────────────── + +const viewer = $('slideshow-viewer'); +const viewerSlide = $('slideshow-slide'); +const viewerTitle = $('slideshow-title'); +const viewerCounter = $('slideshow-counter'); +const viewerPrev = $('slideshow-prev'); +const viewerNext = $('slideshow-next'); +const viewerClose = $('slideshow-close'); + +let vSlides = []; +let vCurrent = 0; +let vOpen = false; + +function openViewer(slides, startIdx = 0) { + if (!viewer || !slides?.length) return; + vSlides = slides; + vCurrent = Math.max(0, Math.min(startIdx, slides.length - 1)); + vOpen = true; + viewer.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + renderViewer(); + document.addEventListener('keydown', onViewerKey, true); +} + +function closeViewer() { + if (!vOpen) return; + vOpen = false; + viewer.classList.add('hidden'); + document.body.style.overflow = ''; + document.removeEventListener('keydown', onViewerKey, true); + viewerSlide.innerHTML = ''; + vSlides = []; +} + +function renderViewer() { + const card = vSlides[vCurrent]; + if (!card) return; + viewerTitle.textContent = card.title || ''; + viewerCounter.textContent = `${vCurrent + 1} / ${vSlides.length}`; + + const slide = extractCardSlide(card.body); + if (slide && slide.src) { + renderImageSlide(slide); + } else { + renderTextSlide(card); + } +} + +function renderTextSlide(card) { + const mdRender = hyprcrd.markdown?.render; + const html = mdRender ? mdRender(card.body || '') : `
${escHtml(card.body || '')}
`; + viewerSlide.innerHTML = ` +
+ ${card.title ? `

${escHtml(card.title)}

` : ''} +
${html}
+
`; +} + +function renderImageSlide(slide) { + const rects = slide.hotspots.map((h, i) => { + const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`; + const label = resolveTargetLabel(h.target) || h.target || ''; + return `
`; + }).join(''); + viewerSlide.innerHTML = ` +
+ + ${rects} +
`; + + viewerSlide.querySelectorAll('[data-hs]').forEach(el => { + const idx = parseInt(el.dataset.hs, 10); + el.addEventListener('click', async (e) => { + e.stopPropagation(); + e.preventDefault(); + const target = slide.hotspots[idx]?.target; + if (target) await navigateTarget(target); + }); + }); +} + +async function navigateTarget(target) { + const t = String(target).trim(); + if (t === 'slide:next') return nextSlide(); + if (t === 'slide:prev') return prevSlide(); + if (t === 'slide:first') { vCurrent = 0; renderViewer(); return; } + const slideMatch = t.match(/^slide:(\d+)$/); + if (slideMatch) { + const idx = parseInt(slideMatch[1], 10); + if (idx >= 0 && idx < vSlides.length) { vCurrent = idx; renderViewer(); } + return; + } + const cardMatch = t.match(/^card:(.+)$/); + if (cardMatch) { + const id = cardMatch[1]; + let idx = vSlides.findIndex(c => String(c.id) === String(id)); + if (idx < 0) { + // Cross-group jump: load the target card from the API and append it to the slide list + try { + const cards = await hyprcrd.api('api/cards?ids=' + encodeURIComponent(id)); + const card = Array.isArray(cards) ? cards.find(c => String(c.id) === String(id)) : null; + if (card) { + vSlides.push(card); + idx = vSlides.length - 1; + } + } catch {} + } + if (idx >= 0) { vCurrent = idx; renderViewer(); } + else console.warn('[slideshow] target card not found:', id); + return; + } + console.warn('[slideshow] unknown target:', target); +} + +function nextSlide() { + if (vCurrent < vSlides.length - 1) { vCurrent++; renderViewer(); } +} +function prevSlide() { + if (vCurrent > 0) { vCurrent--; renderViewer(); } +} + +function onViewerKey(e) { + if (!vOpen) return; + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeViewer(); return; } + if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); nextSlide(); return; } + if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); e.stopPropagation(); prevSlide(); return; } + if (e.key === 'Home') { e.preventDefault(); e.stopPropagation(); vCurrent = 0; renderViewer(); return; } + if (e.key === 'End') { e.preventDefault(); e.stopPropagation(); vCurrent = vSlides.length - 1; renderViewer(); return; } + if (e.key === 'h' || e.key === 'H') { viewer.classList.toggle('debug-hotspots'); e.preventDefault(); } +} + +viewerPrev?.addEventListener('click', (e) => { e.stopPropagation(); prevSlide(); }); +viewerNext?.addEventListener('click', (e) => { e.stopPropagation(); nextSlide(); }); +viewerClose?.addEventListener('click', (e) => { e.stopPropagation(); closeViewer(); }); + +// ── Start viewer from group-bar button ─────────────────────────── + +async function startPresentationForGroup(parentId) { + if (parentId == null) return; + // Fetch full cards so bodies are available + const all = await hyprcrd.api('api/cards'); + if (!Array.isArray(all)) return; + // Refresh title cache from the same payload (saves a round-trip) + cardTitleCache.clear(); + for (const c of all) cardTitleCache.set(String(c.id), c.title || ''); + const parent = all.find(c => String(c.id) === String(parentId)); + if (!parent) return; + const children = all + .filter(c => String(c.parent_id) === String(parentId)) + .sort((a, b) => (a.position || 0) - (b.position || 0)); + const slides = [parent, ...children]; + openViewer(slides, 0); +} + +// ── Group-bar button injection ─────────────────────────────────── + +function injectGroupBarButton() { + const bar = $('group-bar'); + if (!bar) return; + if (bar.classList.contains('hidden')) return; + if (!bar.dataset.groupId) return; + if (bar.querySelector('.slideshow-present-btn')) return; + const btn = document.createElement('button'); + btn.className = 'slideshow-present-btn'; + btn.type = 'button'; + btn.innerHTML = 'present'; + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const pid = bar.dataset.groupId; + if (pid) await startPresentationForGroup(pid); + }); + bar.appendChild(btn); +} + +const groupBar = $('group-bar'); +if (groupBar) { + const mo = new MutationObserver(() => injectGroupBarButton()); + mo.observe(groupBar, { childList: true, subtree: false, attributes: true, attributeFilter: ['class'] }); + injectGroupBarButton(); +} + +// ── Hotspot editor ─────────────────────────────────────────────── + +const ed = { + overlay: $('slideshow-editor'), + img: $('slideshow-ed-img'), + stage: $('slideshow-ed-stage'), + layer: $('slideshow-ed-layer'), + srcInput: $('slideshow-ed-src'), + galleryBtn: $('slideshow-ed-gallery'), + pickBtn: $('slideshow-ed-pick'), + fileInput: $('slideshow-ed-file'), + deleteBtn: $('slideshow-ed-delete'), + okBtn: $('slideshow-ed-ok'), + cancelBtn: $('slideshow-ed-cancel'), +}; + +let edHotspots = []; +let edSelected = -1; +let edDrag = null; // { startX, startY, draft } +let edOnDone = null; // callback({src, hotspots}) or null for cancel + +function edOpen({ src, hotspots }, onDone) { + edHotspots = hotspots.map(h => ({ ...h })); + edSelected = -1; + edOnDone = onDone; + ed.srcInput.value = src || ''; + ed.img.src = src || ''; + ed.overlay.classList.remove('hidden'); + renderEdRects(); + updateEdDeleteBtn(); + // Refresh card titles so labels resolve correctly + refreshCardTitles().then(renderEdRects); +} + +function edClose(commit) { + ed.overlay.classList.add('hidden'); + if (commit && edOnDone) { + edOnDone({ src: ed.srcInput.value.trim(), hotspots: edHotspots }); + } + edOnDone = null; + edHotspots = []; + edSelected = -1; + edDrag = null; +} + +const EDGE_HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; + +function renderEdRects() { + ed.layer.innerHTML = ''; + edHotspots.forEach((h, i) => { + const el = document.createElement('div'); + const isSelected = i === edSelected; + el.className = 'ed-rect' + (isSelected ? ' selected' : ''); + el.style.left = h.x + '%'; + el.style.top = h.y + '%'; + el.style.width = h.w + '%'; + el.style.height = h.h + '%'; + el.dataset.idx = String(i); + let handles = ''; + if (isSelected) { + handles = EDGE_HANDLES.map(d => `
`).join(''); + } + const labelText = resolveTargetLabel(h.target) || h.target || '?'; + el.innerHTML = `${escHtml(labelText)}${handles}`; + el.addEventListener('mousedown', (e) => onRectMouseDown(e, i)); + el.addEventListener('dblclick', (e) => { + e.stopPropagation(); + edSelected = i; + openPicker((target) => { + if (target) { edHotspots[i].target = target; renderEdRects(); } + }); + }); + ed.layer.appendChild(el); + }); +} + +function updateEdDeleteBtn() { + if (!ed.deleteBtn) return; + if (edSelected >= 0) ed.deleteBtn.removeAttribute('disabled'); + else ed.deleteBtn.setAttribute('disabled', 'disabled'); +} + +function edLayerPct(e) { + const imgRect = ed.img.getBoundingClientRect(); + const x = ((e.clientX - imgRect.left) / imgRect.width) * 100; + const y = ((e.clientY - imgRect.top) / imgRect.height) * 100; + return { + x: Math.max(0, Math.min(100, x)), + y: Math.max(0, Math.min(100, y)), + }; +} + +function clampPct(n, min, max) { return Math.max(min, Math.min(max, n)); } + +function onRectMouseDown(e, idx) { + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + const handleEl = e.target.closest('.ed-handle'); + edSelected = idx; + const p = edLayerPct(e); + const startRect = { ...edHotspots[idx] }; + if (handleEl) { + edDrag = { mode: 'resize', startX: p.x, startY: p.y, startRect, idx, handle: handleEl.dataset.handle }; + } else { + edDrag = { mode: 'move', startX: p.x, startY: p.y, startRect, idx }; + } + renderEdRects(); + updateEdDeleteBtn(); +} + +function onEdMouseDown(e) { + if (e.button !== 0) return; + if (e.target.closest('.ed-rect')) return; + if (!ed.img.src || !ed.img.complete) return; + e.preventDefault(); + edSelected = -1; + updateEdDeleteBtn(); + const p = edLayerPct(e); + const draft = document.createElement('div'); + draft.className = 'ed-rect-draft'; + draft.style.left = p.x + '%'; + draft.style.top = p.y + '%'; + draft.style.width = '0%'; + draft.style.height = '0%'; + ed.layer.appendChild(draft); + edDrag = { mode: 'create', startX: p.x, startY: p.y, draft }; + renderEdRects(); +} + +function onEdMouseMove(e) { + if (!edDrag) return; + const p = edLayerPct(e); + + if (edDrag.mode === 'create') { + const x = Math.min(edDrag.startX, p.x); + const y = Math.min(edDrag.startY, p.y); + const w = Math.abs(p.x - edDrag.startX); + const h = Math.abs(p.y - edDrag.startY); + edDrag.draft.style.left = x + '%'; + edDrag.draft.style.top = y + '%'; + edDrag.draft.style.width = w + '%'; + edDrag.draft.style.height = h + '%'; + return; + } + + // move / resize on existing rect + const dx = p.x - edDrag.startX; + const dy = p.y - edDrag.startY; + const s = edDrag.startRect; + let r = { x: s.x, y: s.y, w: s.w, h: s.h }; + + if (edDrag.mode === 'move') { + r.x = clampPct(s.x + dx, 0, 100 - s.w); + r.y = clampPct(s.y + dy, 0, 100 - s.h); + } else if (edDrag.mode === 'resize') { + const h = edDrag.handle; + if (h.includes('w')) { r.x = s.x + dx; r.w = s.w - dx; } + if (h.includes('e')) { r.w = s.w + dx; } + if (h.includes('n')) { r.y = s.y + dy; r.h = s.h - dy; } + if (h.includes('s')) { r.h = s.h + dy; } + // Flip when dragged past the opposite edge + if (r.w < 0) { r.x = r.x + r.w; r.w = -r.w; } + if (r.h < 0) { r.y = r.y + r.h; r.h = -r.h; } + // Clamp inside the image + if (r.x < 0) { r.w = Math.max(0, r.w + r.x); r.x = 0; } + if (r.y < 0) { r.h = Math.max(0, r.h + r.y); r.y = 0; } + if (r.x + r.w > 100) r.w = 100 - r.x; + if (r.y + r.h > 100) r.h = 100 - r.y; + } + + edHotspots[edDrag.idx] = { ...r, target: s.target }; + const el = ed.layer.querySelector(`.ed-rect[data-idx="${edDrag.idx}"]`); + if (el) { + el.style.left = r.x + '%'; + el.style.top = r.y + '%'; + el.style.width = r.w + '%'; + el.style.height = r.h + '%'; + } +} + +function onEdMouseUp(e) { + if (!edDrag) return; + + if (edDrag.mode === 'create') { + const p = edLayerPct(e); + const x = Math.min(edDrag.startX, p.x); + const y = Math.min(edDrag.startY, p.y); + const w = Math.abs(p.x - edDrag.startX); + const h = Math.abs(p.y - edDrag.startY); + edDrag.draft.remove(); + edDrag = null; + if (w < 1 || h < 1) { renderEdRects(); return; } + openPicker((target) => { + if (!target) { renderEdRects(); return; } + edHotspots.push({ x, y, w, h, target }); + edSelected = edHotspots.length - 1; + renderEdRects(); + updateEdDeleteBtn(); + }); + return; + } + + // move / resize: prune degenerate rect, keep selection + const idx = edDrag.idx; + edDrag = null; + const r = edHotspots[idx]; + if (r && (r.w < 0.5 || r.h < 0.5)) { + edHotspots.splice(idx, 1); + edSelected = -1; + updateEdDeleteBtn(); + } + renderEdRects(); +} + +ed.stage?.addEventListener('mousedown', onEdMouseDown); +window.addEventListener('mousemove', (e) => { if (ed.overlay && !ed.overlay.classList.contains('hidden')) onEdMouseMove(e); }); +window.addEventListener('mouseup', (e) => { if (ed.overlay && !ed.overlay.classList.contains('hidden')) onEdMouseUp(e); }); + +ed.deleteBtn?.addEventListener('click', () => { + if (edSelected < 0) return; + edHotspots.splice(edSelected, 1); + edSelected = -1; + renderEdRects(); + updateEdDeleteBtn(); +}); + +ed.okBtn?.addEventListener('click', () => edClose(true)); +ed.cancelBtn?.addEventListener('click', () => edClose(false)); +ed.overlay?.addEventListener('click', (e) => { if (e.target === ed.overlay) edClose(false); }); + +ed.srcInput?.addEventListener('change', () => { + ed.img.src = ed.srcInput.value.trim(); +}); + +ed.pickBtn?.addEventListener('click', () => ed.fileInput?.click()); +ed.fileInput?.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const url = await uploadImage(file); + if (url) { + ed.srcInput.value = url; + ed.img.src = url; + } + e.target.value = ''; +}); + +ed.galleryBtn?.addEventListener('click', () => { + openGalleryPicker((url) => { + if (url) { + ed.srcInput.value = url; + ed.img.src = url; + } + }); +}); + +async function uploadImage(file) { + const form = new FormData(); + form.append('file', file, file.name || 'slide.png'); + try { + const res = await fetch('api/upload', { + method: 'POST', + body: form, + headers: { 'X-CSRF-Token': hyprcrd.util.csrf() }, + }); + const data = await res.json(); + return data?.url || null; + } catch { + return null; + } +} + +// ── Gallery picker (for hotspot editor) ────────────────────────── + +const gp = { + overlay: $('slideshow-gallery-picker'), + grid: $('slideshow-gp-grid'), + count: $('slideshow-gp-count'), + uploadBtn: $('slideshow-gp-upload'), + fileInput: $('slideshow-gp-file'), + cancelBtn: $('slideshow-gp-cancel'), +}; + +let gpOnDone = null; + +async function openGalleryPicker(onDone) { + gpOnDone = onDone; + gp.overlay.classList.remove('hidden'); + await refreshGalleryPicker(); +} + +function closeGalleryPicker(url) { + gp.overlay.classList.add('hidden'); + const cb = gpOnDone; + gpOnDone = null; + if (cb) cb(url || null); +} + +async function refreshGalleryPicker() { + let files = []; + try { files = await hyprcrd.api('api/uploads') || []; } catch {} + const images = files.filter(f => !/\.(mp3|ogg|wav|m4a)$/i.test(f.url || '')); + gp.count.textContent = `${images.length} image${images.length !== 1 ? 's' : ''}`; + if (!images.length) { + gp.grid.innerHTML = `
+ +

no images yet — upload above

+
`; + return; + } + gp.grid.innerHTML = images.map(f => ` +
+ +
${escHtml(f.name || '')}
+
`).join(''); + gp.grid.querySelectorAll('[data-url]').forEach(el => { + el.addEventListener('click', () => closeGalleryPicker(el.dataset.url)); + }); +} + +gp.cancelBtn?.addEventListener('click', () => closeGalleryPicker(null)); +gp.overlay?.addEventListener('click', (e) => { if (e.target === gp.overlay) closeGalleryPicker(null); }); +gp.uploadBtn?.addEventListener('click', () => gp.fileInput?.click()); +gp.fileInput?.addEventListener('change', async (e) => { + const files = Array.from(e.target.files || []); + if (!files.length) return; + for (const file of files) await uploadImage(file); + e.target.value = ''; + await refreshGalleryPicker(); +}); + +// ── Target picker ──────────────────────────────────────────────── + +const picker = { + overlay: $('slideshow-picker'), + search: $('slideshow-pick-search'), + list: $('slideshow-pick-list'), + cancelBtn: $('slideshow-pick-cancel'), +}; + +let pickerOnDone = null; +let pickerCards = []; + +async function openPicker(onDone) { + pickerOnDone = onDone; + picker.overlay.classList.remove('hidden'); + picker.search.value = ''; + // Lazy-load cards list + if (pickerCards.length === 0) { + const cards = await hyprcrd.api('api/cards'); + if (Array.isArray(cards)) pickerCards = cards; + } + renderPickerList(''); + setTimeout(() => picker.search?.focus(), 10); +} + +function closePicker(target) { + picker.overlay.classList.add('hidden'); + const cb = pickerOnDone; + pickerOnDone = null; + if (cb) cb(target || null); +} + +function renderPickerList(query) { + const q = (query || '').trim().toLowerCase(); + const filtered = q + ? pickerCards.filter(c => (c.title || '').toLowerCase().includes(q)) + : pickerCards.slice(0, 50); + picker.list.innerHTML = filtered.map(c => { + const col = c.color ? `background:${c.color};` : ''; + return ``; + }).join('') || `
no matches
`; +} + +picker.search?.addEventListener('input', () => renderPickerList(picker.search.value)); +picker.list?.addEventListener('click', (e) => { + const btn = e.target.closest('[data-pick-target]'); + if (btn) closePicker(btn.dataset.pickTarget); +}); +picker.overlay?.addEventListener('click', (e) => { + if (e.target === picker.overlay) closePicker(null); +}); +picker.cancelBtn?.addEventListener('click', () => closePicker(null)); + +// Relative targets (wired via event delegation on the whole overlay) +picker.overlay?.addEventListener('click', (e) => { + const btn = e.target.closest('[data-pick-target]'); + if (btn && !e.target.closest('#slideshow-pick-list')) closePicker(btn.dataset.pickTarget); +}); + +picker.search?.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { e.preventDefault(); closePicker(null); } + else if (e.key === 'Enter') { + const first = picker.list?.querySelector('[data-pick-target]'); + if (first) closePicker(first.dataset.pickTarget); + } +}); + +// ── Editor toolbar command + button ────────────────────────────── + +hyprcrd.editor.registerCommand('slideHotspots', () => { + const api = hyprcrd.editor.api; + if (!api?.body) return; + const body = api.body; + const val = body.value; + const pos = body.selectionStart; + + const existing = findSlideBlock(val, pos); + let initial; + if (existing) { + initial = parseSlideBlock(existing.content); + } else { + initial = { src: '', hotspots: [] }; + } + + edOpen(initial, (result) => { + if (!result) return; + const serialized = serializeSlideBlock(result); + const block = '```slide-image\n' + serialized + '\n```'; + if (existing) { + body.setRangeText(block, existing.start, existing.end, 'end'); + } else { + const s = body.selectionStart; + const prefix = s > 0 && val[s - 1] !== '\n' ? '\n' : ''; + body.setRangeText(prefix + block + '\n', s, body.selectionEnd, 'end'); + } + api.updatePreview?.(); + }); +}); + +function injectToolbarButton() { + const toolbar = $('md-toolbar'); + if (!toolbar) return; + if (toolbar.querySelector('[data-md="slideHotspots"]')) return; + const btn = document.createElement('button'); + btn.setAttribute('data-md', 'slideHotspots'); + btn.setAttribute('title', 'Image slide / hotspots'); + btn.className = 'toolbar-btn'; + btn.innerHTML = ''; + const spacer = toolbar.querySelector('.flex-1'); + if (spacer) toolbar.insertBefore(btn, spacer); + else toolbar.appendChild(btn); +} +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', injectToolbarButton); +} else { + injectToolbarButton(); +} + +// ── Plugin self-registration ───────────────────────────────────── + +hyprcrd.registerPlugin({ + name: 'slideshow', + version: '1.0.0', + description: 'Presentation mode for card groups with image slides and clickable hotspots.', +}); diff --git a/public/plugins/tests/relay.test.js b/public/plugins/tests/relay.test.js index 5677e22..824fb56 100644 --- a/public/plugins/tests/relay.test.js +++ b/public/plugins/tests/relay.test.js @@ -3,7 +3,8 @@ import { describe, it, expect, csrf } from './runner.js'; describe('Relay: Push API', () => { it('push without config returns error', async () => { const settings = await fetch('api/relay/settings').then(r => r.json()); - if (settings.url && settings.has_key) return; // skip if configured + // The GET endpoint echoes the secret directly as `key` (single-user local app). + if (settings.url && settings.key) return; // skip if configured const res = await fetch('api/relay/push', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, @@ -25,7 +26,7 @@ describe('Relay: Push API', () => { describe('Relay: Poll API', () => { it('poll without config returns 0 pulled', async () => { const settings = await fetch('api/relay/settings').then(r => r.json()); - if (settings.url && settings.has_key) return; + if (settings.url && settings.key) return; const res = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': csrf() }, diff --git a/public/plugins/tests/snippets.test.js b/public/plugins/tests/snippets.test.js index 41f898c..6cf4e87 100644 --- a/public/plugins/tests/snippets.test.js +++ b/public/plugins/tests/snippets.test.js @@ -19,7 +19,11 @@ describe('Snippets: Date & Time', () => { const body = $('#editor-body'); typeSnippet(body, '?dat'); await sleep(100); - const today = new Date().toISOString().substring(0, 10); + // Match editor's local-time formatting (not UTC) so the assertion doesn't + // drift at midnight depending on timezone offset. + const d = new Date(); + const pad = n => String(n).padStart(2, '0'); + const today = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; expect(body.value).toContain(today); });