@@ -642,7 +798,6 @@ if (str_starts_with($route, '/api/')) {
all buckets
-
esc
close
@@ -751,6 +906,7 @@ if (str_starts_with($route, '/api/')) {
+
@@ -836,13 +992,14 @@ if (str_starts_with($route, '/api/')) {
preview
-
diff --git a/public/js/app.js b/public/js/app.js
index 88988f8..6e0a50f 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -54,6 +54,18 @@ async function loadCards() {
renderBoard();
}
+// ── Help ──
+
+const helpOverlay = document.getElementById('help');
+function openHelp() { helpOverlay?.classList.add('open'); }
+function closeHelp() { helpOverlay?.classList.remove('open'); }
+document.getElementById('help-btn')?.addEventListener('click', openHelp);
+document.getElementById('help-close')?.addEventListener('click', closeHelp);
+helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); });
+document.addEventListener('keydown', e => {
+ if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); }
+});
+
// ── Export / Import ──
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js
index 6a2d789..7f8ec6e 100644
--- a/public/js/modules/detail.js
+++ b/public/js/modules/detail.js
@@ -371,6 +371,7 @@ export function closeDetail(options = {}) {
closeImageFullscreen();
detail.classList.remove('open', 'zen');
+ removeZenHandles();
document.documentElement.classList.remove('detail-zen-open');
document.body.classList.remove('detail-zen-open');
$('split-right')?.classList.add('hidden');
@@ -614,6 +615,84 @@ document.addEventListener('keydown', e => {
// Tree view toggle
// Extract selection to new card
+const ZEN_WIDTH_KEY = 'hyprcrd-zen-width';
+let zenWidth = parseInt(localStorage.getItem(ZEN_WIDTH_KEY)) || 680;
+let zenHandles = [];
+
+function applyZenWidth() {
+ if (!scroll) return;
+ if (detail.classList.contains('zen')) {
+ scroll.style.maxWidth = zenWidth + 'px';
+ // Position handles at content edges
+ const margin = Math.max(0, (window.innerWidth - zenWidth) / 2);
+ zenHandles.forEach(h => {
+ h.el.style.left = h.side === 'left' ? (margin - 12) + 'px' : '';
+ h.el.style.right = h.side === 'right' ? (margin - 12) + 'px' : '';
+ });
+ } else {
+ scroll.style.maxWidth = '';
+ }
+}
+
+function createZenHandles() {
+ removeZenHandles();
+ ['left', 'right'].forEach(side => {
+ const el = document.createElement('div');
+ Object.assign(el.style, {
+ position: 'fixed', top: '0', bottom: '0', width: '24px',
+ cursor: 'col-resize', zIndex: '200',
+ });
+ const bar = document.createElement('div');
+ Object.assign(bar.style, {
+ position: 'absolute', top: '50%', transform: 'translateY(-50%)',
+ width: '4px', height: '48px', borderRadius: '2px',
+ background: 'var(--t-muted)', opacity: '0.08', transition: 'opacity 0.2s',
+ });
+ bar.style[side === 'left' ? 'right' : 'left'] = '8px';
+ el.appendChild(bar);
+
+ el.addEventListener('mouseenter', () => { bar.style.opacity = '0.5'; });
+ el.addEventListener('mouseleave', () => { if (!el._dragging) bar.style.opacity = '0.08'; });
+
+ let startX, startW;
+ el.addEventListener('mousedown', e => {
+ e.preventDefault();
+ startX = e.clientX;
+ startW = zenWidth;
+ el._dragging = true;
+ bar.style.opacity = '0.7';
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ const onMove = ev => {
+ const dx = ev.clientX - startX;
+ zenWidth = Math.max(400, Math.min(window.innerWidth - 80, startW + (side === 'left' ? -dx : dx) * 2));
+ applyZenWidth();
+ };
+ const onUp = () => {
+ el._dragging = false;
+ bar.style.opacity = '0.08';
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ localStorage.setItem(ZEN_WIDTH_KEY, String(zenWidth));
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+
+ document.body.appendChild(el);
+ zenHandles.push({ el, side });
+ });
+ applyZenWidth();
+}
+
+function removeZenHandles() {
+ zenHandles.forEach(h => h.el.remove());
+ zenHandles = [];
+}
+
$('detail-zen')?.addEventListener('click', () => {
detail.classList.toggle('zen');
const isZen = detail.classList.contains('zen');
@@ -621,8 +700,12 @@ $('detail-zen')?.addEventListener('click', () => {
document.body.classList.toggle('detail-zen-open', isZen);
const icon = $('detail-zen')?.querySelector('i');
if (icon) icon.className = isZen ? 'ph ph-corners-in text-sm' : 'ph ph-corners-out text-sm';
+ if (isZen) createZenHandles();
+ else { removeZenHandles(); scroll.style.maxWidth = ''; }
});
+window.addEventListener('resize', () => { if (detail.classList.contains('zen')) applyZenWidth(); });
+
// ── Split pane (right) ──
const splitPane = $('split-right');
@@ -719,18 +802,39 @@ function renderSplitGroups(groups) {
function searchAllCards(query) {
const q = query.toLowerCase().trim();
- return state.cards
- .filter(c => c.id != state.viewingCard?.id)
+ const parts = q.split(/\s+/).filter(Boolean);
+ const tagFilters = parts.filter(p => p.startsWith('#') && p.length > 1).map(p => p.substring(1));
+ const words = parts.filter(p => !p.startsWith('#'));
+ const textQ = words.join(' ');
+
+ let pool = state.cards.filter(c => c.id != state.viewingCard?.id);
+ if (tagFilters.length) {
+ pool = pool.filter(c => {
+ const ct = parseTags(c);
+ return tagFilters.every(tf => ct.some(t => t === tf || t.startsWith(tf + '/') || t.includes(tf)));
+ });
+ }
+ if (!words.length) return pool.slice(0, 15);
+
+ return pool
.map(c => {
const t = (c.title || '').toLowerCase();
const b = stripMd(c.body || '').toLowerCase();
- let score = 0;
- if (t.includes(q)) score += 50;
- if (t.startsWith(q)) score += 20;
- if (b.includes(q)) score += 10;
- // fuzzy on title
- const fm = fuzzyMatch(q, t);
- if (fm !== null) score += 5 + fm;
+ const tags = (c.tags || '').toLowerCase();
+ let score = tagFilters.length ? 30 : 0;
+ if (t.includes(textQ)) score += 50;
+ if (t.startsWith(textQ)) score += 20;
+ if (b.includes(textQ)) score += 10;
+ if (tags.includes(textQ)) score += 40;
+ for (const w of words) {
+ if (t.includes(w)) score += 15;
+ if (b.includes(w)) score += 5;
+ if (tags.includes(w)) score += 20;
+ }
+ if (!score) {
+ const fm = fuzzyMatch(textQ, t);
+ if (fm !== null) score += 5 + fm;
+ }
return score > 0 ? { card: c, score } : null;
})
.filter(Boolean)
@@ -740,6 +844,18 @@ function searchAllCards(query) {
}
let splitCardId = null;
+let splitLastQuery = '';
+
+function restoreSplitList() {
+ if (splitLastQuery) {
+ splitSearch.value = splitLastQuery;
+ const results = searchAllCards(splitLastQuery);
+ renderSplitPicks(results, `${results.length} result${results.length !== 1 ? 's' : ''}`);
+ } else {
+ openSplitPane();
+ }
+ splitContent.innerHTML = '';
+}
function loadSplitCard(id) {
const card = state.cards.find(c => c.id == id);
@@ -747,13 +863,13 @@ function loadSplitCard(id) {
splitCardId = id;
splitPicks.innerHTML = `
- related cards
+ ${splitLastQuery ? 'results' : 'related cards'}
focus
`;
- splitPicks.querySelector('#split-back').addEventListener('click', openSplitPane);
+ splitPicks.querySelector('#split-back').addEventListener('click', restoreSplitList);
splitPicks.querySelector('#split-promote').addEventListener('click', () => promoteSplitCard(id));
splitSearch.value = '';
splitContent.innerHTML = `
@@ -779,6 +895,7 @@ function openSplitPane() {
if (!splitPane) return;
splitPane.classList.remove('hidden');
splitSearch.value = '';
+ splitLastQuery = '';
splitContent.innerHTML = '';
const tagged = findTaggedCards(state.viewingCard);
const related = findRelatedCards(state.viewingCard);
@@ -800,9 +917,17 @@ $('split-close')?.addEventListener('click', () => splitPane.classList.add('hidde
splitSearch?.addEventListener('input', () => {
const q = splitSearch.value.trim();
+ splitLastQuery = q;
+ splitContent.innerHTML = '';
if (!q) {
+ const tagged = findTaggedCards(state.viewingCard);
const related = findRelatedCards(state.viewingCard);
- renderSplitPicks(related, 'related');
+ const taggedIds = new Set(tagged.map(c => c.id));
+ const relatedOnly = related.filter(c => !taggedIds.has(c.id));
+ renderSplitGroups([
+ { label: 'tags', cards: tagged },
+ { label: 'related', cards: relatedOnly },
+ ]);
return;
}
const results = searchAllCards(q);
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index e91142c..23fd992 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -139,7 +139,9 @@ export function openModal(card = null) {
selectColor(card ? card.color : '#38bdf8');
state.previewVisible = true;
preview.classList.remove('hidden');
+ editorDivider?.classList.remove('hidden');
$('toggle-preview').classList.add('text-t-accent');
+ applyEditorSplit();
updatePreview();
modal.classList.add('open');
if (linesVisible) updateGutter();
@@ -296,6 +298,7 @@ export function closeModal({ animate = true } = {}) {
else clearMorphGhost();
hideLinkAc();
hideTagAc();
+ hideSnippetAc();
modal.classList.remove('open');
state.editingId = null;
state.returnToGalleryAfterEdit = false;
@@ -388,13 +391,68 @@ $('md-toolbar')?.addEventListener('click', e => {
cmds[btn.dataset.md]?.();
});
+const editorDivider = $('editor-divider');
+const EDITOR_SPLIT_KEY = 'hyprcrd-editor-split';
+let editorSplit = parseFloat(localStorage.getItem(EDITOR_SPLIT_KEY)) || 50;
+
+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}%`;
+}
+
$('toggle-preview')?.addEventListener('click', () => {
state.previewVisible = !state.previewVisible;
preview.classList.toggle('hidden', !state.previewVisible);
+ editorDivider?.classList.toggle('hidden', !state.previewVisible);
$('toggle-preview').classList.toggle('text-t-accent', state.previewVisible);
+ if (state.previewVisible) {
+ applyEditorSplit();
+ } else {
+ if (editorPane) editorPane.style.flex = '';
+ preview.style.flex = '';
+ }
updatePreview();
});
+if (editorDivider) {
+ const bar = editorDivider.firstElementChild;
+ editorDivider.addEventListener('mouseenter', () => { if (bar) bar.style.opacity = '0.5'; });
+ editorDivider.addEventListener('mouseleave', () => { if (bar && !editorDivider._dragging) bar.style.opacity = '0.12'; });
+
+ editorDivider.addEventListener('mousedown', e => {
+ e.preventDefault();
+ editorDivider._dragging = true;
+ if (bar) bar.style.opacity = '0.7';
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ const container = editorDivider.parentElement;
+ const startX = e.clientX;
+ const startSplit = editorSplit;
+
+ const onMove = ev => {
+ const rect = container.getBoundingClientRect();
+ const dx = ev.clientX - startX;
+ const pct = startSplit + (dx / rect.width) * 100;
+ editorSplit = Math.max(25, Math.min(75, pct));
+ applyEditorSplit();
+ };
+ const onUp = () => {
+ editorDivider._dragging = false;
+ if (bar) bar.style.opacity = '0.12';
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ localStorage.setItem(EDITOR_SPLIT_KEY, String(editorSplit));
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+}
+
$('add-btn')?.addEventListener('click', () => openModal());
$('modal-cancel')?.addEventListener('click', closeModal);
$('modal-save')?.addEventListener('click', saveOnly);
@@ -455,7 +513,147 @@ function completeLinkAc(idx) {
hideLinkAc(); updatePreview(); body.focus();
}
-body.addEventListener('input', () => {
+const SNIPPET_HINTS = [
+ { key: 'dat', label: 'date', desc: '2026-04-07' },
+ { key: 'time', label: 'time', desc: '22:53' },
+ { key: 'now', label: 'date+time', desc: '2026-04-07 22:53' },
+ { key: 'week', label: 'calendar week', desc: 'KW15' },
+ { key: 'todo', label: 'checkbox', desc: '- [ ]' },
+ { key: 'hr', label: 'divider', desc: '---' },
+ { key: 'h1', label: 'heading 1', desc: '# ' },
+ { key: 'h2', label: 'heading 2', desc: '## ' },
+ { key: 'h3', label: 'heading 3', desc: '### ' },
+ { key: 'table', label: 'table', desc: '| | |' },
+ { key: 'code', label: 'code block', desc: '```' },
+];
+
+const snippetAc = $('snippet-ac');
+let snippetAcVisible = false, snippetAcItems = [], snippetAcActive = 0;
+
+function getSnippetQuery() {
+ const before = body.value.substring(0, body.selectionStart);
+ const m = before.match(/\?([a-z0-9]*)$/);
+ if (!m) return null;
+ // Don't show for math expressions (starts with digit)
+ if (m[1] && /^\d/.test(m[1])) return null;
+ return { start: before.length - m[0].length, query: m[1] };
+}
+
+function showSnippetAc(query) {
+ const q = query.toLowerCase();
+ snippetAcItems = SNIPPET_HINTS.filter(s => !q || s.key.includes(q) || s.label.includes(q));
+ if (!snippetAcItems.length) { hideSnippetAc(); return; }
+ snippetAcActive = 0;
+ snippetAc.innerHTML = snippetAcItems.map((s, i) => `
+
+ ?${esc(s.key)}
+ ${esc(s.label)}
+ ${esc(s.desc)}
+
`).join('');
+ snippetAc.querySelectorAll('.ac-item').forEach(el => {
+ el.addEventListener('mousedown', e => { e.preventDefault(); completeSnippetAc(parseInt(el.dataset.idx)); });
+ });
+ const rect = body.getBoundingClientRect();
+ const lines = body.value.substring(0, body.selectionStart).split('\n');
+ snippetAc.style.top = Math.min(rect.top + (lines.length - 1) * 22 - body.scrollTop + 26, innerHeight - 200) + 'px';
+ snippetAc.style.left = Math.min(rect.left + 40, innerWidth - 340) + 'px';
+ snippetAc.classList.remove('hidden');
+ snippetAcVisible = true;
+}
+
+function hideSnippetAc() { snippetAc.classList.add('hidden'); snippetAcVisible = false; }
+
+function completeSnippetAc(idx) {
+ const item = snippetAcItems[idx];
+ if (!item) return;
+ const info = getSnippetQuery();
+ if (!info) { hideSnippetAc(); return; }
+ const snippet = getSnippet(item.key);
+ if (snippet === null) { hideSnippetAc(); return; }
+ const before = body.value.substring(0, info.start);
+ const after = body.value.substring(body.selectionStart);
+ const cursorInside = snippet.includes('\n') ? snippet.indexOf('\n') + 1 : snippet.length;
+ body.value = before + snippet + after;
+ body.selectionStart = body.selectionEnd = info.start + cursorInside;
+ hideSnippetAc(); updatePreview(); body.focus();
+}
+
+document.addEventListener('click', e => {
+ if (snippetAcVisible && !snippetAc.contains(e.target) && e.target !== body) hideSnippetAc();
+});
+
+function getSnippet(key) {
+ const d = new Date();
+ const pad = n => String(n).padStart(2, '0');
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ // ISO week number
+ const jan4 = new Date(d.getFullYear(), 0, 4);
+ const week = Math.ceil(((d - jan4) / 86400000 + jan4.getDay() + 1) / 7);
+
+ const snippets = {
+ dat: date,
+ time: time,
+ now: `${date} ${time}`,
+ week: `KW${week}`,
+ todo: '- [ ] ',
+ hr: '---',
+ h1: '# ',
+ h2: '## ',
+ h3: '### ',
+ table: '| col1 | col2 | col3 |\n| --- | --- | --- |\n| | | |',
+ code: '```\n\n```',
+ };
+ return snippets[key] ?? null;
+}
+
+body.addEventListener('input', e => {
+ // Inline snippets & calc: ?keyword SPACE or ?expr SPACE
+ if (e.inputType === 'insertText' && e.data === ' ') {
+ const pos = body.selectionStart;
+ const before = body.value.substring(0, pos - 1);
+
+ // Text snippets: ?dat, ?time, ?now, ?week, ?todo, ?hr, ?h1-3, ?table, ?code
+ const sm = before.match(/\?([a-z][a-z0-9]*)$/);
+ if (sm) {
+ const snippet = getSnippet(sm[1]);
+ if (snippet !== null) {
+ const start = pos - 1 - sm[0].length;
+ const cursorInside = snippet.includes('\n') ? snippet.indexOf('\n') + 1 : snippet.length;
+ body.value = body.value.substring(0, start) + snippet + body.value.substring(pos);
+ body.selectionStart = body.selectionEnd = start + cursorInside;
+ updatePreview();
+ return;
+ }
+ }
+
+ // Math calc: ?3+2 → replaces with result; 34+56? → 34+56=90
+ const cm = before.match(/\?([0-9][0-9+\-*\/().,%^ ]*[0-9)%])$/);
+ const cm2 = !cm ? before.match(/([0-9][0-9+\-*\/().,%^ ]*[0-9)%])\?$/) : null;
+ const calcMatch = cm || cm2;
+ if (calcMatch) {
+ try {
+ const expr = calcMatch[1].replace(/%/g, '/100*').replace(/\^/g, '**');
+ const result = Function('"use strict"; return (' + expr + ')')();
+ if (typeof result === 'number' && isFinite(result)) {
+ const rounded = Math.round(result * 1e10) / 1e10;
+ const start = pos - 1 - calcMatch[0].length;
+ if (cm) {
+ // ?expr → result
+ body.value = body.value.substring(0, start) + rounded + body.value.substring(pos);
+ body.selectionStart = body.selectionEnd = start + String(rounded).length;
+ } else {
+ // expr? → expr=result
+ const replacement = calcMatch[1] + '=' + rounded;
+ body.value = body.value.substring(0, start) + replacement + body.value.substring(pos);
+ body.selectionStart = body.selectionEnd = start + replacement.length;
+ }
+ }
+ } catch {}
+ }
+ }
+ const snippetInfo = getSnippetQuery();
+ snippetInfo ? showSnippetAc(snippetInfo.query) : hideSnippetAc();
const linkInfo = getLinkQuery();
linkInfo ? showLinkAc(linkInfo.query) : hideLinkAc();
const tagInfo = getTagQuery();
@@ -511,9 +709,11 @@ function hideTagAc() { tagAc.classList.add('hidden'); tagAcVisible = false; }
document.addEventListener('click', e => {
if (tagAcVisible && !tagAc.contains(e.target) && e.target !== body) hideTagAc();
+ if (snippetAcVisible && !snippetAc.contains(e.target) && e.target !== body) hideSnippetAc();
});
document.addEventListener('keydown', e => {
- if (tagAcVisible && e.key === 'Escape') { hideTagAc(); }
+ if (tagAcVisible && e.key === 'Escape') hideTagAc();
+ if (snippetAcVisible && e.key === 'Escape') hideSnippetAc();
});
function completeTagAc(idx) {
@@ -596,6 +796,17 @@ body.addEventListener('keydown', e => {
});
return;
}
+ if (snippetAcVisible) {
+ if (e.key === 'ArrowDown') { e.preventDefault(); snippetAcActive = Math.min(snippetAcActive + 1, snippetAcItems.length - 1); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); snippetAcActive = Math.max(snippetAcActive - 1, 0); }
+ else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeSnippetAc(snippetAcActive); return; }
+ else if (e.key === 'Escape') { e.preventDefault(); hideSnippetAc(); return; }
+ snippetAc.querySelectorAll('.ac-item').forEach((el, i) => {
+ el.classList.toggle('active', i === snippetAcActive);
+ if (i === snippetAcActive) el.scrollIntoView({ block: 'nearest' });
+ });
+ return;
+ }
// Alt+Arrow: move line
if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
@@ -611,7 +822,7 @@ body.addEventListener('keydown', e => {
if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); }
});
-body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); });
+body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
// ── File upload (paste + drag-drop) ──