327 lines
12 KiB
JavaScript
327 lines
12 KiB
JavaScript
// ── Detail view ──
|
|
|
|
import { $, esc, api } from './util.js';
|
|
import { state, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
|
|
import { renderMd, firstImage } 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;
|
|
|
|
// ── Zoom ──
|
|
|
|
const ZOOM_KEY = 'hyprcrd-detail-zoom';
|
|
let zoom = parseFloat(localStorage.getItem(ZOOM_KEY) || '1') || 1;
|
|
|
|
function applyZoom() {
|
|
if (!titleEl) return;
|
|
titleEl.style.fontSize = `${(1.125 * zoom).toFixed(3)}rem`;
|
|
bodyEl.style.fontSize = `${(0.875 * zoom).toFixed(3)}rem`;
|
|
metaEl.style.fontSize = `${Math.max(0.625, 0.6875 * zoom).toFixed(3)}rem`;
|
|
titleEl.style.lineHeight = `${Math.max(1.35, 1.55 + (zoom - 1) * 0.3).toFixed(3)}`;
|
|
bodyEl.style.lineHeight = `${Math.max(1.5, 1.7 + (zoom - 1) * 0.35).toFixed(3)}`;
|
|
metaEl.style.lineHeight = `${Math.max(1.2, 1.35 + (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();
|
|
zoom = Math.min(2.2, Math.max(0.75, zoom + (e.deltaY < 0 ? 0.08 : -0.08)));
|
|
zoom = Math.round(zoom * 100) / 100;
|
|
localStorage.setItem(ZOOM_KEY, String(zoom));
|
|
applyZoom();
|
|
}, { passive: false });
|
|
|
|
// ── Open / close ──
|
|
|
|
export function openDetail(id) {
|
|
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.viewingCard = card;
|
|
|
|
renderSidebar(card);
|
|
showContent(card);
|
|
detail.classList.add('open');
|
|
}
|
|
|
|
function clearMorphGhost() {
|
|
if (morphTimer) {
|
|
clearTimeout(morphTimer);
|
|
morphTimer = null;
|
|
}
|
|
if (morphGhost) {
|
|
morphGhost.remove();
|
|
morphGhost = null;
|
|
}
|
|
}
|
|
|
|
function resolveMorphTarget(cardId) {
|
|
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;
|
|
|
|
const from = panel.getBoundingClientRect();
|
|
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 = state.cards.find(c => c.id == target.dataset.id);
|
|
Object.assign(frame.style, {
|
|
left: `${from.left}px`,
|
|
top: `${from.top}px`,
|
|
width: `${from.width}px`,
|
|
height: `${from.height}px`,
|
|
borderColor: targetCard?.color || card?.color || 'var(--t-accent2)',
|
|
position: 'fixed',
|
|
inset: 'auto',
|
|
margin: '0',
|
|
});
|
|
|
|
document.body.appendChild(frame);
|
|
morphGhost = frame;
|
|
|
|
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 cardId = state.viewingCard?.id;
|
|
|
|
if (animate) startCloseMorph(cardId);
|
|
else clearMorphGhost();
|
|
|
|
detail.classList.remove('open', 'zen');
|
|
document.documentElement.classList.remove('detail-zen-open');
|
|
document.body.classList.remove('detail-zen-open');
|
|
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();
|
|
}
|
|
}
|
|
|
|
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');
|
|
$('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' : ''}" title="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');
|
|
}
|
|
}
|
|
|
|
// ── Content ──
|
|
|
|
function showContent(card) {
|
|
$('detail-color').style.background = card.color;
|
|
titleEl.textContent = card.title;
|
|
bodyEl.innerHTML = renderMd(card.body);
|
|
|
|
// Interactive checkboxes
|
|
bodyEl.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => {
|
|
cb.disabled = false;
|
|
cb.addEventListener('change', 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;
|
|
window._renderBoard();
|
|
});
|
|
});
|
|
|
|
metaEl.textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at +
|
|
(card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
|
|
applyZoom();
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
|
|
// Zen mode
|
|
$('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';
|
|
});
|
|
|
|
$('detail-edit')?.addEventListener('click', () => {
|
|
if (!state.viewingCard) return;
|
|
const card = state.viewingCard;
|
|
state.openedFromSearch = false;
|
|
closeDetail({ animate: false });
|
|
window._closeSearch();
|
|
window._openModal(card);
|
|
});
|
|
|
|
$('detail-delete')?.addEventListener('click', async () => {
|
|
if (!state.viewingCard) return;
|
|
const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
|
if (!ok) return;
|
|
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
|
await api(`api/cards/${state.viewingCard.id}`, { method: 'DELETE' });
|
|
closeDetail({ animate: 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);
|
|
}
|