New toggle under Settings → Behavior → Interactions: "Switch to card's bucket on open (search / link)". When enabled, openDetail calls activateBucket(card.bucket_id) so the board and bucket bar update to show the card's context. Skipped when already on 'all' or the correct bucket. Default off. Uses activateBucket() (from buckets.js) instead of raw state assignment so the bucket-bar highlight tracks the switch. Tests: two cases — setting on switches activeBucket + verifies the bucket-tab.active in the bar matches; setting off leaves the bucket unchanged. Both create/delete probe cards in try/finally and restore the setting + activeBucket in afterEach. Bumps to 1.9.27. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1149 lines
44 KiB
JavaScript
1149 lines
44 KiB
JavaScript
// ── Detail view ──
|
|
|
|
import { $, esc, api, fuzzyMatch } from './util.js';
|
|
import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
|
|
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
|
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
|
import { showDialog } from './dialog.js';
|
|
import { activateBucket } from './buckets.js';
|
|
|
|
const detail = $('detail');
|
|
const scroll = $('detail-scroll');
|
|
const titleEl = $('detail-title');
|
|
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 ──
|
|
|
|
const ZOOM_KEY = 'hyprcrd-detail-zoom';
|
|
|
|
function applyZoom() {
|
|
if (!titleEl) return;
|
|
titleEl.style.fontSize = `${(1.125 * state.zoom).toFixed(3)}rem`;
|
|
bodyEl.style.fontSize = `${(0.875 * state.zoom).toFixed(3)}rem`;
|
|
metaEl.style.fontSize = `${Math.max(0.625, 0.6875 * state.zoom).toFixed(3)}rem`;
|
|
titleEl.style.lineHeight = `${Math.max(1.35, 1.55 + (state.zoom - 1) * 0.3).toFixed(3)}`;
|
|
bodyEl.style.lineHeight = `${Math.max(1.5, 1.7 + (state.zoom - 1) * 0.35).toFixed(3)}`;
|
|
metaEl.style.lineHeight = `${Math.max(1.2, 1.35 + (state.zoom - 1) * 0.25).toFixed(3)}`;
|
|
}
|
|
applyZoom();
|
|
|
|
scroll?.addEventListener('wheel', e => {
|
|
if (!detail.classList.contains('open') || !e.shiftKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
|
|
e.preventDefault();
|
|
state.zoom = Math.min(2.2, Math.max(0.75, state.zoom + (e.deltaY < 0 ? 0.08 : -0.08)));
|
|
state.zoom = Math.round(state.zoom * 100) / 100;
|
|
localStorage.setItem(ZOOM_KEY, String(state.zoom));
|
|
applyZoom();
|
|
}, { passive: false });
|
|
|
|
// ── Open / close ──
|
|
|
|
export function openDetail(id, options = {}) {
|
|
const card = state.cards.find(c => c.id == id);
|
|
if (!card) return;
|
|
markRecent(id);
|
|
|
|
// Optional: switch the board to the card's bucket so the user
|
|
// sees context. Only when opened via search, wikilink, or
|
|
// programmatic call — not when the user clicked a board card
|
|
// (they're already in the right bucket). Gated by a setting
|
|
// under Behavior → Interactions.
|
|
if (card.bucket_id
|
|
&& localStorage.getItem('hyprcrd-open-switches-bucket') === '1'
|
|
&& state.activeBucket !== 'all'
|
|
&& String(state.activeBucket) !== String(card.bucket_id)) {
|
|
activateBucket(card.bucket_id);
|
|
}
|
|
|
|
const el = $('board')?.querySelector(`[data-id="${id}"]`);
|
|
if (el) el.style.borderBottom = `3px solid ${card.color}`;
|
|
|
|
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;
|
|
|
|
renderSidebar(card);
|
|
showContent(card);
|
|
dismissStaleBanner();
|
|
detail.classList.add('open');
|
|
|
|
// Let plugins (collab-buckets unread-dot, etc.) react to the open.
|
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id });
|
|
}
|
|
|
|
// ── Remote-update detection ──
|
|
//
|
|
// When a foreign edit arrives for the card the user is currently viewing,
|
|
// the pull replaces state.cards but the open detail view keeps rendering
|
|
// the stale object it captured on open. Instead of force-re-rendering
|
|
// under the user's finger (jarring when they're mid-read), surface a
|
|
// banner with a manual Reload button.
|
|
|
|
let staleBannerEl = null;
|
|
|
|
function dismissStaleBanner() {
|
|
if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; }
|
|
}
|
|
|
|
function showStaleBanner(fresh) {
|
|
dismissStaleBanner();
|
|
const host = detail.querySelector('#detail-controls')?.parentElement;
|
|
if (!host) return;
|
|
const bar = document.createElement('div');
|
|
bar.className = 'detail-stale-banner';
|
|
bar.innerHTML = `
|
|
<i class="ph ph-info"></i>
|
|
<span>This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}.</span>
|
|
<button class="detail-stale-reload">
|
|
<i class="ph ph-arrows-clockwise text-xs"></i>
|
|
<span>Reload</span>
|
|
</button>
|
|
<button class="detail-stale-dismiss" aria-label="Dismiss"><i class="ph ph-x text-xs"></i></button>
|
|
`;
|
|
host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling);
|
|
staleBannerEl = bar;
|
|
bar.querySelector('.detail-stale-reload').addEventListener('click', () => {
|
|
const current = state.cards.find(c => c.id == state.viewingCard?.id);
|
|
if (current) {
|
|
state.viewingCard = current;
|
|
renderSidebar(current);
|
|
showContent(current);
|
|
// Treat the reload as an explicit "I've now seen the new version"
|
|
// — re-fire detail.opened so collab-buckets clears the red dot
|
|
// on the board card and persists mark-card-seen to the backend.
|
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: current.id });
|
|
}
|
|
dismissStaleBanner();
|
|
});
|
|
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
|
|
}
|
|
|
|
function timeAgo(iso) {
|
|
if (!iso) return '';
|
|
// SQLite stores CURRENT_TIMESTAMP as UTC without a tz marker
|
|
// ("2026-04-14 12:34:56"). Without an explicit Z, JS parses it as
|
|
// local time — off by the tz offset (2h on CEST, hence "2h ago"
|
|
// for a 30-second-old update).
|
|
const normalized = String(iso).includes('T') || /[Zz]|[+-]\d\d:?\d\d$/.test(iso)
|
|
? iso
|
|
: iso.replace(' ', 'T') + 'Z';
|
|
const t = new Date(normalized).getTime();
|
|
if (!isFinite(t)) return '';
|
|
const s = Math.max(0, (Date.now() - t) / 1000);
|
|
if (s < 60) return `${Math.floor(s)}s ago`;
|
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
return `${Math.floor(s / 86400)}d ago`;
|
|
}
|
|
|
|
// After every full card reload, check whether the card we're viewing is
|
|
// newer in the fresh state. If so, show the banner. Dismiss it if the
|
|
// detail view closed in the meantime.
|
|
function checkStaleViewingCard() {
|
|
if (!detail.classList.contains('open') || !state.viewingCard) {
|
|
dismissStaleBanner();
|
|
return;
|
|
}
|
|
const viewingId = state.viewingCard.id;
|
|
const fresh = state.cards.find(c => c.id == viewingId);
|
|
if (!fresh) return; // card deleted — separate UX, skip banner
|
|
const old = state.viewingCard;
|
|
// Compare updated_at OR any field that might have changed. The detail
|
|
// view captures viewingCard by reference on open, so a replaced
|
|
// state.cards array (via _reloadAll) gives us a different object — if
|
|
// any visible field differs, surface the banner.
|
|
const changed =
|
|
(fresh.updated_at || '') > (old.updated_at || '') ||
|
|
(fresh.title || '') !== (old.title || '') ||
|
|
(fresh.body || '') !== (old.body || '') ||
|
|
(fresh.color || '') !== (old.color || '') ||
|
|
(fresh.tags || '') !== (old.tags || '');
|
|
if (changed) showStaleBanner(fresh);
|
|
}
|
|
window.hyprcrd?.hooks?.on?.('cards.reloaded', checkStaleViewingCard);
|
|
|
|
function clearMorphGhost() {
|
|
if (morphTimer) {
|
|
clearTimeout(morphTimer);
|
|
morphTimer = null;
|
|
}
|
|
if (morphGhost) {
|
|
morphGhost.remove();
|
|
morphGhost = null;
|
|
}
|
|
}
|
|
|
|
let galleryImages = [];
|
|
let galleryIndex = 0;
|
|
|
|
function ensureImageFullscreenOverlay() {
|
|
if (imageFsOverlay) return imageFsOverlay;
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'detail-image-fs-overlay';
|
|
overlay.innerHTML = `
|
|
<button type="button" class="detail-image-fs-close" aria-label="close image">
|
|
<i class="ph ph-x text-xl"></i>
|
|
</button>
|
|
<button type="button" class="detail-image-fs-nav detail-image-fs-prev" aria-label="previous"><i class="ph ph-caret-left"></i></button>
|
|
<button type="button" class="detail-image-fs-nav detail-image-fs-next" aria-label="next"><i class="ph ph-caret-right"></i></button>
|
|
<div class="detail-image-fs-counter"></div>
|
|
<div class="detail-image-fs-stage">
|
|
<img class="detail-image-fs-img" alt="">
|
|
</div>
|
|
`;
|
|
overlay.addEventListener('click', e => {
|
|
if (e.target === overlay) closeImageFullscreen();
|
|
});
|
|
overlay.querySelector('.detail-image-fs-close')?.addEventListener('click', closeImageFullscreen);
|
|
overlay.querySelector('.detail-image-fs-prev')?.addEventListener('click', e => { e.stopPropagation(); navigateGallery(-1); });
|
|
overlay.querySelector('.detail-image-fs-next')?.addEventListener('click', e => { e.stopPropagation(); navigateGallery(1); });
|
|
|
|
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 updateGalleryNav() {
|
|
if (!imageFsOverlay) return;
|
|
const hasGallery = galleryImages.length > 1;
|
|
imageFsOverlay.querySelector('.detail-image-fs-prev').style.display = hasGallery ? '' : 'none';
|
|
imageFsOverlay.querySelector('.detail-image-fs-next').style.display = hasGallery ? '' : 'none';
|
|
const counter = imageFsOverlay.querySelector('.detail-image-fs-counter');
|
|
counter.style.display = hasGallery ? '' : 'none';
|
|
if (hasGallery) counter.textContent = `${galleryIndex + 1} / ${galleryImages.length}`;
|
|
}
|
|
|
|
function navigateGallery(dir) {
|
|
if (galleryImages.length < 2) return;
|
|
galleryIndex = (galleryIndex + dir + galleryImages.length) % galleryImages.length;
|
|
resetImageFsTransform();
|
|
if (imageFsImg) {
|
|
imageFsImg.src = galleryImages[galleryIndex];
|
|
}
|
|
updateGalleryNav();
|
|
}
|
|
|
|
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 = '', gallery = []) {
|
|
if (!src) return;
|
|
const overlay = ensureImageFullscreenOverlay();
|
|
resetImageFsTransform();
|
|
galleryImages = gallery.length > 1 ? gallery : [];
|
|
galleryIndex = galleryImages.indexOf(src);
|
|
if (galleryIndex < 0) galleryIndex = 0;
|
|
if (imageFsImg) {
|
|
imageFsImg.src = src;
|
|
imageFsImg.alt = alt || 'image';
|
|
}
|
|
updateGalleryNav();
|
|
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;
|
|
|
|
const candidates = [];
|
|
const pushCandidate = id => {
|
|
if (!id) return;
|
|
const s = String(id);
|
|
if (!candidates.includes(s)) candidates.push(s);
|
|
};
|
|
|
|
// 1) direct card
|
|
pushCandidate(cardId);
|
|
|
|
const card = state.cards.find(c => c.id == cardId);
|
|
if (card) {
|
|
if (card.parent_id) {
|
|
// 2) group parent (typical board representation for grouped cards)
|
|
pushCandidate(card.parent_id);
|
|
const parent = state.cards.find(c => c.id == card.parent_id);
|
|
if (parent) {
|
|
// 3) any visible member in expanded group view
|
|
pushCandidate(parent.id);
|
|
getChildren(parent.id).forEach(ch => pushCandidate(ch.id));
|
|
}
|
|
} else {
|
|
const children = getChildren(card.id);
|
|
if (children.length) {
|
|
// parent with children: include visible members for expanded group view
|
|
pushCandidate(card.id);
|
|
children.forEach(ch => pushCandidate(ch.id));
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const id of candidates) {
|
|
const el = board.querySelector(`[data-id="${id}"]`);
|
|
if (el) return el;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function startCloseMorph(cardId) {
|
|
if (!cardId) return;
|
|
if ($('search')?.classList.contains('open')) return;
|
|
|
|
const card = state.cards.find(c => c.id == cardId);
|
|
const panel = detail?.firstElementChild;
|
|
const target = resolveMorphTarget(cardId);
|
|
if (!panel || !target) return;
|
|
|
|
// Use known inset values instead of getBoundingClientRect to avoid offset issues
|
|
const isZen = detail.classList.contains('zen');
|
|
const inset = isZen ? 0 : 40;
|
|
const from = {
|
|
left: inset,
|
|
top: inset,
|
|
width: window.innerWidth - inset * 2,
|
|
height: window.innerHeight - inset * 2,
|
|
};
|
|
const to = target.getBoundingClientRect();
|
|
if (from.width < 40 || from.height < 40 || to.width < 20 || to.height < 20) return;
|
|
|
|
// Skip if target card is off-screen; morph would look broken.
|
|
const visible = to.bottom > 0 && to.top < window.innerHeight && to.right > 0 && to.left < window.innerWidth;
|
|
if (!visible) return;
|
|
|
|
clearMorphGhost();
|
|
|
|
const frame = document.createElement('div');
|
|
frame.className = 'detail-close-frame';
|
|
const targetCard = target.dataset.id
|
|
? state.cards.find(c => c.id == target.dataset.id)
|
|
: card;
|
|
Object.assign(frame.style, {
|
|
position: 'fixed',
|
|
left: `${from.left}px`,
|
|
top: `${from.top}px`,
|
|
width: `${from.width}px`,
|
|
height: `${from.height}px`,
|
|
borderColor: targetCard?.color || card?.color || 'var(--t-accent2)',
|
|
margin: '0',
|
|
});
|
|
|
|
document.body.appendChild(frame);
|
|
morphGhost = frame;
|
|
|
|
// Force reflow so the browser commits the starting position before transitioning
|
|
void frame.offsetWidth;
|
|
|
|
requestAnimationFrame(() => {
|
|
frame.style.left = `${to.left}px`;
|
|
frame.style.top = `${to.top}px`;
|
|
frame.style.width = `${to.width}px`;
|
|
frame.style.height = `${to.height}px`;
|
|
frame.style.opacity = '0.32';
|
|
frame.style.borderRadius = '4px';
|
|
});
|
|
|
|
target.classList.add('card-morph-target');
|
|
setTimeout(() => target.classList.remove('card-morph-target'), 360);
|
|
|
|
const done = () => {
|
|
if (frame === morphGhost) morphGhost = null;
|
|
frame.remove();
|
|
};
|
|
frame.addEventListener('transitionend', done, { once: true });
|
|
morphTimer = setTimeout(done, 430);
|
|
}
|
|
|
|
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');
|
|
dismissStaleBanner();
|
|
removeZenHandles();
|
|
document.documentElement.classList.remove('detail-zen-open');
|
|
document.body.classList.remove('detail-zen-open');
|
|
$('split-right')?.classList.add('hidden');
|
|
const icon = $('detail-zen')?.querySelector('i');
|
|
if (icon) icon.className = 'ph ph-corners-out text-sm';
|
|
state.viewingCard = null;
|
|
if (state.openedFromSearch) {
|
|
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?.();
|
|
}
|
|
if (state._returnToCalendar) {
|
|
state._returnToCalendar = false;
|
|
window._openCalendar?.();
|
|
}
|
|
// Return focus to body so board shortcuts work immediately.
|
|
// Skipped when we just focused the search input (openedFromSearch).
|
|
if (!state.openedFromSearch && document.activeElement && document.activeElement !== document.body) {
|
|
document.activeElement.blur();
|
|
}
|
|
}
|
|
|
|
export function showDetailCard(id) {
|
|
const card = state.cards.find(c => c.id == id);
|
|
if (!card) return;
|
|
state.viewingCard = card;
|
|
markRecent(id);
|
|
|
|
document.querySelectorAll('#detail-sidebar-list .sidebar-card').forEach(sc => {
|
|
const active = sc.dataset.sid == id;
|
|
sc.classList.toggle('border-t-accent', active);
|
|
sc.classList.toggle('bg-t-raised', active);
|
|
sc.classList.toggle('border-transparent', !active);
|
|
});
|
|
showContent(card);
|
|
}
|
|
|
|
// ── Sidebar ──
|
|
|
|
function renderSidebar(card) {
|
|
const sidebar = $('detail-sidebar');
|
|
const list = $('detail-sidebar-list');
|
|
const groupCards = getGroupCards(card);
|
|
|
|
if (groupCards && groupCards.length > 1) {
|
|
sidebar.classList.remove('hidden');
|
|
sidebar.dataset.groupId = String(groupCards[0].id);
|
|
$('detail-sidebar-count').textContent = groupCards.length;
|
|
list.innerHTML = groupCards.map(gc => {
|
|
const active = gc.id == card.id;
|
|
const img = firstImage(gc.body);
|
|
return `
|
|
<div class="sidebar-card flex items-center gap-2 px-3 py-2.5 cursor-pointer border-l-2 transition-colors
|
|
${active ? 'border-t-accent bg-t-raised' : 'border-transparent hover:bg-t-raised/50'}" data-sid="${gc.id}">
|
|
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
|
|
${img ? `<span class="w-6 h-6 rounded-full shrink-0 bg-cover bg-center border border-t-border" style="background-image:url('${img}')"></span>` : ''}
|
|
<span class="font-pixel text-[10px] ${active ? 'text-t-bright' : 'text-t-muted'} truncate flex-1">${esc(gc.title)}</span>
|
|
<button onclick="event.stopPropagation();window._ungroupCard(${gc.id})" class="text-t-dim hover:text-t-danger transition-colors shrink-0 ${gc.id == groupCards[0]?.id ? 'invisible' : ''}" data-tip="ungroup">
|
|
<i class="ph ph-x text-[10px]"></i>
|
|
</button>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
list.querySelectorAll('.sidebar-card').forEach(sc => {
|
|
sc.addEventListener('click', () => showDetailCard(sc.dataset.sid));
|
|
});
|
|
} else {
|
|
sidebar.classList.add('hidden');
|
|
delete sidebar.dataset.groupId;
|
|
}
|
|
}
|
|
|
|
// ── Content ──
|
|
|
|
function updateDoneButton(card) {
|
|
const isDone = (card.tags || '').split(',').includes('done');
|
|
const btn = $('detail-done');
|
|
const label = $('detail-done-label');
|
|
if (btn) {
|
|
btn.classList.toggle('text-t-accent', isDone);
|
|
btn.classList.toggle('border-t-accent', isDone);
|
|
}
|
|
if (label) label.textContent = isDone ? 'Undone' : 'Done';
|
|
}
|
|
|
|
function showContent(card) {
|
|
$('detail-color').style.background = card.color;
|
|
titleEl.textContent = card.title;
|
|
updateDoneButton(card);
|
|
const doneCorner = $('detail-done-corner');
|
|
if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
|
|
bodyEl.innerHTML = renderMd(card.body);
|
|
addCodeCopyButtons(bodyEl);
|
|
formatTodoItems(bodyEl);
|
|
applyReadingResizeHandles(bodyEl);
|
|
bindDetailImageFullscreen(bodyEl);
|
|
|
|
// Interactive checkboxes
|
|
bindCheckboxes(bodyEl, card);
|
|
}
|
|
|
|
export function bindCheckboxes(container, card) {
|
|
container.querySelectorAll('.todo-item').forEach((item, idx) => {
|
|
const btn = item.querySelector('.todo-cb');
|
|
if (!btn) return;
|
|
const toggle = async () => {
|
|
let n = 0;
|
|
const body = card.body.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m);
|
|
card.body = body;
|
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
|
|
const c = state.cards.find(x => x.id == card.id);
|
|
if (c) c.body = body;
|
|
btn.classList.toggle('checked');
|
|
item.classList.toggle('todo-done');
|
|
window._renderBoard();
|
|
};
|
|
item.addEventListener('click', toggle);
|
|
});
|
|
|
|
metaEl.textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at +
|
|
(card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
|
|
applyZoom();
|
|
|
|
// Tags
|
|
const tags = parseTags(card);
|
|
const tagEl = $('detail-tags');
|
|
if (tagEl) {
|
|
if (tags.length) {
|
|
tagEl.classList.remove('hidden');
|
|
tagEl.innerHTML = tags.map(t => tagChipHtml(t)).join('');
|
|
tagEl.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
|
|
seg.addEventListener('click', e => {
|
|
const tag = seg.dataset.tag;
|
|
if (e.ctrlKey || e.metaKey) {
|
|
const idx = state.activeTags.indexOf(tag);
|
|
if (idx >= 0) state.activeTags.splice(idx, 1);
|
|
else state.activeTags.push(tag);
|
|
} else {
|
|
state.activeTags = [tag];
|
|
}
|
|
window._renderTagBar?.();
|
|
window._renderBoard();
|
|
});
|
|
});
|
|
} else {
|
|
tagEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Backlinks
|
|
const pattern = `[[${card.title}]]`.toLowerCase();
|
|
const backlinks = state.cards.filter(c => c.id != card.id && (c.body || '').toLowerCase().includes(pattern));
|
|
const blBox = $('detail-backlinks');
|
|
const blList = $('detail-backlinks-list');
|
|
if (backlinks.length) {
|
|
blBox.classList.remove('hidden');
|
|
blList.innerHTML = backlinks.map(b => `
|
|
<button data-open-card="${b.id}" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
|
|
<span class="w-1.5 h-1.5 rounded-full shrink-0" style="background:${b.color}"></span>
|
|
<span class="font-pixel text-[10px]">${esc(b.title)}</span>
|
|
</button>`).join('');
|
|
blList.querySelectorAll('[data-open-card]').forEach(btn => {
|
|
btn.addEventListener('click', () => openDetail(btn.dataset.openCard));
|
|
});
|
|
} else {
|
|
blBox.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
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.dataset.tip = '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);
|
|
});
|
|
});
|
|
}
|
|
|
|
export function bindDetailImageFullscreen(root) {
|
|
if (!root) return;
|
|
|
|
// Gallery grids: single click, with prev/next
|
|
root.querySelectorAll('.md-gallery').forEach(gallery => {
|
|
const srcs = Array.from(gallery.querySelectorAll('img')).map(i => i.getAttribute('src'));
|
|
gallery.querySelectorAll('.md-gallery-item').forEach(item => {
|
|
const img = item.querySelector('img');
|
|
if (!img) return;
|
|
item.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openImageFullscreen(img.getAttribute('src'), img.getAttribute('alt') || 'image', srcs);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Regular images: double-click, no gallery nav
|
|
root.querySelectorAll('img').forEach(img => {
|
|
if (img.closest('.md-gallery')) return;
|
|
img.dataset.tip = 'double-click to 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)
|
|
detail.addEventListener('click', e => {
|
|
const link = e.target.closest('.card-link[data-card-id]');
|
|
if (link) { openDetail(link.dataset.cardId); return; }
|
|
if (e.target === detail) closeDetail();
|
|
});
|
|
$('detail-close')?.addEventListener('click', closeDetail);
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (!imageFsOverlay?.classList.contains('open')) return;
|
|
if (e.key === 'ArrowLeft') { navigateGallery(-1); e.preventDefault(); return; }
|
|
if (e.key === 'ArrowRight') { navigateGallery(1); e.preventDefault(); return; }
|
|
if (e.key !== 'Escape') return;
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
closeImageFullscreen();
|
|
}, true);
|
|
|
|
// Zen mode
|
|
// 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');
|
|
document.documentElement.classList.toggle('detail-zen-open', isZen);
|
|
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');
|
|
const splitSearch = $('split-search');
|
|
const splitPicks = $('split-picks');
|
|
const splitContent = $('split-content');
|
|
|
|
function findTaggedCards(card) {
|
|
if (!card) return [];
|
|
const myTags = parseTags(card);
|
|
if (!myTags.length) return [];
|
|
return state.cards
|
|
.filter(c => c.id != card.id)
|
|
.map(c => {
|
|
const ct = parseTags(c);
|
|
const shared = myTags.filter(t => ct.some(ct2 => ct2 === t || ct2.startsWith(t + '/') || t.startsWith(ct2 + '/')));
|
|
return shared.length ? { card: c, score: shared.length } : null;
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 12)
|
|
.map(s => s.card);
|
|
}
|
|
|
|
function findRelatedCards(card) {
|
|
if (!card) return [];
|
|
const id = card.id;
|
|
const title = (card.title || '').toLowerCase();
|
|
const body = (card.body || '').toLowerCase();
|
|
|
|
const stopwords = new Set(['the','and','for','that','this','with','from','are','was','were','been','have','has','had','not','but','what','all','can','her','his','how','its','may','new','now','old','see','way','who','did','get','let','say','she','too','use','eine','ein','der','die','das','und','ist','von','mit','den','dem','des','auf','fuer','auch','sich','noch','wie','man','aber','nach','bei','nur','dann','wenn','oder','als','bis','zum','zur','kann','doch','ich','wir','sie','aus','vor']);
|
|
const words = `${card.title} ${stripMd(card.body)}`.toLowerCase()
|
|
.split(/[^a-zA-Z0-9äöüß]+/)
|
|
.filter(w => w.length >= 3 && !stopwords.has(w));
|
|
const wordSet = [...new Set(words)];
|
|
|
|
return state.cards
|
|
.filter(c => c.id != id)
|
|
.map(c => {
|
|
let score = 0;
|
|
const ct = (c.title || '').toLowerCase();
|
|
const cb = stripMd(c.body || '').toLowerCase();
|
|
|
|
if (body.includes(`[[${c.title.toLowerCase()}]]`)) score += 80;
|
|
if ((c.body || '').toLowerCase().includes(`[[${title}]]`)) score += 80;
|
|
if (card.bucket_id && c.bucket_id == card.bucket_id) score += 15;
|
|
if (card.parent_id && c.parent_id == card.parent_id) score += 20;
|
|
|
|
for (const w of wordSet) {
|
|
if (ct.includes(w)) score += 6;
|
|
if (cb.includes(w)) score += 2;
|
|
}
|
|
|
|
return score > 0 ? { card: c, score } : null;
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 12)
|
|
.map(s => s.card);
|
|
}
|
|
|
|
function renderSplitGroup(cards, label) {
|
|
if (!cards.length) return '';
|
|
return `<div class="px-3 pt-2 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${label}</span></div>` +
|
|
cards.map(c => `<div class="split-pick" data-split-card="${c.id}">
|
|
<span class="sp-dot" style="background:${c.color}"></span>
|
|
<span class="font-pixel text-[10px] truncate flex-1">${esc(c.title)}</span>
|
|
${c.bucket_name ? `<span class="text-[8px] text-t-dim font-pixel">${esc(c.bucket_name)}</span>` : ''}
|
|
</div>`).join('');
|
|
}
|
|
|
|
function renderSplitPicks(cards, label) {
|
|
if (!cards.length) {
|
|
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px] cursor-blink">no matches</div>`;
|
|
return;
|
|
}
|
|
splitPicks.innerHTML = renderSplitGroup(cards, label);
|
|
splitPicks.querySelectorAll('[data-split-card]').forEach(el => {
|
|
el.addEventListener('click', () => loadSplitCard(el.dataset.splitCard));
|
|
});
|
|
}
|
|
|
|
function renderSplitGroups(groups) {
|
|
const html = groups.filter(g => g.cards.length).map(g => renderSplitGroup(g.cards, g.label)).join('');
|
|
if (!html) {
|
|
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px] cursor-blink">no matches</div>`;
|
|
return;
|
|
}
|
|
splitPicks.innerHTML = html;
|
|
splitPicks.querySelectorAll('[data-split-card]').forEach(el => {
|
|
el.addEventListener('click', () => loadSplitCard(el.dataset.splitCard));
|
|
});
|
|
}
|
|
|
|
function searchAllCards(query) {
|
|
const q = query.toLowerCase().trim();
|
|
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();
|
|
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)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 15)
|
|
.map(s => s.card);
|
|
}
|
|
|
|
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);
|
|
if (!card) return;
|
|
splitCardId = id;
|
|
splitPicks.innerHTML = `<div class="flex items-center border-b border-t-border">
|
|
<button id="split-back" class="flex items-center gap-1.5 flex-1 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors">
|
|
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">${splitLastQuery ? 'results' : 'related cards'}</span>
|
|
</button>
|
|
<button id="split-promote" class="flex items-center gap-1.5 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors" data-tip="open as main card">
|
|
<i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">Focus</span>
|
|
</button>
|
|
</div>`;
|
|
splitPicks.querySelector('#split-back').addEventListener('click', restoreSplitList);
|
|
splitPicks.querySelector('#split-promote').addEventListener('click', () => promoteSplitCard(id));
|
|
splitSearch.value = '';
|
|
splitContent.innerHTML = `
|
|
<div class="card-bar w-16 mb-3 rounded-sm" style="background:${card.color}"></div>
|
|
<h2 class="font-pixel text-t-bright text-sm mb-3 leading-relaxed">${esc(card.title)}</h2>
|
|
<div class="prose text-xs text-t-text">${renderMd(card.body)}</div>
|
|
<div class="mt-6 pt-3 border-t border-t-border text-[10px] text-t-dim font-pixel">
|
|
> ${card.created_at}${card.bucket_name ? ' | ' + esc(card.bucket_name) : ''}
|
|
</div>`;
|
|
splitContent.querySelectorAll('.card-link[data-card-id]').forEach(link => {
|
|
link.addEventListener('click', () => loadSplitCard(link.dataset.cardId));
|
|
});
|
|
bindCheckboxes(splitContent, card);
|
|
addCodeCopyButtons(splitContent);
|
|
formatTodoItems(splitContent);
|
|
}
|
|
|
|
function promoteSplitCard(id) {
|
|
// Open the split card as the main detail card, then refresh related in split pane
|
|
openDetail(id);
|
|
openSplitPane();
|
|
}
|
|
|
|
function openSplitPane() {
|
|
if (!splitPane) return;
|
|
splitPane.classList.remove('hidden');
|
|
splitSearch.value = '';
|
|
splitLastQuery = '';
|
|
splitContent.innerHTML = '';
|
|
const tagged = findTaggedCards(state.viewingCard);
|
|
const related = findRelatedCards(state.viewingCard);
|
|
// Dedupe: remove tagged cards from 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 },
|
|
]);
|
|
splitSearch.focus();
|
|
}
|
|
|
|
$('split-right-btn')?.addEventListener('click', () => {
|
|
if (splitPane.classList.contains('hidden')) openSplitPane();
|
|
});
|
|
|
|
$('split-close')?.addEventListener('click', () => splitPane.classList.add('hidden'));
|
|
|
|
splitSearch?.addEventListener('input', () => {
|
|
const q = splitSearch.value.trim();
|
|
splitLastQuery = q;
|
|
splitContent.innerHTML = '';
|
|
if (!q) {
|
|
const tagged = findTaggedCards(state.viewingCard);
|
|
const related = findRelatedCards(state.viewingCard);
|
|
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);
|
|
renderSplitPicks(results, `${results.length} result${results.length !== 1 ? 's' : ''}`);
|
|
});
|
|
|
|
splitSearch?.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') { splitPane.classList.add('hidden'); e.stopPropagation(); }
|
|
if (e.key === 'Enter') {
|
|
const first = splitPicks.querySelector('[data-split-card]');
|
|
if (first) loadSplitCard(first.dataset.splitCard);
|
|
}
|
|
});
|
|
|
|
$('detail-edit')?.addEventListener('click', () => {
|
|
if (!state.viewingCard) return;
|
|
const card = state.viewingCard;
|
|
const fromGallery = state.openedFromGallery;
|
|
const fromCalendar = state._returnToCalendar;
|
|
state.openedFromSearch = false;
|
|
state.openedFromGallery = false;
|
|
state.galleryOrigin = null;
|
|
state.returnToGalleryAfterEdit = fromGallery;
|
|
state._returnToCalendar = false;
|
|
closeDetail({ animate: false, restoreGallery: false });
|
|
// Hide calendar so modal is visible
|
|
if (fromCalendar) {
|
|
$('calendar')?.classList.remove('open');
|
|
state._returnToCalendar = true;
|
|
}
|
|
$('gallery')?.classList.remove('open');
|
|
window._closeSearch();
|
|
window._openModal(card);
|
|
});
|
|
|
|
$('detail-done')?.addEventListener('click', async () => {
|
|
const card = state.viewingCard;
|
|
if (!card) return;
|
|
const isDone = (card.tags || '').split(',').includes('done');
|
|
let newBody;
|
|
if (isDone) {
|
|
newBody = card.body.replace(/\s*#done\b/g, '');
|
|
} else {
|
|
newBody = (card.body || '').trimEnd() + ' #done';
|
|
}
|
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody, due_date: isDone ? card.due_date : null } });
|
|
card.body = newBody;
|
|
if (!isDone) card.due_date = null;
|
|
const c = state.cards.find(x => x.id == card.id);
|
|
if (c) { c.body = newBody; c.tags = newBody.match(/#[a-z0-9_/-]+/gi)?.map(t => t.substring(1).toLowerCase()).join(',') || ''; if (!isDone) c.due_date = null; }
|
|
showContent(card);
|
|
window._renderBoard();
|
|
const doneCorner = $('detail-done-corner');
|
|
if (doneCorner) doneCorner.classList.toggle('hidden', !newBody.includes('#done'));
|
|
});
|
|
|
|
$('detail-delete')?.addEventListener('click', async () => {
|
|
if (!state.viewingCard) return;
|
|
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
|
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));
|
|
await api(`api/cards/${state.viewingCard.id}`, { method: 'DELETE' });
|
|
state.galleryOrigin = null;
|
|
closeDetail({ animate: false, restoreGallery: false });
|
|
await window._reloadAll();
|
|
});
|
|
|
|
export async function ungroupCard(id) {
|
|
await api(`api/cards/${id}`, { method: 'PATCH', body: { parent_id: null } });
|
|
await window._reloadAll();
|
|
if (state.viewingCard) openDetail(state.viewingCard.id == id ? id : state.viewingCard.id);
|
|
}
|