slideshow
This commit is contained in:
1 parent
988a5410f7
commit
262b853c5b
18 files changed
+1305
-30
No files matched your search
@@ -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 = `
|
||||
<button id="group-back" 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-arrow-left text-sm"></i><span class="font-pixel text-[10px]">back</span>
|
||||
@@ -781,6 +782,7 @@ function renderGroupBar(cards) {
|
||||
$('group-back')?.addEventListener('click', () => { state.activeGroup = null; renderBoard(); });
|
||||
} else {
|
||||
bar.classList.add('hidden');
|
||||
delete bar.dataset.groupId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ async function showBucketCtx(x, y, bucket) {
|
||||
hideBucketCtx();
|
||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `"${bucket.name}" loeschen? ${cards.length} Karte(n) bleiben erhalten.` : `"${bucket.name}" loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
||||
@@ -86,7 +86,7 @@ async function showBucketCtx(x, y, bucket) {
|
||||
else if (a === 'delete-all') {
|
||||
hideBucketCtx();
|
||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: `"${bucket.name}" und ${cards.length} Karte(n) loeschen?`, danger: true, okLabel: 'delete all' });
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'delete all' });
|
||||
if (!ok) return;
|
||||
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
||||
|
||||
@@ -226,7 +226,7 @@ export function showContextMenu(x, y, card) {
|
||||
else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); }
|
||||
else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } }
|
||||
else if (a === 'dissolve') {
|
||||
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Gruppe "${card.title}" aufloesen?`, danger: true, okLabel: 'dissolve' });
|
||||
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Dissolve group "${card.title}"?`, danger: true, okLabel: 'dissolve' });
|
||||
if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); }
|
||||
}
|
||||
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
||||
@@ -234,7 +234,7 @@ export function showContextMenu(x, y, card) {
|
||||
hideContextMenu();
|
||||
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
||||
if (needConfirm) {
|
||||
const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
const wasSync = !!card.caldav_uid;
|
||||
|
||||
@@ -1015,7 +1015,7 @@ $('detail-done')?.addEventListener('click', async () => {
|
||||
$('detail-delete')?.addEventListener('click', async () => {
|
||||
if (!state.viewingCard) return;
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${state.viewingCard.title}"?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
||||
|
||||
@@ -517,8 +517,11 @@ const editorPane = $('editor-pane');
|
||||
|
||||
function applyEditorSplit() {
|
||||
if (!state.previewVisible) return;
|
||||
if (editorPane) editorPane.style.flex = `0 0 ${editorSplit}%`;
|
||||
preview.style.flex = `0 0 ${100 - editorSplit}%`;
|
||||
// Use grow-based splits with flex-basis:0 so both panes share the available
|
||||
// space AFTER the divider is subtracted. Using `0 0 X%` overflows the container
|
||||
// because X% + (100-X)% + divider > 100%, which clips content on the right.
|
||||
if (editorPane) editorPane.style.flex = `${editorSplit} 1 0`;
|
||||
preview.style.flex = `${100 - editorSplit} 1 0`;
|
||||
}
|
||||
|
||||
$('toggle-preview')?.addEventListener('click', () => {
|
||||
|
||||
@@ -28,6 +28,11 @@ function getReferencedUrls() {
|
||||
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
|
||||
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
|
||||
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());
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
@@ -78,6 +83,7 @@ export async function openGallery() {
|
||||
<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">
|
||||
<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-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" title="delete"><i class="ph ph-trash text-sm"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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) {
|
||||
<button class="orphan-lb-create flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">new card</span>
|
||||
</button>
|
||||
<button class="orphan-lb-slide flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">new slide</span>
|
||||
</button>
|
||||
<button class="orphan-lb-delete flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span>
|
||||
</button>
|
||||
@@ -183,8 +204,16 @@ function openOrphanLightbox(file) {
|
||||
window._openModal({ id: null, title: name, body: ``, 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();
|
||||
});
|
||||
@@ -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(/<img[^>]+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(/^<img[^>]+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;
|
||||
}
|
||||
Reference in new issue
Block a user