calender stuff

This commit is contained in:
th committed 2026-04-08 14:04:23 +02:00
1 parent 8e78468743
commit e0fca064ae
15 files changed
+1716 -67

No files matched your search

+5 -2
View File
@@ -15,7 +15,8 @@ import { showContextMenu } from './modules/context-menu.js';
import { undo, canUndo } from './modules/undo.js';
import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar } from './modules/calendar.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
import { startBackgroundSync, triggerSync } from './modules/caldav.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -26,13 +27,14 @@ window._closeSearch = closeSearch;
window._closeTasks = closeTasks;
window._renderBoard = renderBoard;
window._openCalendar = openCalendar;
window._triggerSync = triggerSync;
window._renderTagBar = renderTagBar;
window._showContextMenu = showContextMenu;
window._ungroupCard = ungroupCard;
window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
window._reloadAll = async () => { state.cards = await api('api/cards'); renderBoard(); };
window._reloadAll = async () => { state.cards = await api('api/cards'); renderBoard(); refreshCalendar(); };
window._extractToCard = async (text) => {
if (!text?.trim()) return;
const lines = text.trim().split('\n');
@@ -134,6 +136,7 @@ async function checkAuth() {
loadBuckets();
loadCards();
startBackgroundSync();
}
// ── Global keyboard ──
+180 -36
View File
@@ -4,6 +4,7 @@ import { $, esc, api } from './util.js';
import { state, sortedCards, parseTags, tagChipHtml, getChildren, getRecent, getFavorites, isFavorite, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js';
import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js';
import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
import { getCalConfig } from './caldav.js';
const board = $('board');
let activeDragEl = null;
@@ -90,6 +91,39 @@ export function updateSortUI() {
if (icon) icon.className = `ph ${SORT_ICONS[state.sortMode]} text-xs`;
}
$('superview-toggle')?.addEventListener('click', () => {
state.superView = !state.superView;
localStorage.setItem('hyprcrd-superview', state.superView ? '1' : '0');
const btn = $('superview-toggle');
const icon = btn?.querySelector('i');
if (icon) icon.className = `ph ${state.superView ? 'ph-squares-four' : 'ph-list'} text-xs`;
if (btn) btn.classList.toggle('text-t-accent', state.superView);
renderBoard();
});
if (state.superView) {
const btn = $('superview-toggle');
const icon = btn?.querySelector('i');
if (icon) icon.className = 'ph ph-squares-four 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');
const btn = $('hide-termine');
const icon = btn?.querySelector('i');
if (icon) icon.className = `ph ${state.hideTermine ? 'ph-calendar-check' : 'ph-calendar-x'} text-xs`;
if (btn) btn.classList.toggle('text-t-accent', state.hideTermine);
renderBoard();
});
// Init toggle state
if (state.hideTermine) {
const btn = $('hide-termine');
const icon = btn?.querySelector('i');
if (icon) icon.className = 'ph ph-calendar-check text-xs';
if (btn) btn.classList.add('text-t-accent');
}
$('sort-toggle')?.addEventListener('click', () => {
state.sortMode = SORT_MODES[(SORT_MODES.indexOf(state.sortMode) + 1) % SORT_MODES.length];
localStorage.setItem('hyprcrd-sort', state.sortMode);
@@ -97,6 +131,49 @@ $('sort-toggle')?.addEventListener('click', () => {
renderBoard();
});
function renderCardHtml(c, inGroup = false, recent = []) {
const coverImg = leadingImage(c.body);
const thumbImg = coverImg ? null : firstImage(c.body);
const children = getChildren(c.id);
const isGroup = !inGroup && children.length > 0;
const todos = getTodos(c.body);
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
const hasCover = !!coverImg;
const tags = parseTags(c);
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
let calCfg = null;
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;
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}">
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
<div class="card-bar w-full card-layer" style="background:${cardColor}"></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' : ''}">
<div class="flex items-start gap-2 mb-2">
${calIcon ? `<i class="ph ph-${calIcon} text-xs mt-0.5 shrink-0" style="color:${cardColor}"></i>` : ''}
${isFavorite(c.id) ? `<i class="ph-fill ph-star text-amber-400 text-xs mt-0.5 shrink-0"></i>` : ''}
${todos ? `<i class="ph ${todos.done === todos.total ? 'ph-check-square text-t-accent' : 'ph-squares-four text-t-muted'} text-sm mt-0.5 shrink-0"></i>` : ''}
<h3 class="font-pixel text-xs text-t-bright leading-relaxed line-clamp-2">${esc(c.title)}</h3>
</div>
${todos ? `<div class="mb-2"><div class="flex items-center gap-2 mb-1">
<div class="flex-1 h-1.5 bg-t-border rounded-full overflow-hidden"><div class="h-full rounded-full transition-all" style="width:${todoPct}%;background:${cardColor}"></div></div>
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
</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>
${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.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
</div>
</div>
</div>`;
}
// ── Render ──
export function renderBoard() {
@@ -122,7 +199,8 @@ export function renderBoard() {
const favs = getFavorites();
cards = cards.filter(c => favs.includes(String(c.id)));
} else if (state.activeBucket === 'journal') {
cards = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title));
const today = new Date().toISOString().substring(0, 10);
cards = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title) && c.title.substring(0, 10) <= today);
cards.sort((a, b) => b.title.localeCompare(a.title));
} else if (state.activeBucket === 'none') {
cards = cards.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title));
@@ -131,6 +209,10 @@ export function renderBoard() {
}
}
}
// Hide appointments
if (state.hideTermine) {
cards = cards.filter(c => !parseTags(c).includes('termin'));
}
// Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel
if (state.activeTags.length) {
cards = cards.filter(c => {
@@ -149,6 +231,7 @@ export function renderBoard() {
renderGroupBar(cards);
if (!cards.length) {
board.style.display = '';
board.innerHTML = `<div class="col-span-full text-center py-20">
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
@@ -160,42 +243,103 @@ export function renderBoard() {
const recent = getRecent();
const inGroup = !!state.activeGroup;
board.innerHTML = cards.map(c => {
const coverImg = leadingImage(c.body);
const thumbImg = coverImg ? null : firstImage(c.body);
const children = getChildren(c.id);
const isGroup = !inGroup && children.length > 0;
const todos = getTodos(c.body);
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
const hasCover = !!coverImg;
const tags = parseTags(c);
if (state.superView) {
// Compact multi-column list view — override board grid
board.style.display = 'block';
const cols = cards.length <= 20 ? 2 : cards.length <= 50 ? 3 : cards.length <= 100 ? 4 : 5;
board.innerHTML = `<div class="superview-grid" style="column-count:${cols};">` + cards.map(c => {
const tags = parseTags(c);
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
let calCfg = null;
if (isTermin) { for (const t of tags) { if (t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) { calCfg = cfg; break; } } } }
const dotColor = calCfg?.color || c.color;
const icon = isTermin ? (calCfg?.icon || 'calendar-dots') : (isFavorite(c.id) ? 'star' : null);
const iconColor = isTermin ? dotColor : (isFavorite(c.id) ? '#fbbf24' : 'var(--t-dim)');
return `<div class="superview-item" data-id="${c.id}">
<span class="superview-dot" style="background:${dotColor}"></span>
${icon ? `<i class="ph ph-${icon} superview-icon" style="color:${iconColor}"></i>` : ''}
<span class="superview-title">${esc(c.title)}</span>
</div>`;
}).join('') + '</div>';
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:${c.color}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + c.color : ''}" draggable="true" data-id="${c.id}">
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};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' : ''}">
<div class="flex items-start gap-2 mb-2">
${isFavorite(c.id) ? `<i class="ph-fill ph-star text-amber-400 text-xs mt-0.5 shrink-0"></i>` : ''}
${todos ? `<i class="ph ${todos.done === todos.total ? 'ph-check-square text-t-accent' : 'ph-squares-four text-t-muted'} text-sm mt-0.5 shrink-0"></i>` : ''}
<h3 class="font-pixel text-xs text-t-bright leading-relaxed line-clamp-2">${esc(c.title)}</h3>
</div>
${todos ? `<div class="mb-2"><div class="flex items-center gap-2 mb-1">
<div class="flex-1 h-1.5 bg-t-border rounded-full overflow-hidden"><div class="h-full rounded-full transition-all" style="width:${todoPct}%;background:${c.color}"></div></div>
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
</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>
${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.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
</div>
</div>
</div>`;
}).join('');
let hoverPopup = null, hoverTimer = null, hoverCloseTimer = null;
function clearHover() {
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
if (hoverCloseTimer) { clearTimeout(hoverCloseTimer); hoverCloseTimer = null; }
if (hoverPopup) { hoverPopup.remove(); hoverPopup = null; }
}
function delayedClose() {
hoverCloseTimer = setTimeout(clearHover, 150);
}
function cancelClose() {
if (hoverCloseTimer) { clearTimeout(hoverCloseTimer); hoverCloseTimer = null; }
}
board.querySelectorAll('.superview-item[data-id]').forEach(el => {
el.addEventListener('click', () => { clearHover(); window._openDetail(el.dataset.id); });
el.addEventListener('dblclick', () => { clearHover(); const card = state.cards.find(c => c.id == el.dataset.id); if (card) window._openModal(card); });
el.addEventListener('contextmenu', e => { e.preventDefault(); clearHover(); const card = state.cards.find(c => c.id == el.dataset.id); if (card) window._showContextMenu(e.clientX, e.clientY, card); });
el.addEventListener('mouseenter', () => {
cancelClose();
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
hoverTimer = setTimeout(() => {
if (hoverPopup) { hoverPopup.remove(); hoverPopup = null; }
const card = state.cards.find(c => c.id == el.dataset.id);
if (!card) return;
const rect = el.getBoundingClientRect();
hoverPopup = document.createElement('div');
Object.assign(hoverPopup.style, {
position: 'fixed', zIndex: '100',
width: '280px',
filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))',
});
const textEnd = el.querySelector('.superview-title');
const textRect = textEnd ? textEnd.getBoundingClientRect() : rect;
const left = textRect.right + 8;
const useLeft = left + 290 < window.innerWidth;
hoverPopup.style.left = (useLeft ? left : textRect.left - 288) + 'px';
hoverPopup.style.top = Math.min(rect.top - 20, window.innerHeight - 320) + 'px';
hoverPopup.innerHTML = renderCardHtml(card);
hoverPopup.addEventListener('mouseenter', e => { e.stopPropagation(); cancelClose(); });
hoverPopup.addEventListener('mouseleave', delayedClose);
hoverPopup.addEventListener('mouseover', e => e.stopPropagation());
// Tag clicks inside hover card
hoverPopup.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
seg.addEventListener('click', e => {
e.stopPropagation();
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 = state.activeTags.length === 1 && state.activeTags[0] === tag ? [] : [tag];
}
clearHover();
window._renderTagBar?.();
renderBoard();
});
});
// Click on hover card opens detail
hoverPopup.querySelector('.board-card')?.addEventListener('click', () => {
clearHover();
window._openDetail(card.id);
});
document.body.appendChild(hoverPopup);
}, 400);
});
el.addEventListener('mouseleave', delayedClose);
});
const fc = $('footer-count');
if (fc) fc.textContent = `${cards.length} cards · ${state.cards.length} total`;
return;
}
board.style.display = '';
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
bindCardEvents();
+242
View File
@@ -0,0 +1,242 @@
// ── CalDAV Sync ──
import { $, esc, api, getCsrf } from './util.js';
const CAL_ICONS = [
{ id: 'calendar-dots', label: 'calendar' },
{ id: 'briefcase', label: 'work' },
{ id: 'house', label: 'home' },
{ id: 'heart', label: 'personal' },
{ id: 'cake', label: 'birthday' },
{ id: 'graduation-cap', label: 'education' },
{ id: 'airplane', label: 'travel' },
{ id: 'basketball', label: 'sport' },
{ id: 'first-aid', label: 'health' },
{ id: 'star', label: 'important' },
{ id: 'users', label: 'team' },
{ id: 'phone', label: 'calls' },
];
const CAL_COLORS = ['#38bdf8', '#8b5cf6', '#fb7185', '#fbbf24', '#4ade80', '#fb923c', '#22d3ee', '#f472b6', '#a3e635', '#94a3b8'];
let calendarConfigs = {}; // slug → { color, icon }
const CAL_CONFIG_KEY = 'hyprcrd-cal-configs';
const overlay = $('caldav-settings');
const urlInput = $('caldav-url');
const userInput = $('caldav-user');
const passInput = $('caldav-pass');
const testStatus = $('caldav-test-status');
const syncStatus = $('caldav-sync-status');
const logEl = $('caldav-log');
export function openCaldavSettings() {
overlay?.classList.add('open');
loadSettings();
}
export function closeCaldavSettings() {
overlay?.classList.remove('open');
}
function loadCalConfigs() {
try { calendarConfigs = JSON.parse(localStorage.getItem(CAL_CONFIG_KEY) || '{}'); } catch { calendarConfigs = {}; }
}
function saveCalConfigs() {
localStorage.setItem(CAL_CONFIG_KEY, JSON.stringify(calendarConfigs));
}
export function getCalConfig(slug) {
loadCalConfigs();
return calendarConfigs[slug] || null;
}
export function getAllCalConfigs() {
loadCalConfigs();
return calendarConfigs;
}
function renderCalList(calendars) {
const container = $('caldav-cal-list');
const wrap = $('caldav-calendars');
if (!container || !wrap || !calendars?.length) return;
loadCalConfigs();
wrap.classList.remove('hidden');
container.innerHTML = calendars.map(cal => {
const slug = cal.name.toLowerCase().replace(/[^a-z0-9_-]/g, '');
const cfg = calendarConfigs[slug] || {};
const activeColor = cfg.color || '#94a3b8';
const activeIcon = cfg.icon || 'calendar-dots';
return `<div class="flex items-center gap-2 py-1" data-cal-slug="${slug}">
<span style="width:10px;height:10px;border-radius:2px;background:${activeColor};flex-shrink:0;"></span>
<span class="font-pixel text-[10px] text-t-bright flex-1">${esc(cal.name)}</span>
<div class="flex gap-1">
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
</div>
<div class="flex gap-1 ml-1">
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" title="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
</div>
</div>`;
}).join('');
container.querySelectorAll('.cal-cfg-color').forEach(btn => {
btn.addEventListener('click', () => {
const slug = btn.closest('[data-cal-slug]').dataset.calSlug;
if (!calendarConfigs[slug]) calendarConfigs[slug] = {};
calendarConfigs[slug].color = btn.dataset.color;
saveCalConfigs();
renderCalList(calendars);
});
});
container.querySelectorAll('.cal-cfg-icon').forEach(btn => {
btn.addEventListener('click', () => {
const slug = btn.closest('[data-cal-slug]').dataset.calSlug;
if (!calendarConfigs[slug]) calendarConfigs[slug] = {};
calendarConfigs[slug].icon = btn.dataset.icon;
saveCalConfigs();
renderCalList(calendars);
});
});
}
async function loadSettings() {
const s = await api('api/caldav/settings');
if (!s) return;
if (urlInput) urlInput.value = s.url || '';
if (userInput) userInput.value = s.username || '';
if (passInput) passInput.value = '';
if (passInput && s.has_password) passInput.placeholder = '(saved)';
if (syncStatus) syncStatus.textContent = s.last_sync ? `last sync: ${s.last_sync}` : '';
// Load calendar list if configured
if (s.url && s.has_password) {
const test = await fetch('api/caldav/test', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: '{}',
}).then(r => r.json()).catch(() => null);
if (test?.calendars) renderCalList(test.calendars);
}
}
$('caldav-close')?.addEventListener('click', closeCaldavSettings);
overlay?.addEventListener('click', e => { if (e.target === overlay) closeCaldavSettings(); });
$('caldav-test')?.addEventListener('click', async () => {
testStatus.textContent = 'testing...';
testStatus.style.color = 'var(--t-dim)';
const body = { url: urlInput.value, username: userInput.value };
if (passInput.value) body.password = passInput.value;
const res = await fetch('api/caldav/test', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify(body),
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
if (res.ok) {
const names = (res.calendars || []).map(c => c.name).join(', ');
testStatus.textContent = `connected (${names || 'no calendars'})`;
testStatus.style.color = 'var(--t-accent)';
if (res.calendars) renderCalList(res.calendars);
} else {
testStatus.textContent = res.error || 'failed';
testStatus.style.color = 'var(--t-danger)';
}
showConnectionLog(res.log);
});
$('caldav-save')?.addEventListener('click', async () => {
const body = { url: urlInput.value, username: userInput.value, enabled: true };
if (passInput.value) body.password = passInput.value;
await api('api/caldav/settings', { method: 'POST', body });
testStatus.textContent = 'saved';
testStatus.style.color = 'var(--t-accent)';
});
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
export async function triggerSync() {
const btn = $('cal-sync');
if (btn) btn.style.opacity = '0.5';
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/caldav/sync', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
if (btn) btn.style.opacity = '';
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 : ''}`;
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
await window._reloadAll?.();
showLog(res.log);
showSyncNotification(s);
} else {
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
}
function showLog(log) {
if (!logEl || !log?.length) return;
logEl.classList.remove('hidden');
logEl.innerHTML = log.map(l =>
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
).join('');
}
function showConnectionLog(lines) {
if (!logEl || !lines?.length) return;
logEl.classList.remove('hidden');
logEl.innerHTML = lines.map(l =>
`<div class="${l.startsWith('ERROR') || l.startsWith('EXCEPTION') ? 'text-t-danger' : l.startsWith('OK') ? 'text-t-accent' : 'text-t-dim'}">${l.replace(/</g, '&lt;')}</div>`
).join('');
}
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
$('cal-sync')?.addEventListener('click', () => triggerSync());
// Auto-sync throttle
let lastAutoSync = 0;
export function autoSync() {
const now = Date.now();
if (now - lastAutoSync < 60000) return;
lastAutoSync = now;
triggerSync();
}
// Background sync every 2 minutes
function showSyncNotification(stats) {
const s = stats || {};
const parts = [];
if (s.pulled) parts.push(`${s.pulled} received`);
if (s.pushed) parts.push(`${s.pushed} sent`);
if (s.deleted) parts.push(`${s.deleted} deleted`);
if (!parts.length) return;
const note = document.createElement('div');
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
document.body.appendChild(note);
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
}
async function backgroundSync() {
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);
}
}
} catch {}
}
// 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
}
+236 -21
View File
@@ -3,6 +3,7 @@
import { $, esc } from './util.js';
import { state } from './state.js';
import { stripMd, getTodos } from './markdown.js';
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
const overlay = $('calendar');
const grid = $('cal-grid');
@@ -16,19 +17,126 @@ function pad(n) { return String(n).padStart(2, '0'); }
function dateStr(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}`; }
function cardDate(c) {
// Title starts with date (journal "2026-04-08" or appointment "2026-04-08 — Meeting")
function cardBaseDate(c) {
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
if (m) return m[1];
// Fallback: created date
return (c.created_at || '').substring(0, 10);
}
function getCardsForDate(ds) {
return state.cards.filter(c => cardDate(c) === ds);
function parseRRule(rrule) {
if (!rrule) return null;
const parts = {};
for (const p of rrule.split(';')) {
const [k, v] = p.split('=');
parts[k] = v;
}
return parts;
}
function isJournal(card) { return /^\d{4}-\d{2}-\d{2}/.test(card.title); }
function matchesRRule(card, targetDateStr) {
const rrule = parseRRule(card.caldav_rrule);
if (!rrule || !rrule.FREQ) return false;
const base = cardBaseDate(card);
if (!base) return false;
const baseDate = new Date(base + 'T00:00:00');
const target = new Date(targetDateStr + 'T00:00:00');
// Don't match before the start date
if (target < baseDate) return false;
// Same date is handled by cardBaseDate match
if (targetDateStr === base) return false;
// COUNT / UNTIL limits
const count = rrule.COUNT ? parseInt(rrule.COUNT) : null;
const until = rrule.UNTIL ? new Date(rrule.UNTIL.substring(0, 4) + '-' + rrule.UNTIL.substring(4, 6) + '-' + rrule.UNTIL.substring(6, 8) + 'T23:59:59') : null;
if (until && target > until) return false;
const interval = parseInt(rrule.INTERVAL || '1');
const freq = rrule.FREQ;
if (freq === 'DAILY') {
const diff = Math.round((target - baseDate) / 86400000);
if (diff % interval !== 0) return false;
return !count || (diff / interval) < count;
}
if (freq === 'WEEKLY') {
const diff = Math.round((target - baseDate) / 86400000);
if (rrule.BYDAY) {
const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
const allowed = rrule.BYDAY.split(',').map(d => dayMap[d]);
if (!allowed.includes(target.getDay())) return false;
const weekDiff = Math.floor(diff / 7);
return weekDiff % interval === 0 || (weekDiff + 1) % interval === 0;
}
if (target.getDay() !== baseDate.getDay()) return false;
const weekDiff = Math.round(diff / 7);
return weekDiff % interval === 0 && (!count || weekDiff < count);
}
if (freq === 'MONTHLY') {
if (target.getDate() !== baseDate.getDate()) return false;
const monthDiff = (target.getFullYear() - baseDate.getFullYear()) * 12 + target.getMonth() - baseDate.getMonth();
return monthDiff > 0 && monthDiff % interval === 0 && (!count || monthDiff / interval < count);
}
if (freq === 'YEARLY') {
if (target.getMonth() !== baseDate.getMonth() || target.getDate() !== baseDate.getDate()) return false;
const yearDiff = target.getFullYear() - baseDate.getFullYear();
return yearDiff > 0 && yearDiff % interval === 0 && (!count || yearDiff / interval < count);
}
return false;
}
function getCardsForDate(ds) {
const results = [];
const targetYear = parseInt(ds.substring(0, 4));
for (const c of state.cards) {
if (cardBaseDate(c) === ds) {
results.push({ ...c, _recurring: false });
} else if (c.caldav_rrule && matchesRRule(c, ds)) {
const baseYear = parseInt(cardBaseDate(c).substring(0, 4));
const rrule = parseRRule(c.caldav_rrule);
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
results.push({ ...c, _recurring: true, _age: age });
} else if ((c.tags || '').split(',').includes('geburtstag')) {
// Local birthday cards repeat yearly without RRULE
const base = cardBaseDate(c);
if (base.substring(5) === ds.substring(5) && ds !== base) {
const age = targetYear - parseInt(base.substring(0, 4));
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
}
}
}
return results;
}
function getCardCalConfig(card) {
const tags = (card.tags || '').split(',');
// Find a tag that matches a calendar config (skip termin/geburtstag)
for (const t of tags) {
if (t && t !== 'termin' && t !== 'geburtstag') {
const cfg = getCalConfig(t);
if (cfg) return cfg;
}
}
return null;
}
function isJournal(card) {
if (!/^\d{4}-\d{2}-\d{2}/.test(card.title)) return false;
// Appointments (termin/geburtstag/caldav) are NOT journals
const tags = (card.tags || '').split(',');
if (tags.includes('termin') || tags.includes('geburtstag')) return false;
if (card.caldav_uid) return false;
// "YYYY-MM-DD — Something" is an appointment, pure "YYYY-MM-DD" is journal
if (/^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title)) return false;
return true;
}
function isAppointment(card) {
const tags = (card.tags || '').split(',');
return tags.includes('termin') || card.caldav_uid || /^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title);
}
function render() {
title.textContent = `${MONTHS[viewMonth]} ${viewYear}`;
@@ -68,12 +176,41 @@ function render() {
}
const isToday = ds === todayStr;
const cards = getCardsForDate(ds);
const journals = cards.filter(isJournal);
const others = cards.filter(c => !isJournal(c));
const isBday = c => (c._recurring && c._age != null) || (c.tags || '').split(',').includes('geburtstag');
const birthdays = cards.filter(isBday);
const appointments = cards.filter(c => !isBday(c) && isAppointment(c));
const journals = cards.filter(c => !isBday(c) && !isAppointment(c) && isJournal(c));
const others = cards.filter(c => !isBday(c) && !isAppointment(c) && !isJournal(c));
html += `<div class="cal-cell ${inMonth ? '' : 'cal-cell-out'} ${isToday ? 'cal-cell-today' : ''}" data-date="${ds}">`;
html += `<div class="cal-cell-num ${isToday ? 'text-t-accent' : ''}">${displayNum}</div>`;
html += '<div class="cal-cell-items">';
// Birthdays: red, cake icon, name only
for (const c of birthdays) {
const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
const age = c._age != null ? c._age : (parseInt(ds.substring(0, 4)) - parseInt(cardBaseDate(c).substring(0, 4))) || null;
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:#fb7185;color:#fb7185;">`;
html += `<i class="ph ph-cake" style="font-size:9px;flex-shrink:0;"></i>`;
html += `<span class="truncate">${esc(name)}</span>`;
if (age) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;flex-shrink:0;opacity:0.7;">${age}</span>`;
html += `</div>`;
}
// Appointments: show event name + optional time, with calendar color/icon
for (const c of appointments) {
const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
const timeMatch = (c.body || '').match(/\*\*Zeit:\*\*\s*(\d{1,2}:\d{2})/);
const timeStr = timeMatch ? timeMatch[1] : '';
const calCfg = getCardCalConfig(c);
const itemColor = calCfg?.color || c.color;
const itemIcon = calCfg?.icon || null;
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
html += `<span class="truncate">${esc(name)}</span>`;
html += `</div>`;
}
// Journals
for (const c of journals) {
const body = stripMd(c.body || '').substring(0, 60);
const todos = getTodos(c.body);
@@ -81,9 +218,14 @@ function render() {
if (todos) html += `<span class="cal-todo">${todos.done}/${todos.total}</span>`;
html += `<span class="truncate">${esc(body || '(empty)')}</span></div>`;
}
// Other cards
for (const c of others) {
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${c.color}">`;
html += `<span class="truncate">${esc(c.title)}</span></div>`;
if (c._recurring) html += `<i class="ph ph-arrows-clockwise" style="font-size:9px;color:var(--t-dim);flex-shrink:0;"></i>`;
else if (c.caldav_uid) html += `<i class="ph ph-cloud-check" style="font-size:9px;color:var(--t-dim);flex-shrink:0;"></i>`;
const label = c._recurring ? (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title : c.title;
html += `<span class="truncate">${esc(label)}</span>`;
html += `</div>`;
}
html += '</div>';
html += '</div>';
@@ -92,13 +234,34 @@ function render() {
grid.innerHTML = html;
// Click on card item → open card
// Click on card item → open card (hide calendar, restore on return)
grid.querySelectorAll('[data-card-id]').forEach(el => {
let clickTimer = null;
el.addEventListener('click', e => {
e.stopPropagation();
if (clickTimer) return;
clickTimer = setTimeout(() => {
clickTimer = null;
closeDayPopup();
state._returnToCalendar = true;
overlay.style.visibility = 'hidden';
window._openDetail(el.dataset.cardId);
}, 250);
});
el.addEventListener('dblclick', e => {
e.stopPropagation();
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
closeDayPopup();
state._returnToCalendar = true;
window._openDetail(el.dataset.cardId);
closeCalendar();
const card = state.cards.find(c => c.id == el.dataset.cardId);
if (card) window._openModal(card);
});
el.addEventListener('contextmenu', e => {
e.preventDefault();
e.stopPropagation();
const card = state.cards.find(c => c.id == el.dataset.cardId);
if (card) window._showContextMenu(e.clientX, e.clientY, card);
});
});
// Click on cell → open day popup
@@ -137,12 +300,12 @@ function openDayPopup(ds, cellEl) {
position: 'fixed', zIndex: '200',
background: 'var(--t-surface)', border: '1px solid var(--t-border)',
boxShadow: '0 8px 32px rgba(0,0,0,0.4)', borderRadius: '4px',
minWidth: '240px', maxWidth: '360px', maxHeight: '320px',
minWidth: '360px', maxWidth: '520px', maxHeight: '320px',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
});
// Position near the cell
const x = Math.min(rect.left + rect.width / 2 - 120, window.innerWidth - 370);
const x = Math.min(rect.left + rect.width / 2 - 180, window.innerWidth - 530);
const y = rect.bottom + 4 > window.innerHeight - 100 ? rect.top - 4 : rect.bottom + 4;
dayPopup.style.left = Math.max(8, x) + 'px';
if (rect.bottom + 4 > window.innerHeight - 100) {
@@ -154,10 +317,20 @@ function openDayPopup(ds, cellEl) {
// Header
const header = document.createElement('div');
header.style.cssText = 'padding:8px 12px;border-bottom:1px solid var(--t-border);display:flex;align-items:center;gap:8px;flex-shrink:0;';
const allCfg = getAllCalConfigs();
const calSlugs = Object.keys(allCfg).filter(s => s !== 'geburtstage' && s !== 'birthday');
const calOptions = `<option value="" style="color:var(--t-muted)">standard</option>` +
calSlugs.map(slug => {
const cfg = allCfg[slug];
return `<option value="${slug}" style="color:${cfg.color || '#fb923c'}">${slug}</option>`;
}).join('');
header.innerHTML = `
<span style="font-family:var(--t-font-pixel);font-size:11px;color:var(--t-accent);">${ds}</span>
<span style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);">${cards.length} card${cards.length !== 1 ? 's' : ''}</span>
<span style="flex:1"></span>
<button class="day-popup-new" data-type="birthday" style="font-family:var(--t-font-pixel);font-size:9px;color:#fb7185;background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;"><i class="ph ph-cake" style="font-size:9px;"></i> geb.</button>
<select id="day-popup-cal" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:var(--t-bg);border:1px solid var(--t-border);padding:2px 4px;border-radius:2px;cursor:pointer;">${calOptions}</select>
<button class="day-popup-new" data-type="appointment" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;">+ termin</button>
<button class="day-popup-new" data-type="journal" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;">+ journal</button>`;
dayPopup.appendChild(header);
@@ -167,12 +340,18 @@ function openDayPopup(ds, cellEl) {
list.style.cssText = 'overflow-y:auto;flex:1;padding:4px 0;';
if (cards.length) {
list.innerHTML = cards.map(c => {
const isJ = isJournal(c);
const label = isJ ? (stripMd(c.body || '').substring(0, 80) || '(empty journal)') : c.title;
const isBday = c._recurring && c._age != null;
const isJ = isJournal(c) && !c._recurring;
const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
const popupLabel = isBday ? name : c._recurring ? name : isJ ? (stripMd(c.body || '').substring(0, 80) || '(empty journal)') : c.title;
const todos = getTodos(c.body);
return `<div class="day-popup-card" data-card-id="${c.id}" style="padding:6px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:background 0.1s;">
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:${c.color}"></span>
<span style="font-size:11px;color:var(--t-text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(label)}</span>
const dotColor = isBday ? '#fb7185' : c.color;
return `<div class="day-popup-card" data-card-id="${c.id}" style="padding:6px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:background 0.1s;${isBday ? 'color:#fb7185;' : ''}">
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:${dotColor}"></span>
${isBday ? '<i class="ph ph-cake" style="font-size:11px;flex-shrink:0;"></i>' : ''}
<span style="font-size:11px;${isBday ? '' : 'color:var(--t-text);'}flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(popupLabel)}</span>
${isBday ? `<span style="font-family:var(--t-font-pixel);font-size:8px;opacity:0.7;">${c._age}</span>` : ''}
${c._recurring && !isBday ? '<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);">recurring</span>' : ''}
${isJ ? '<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);">journal</span>' : ''}
${todos ? `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-accent);">${todos.done}/${todos.total}</span>` : ''}
</div>`;
@@ -186,12 +365,32 @@ function openDayPopup(ds, cellEl) {
// Hover effect
dayPopup.querySelectorAll('.day-popup-card').forEach(el => {
let clickTimer = null;
el.addEventListener('mouseenter', () => { el.style.background = 'var(--t-raised)'; });
el.addEventListener('mouseleave', () => { el.style.background = ''; });
el.addEventListener('click', () => {
if (clickTimer) return;
clickTimer = setTimeout(() => {
clickTimer = null;
closeDayPopup();
state._returnToCalendar = true;
overlay.style.visibility = 'hidden';
window._openDetail(el.dataset.cardId);
}, 250);
});
el.addEventListener('dblclick', () => {
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
closeDayPopup();
state._returnToCalendar = true;
window._openDetail(el.dataset.cardId);
closeCalendar();
const card = state.cards.find(c => c.id == el.dataset.cardId);
if (card) window._openModal(card);
});
el.addEventListener('contextmenu', e => {
e.preventDefault();
e.stopPropagation();
const card = state.cards.find(c => c.id == el.dataset.cardId);
if (card) window._showContextMenu(e.clientX, e.clientY, card);
});
});
@@ -199,6 +398,9 @@ function openDayPopup(ds, cellEl) {
dayPopup.querySelectorAll('.day-popup-new').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.type;
// Read select before closing popup
const select = dayPopup?.querySelector('#day-popup-cal');
const selectedCal = select?.value || '';
closeDayPopup();
state._returnToCalendar = true;
closeCalendar();
@@ -210,8 +412,15 @@ function openDayPopup(ds, cellEl) {
}
window._openModal({ id: null, title: ds, body: '', color: '#38bdf8', bucket_id: null });
} else if (type === 'appointment') {
const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin`;
window._openModal({ id: null, title: `${ds} — `, body, color: '#fb923c', bucket_id: null });
const calSlug = selectedCal;
const cfg = calSlug ? getCalConfig(calSlug) : null;
const col = cfg?.color || '#fb923c';
const calTag = calSlug ? ` #${calSlug}` : '';
const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null });
} else if (type === 'birthday') {
const body = `#geburtstag`;
window._openModal({ id: null, title: `${ds} — `, body, color: '#fb7185', bucket_id: null });
}
});
});
@@ -228,12 +437,18 @@ function openDayPopup(ds, cellEl) {
}, 0);
}
export function refreshCalendar() {
if (overlay?.classList.contains('open')) render();
}
export function openCalendar() {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
overlay.classList.add('open');
overlay.style.visibility = '';
autoSync();
}
export function closeCalendar() {
+1 -1
View File
@@ -169,7 +169,7 @@ export function showContextMenu(x, y, card) {
if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); }
}
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
else if (a === 'delete') { hideContextMenu(); const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); if (ok) { pushCardDelete('delete', snapshotCard(card)); await api(`api/cards/${card.id}`, { method: 'DELETE' }); await window._reloadAll(); } }
else if (a === 'delete') { hideContextMenu(); const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); if (ok) { const wasSync = !!card.caldav_uid; pushCardDelete('delete', snapshotCard(card)); await api(`api/cards/${card.id}`, { method: 'DELETE' }); await window._reloadAll(); if (wasSync) window._triggerSync?.(); } }
}));
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
+7
View File
@@ -950,11 +950,18 @@ $('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);
+5 -1
View File
@@ -340,6 +340,10 @@ async function doSave(close, { animate = true } = {}) {
}
}
await window._reloadAll();
// Auto-sync if card has #termin or #geburtstag
if (data.body && /#(termin|geburtstag)\b/.test(data.body)) {
window._triggerSync?.();
}
if (close) {
closeModal({ animate });
if (returnToGallery) window._openGallery?.();
@@ -415,7 +419,7 @@ $('toggle-preview')?.addEventListener('click', () => {
if (state.previewVisible) {
applyEditorSplit();
} else {
if (editorPane) editorPane.style.flex = '';
if (editorPane) editorPane.style.flex = '1 1 0%';
preview.style.flex = '';
}
updatePreview();
+10 -2
View File
@@ -7,6 +7,8 @@ export const state = {
buckets: [],
activeBucket: 'all',
activeTags: [],
hideTermine: localStorage.getItem('hyprcrd-hide-termine') === '1',
superView: localStorage.getItem('hyprcrd-superview') === '1',
activeGroup: null,
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
editingId: null,
@@ -42,11 +44,17 @@ export function tagChipHtml(tag) {
return `<span class="tag-chip">${segs.join('<span class="tag-sep">/</span>')}</span>`;
}
function effectiveDate(c) {
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
if (m) return m[1];
return (c.created_at || '').substring(0, 10);
}
export function sortedCards(cards) {
const arr = [...cards];
switch (state.sortMode) {
case 'newest': return arr.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
case 'oldest': return arr.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
case 'newest': return arr.sort((a, b) => effectiveDate(b).localeCompare(effectiveDate(a)));
case 'oldest': return arr.sort((a, b) => effectiveDate(a).localeCompare(effectiveDate(b)));
case 'alpha': return arr.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
case 'tag': return arr.sort((a, b) => {
const at = (a.tags || '').split(',')[0] || '\uffff';