- per-card unread dot on board (collab-buckets): backend tracks last_seen_at per card, poll exposes unread_cards, frontend decorates matching board cards; cleared on any open path via detail.opened hook - detail.opened + cards.reloaded hooks on the hyprcrd event bus - stale banner in detail view with manual Reload when the currently viewed card got updated by a peer — no content swap under the user - collab image sync: push attachments BEFORE card (race fix) + pull-side self-heal scans every collab card for missing uploads each poll - relay user deletion now purges chat_messages sent by deleted user so ghost threads stop appearing for their peers - calendar header nav buttons styled as square bordered buttons that honor --ui-radius-button; title bar widened to 280px so font variants don't wrap - .toolbar-btn now uses var(--ui-radius-button) instead of hardcoded 3px Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1115 lines
42 KiB
JavaScript
1115 lines
42 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';
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
dismissStaleBanner();
|
|
});
|
|
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
|
|
}
|
|
|
|
function timeAgo(iso) {
|
|
const t = new Date(iso).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.
|
|
window.hyprcrd?.hooks?.on?.('cards.reloaded', () => {
|
|
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;
|
|
if ((fresh.updated_at || '') > (old.updated_at || '')) {
|
|
showStaleBanner(fresh);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|