From 9c71e650410e9a2a4f57f356e098067dd50b480a Mon Sep 17 00:00:00 2001 From: th Date: Mon, 6 Apr 2026 21:39:37 +0200 Subject: [PATCH] bak --- public/css/style.css | 114 +++++++++++++++- public/index.php | 7 +- public/js/app.js | 3 +- public/js/modules/board.js | 4 +- public/js/modules/buckets.js | 36 ++++- public/js/modules/detail.js | 245 ++++++++++++++++++++++++++++++++++- public/js/modules/editor.js | 18 ++- public/js/modules/gallery.js | 9 +- public/js/modules/state.js | 3 + 9 files changed, 423 insertions(+), 16 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 4a6072a..17f9d13 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -36,6 +36,7 @@ body::after { /* Cards */ .card-bar { image-rendering: pixelated; height: 3px; } +.board-card { min-height: 180px; } .card-layer { position: relative; z-index: 1; } .card-has-cover { background-size: cover; @@ -43,6 +44,9 @@ body::after { background-repeat: no-repeat; overflow: hidden; } +.card-group.card-has-cover { + overflow: visible; +} .card-cover-overlay { position: absolute; inset: 0; @@ -74,7 +78,10 @@ body::after { right: -6px; min-width: 20px; height: 20px; - border-radius: 10px; + padding: 0 6px; + border-radius: 999px; + box-sizing: border-box; + white-space: nowrap; display: flex; align-items: center; justify-content: center; @@ -185,6 +192,61 @@ body.detail-zen-open { overflow: hidden; } .overlay:not(.open) { opacity: 0; pointer-events: none; } .overlay.open { opacity: 1; } +/* Detail image fullscreen */ +body.detail-image-fs-open { overflow: hidden; } +#detail-body img { cursor: zoom-in; } +.detail-image-fs-overlay { + position: fixed; + inset: 0; + z-index: 170; + display: none; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(2, 6, 23, 0.94); +} +.detail-image-fs-overlay.open { display: flex; } +.detail-image-fs-stage { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} +.detail-image-fs-img { + max-width: min(96vw, 1900px); + max-height: 94vh; + object-fit: contain; + border: 1px solid var(--t-border); + box-shadow: 0 16px 60px rgba(0, 0, 0, 0.55); + cursor: grab; + user-select: none; + -webkit-user-drag: none; + will-change: transform; +} +.detail-image-fs-overlay.dragging .detail-image-fs-img { cursor: grabbing; } +.detail-image-fs-close { + position: fixed; + top: 12px; + right: 12px; + z-index: 171; + width: 34px; + height: 34px; + display: grid; + place-items: center; + padding: 0; + border: none; + background: transparent; + color: #fff; + font-family: inherit; + cursor: pointer; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7); + transition: color 0.12s, transform 0.12s; +} +.detail-image-fs-close:hover { + color: var(--t-accent); + transform: scale(1.06); +} + /* Theme picker */ .theme-picker { position: relative; } .theme-menu { display: none; position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 200; } @@ -201,6 +263,14 @@ body.detail-zen-open { overflow: hidden; } } .bucket-tab.drag-over i, .bucket-tab.drag-over span { color: inherit !important; } +.bucket-add.drag-over { + background: var(--t-accent) !important; + color: var(--t-bg) !important; + transform: scale(1.05); + transition: all 0.15s ease; +} +.bucket-add.drag-over i, +.bucket-add.drag-over span { color: inherit !important; } .bucket-drop-left { border-left: 3px solid var(--t-accent) !important; } .bucket-drop-right { border-right: 3px solid var(--t-accent) !important; } @@ -321,3 +391,45 @@ body.detail-zen-open { overflow: hidden; } .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; } + +/* Temporary reading aid in open detail view (not persisted) */ +#detail-body .detail-reading-resize { + position: relative; + padding-bottom: 1rem; + min-height: 1.8em; +} +#detail-body .detail-reading-resize:hover, +#detail-body .detail-reading-resize.resizing { + outline: 1px dashed var(--t-border); + outline-offset: 1px; +} +#detail-body .detail-font-dragline { + position: absolute; + left: 0.4rem; + right: 0.4rem; + bottom: 0.2rem; + height: 10px; + padding: 0; + border: none; + background: transparent; + cursor: ns-resize; + opacity: 0; + transition: opacity 0.12s ease; +} +#detail-body .detail-font-dragline::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 4px; + border-top: 1px dashed var(--t-border); + transition: border-color 0.12s ease; +} +#detail-body .detail-reading-resize:hover .detail-font-dragline, +#detail-body .detail-reading-resize.resizing .detail-font-dragline { + opacity: 1; +} +#detail-body .detail-font-dragline:hover::before, +#detail-body .detail-reading-resize.resizing .detail-font-dragline::before { + border-color: var(--t-accent); +} diff --git a/public/index.php b/public/index.php index f23ab5a..6486d5e 100644 --- a/public/index.php +++ b/public/index.php @@ -141,8 +141,11 @@ if (str_starts_with($route, '/api/')) { $title = trim($input['title'] ?? ''); if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; } $pos = $input['position'] ?? 0; - $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position) VALUES (:title, :body, :color, :bucket_id, :position)')->execute([ - 'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'position' => validInt($pos), + $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position)')->execute([ + 'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null), + 'bucket_id' => validInt($input['bucket_id'] ?? null, null), + 'parent_id' => validInt($input['parent_id'] ?? null, null), + 'position' => validInt($pos), ]); echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch()); exit; diff --git a/public/js/app.js b/public/js/app.js index e0b1805..3dd032a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -13,13 +13,14 @@ import { openModal, closeModal, saveCard } from './modules/editor.js'; import { openSearch, closeSearch } from './modules/search.js'; import { showContextMenu } from './modules/context-menu.js'; import { undo, canUndo } from './modules/undo.js'; -import { closeGallery } from './modules/gallery.js'; +import { openGallery, closeGallery } from './modules/gallery.js'; import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js'; // ── Global API (for inline onclick handlers and cross-module calls) ── window._openDetail = openDetail; window._openModal = openModal; +window._openGallery = openGallery; window._closeSearch = closeSearch; window._closeTasks = closeTasks; window._renderBoard = renderBoard; diff --git a/public/js/modules/board.js b/public/js/modules/board.js index 4729957..d3677cd 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -79,11 +79,11 @@ export function renderBoard() { const hasCover = !!coverImg; return ` -
${hasCover ? '
' : ''}
- ${isGroup ? `
${children.length + 1}
` : ''} + ${isGroup ? `
${children.length + 1}
` : ''} ${thumbImg ? `
` : ''}
diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js index e73c6ac..4280136 100644 --- a/public/js/modules/buckets.js +++ b/public/js/modules/buckets.js @@ -121,6 +121,15 @@ export function populateSelects() { } } +function autoBucketNameFromCard(card) { + const existing = new Set(state.buckets.map(b => String(b.name || '').trim().toLowerCase()).filter(Boolean)); + const base = String(card?.title || '').trim().replace(/\s+/g, ' ').slice(0, 40) || 'bucket'; + if (!existing.has(base.toLowerCase())) return base; + let i = 2; + while (existing.has(`${base} ${i}`.toLowerCase())) i++; + return `${base} ${i}`; +} + function updateHeaderTint() { const tint = $('header-tint'); if (!tint) return; @@ -145,7 +154,7 @@ function renderBar() { const active = state.activeBucket == b.id; return ``; }).join('')} - `; + `; updateHeaderTint(); @@ -223,11 +232,34 @@ function renderBar() { }); }); - $('add-bucket-btn')?.addEventListener('click', async () => { + const addBtn = $('add-bucket-btn'); + addBtn?.addEventListener('click', async () => { const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' }); if (!name || !name.trim()) return; await api('api/buckets', { method: 'POST', body: { name: name.trim() } }); await loadBuckets(); window._renderBoard(); }); + + const isCardDrag = e => Array.from(e.dataTransfer?.types || []).includes('text/plain'); + + addBtn?.addEventListener('dragover', e => { + if (!isCardDrag(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + addBtn.classList.add('drag-over'); + }); + addBtn?.addEventListener('dragleave', () => addBtn.classList.remove('drag-over')); + addBtn?.addEventListener('drop', async e => { + addBtn.classList.remove('drag-over'); + const cardId = e.dataTransfer.getData('text/plain'); + if (!cardId) return; + e.preventDefault(); + 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 loadBuckets(); + await window._reloadAll(); + }); } diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js index c52ff01..9a395b9 100644 --- a/public/js/modules/detail.js +++ b/public/js/modules/detail.js @@ -13,6 +13,18 @@ const bodyEl = $('detail-body'); const metaEl = $('detail-meta'); let morphTimer = null; let morphGhost = null; +let imageFsOverlay = null; +let imageFsImg = null; +let fsScale = 1; +let fsTx = 0; +let fsTy = 0; +let fsPanning = false; +let fsStartX = 0; +let fsStartY = 0; +let fsStartTx = 0; +let fsStartTy = 0; +const FS_MIN_SCALE = 0.35; +const FS_MAX_SCALE = 8; // ── Zoom ── @@ -41,7 +53,7 @@ scroll?.addEventListener('wheel', e => { // ── Open / close ── -export function openDetail(id) { +export function openDetail(id, options = {}) { const card = state.cards.find(c => c.id == id); if (!card) return; markRecent(id); @@ -51,6 +63,11 @@ export function openDetail(id) { if (!detail.classList.contains('open')) { state.openedFromSearch = $('search')?.classList.contains('open') || false; + state.openedFromGallery = !!options.fromGallery; + state.galleryOrigin = options.fromGallery ? (options.galleryOrigin || null) : null; + } else if (options.fromGallery) { + state.openedFromGallery = true; + state.galleryOrigin = options.galleryOrigin || state.galleryOrigin || null; } state.viewingCard = card; @@ -70,7 +87,139 @@ function clearMorphGhost() { } } +function ensureImageFullscreenOverlay() { + if (imageFsOverlay) return imageFsOverlay; + + const overlay = document.createElement('div'); + overlay.className = 'detail-image-fs-overlay'; + overlay.innerHTML = ` + +
+ +
+ `; + overlay.addEventListener('click', e => { + if (e.target === overlay) closeImageFullscreen(); + }); + overlay.querySelector('.detail-image-fs-close')?.addEventListener('click', closeImageFullscreen); + + document.body.appendChild(overlay); + imageFsOverlay = overlay; + imageFsImg = overlay.querySelector('.detail-image-fs-img'); + if (imageFsImg) { + imageFsImg.draggable = false; + imageFsImg.addEventListener('mousedown', onImageFsMouseDown); + } + overlay.addEventListener('wheel', onImageFsWheel, { passive: false }); + document.addEventListener('mousemove', onImageFsMouseMove); + document.addEventListener('mouseup', onImageFsMouseUp); + return overlay; +} + +function applyImageFsTransform() { + if (!imageFsImg) return; + imageFsImg.style.transform = `translate(${fsTx}px, ${fsTy}px) scale(${fsScale})`; +} + +function resetImageFsTransform() { + fsScale = 1; + fsTx = 0; + fsTy = 0; + fsPanning = false; + imageFsOverlay?.classList.remove('dragging'); + applyImageFsTransform(); +} + +function onImageFsWheel(e) { + if (!imageFsOverlay?.classList.contains('open') || !imageFsImg) return; + e.preventDefault(); + + const old = fsScale; + const factor = Math.exp(-e.deltaY * 0.0015); + const next = Math.min(FS_MAX_SCALE, Math.max(FS_MIN_SCALE, old * factor)); + if (Math.abs(next - old) < 0.0001) return; + + const r = imageFsOverlay.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const px = e.clientX - cx; + const py = e.clientY - cy; + + fsTx = px - ((px - fsTx) / old) * next; + fsTy = py - ((py - fsTy) / old) * next; + fsScale = next; + applyImageFsTransform(); +} + +function onImageFsMouseDown(e) { + if (!imageFsOverlay?.classList.contains('open')) return; + if (e.button !== 0) return; + e.preventDefault(); + fsPanning = true; + fsStartX = e.clientX; + fsStartY = e.clientY; + fsStartTx = fsTx; + fsStartTy = fsTy; + imageFsOverlay.classList.add('dragging'); +} + +function onImageFsMouseMove(e) { + if (!fsPanning || !imageFsOverlay?.classList.contains('open')) return; + fsTx = fsStartTx + (e.clientX - fsStartX); + fsTy = fsStartTy + (e.clientY - fsStartY); + applyImageFsTransform(); +} + +function onImageFsMouseUp() { + if (!fsPanning) return; + fsPanning = false; + imageFsOverlay?.classList.remove('dragging'); +} + +function openImageFullscreen(src, alt = '') { + if (!src) return; + const overlay = ensureImageFullscreenOverlay(); + resetImageFsTransform(); + if (imageFsImg) { + imageFsImg.src = src; + imageFsImg.alt = alt || 'image'; + } + overlay.classList.add('open'); + document.body.classList.add('detail-image-fs-open'); +} + +function closeImageFullscreen() { + if (!imageFsOverlay) return; + fsPanning = false; + imageFsOverlay.classList.remove('dragging'); + imageFsOverlay.classList.remove('open'); + document.body.classList.remove('detail-image-fs-open'); + if (imageFsImg) { + imageFsImg.removeAttribute('src'); + imageFsImg.alt = ''; + imageFsImg.style.transform = ''; + } + fsScale = 1; + fsTx = 0; + fsTy = 0; +} + function resolveMorphTarget(cardId) { + if (state.openedFromGallery) { + const grid = $('gallery-grid'); + if (grid) { + if (state.galleryOrigin) { + const exact = Array.from(grid.querySelectorAll('[data-gallery-origin]')) + .find(el => el.dataset.galleryOrigin === state.galleryOrigin); + if (exact) return exact; + } + const byCard = grid.querySelector(`[data-card-id="${cardId}"]`); + if (byCard) return byCard; + } + } + const board = $('board'); if (!board || !cardId) return null; @@ -133,7 +282,9 @@ function startCloseMorph(cardId) { const frame = document.createElement('div'); frame.className = 'detail-close-frame'; - const targetCard = state.cards.find(c => c.id == target.dataset.id); + const targetCard = target.dataset.id + ? state.cards.find(c => c.id == target.dataset.id) + : card; Object.assign(frame.style, { left: `${from.left}px`, top: `${from.top}px`, @@ -170,10 +321,12 @@ function startCloseMorph(cardId) { export function closeDetail(options = {}) { const animate = options?.animate !== false; + const restoreGallery = options?.restoreGallery !== false; const cardId = state.viewingCard?.id; if (animate) startCloseMorph(cardId); else clearMorphGhost(); + closeImageFullscreen(); detail.classList.remove('open', 'zen'); document.documentElement.classList.remove('detail-zen-open'); @@ -185,6 +338,12 @@ export function closeDetail(options = {}) { state.openedFromSearch = false; $('search-input')?.focus(); } + if (state.openedFromGallery) { + const galleryOpen = $('gallery')?.classList.contains('open'); + state.openedFromGallery = false; + state.galleryOrigin = null; + if (restoreGallery && !galleryOpen) window._openGallery?.(); + } } export function showDetailCard(id) { @@ -241,6 +400,8 @@ function showContent(card) { $('detail-color').style.background = card.color; titleEl.textContent = card.title; bodyEl.innerHTML = renderMd(card.body); + applyReadingResizeHandles(bodyEl); + bindDetailImageFullscreen(bodyEl); // Interactive checkboxes bodyEl.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => { @@ -280,6 +441,68 @@ function showContent(card) { } } +function applyReadingResizeHandles(root) { + if (!root) return; + // Reading aid only: temporary per-block font-size scaling (not persisted). + root.querySelectorAll('p, blockquote, pre').forEach(el => { + el.classList.add('detail-reading-resize'); + + const line = document.createElement('button'); + line.type = 'button'; + line.className = 'detail-font-dragline'; + line.title = 'drag to resize text'; + line.setAttribute('aria-label', 'resize text'); + el.appendChild(line); + + line.addEventListener('mousedown', e => { + e.preventDefault(); + e.stopPropagation(); + + const startY = e.clientY; + const startFont = parseFloat(getComputedStyle(el).fontSize) || 14; + const startLineHeightPx = parseFloat(getComputedStyle(el).lineHeight); + const lineRatio = Number.isFinite(startLineHeightPx) && startLineHeightPx > 0 + ? startLineHeightPx / startFont + : 1.65; + const prevSelect = document.body.style.userSelect; + const min = 12; + const max = 44; + + el.classList.add('resizing'); + document.body.style.userSelect = 'none'; + + const onMove = ev => { + const delta = ev.clientY - startY; + const next = Math.min(max, Math.max(min, startFont + delta * 0.08)); + el.style.fontSize = `${next.toFixed(2)}px`; + el.style.lineHeight = `${(next * lineRatio).toFixed(2)}px`; + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.userSelect = prevSelect; + el.classList.remove('resizing'); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + }); +} + +function bindDetailImageFullscreen(root) { + if (!root) return; + root.querySelectorAll('img').forEach(img => { + img.title = 'double-click to open fullscreen'; + img.addEventListener('dblclick', e => { + e.preventDefault(); + e.stopPropagation(); + openImageFullscreen(img.getAttribute('src'), img.getAttribute('alt') || 'image'); + }); + }); +} + // ── Event listeners ── // Delegated card-link clicks (replaces inline onclick) @@ -290,6 +513,14 @@ detail.addEventListener('click', e => { }); $('detail-close')?.addEventListener('click', closeDetail); +document.addEventListener('keydown', e => { + if (e.key !== 'Escape') return; + if (!imageFsOverlay?.classList.contains('open')) return; + e.preventDefault(); + e.stopImmediatePropagation(); + closeImageFullscreen(); +}, true); + // Zen mode $('detail-zen')?.addEventListener('click', () => { detail.classList.toggle('zen'); @@ -303,8 +534,13 @@ $('detail-zen')?.addEventListener('click', () => { $('detail-edit')?.addEventListener('click', () => { if (!state.viewingCard) return; const card = state.viewingCard; + const fromGallery = state.openedFromGallery; state.openedFromSearch = false; - closeDetail({ animate: false }); + state.openedFromGallery = false; + state.galleryOrigin = null; + state.returnToGalleryAfterEdit = fromGallery; + closeDetail({ animate: false, restoreGallery: false }); + $('gallery')?.classList.remove('open'); window._closeSearch(); window._openModal(card); }); @@ -315,7 +551,8 @@ $('detail-delete')?.addEventListener('click', async () => { if (!ok) return; pushCardDelete('delete', snapshotCard(state.viewingCard)); await api(`api/cards/${state.viewingCard.id}`, { method: 'DELETE' }); - closeDetail({ animate: false }); + state.galleryOrigin = null; + closeDetail({ animate: false, restoreGallery: false }); await window._reloadAll(); }); diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index 3531a7d..6ffb436 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -59,7 +59,11 @@ export function openModal(card = null) { inpTitle.focus(); } -export function closeModal() { modal.classList.remove('open'); state.editingId = null; } +export function closeModal() { + modal.classList.remove('open'); + state.editingId = null; + state.returnToGalleryAfterEdit = false; +} function updatePreview() { if (state.previewVisible) previewContent.innerHTML = renderMd(body.value); @@ -68,16 +72,26 @@ function updatePreview() { export async function saveCard() { const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null }; if (!data.title.trim()) return; + const returnToGallery = state.returnToGalleryAfterEdit; if (state.editingId) { const prev = snapshotCard(state.cards.find(c => c.id == state.editingId)); if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id }); await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data }); } else { - data.position = -1; + if (state.activeGroup) { + const parent = state.cards.find(c => c.id == state.activeGroup); + const groupCards = parent ? [parent, ...state.cards.filter(c => c.parent_id == parent.id)] : []; + const maxPos = groupCards.reduce((max, c) => Math.max(max, Number(c.position) || 0), -1); + data.parent_id = parent ? parent.id : state.activeGroup; + data.position = maxPos + 1; + } else { + data.position = -1; + } await api('api/cards', { method: 'POST', body: data }); } closeModal(); await window._reloadAll(); + if (returnToGallery) window._openGallery?.(); } // ── Markdown toolbar ── diff --git a/public/js/modules/gallery.js b/public/js/modules/gallery.js index 05c548c..1e9489e 100644 --- a/public/js/modules/gallery.js +++ b/public/js/modules/gallery.js @@ -77,7 +77,7 @@ export async function openGallery() { if (linked.length) { if (orphaned.length) html += `
linked to cards
`; html += linked.map(img => ` -