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 => `
-
+
@@ -94,7 +94,12 @@ export async function openGallery() {
// Linked image clicks → open card
grid.querySelectorAll('[data-card-id]').forEach(item => {
- item.addEventListener('click', () => { closeGallery(); window._openDetail(item.dataset.cardId); });
+ item.addEventListener('click', () => {
+ window._openDetail(item.dataset.cardId, {
+ fromGallery: true,
+ galleryOrigin: item.dataset.galleryOrigin || null,
+ });
+ });
});
// Orphan: create card from image
diff --git a/public/js/modules/state.js b/public/js/modules/state.js
index 39a2c23..970c7be 100644
--- a/public/js/modules/state.js
+++ b/public/js/modules/state.js
@@ -9,6 +9,9 @@ export const state = {
editingId: null,
viewingCard: null,
openedFromSearch: false,
+ openedFromGallery: false,
+ galleryOrigin: null,
+ returnToGalleryAfterEdit: false,
previewVisible: false,
};