haufen sachen

This commit is contained in:
th committed 2026-04-08 22:46:48 +02:00
1 parent 416ca9e854
commit e6243701a3
20 files changed
+1562 -116

No files matched your search

+244 -12
View File
@@ -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) {
+70 -22
View File
@@ -21,7 +21,7 @@ const CAL_COLORS = ['#38bdf8', '#8b5cf6', '#fb7185', '#fbbf24', '#4ade80', '#fb9
let calendarConfigs = {}; // slug → { color, icon }
const CAL_CONFIG_KEY = 'hyprcrd-cal-configs';
const overlay = $('caldav-settings');
const settingsOverlay = $('settings-overlay');
const urlInput = $('caldav-url');
const userInput = $('caldav-user');
const passInput = $('caldav-pass');
@@ -30,12 +30,15 @@ const syncStatus = $('caldav-sync-status');
const logEl = $('caldav-log');
export function openCaldavSettings() {
overlay?.classList.add('open');
// Open settings overlay on sync tab
settingsOverlay?.classList.add('open');
settingsOverlay?.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'sync'));
settingsOverlay?.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.toggle('hidden', p.id !== 'settings-panel-sync'));
loadSettings();
}
export function closeCaldavSettings() {
overlay?.classList.remove('open');
settingsOverlay?.classList.remove('open');
}
function loadCalConfigs() {
@@ -97,7 +100,7 @@ function renderCalList(calendars) {
});
}
async function loadSettings() {
export async function loadSettings() {
const s = await api('api/caldav/settings');
if (!s) return;
if (urlInput) urlInput.value = s.url || '';
@@ -115,8 +118,6 @@ async function loadSettings() {
}
}
$('caldav-close')?.addEventListener('click', closeCaldavSettings);
overlay?.addEventListener('click', e => { if (e.target === overlay) closeCaldavSettings(); });
$('caldav-test')?.addEventListener('click', async () => {
testStatus.textContent = 'testing...';
@@ -149,6 +150,19 @@ $('caldav-save')?.addEventListener('click', async () => {
});
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
$('notes-reset')?.addEventListener('click', async () => {
if (syncStatus) { syncStatus.textContent = 'resetting notes...'; syncStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/notes/reset', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
const s = res.stats || {};
if (syncStatus) { syncStatus.textContent = `reset: ${res.reset || 0} cleared, ${s.pulled || 0} re-pulled`; syncStatus.style.color = 'var(--t-accent)'; }
await window._reloadAll?.();
} else {
if (syncStatus) { syncStatus.textContent = res?.error || 'reset failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
});
export async function triggerSync() {
const btn = $('cal-sync');
@@ -161,14 +175,22 @@ export async function triggerSync() {
if (btn) btn.style.opacity = '';
// Also sync WebDAV Notes
const notesRes = await fetch('api/notes/sync', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => null);
if (res.ok) {
const s = res.stats || {};
const hasErrors = (res.log || []).some(l => l.status === 'error');
const msg = `pushed:${s.pushed || 0} pulled:${s.pulled || 0} del:${s.deleted || 0}${s.conflicts ? ' conflicts:' + s.conflicts : ''}`;
const ns = notesRes?.stats || {};
const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) };
const allLog = [...(res.log || []), ...(notesRes?.log || [])];
const hasErrors = allLog.some(l => l.status === 'error');
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${ns.pulled ? ' notes:' + ns.pulled : ''}`;
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
await window._reloadAll?.();
showLog(res.log);
showSyncNotification(s);
showLog(allLog);
showSyncNotification(total);
} else {
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
@@ -190,6 +212,25 @@ function showConnectionLog(lines) {
).join('');
}
$('notes-rekey')?.addEventListener('click', async () => {
const { showDialog } = await import('./dialog.js');
const oldPass = await showDialog({ title: 're-key notes', input: 'password', placeholder: 'old password', okLabel: 're-key' });
if (!oldPass) return;
if (syncStatus) { syncStatus.textContent = 're-keying notes...'; syncStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/notes/rekey', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ old_password: oldPass }),
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
if (syncStatus) { syncStatus.textContent = `re-keyed ${res.rekeyed} notes — syncing...`; syncStatus.style.color = 'var(--t-accent)'; }
// Re-pull with correct key
const pullRes = await fetch('api/notes/reset', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
await window._reloadAll?.();
if (syncStatus) syncStatus.textContent = `re-keyed ${res.rekeyed} notes, ${pullRes?.stats?.pulled || 0} re-pulled`;
} else {
if (syncStatus) { syncStatus.textContent = res?.error || 're-key failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
});
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
$('cal-sync')?.addEventListener('click', () => triggerSync());
@@ -218,25 +259,32 @@ function showSyncNotification(stats) {
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
}
let bgSyncRunning = false;
async function backgroundSync() {
if (bgSyncRunning) return;
bgSyncRunning = true;
try {
const res = await fetch('api/caldav/sync', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
const s = res.stats || {};
if (s.pulled > 0 || s.pushed > 0 || s.deleted > 0) {
await window._reloadAll?.();
showSyncNotification(s);
}
const headers = { 'X-CSRF-Token': getCsrf() };
const [res, notesRes] = await Promise.all([
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/notes/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
]);
const s = res?.stats || {};
const ns = notesRes?.stats || {};
const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) };
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
await window._reloadAll?.();
showSyncNotification(total);
}
} catch {}
} catch {} finally {
bgSyncRunning = false;
}
}
// Start background sync after app is ready
let bgSyncInterval = null;
export function startBackgroundSync() {
if (bgSyncInterval) return;
backgroundSync(); // first sync immediately
bgSyncInterval = setInterval(backgroundSync, 120000); // then every 2 minutes
backgroundSync();
bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
}
+16
View File
@@ -35,6 +35,8 @@ export function showContextMenu(x, y, card) {
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span></button>
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">duplicate</span></button>
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `<button class="ctx-item" data-action="move-date"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">move to date</span></button>` : ''}
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'due: ' + card.due_date : 'set due date'}</span></button>
${card.due_date ? `<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm text-t-accent"></i><span class="font-pixel text-[10px]">mark done</span></button>` : ''}
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
<div class="ctx-sep"></div>
@@ -188,6 +190,20 @@ export function showContextMenu(x, y, card) {
await window._reloadAll();
if (card.caldav_uid) window._triggerSync?.();
}
else if (a === 'due-done') {
hideContextMenu();
// Clear due date and mark as done
const bodyWithDone = (card.body || '').includes('#done') ? card.body : (card.body || '').trimEnd() + ' #done';
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: null, body: bodyWithDone } });
await window._reloadAll();
}
else if (a === 'due-date') {
hideContextMenu();
const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'update' : 'set' });
if (newDate === null) return;
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: newDate || null } });
await window._reloadAll();
}
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
+36 -1
View File
@@ -444,9 +444,23 @@ function renderSidebar(card) {
// ── 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);
@@ -575,7 +589,7 @@ function applyReadingResizeHandles(root) {
});
}
function bindDetailImageFullscreen(root) {
export function bindDetailImageFullscreen(root) {
if (!root) return;
// Gallery grids: single click, with prev/next
@@ -978,6 +992,27 @@ $('detail-edit')?.addEventListener('click', () => {
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;
const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
+56 -2
View File
@@ -112,6 +112,58 @@ CARD_COLORS.forEach(c => {
presets.appendChild(btn);
});
const inpDueValue = $('inp-due-value');
const inpDueLabel = $('inp-due-label');
const inpDueBtn = $('inp-due-date');
function setDueDate(date) {
if (inpDueValue) inpDueValue.value = date || '';
if (inpDueLabel) inpDueLabel.textContent = date || 'no due';
if (inpDueBtn) {
inpDueBtn.classList.toggle('text-t-accent', !!date);
inpDueBtn.classList.toggle('border-t-accent', !!date);
inpDueBtn.classList.toggle('text-t-dim', !date);
}
}
inpDueBtn?.addEventListener('click', async () => {
const { showDialog } = await import('./dialog.js');
const current = inpDueValue?.value || '';
const result = await showDialog({
title: current ? 'change due date' : 'set due date',
input: 'date',
defaultValue: current || new Date().toISOString().substring(0, 10),
okLabel: current ? 'update' : 'set',
});
if (result === null) return;
setDueDate(result || '');
});
const inpDoneToggle = $('inp-done-toggle');
const inpDoneLabel = $('inp-done-label');
function updateDoneToggle() {
const isDone = body.value.includes('#done');
if (inpDoneToggle) {
inpDoneToggle.classList.toggle('text-t-accent', isDone);
inpDoneToggle.classList.toggle('border-t-accent', isDone);
inpDoneToggle.classList.toggle('text-t-dim', !isDone);
}
if (inpDoneLabel) inpDoneLabel.textContent = isDone ? 'undone' : 'done';
}
inpDoneToggle?.addEventListener('click', () => {
const isDone = body.value.includes('#done');
if (isDone) {
body.value = body.value.replace(/\s*#done\b/g, '');
} else {
body.value = body.value.trimEnd() + ' #done';
setDueDate('');
}
updateDoneToggle();
updatePreview();
});
function selectColor(hex) {
inpColor.value = hex;
const bar = $('editor-color');
@@ -137,6 +189,8 @@ export function openModal(card = null) {
inpBucket.value = card?.bucket_id ? card.bucket_id
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
selectColor(card ? card.color : '#38bdf8');
setDueDate(card?.due_date || '');
updateDoneToggle();
state.previewVisible = true;
preview.classList.remove('hidden');
editorDivider?.classList.remove('hidden');
@@ -328,7 +382,7 @@ function updatePreview() {
}
async function doSave(close, { animate = true } = {}) {
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null };
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null };
if (!data.title.trim()) return;
const returnToGallery = state.returnToGalleryAfterEdit;
if (state.editingId) {
@@ -975,7 +1029,7 @@ body.addEventListener('drop', e => {
document.addEventListener('mousedown', e => {
const img = e.target.closest('.prose img');
if (!img) return;
if (!img || img.closest('.md-gallery-item')) return;
e.preventDefault();
img.classList.add('resizing');
const startX = e.clientX, startW = img.offsetWidth;
+1 -1
View File
@@ -13,7 +13,7 @@ function mediaTile(url, extraClass = '') {
</div>`;
}
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden">
<img src="${url}" class="w-full h-full object-cover${extraClass.includes('danger') ? ' opacity-60' : ''}" loading="lazy">
<img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
</div>`;
}
import { state } from './state.js';
+43 -2
View File
@@ -13,7 +13,7 @@ marked.use({ breaks: true, gfm: true, renderer: {
const purifyConfig = {
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel'],
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'],
ALLOW_DATA_ATTR: true,
};
@@ -38,7 +38,48 @@ function normalizeListBlock(block) {
}
export function renderMd(src) {
let processed = String(src || '').replace(/\r\n?/g, '\n')
let processed = String(src || '').replace(/\r\n?/g, '\n');
// Convert Unicode box-drawing tables to HTML
processed = processed.replace(/([ \t]*[┌╔][\s\S]*?[┘╝][ \t]*)/g, (block) => {
const lines = block.split('\n').filter(l => l.trim());
const rows = [];
let currentRow = null;
for (const line of lines) {
const trimmed = line.trim();
// Skip border lines (┌─┬─┐, ├─┼─┤, └─┴─┘, ╔═╦═╗, etc.)
if (/^[┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║\s]+$/.test(trimmed)) {
if (currentRow) { rows.push(currentRow); currentRow = null; }
continue;
}
// Data row: │ cell │ cell │ or ║ cell ║
if (/^[│║]/.test(trimmed)) {
const cells = trimmed.split(/[│║]/).slice(1, -1).map(c => c.trim());
if (currentRow && currentRow.length === cells.length) {
// Continuation line — append to previous cells
for (let i = 0; i < cells.length; i++) {
if (cells[i]) currentRow[i] = ((currentRow[i] || '') + ' ' + cells[i]).trim();
}
} else {
if (currentRow) rows.push(currentRow);
currentRow = cells;
}
}
}
if (currentRow) rows.push(currentRow);
if (rows.length < 2) return block;
const header = rows[0];
const body = rows.slice(1);
return '\n<table><thead><tr>' +
header.map(c => `<th>${esc(c)}</th>`).join('') +
'</tr></thead><tbody>' +
body.map(r => '<tr>' + r.map(c => `<td>${esc(c)}</td>`).join('') + '</tr>').join('') +
'</tbody></table>\n';
});
processed = processed
// Gallery fenced block: ```gallery
.replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
const imgs = [];
+159
View File
@@ -0,0 +1,159 @@
// ── Notification banner system ──
// Providers register via addProvider(fn). Each fn(settings) returns an array of items.
import { $, esc } from './util.js';
const banner = $('upcoming-banner');
const providers = [];
const DISMISS_KEY = 'hyprcrd-dismissed-notifs';
const DISMISS_DATE_KEY = 'hyprcrd-dismissed-date';
const SETTINGS_KEY = 'hyprcrd-notif-settings';
// Clear dismissed list daily
if (localStorage.getItem(DISMISS_DATE_KEY) !== new Date().toISOString().substring(0, 10)) {
localStorage.removeItem(DISMISS_KEY);
localStorage.setItem(DISMISS_DATE_KEY, new Date().toISOString().substring(0, 10));
}
function getDismissed() {
try { return JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]'); } catch { return []; }
}
function dismiss(id) {
const d = getDismissed();
if (!d.includes(id)) d.push(id);
localStorage.setItem(DISMISS_KEY, JSON.stringify(d));
render();
}
export function getSettings() {
const defaults = { lookahead: 3, time: 0, birthdays: true, termine: true, overdue: true };
try { return { ...defaults, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') }; } catch { return defaults; }
}
export function saveSettings(s) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
render();
}
/**
* Register a notification provider.
* @param {function} fn - Receives settings, returns array of { id, icon, iconColor, label, when, sort, onClick?, dismissable?, type? }
*/
export function addProvider(fn) {
providers.push(fn);
}
function getAllItems() {
const settings = getSettings();
const items = [];
for (const fn of providers) {
try {
const result = fn(settings);
if (Array.isArray(result)) items.push(...result);
} catch {}
}
items.sort((a, b) => (a.sort || '').localeCompare(b.sort || ''));
return items;
}
export function render() {
if (!banner) return;
const dismissed = getDismissed();
const visible = getAllItems().filter(i => !dismissed.includes(String(i.id)));
if (!visible.length) { banner.classList.add('hidden'); banner.innerHTML = ''; return; }
banner.classList.remove('hidden');
banner.innerHTML = `<div class="flex items-center gap-2 px-6 py-2 bg-t-raised border-b border-t-border overflow-x-auto">
<i class="ph ph-bell-ringing text-t-accent text-sm shrink-0"></i>
${visible.map(i => `<div class="upcoming-item" data-notif-id="${i.id}" style="color:${i.iconColor || 'var(--t-text)'}">
<i class="ph ph-${i.icon || 'info'}" style="font-size:11px;"></i>
${i.when ? `<span class="font-pixel text-[10px]">${esc(i.when)}</span>` : ''}
<span>${esc(i.label)}</span>
${i.dismissable !== false ? `<button class="upcoming-dismiss" data-dismiss="${i.id}"><i class="ph ph-x text-xs"></i></button>` : ''}
</div>`).join('')}
</div>`;
banner.querySelectorAll('[data-notif-id]').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.upcoming-dismiss')) return;
const item = visible.find(i => String(i.id) === el.dataset.notifId);
if (item?.onClick) item.onClick();
});
});
banner.querySelectorAll('.upcoming-dismiss').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
dismiss(btn.dataset.dismiss);
});
});
}
// ── Settings panel integration ──
export function renderSettingsPanel() {
const s = getSettings();
// Populate controls
const lookahead = $('notif-lookahead');
if (lookahead) lookahead.value = String(s.lookahead);
const time = $('notif-time');
if (time) time.value = String(s.time);
const cbBday = $('notif-birthdays');
if (cbBday) cbBday.checked = s.birthdays;
const cbTermin = $('notif-termine');
if (cbTermin) cbTermin.checked = s.termine;
const cbOverdue = $('notif-overdue');
if (cbOverdue) cbOverdue.checked = s.overdue;
// Auto-save on change (use onchange to avoid stacking listeners)
const save = () => saveSettings({
lookahead: parseInt(lookahead?.value || '3'),
time: parseInt(time?.value || '0'),
birthdays: cbBday?.checked ?? true,
termine: cbTermin?.checked ?? true,
overdue: cbOverdue?.checked ?? true,
});
if (lookahead) lookahead.onchange = save;
if (time) time.onchange = save;
if (cbBday) cbBday.onchange = save;
if (cbTermin) cbTermin.onchange = save;
if (cbOverdue) cbOverdue.onchange = save;
// Restore dismissed — replace element to avoid duplicate listeners
const restoreBtn = $('notif-clear-dismissed');
if (restoreBtn) {
const fresh = restoreBtn.cloneNode(true);
restoreBtn.parentNode.replaceChild(fresh, restoreBtn);
fresh.addEventListener('click', () => {
localStorage.removeItem(DISMISS_KEY);
render();
renderStream();
});
}
renderStream();
}
function renderStream() {
const stream = $('notif-stream');
if (!stream) return;
const dismissed = getDismissed();
const all = getAllItems();
if (!all.length) {
stream.innerHTML = '<div class="text-xs text-t-dim font-pixel text-center py-4">no notifications</div>';
return;
}
stream.innerHTML = all.map(i => {
const isDismissed = dismissed.includes(String(i.id));
return `<div class="flex items-center gap-2 px-2 py-1.5 rounded text-xs ${isDismissed ? 'opacity-40' : ''}" style="color:${i.iconColor || 'var(--t-text)'}">
<i class="ph ph-${i.icon || 'info'}" style="font-size:11px;flex-shrink:0;"></i>
${i.when ? `<span class="font-pixel text-[9px] text-t-dim" style="min-width:55px;">${esc(i.when)}</span>` : ''}
<span class="flex-1 truncate">${esc(i.label)}</span>
${isDismissed ? '<span class="font-pixel text-[8px] text-t-dim">dismissed</span>' : ''}
</div>`;
}).join('');
}
+2
View File
@@ -8,6 +8,7 @@ export const state = {
activeBucket: 'all',
activeTags: [],
hideTermine: localStorage.getItem('hyprcrd-hide-termine') === '1',
hideDone: localStorage.getItem('hyprcrd-hide-done') === '1',
superView: localStorage.getItem('hyprcrd-superview') === '1',
activeGroup: null,
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
@@ -45,6 +46,7 @@ export function tagChipHtml(tag) {
}
function effectiveDate(c) {
if (c.due_date) return c.due_date;
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
if (m) return m[1];
return (c.created_at || '').substring(0, 10);
+91 -15
View File
@@ -1,25 +1,101 @@
// ── Theme picker ──
// ── Theme + Settings ──
import { $ } from './util.js';
import { $, api, getCsrf } from './util.js';
import { state } from './state.js';
import { renderSettingsPanel as renderNotifSettings } from './notifications.js';
const picker = $('theme-picker');
const list = $('theme-list');
const overlay = $('settings-overlay');
const themeList = $('settings-theme-list');
if (list) {
// Render theme list in settings
function renderThemes() {
if (!themeList) return;
const current = localStorage.getItem('hyprcrd-theme') || 'slate';
themeList.innerHTML = '';
for (const [key, t] of Object.entries(window.themes)) {
const btn = document.createElement('button');
btn.className = 'flex items-center gap-2.5 w-full px-2 py-1.5 text-left text-xs text-t-muted hover:text-t-bright transition-colors rounded';
btn.dataset.themeDot = key;
btn.innerHTML = `<span class="w-3 h-3 rounded-full shrink-0 border border-t-border" style="background:${t.vars['--t-accent']}"></span>
<span class="font-pixel text-[10px]">${t.label}</span>`;
btn.onclick = () => { window.applyTheme(key); window._renderBoard?.(); };
list.appendChild(btn);
const active = key === current;
btn.className = `flex items-center gap-3 px-3 py-2.5 text-left text-xs border transition-colors rounded ${active ? 'border-t-accent text-t-bright' : 'border-t-border text-t-muted hover:border-t-accent hover:text-t-bright'}`;
btn.innerHTML = `<span class="w-4 h-4 rounded-full shrink-0" style="background:${t.vars['--t-accent']}"></span>
<span class="font-pixel text-[10px] flex-1">${t.label}</span>
${active ? '<i class="ph ph-check text-t-accent text-sm"></i>' : ''}`;
btn.onclick = () => { window.applyTheme(key); renderThemes(); window._renderBoard?.(); };
themeList.appendChild(btn);
}
}
window.applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
// Tab switching
overlay?.querySelectorAll('.settings-tab').forEach(tab => {
tab.addEventListener('click', () => {
overlay.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const panel = tab.dataset.tab;
overlay.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
$(`settings-panel-${panel}`)?.classList.remove('hidden');
if (panel === 'sync') window._loadCaldavSettings?.();
if (panel === 'notifications') renderNotifSettings();
if (panel === 'behavior') initBehaviorSettings();
});
});
if (picker) {
$('theme-toggle')?.addEventListener('click', () => picker.classList.toggle('open'));
document.addEventListener('click', e => { if (!picker.contains(e.target)) picker.classList.remove('open'); });
// Open/Close
function updateStats() {
const sc = $('stats-cards'); if (sc) sc.textContent = state.cards?.length || 0;
const sb = $('stats-buckets'); if (sb) sb.textContent = state.buckets?.length || 0;
const tags = new Set(); (state.cards || []).forEach(c => (c.tags || '').split(',').forEach(t => { if (t) tags.add(t); }));
const st = $('stats-tags'); if (st) st.textContent = tags.size;
}
$('settings-btn')?.addEventListener('click', () => {
renderThemes();
updateStats();
overlay?.classList.add('open');
});
$('settings-close')?.addEventListener('click', () => overlay?.classList.remove('open'));
overlay?.addEventListener('click', e => { if (e.target === overlay) overlay?.classList.remove('open'); });
// Password change
$('settings-pw-save')?.addEventListener('click', async () => {
const current = $('settings-pw-current')?.value;
const newPw = $('settings-pw-new')?.value;
const confirm = $('settings-pw-confirm')?.value;
const status = $('settings-pw-status');
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
const res = await fetch('api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ current, new: newPw }),
}).then(r => r.json()).catch(() => ({ error: 'network error' }));
if (res.ok) {
if (status) { status.textContent = res.rekey ? 'password changed — re-encrypting notes...' : 'password changed'; status.style.color = 'var(--t-accent)'; }
$('settings-pw-current').value = '';
$('settings-pw-new').value = '';
$('settings-pw-confirm').value = '';
// Trigger sync to re-push notes with new encryption key
if (res.rekey) {
window._triggerSync?.();
setTimeout(() => { if (status) status.textContent = 'password changed, notes re-encrypted'; }, 3000);
}
} else {
if (status) { status.textContent = res.error || 'failed'; status.style.color = 'var(--t-danger)'; }
}
});
function initBehaviorSettings() {
const sel = $('setting-current-days');
if (sel) {
sel.value = localStorage.getItem('hyprcrd-current-days') || '2';
sel.onchange = () => {
localStorage.setItem('hyprcrd-current-days', sel.value);
window._renderBoard?.();
};
}
}
// Apply saved theme on load
window.applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');