haufen sachen
This commit is contained in:
1 parent
416ca9e854
commit
e6243701a3
20 files changed
+1562
-116
No files matched your search
+244
-12
@@ -6,7 +6,8 @@ import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js';
|
||||
import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
|
||||
import { getCalConfig } from './caldav.js';
|
||||
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||
import { bindCheckboxes } from './detail.js';
|
||||
import { addProvider as addNotifProvider, render as renderNotifs } from './notifications.js';
|
||||
import { bindCheckboxes, bindDetailImageFullscreen } from './detail.js';
|
||||
|
||||
const board = $('board');
|
||||
const boardPreview = $('board-preview');
|
||||
@@ -76,6 +77,78 @@ $('select-export')?.addEventListener('click', () => {
|
||||
if (!selectedIds.size) return;
|
||||
window.location.href = 'api/export?ids=' + [...selectedIds].join(',');
|
||||
});
|
||||
|
||||
$('select-group')?.addEventListener('click', async () => {
|
||||
if (selectedIds.size < 2) return;
|
||||
const ids = [...selectedIds];
|
||||
const parentId = ids[0]; // First selected becomes parent
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
await api(`api/cards/${ids[i]}`, { method: 'PATCH', body: { parent_id: parentId } });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-bucket')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const bucketOpts = state.buckets.map(b => b.name);
|
||||
const name = await showDialog({ title: 'move to bucket', input: true, placeholder: 'bucket name', okLabel: 'move' });
|
||||
if (!name) return;
|
||||
let bucket = state.buckets.find(b => b.name.toLowerCase() === name.trim().toLowerCase());
|
||||
if (!bucket) {
|
||||
const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
||||
if (created?.id) bucket = created;
|
||||
}
|
||||
if (!bucket) return;
|
||||
for (const id of selectedIds) {
|
||||
await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucket.id } });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-color')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const color = await showDialog({ title: 'set color', input: true, placeholder: '#38bdf8', okLabel: 'apply' });
|
||||
if (!color || !/^#[0-9a-fA-F]{6}$/.test(color.trim())) return;
|
||||
for (const id of selectedIds) {
|
||||
await api(`api/cards/${id}`, { method: 'PATCH', body: { color: color.trim() } });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-tag')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const tag = await showDialog({ title: 'add tag', input: true, placeholder: '#tagname', okLabel: 'add' });
|
||||
if (!tag) return;
|
||||
const cleanTag = tag.trim().replace(/^#/, '');
|
||||
if (!cleanTag) return;
|
||||
for (const id of selectedIds) {
|
||||
const card = state.cards.find(c => c.id == id);
|
||||
if (!card) continue;
|
||||
const body = (card.body || '').trimEnd() + ' #' + cleanTag;
|
||||
await api(`api/cards/${id}`, { method: 'PATCH', body: { body } });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-delete')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
for (const id of selectedIds) {
|
||||
await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-clear')?.addEventListener('click', clearSelection);
|
||||
|
||||
// Custom drag ghost that fades near header
|
||||
@@ -117,10 +190,13 @@ function showBoardPreview(cardId) {
|
||||
$('board-preview-content')?.classList.remove('hidden');
|
||||
if (boardPreviewColor) boardPreviewColor.style.background = card.color;
|
||||
if (boardPreviewTitle) boardPreviewTitle.textContent = card.title;
|
||||
const doneBadge = $('board-preview-done-badge');
|
||||
if (doneBadge) doneBadge.classList.toggle('hidden', !parseTags(card).includes('done'));
|
||||
if (boardPreviewBody) {
|
||||
boardPreviewBody.innerHTML = renderMd(card.body);
|
||||
addCodeCopyButtons(boardPreviewBody);
|
||||
formatTodoItems(boardPreviewBody);
|
||||
bindDetailImageFullscreen(boardPreviewBody);
|
||||
bindCheckboxes(boardPreviewBody, card);
|
||||
}
|
||||
const tags = parseTags(card);
|
||||
@@ -199,6 +275,22 @@ if (state.superView) {
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
}
|
||||
|
||||
$('hide-done')?.addEventListener('click', () => {
|
||||
state.hideDone = !state.hideDone;
|
||||
localStorage.setItem('hyprcrd-hide-done', state.hideDone ? '1' : '0');
|
||||
const btn = $('hide-done');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = `ph ${state.hideDone ? 'ph-check-square' : 'ph-check-circle'} text-xs`;
|
||||
if (btn) btn.classList.toggle('text-t-accent', state.hideDone);
|
||||
renderBoard();
|
||||
});
|
||||
if (state.hideDone) {
|
||||
const btn = $('hide-done');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = 'ph ph-check-square text-xs';
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
}
|
||||
|
||||
$('hide-termine')?.addEventListener('click', () => {
|
||||
state.hideTermine = !state.hideTermine;
|
||||
localStorage.setItem('hyprcrd-hide-termine', state.hideTermine ? '1' : '0');
|
||||
@@ -224,11 +316,12 @@ $('sort-toggle')?.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
const coverImg = leadingImage(c.body);
|
||||
const thumbImg = coverImg ? null : firstImage(c.body);
|
||||
const loaded = c.body != null;
|
||||
const coverImg = loaded ? leadingImage(c.body) : null;
|
||||
const thumbImg = coverImg ? null : (loaded ? firstImage(c.body) : null);
|
||||
const children = getChildren(c.id);
|
||||
const isGroup = !inGroup && children.length > 0;
|
||||
const todos = getTodos(c.body);
|
||||
const todos = loaded ? getTodos(c.body) : null;
|
||||
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
|
||||
const hasCover = !!coverImg;
|
||||
const tags = parseTags(c);
|
||||
@@ -237,12 +330,15 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
if (isTermin) { for (const t of tags) { if (t && t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) { calCfg = cfg; break; } } } }
|
||||
const cardColor = calCfg?.color || c.color;
|
||||
const calIcon = isTermin ? (calCfg?.icon || 'calendar-dots') : null;
|
||||
const overdue = c.due_date && c.due_date < new Date().toISOString().substring(0, 10);
|
||||
const barColor = overdue ? 'var(--t-danger)' : cardColor;
|
||||
|
||||
return `
|
||||
<div class="board-card ${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
|
||||
style="--glow-color:${cardColor}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + cardColor : ''}" draggable="true" data-id="${c.id}">
|
||||
style="--glow-color:${cardColor}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${overdue ? 'border-bottom:3px solid var(--t-danger)' : recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + cardColor : ''}" draggable="true" data-id="${c.id}">
|
||||
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
||||
<div class="card-bar w-full card-layer" style="background:${cardColor}"></div>
|
||||
${tags.includes('done') ? '<div class="card-done-badge"><i class="ph ph-check"></i></div>' : ''}
|
||||
<div class="card-bar w-full card-layer ${overdue ? 'card-bar-pulse' : ''}" style="background:${barColor}"></div>
|
||||
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${cardColor};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
||||
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
|
||||
<div class="p-4 card-layer flex flex-col h-full ${thumbImg ? 'pr-16' : ''}">
|
||||
@@ -255,10 +351,12 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
${todos ? `<div class="mb-2">
|
||||
<div class="todo-progress">${Array.from({length: todos.total}, (_, i) => `<div class="todo-seg" style="background:${i < todos.done ? cardColor : 'var(--t-border)'}"></div>`).join('')}</div>
|
||||
</div>` : ''}
|
||||
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>
|
||||
${loaded
|
||||
? `<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>`
|
||||
: `<div class="flex-1 flex items-center justify-center py-2"><i class="ph ph-spinner card-spinner text-t-dim text-lg"></i></div>`}
|
||||
${tags.length ? `<div class="flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||
<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>
|
||||
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,13 +372,20 @@ export function renderBoard() {
|
||||
cards = parent ? [parent, ...getChildren(parent.id)] : [];
|
||||
} else {
|
||||
if (state.activeBucket === 'current') {
|
||||
// Current shows ALL recently touched cards, including group children
|
||||
// Current: recently opened/edited cards (not just synced)
|
||||
const recent = getRecent();
|
||||
const now = Date.now();
|
||||
const freshMs = 48 * 60 * 60 * 1000;
|
||||
const currentDays = parseInt(localStorage.getItem('hyprcrd-current-days') || '2');
|
||||
const freshMs = currentDays * 24 * 60 * 60 * 1000;
|
||||
cards = state.cards.filter(c => {
|
||||
const tags = (c.tags || '').split(',');
|
||||
if (tags.includes('geburtstag')) return false;
|
||||
// Recently opened (tracked by markRecent)
|
||||
if (recent.includes(String(c.id))) return true;
|
||||
// Recently edited locally (not just synced — check synced_at vs updated_at)
|
||||
const updated = new Date(c.updated_at || c.created_at || 0).getTime();
|
||||
const synced = c.caldav_synced_at ? new Date(c.caldav_synced_at).getTime() : 0;
|
||||
if (synced && Math.abs(updated - synced) < 3000) return false; // sync-only update
|
||||
return (now - updated) < freshMs;
|
||||
});
|
||||
cards.sort((a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''));
|
||||
@@ -300,9 +405,13 @@ export function renderBoard() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hide appointments
|
||||
// Hide appointments + birthdays
|
||||
if (state.hideTermine) {
|
||||
cards = cards.filter(c => !parseTags(c).includes('termin'));
|
||||
cards = cards.filter(c => { const t = parseTags(c); return !t.includes('termin') && !t.includes('geburtstag'); });
|
||||
}
|
||||
// Hide done
|
||||
if (state.hideDone) {
|
||||
cards = cards.filter(c => !parseTags(c).includes('done'));
|
||||
}
|
||||
// Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel
|
||||
if (state.activeTags.length) {
|
||||
@@ -454,8 +563,131 @@ export function renderBoard() {
|
||||
|
||||
// Refresh split preview if open
|
||||
if (boardSplitOpen && boardPreviewCardId) showBoardPreview(boardPreviewCardId);
|
||||
|
||||
// Notification banner
|
||||
renderNotifs();
|
||||
}
|
||||
|
||||
// ── Notification providers ──
|
||||
|
||||
// Upcoming events (birthdays + appointments)
|
||||
addNotifProvider((settings) => {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().substring(0, 10);
|
||||
const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const lookahead = settings.lookahead || 3;
|
||||
const aheadStr = new Date(todayLocal.getTime() + lookahead * 86400000).toISOString().substring(0, 10);
|
||||
const items = [];
|
||||
|
||||
for (const c of state.cards) {
|
||||
const tags = (c.tags || '').split(',');
|
||||
const isGeb = tags.includes('geburtstag');
|
||||
const isTermin = tags.includes('termin');
|
||||
if (isGeb && !settings.birthdays) continue;
|
||||
if (isTermin && !isGeb && !settings.termine) continue;
|
||||
if (!isGeb && !isTermin) continue;
|
||||
|
||||
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (!m) continue;
|
||||
|
||||
let eventDate = m[1];
|
||||
if (isGeb) eventDate = todayStr.substring(0, 4) + eventDate.substring(4);
|
||||
if (eventDate < todayStr || eventDate > aheadStr) continue;
|
||||
|
||||
const name = c.title.replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
|
||||
const evParts = eventDate.split('-').map(Number);
|
||||
const evLocal = new Date(evParts[0], evParts[1] - 1, evParts[2]);
|
||||
const daysAway = Math.round((evLocal - todayLocal) / 86400000);
|
||||
const when = daysAway === 0 ? 'today' : daysAway === 1 ? 'tomorrow' : `in ${daysAway}d`;
|
||||
|
||||
items.push({
|
||||
id: String(c.id),
|
||||
icon: isGeb ? 'cake' : 'calendar-dots',
|
||||
iconColor: isGeb ? '#fb7185' : 'var(--t-text)',
|
||||
label: name,
|
||||
when,
|
||||
sort: eventDate,
|
||||
type: isGeb ? 'birthday' : 'appointment',
|
||||
onClick: () => window._openDetail(c.id),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// Due dates approaching or overdue
|
||||
addNotifProvider((settings) => {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().substring(0, 10);
|
||||
const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const lookahead = settings.lookahead || 3;
|
||||
const aheadStr = new Date(todayLocal.getTime() + lookahead * 86400000).toISOString().substring(0, 10);
|
||||
const items = [];
|
||||
|
||||
for (const c of state.cards) {
|
||||
if (!c.due_date) continue;
|
||||
|
||||
// Overdue
|
||||
if (c.due_date < todayStr) {
|
||||
if (!settings.overdue) continue;
|
||||
items.push({
|
||||
id: 'due-' + c.id,
|
||||
icon: 'warning',
|
||||
iconColor: 'var(--t-danger)',
|
||||
label: c.title,
|
||||
when: 'overdue',
|
||||
sort: '0' + c.due_date,
|
||||
type: 'overdue',
|
||||
onClick: () => window._openDetail(c.id),
|
||||
});
|
||||
}
|
||||
// Upcoming
|
||||
else if (c.due_date <= aheadStr) {
|
||||
const evParts = c.due_date.split('-').map(Number);
|
||||
const evLocal = new Date(evParts[0], evParts[1] - 1, evParts[2]);
|
||||
const daysAway = Math.round((evLocal - todayLocal) / 86400000);
|
||||
const when = daysAway === 0 ? 'today' : daysAway === 1 ? 'tomorrow' : `in ${daysAway}d`;
|
||||
items.push({
|
||||
id: 'due-' + c.id,
|
||||
icon: 'clock-countdown',
|
||||
iconColor: daysAway === 0 ? 'var(--t-danger)' : 'var(--t-accent)',
|
||||
label: c.title,
|
||||
when,
|
||||
sort: c.due_date,
|
||||
type: 'due',
|
||||
onClick: () => window._openDetail(c.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// Overdue tasks (cards with unchecked todos past their date, no due_date set)
|
||||
addNotifProvider((settings) => {
|
||||
if (!settings.overdue) return [];
|
||||
const todayStr = new Date().toISOString().substring(0, 10);
|
||||
const items = [];
|
||||
|
||||
for (const c of state.cards) {
|
||||
if (c.due_date) continue; // handled by due-date provider above
|
||||
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (!m || m[1] >= todayStr) continue;
|
||||
if (!c.body || !/- \[ \]/.test(c.body)) continue;
|
||||
|
||||
const name = c.title.replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
|
||||
items.push({
|
||||
id: 'overdue-' + c.id,
|
||||
icon: 'warning',
|
||||
iconColor: 'var(--t-danger)',
|
||||
label: name,
|
||||
when: 'overdue',
|
||||
sort: '0' + m[1],
|
||||
type: 'overdue',
|
||||
onClick: () => window._openDetail(c.id),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// ── Group bar ──
|
||||
|
||||
function renderGroupBar(cards) {
|
||||
|
||||
Reference in new issue
Block a user