Files
hyprcrd/public/js/app.js
T
2026-04-09 19:14:52 +02:00

235 lines
11 KiB
JavaScript

/* ══════════════════════════════════════
hyprcrd — app.js (orchestrator)
══════════════════════════════════════ */
import { api, setCsrf, getCsrf } from './modules/util.js';
import { state, getGroupCards } from './modules/state.js';
import { showDialog, isDialogOpen } from './modules/dialog.js';
import './modules/theme.js';
import { loadBuckets, renderTagBar } from './modules/buckets.js';
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
import { openSearch, closeSearch } from './modules/search.js';
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, refreshCalendar } from './modules/calendar.js';
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
window._openDetail = openDetail;
window._openModal = openModal;
window._openGallery = openGallery;
window._closeSearch = closeSearch;
window._closeTasks = closeTasks;
window._renderBoard = renderBoard;
window._openCalendar = openCalendar;
window._triggerSync = triggerSync;
window._loadCaldavSettings = loadCaldavSettings;
window._loadRelaySettings = loadRelaySettings;
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(); refreshCalendar(); };
window._extractToCard = async (text) => {
if (!text?.trim()) return;
const lines = text.trim().split('\n');
const title = lines[0].replace(/^#+\s*/, '').replace(/^\*+/, '').replace(/^-\s*/, '').trim() || 'Extracted';
const body = text.trim();
// Inherit context from the source card (viewing or editing)
const source = state.viewingCard || (state.editingId ? state.cards.find(c => c.id == state.editingId) : null);
const data = { title, body, color: source?.color || '#38bdf8', position: -1 };
if (source?.bucket_id) data.bucket_id = source.bucket_id;
if (source?.parent_id) data.parent_id = source.parent_id;
else if (source && state.cards.some(c => c.parent_id == source.id)) data.parent_id = source.id;
await api('api/cards', { method: 'POST', body: data });
await window._reloadAll();
};
// ── Data loading ──
const CARDS_CACHE_KEY = 'hyprcrd-cards-cache';
function getCachedCards() {
try { return JSON.parse(localStorage.getItem(CARDS_CACHE_KEY) || '[]'); } catch { return []; }
}
function setCachedCards(cards) {
try {
const slim = cards.map(c => ({ id: c.id, title: c.title, color: c.color, tags: c.tags, bucket_id: c.bucket_id, bucket_name: c.bucket_name, parent_id: c.parent_id, position: c.position, created_at: c.created_at, updated_at: c.updated_at, due_date: c.due_date, caldav_uid: c.caldav_uid, caldav_rrule: c.caldav_rrule }));
localStorage.setItem(CARDS_CACHE_KEY, JSON.stringify(slim));
} catch {}
}
async function loadCards() {
// Phase 0: Show cached cards instantly (from previous session)
const cached = getCachedCards();
if (cached.length) {
state.cards = cached.map(c => ({ ...c, body: null }));
updateSortUI();
renderBoard();
}
// Phase 1: Load metadata → update board with fresh titles/tags/dates
const meta = await api('api/cards/meta');
if (!meta) return;
state.cards = meta.map(c => ({ ...c, body: null }));
setCachedCards(meta);
updateSortUI();
renderBoard();
// Phase 2: Load full cards in batches → replace spinners with content
const batchSize = 25;
for (let i = 0; i < meta.length; i += batchSize) {
const batch = meta.slice(i, i + batchSize);
const ids = batch.map(c => c.id).join(',');
const full = await api('api/cards?ids=' + ids);
if (!full) continue;
for (const card of full) {
const idx = state.cards.findIndex(c => c.id === card.id);
if (idx >= 0) state.cards[idx] = card;
}
renderBoard();
}
}
// ── Help ──
const helpOverlay = document.getElementById('help');
function openHelp() { helpOverlay?.classList.add('open'); }
function closeHelp() { helpOverlay?.classList.remove('open'); }
document.getElementById('help-btn')?.addEventListener('click', openHelp);
document.getElementById('help-close')?.addEventListener('click', closeHelp);
helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); });
document.addEventListener('keydown', e => {
if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); }
});
// ── Export / Import ──
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
document.getElementById('export-md-btn')?.addEventListener('click', () => { window.location.href = 'api/export-md'; });
const importFile = document.getElementById('import-file');
document.getElementById('import-btn')?.addEventListener('click', () => importFile?.click());
importFile?.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
// Check if it's a selective export (merge) or ask for full replace
const form = new FormData(); form.append('file', file);
const mode = await showDialog({ title: 'import_data', message: 'Wie importieren?', okLabel: 'merge (hinzufuegen)', cancelLabel: 'replace (alles ersetzen)' });
// mode: true = merge, null = replace
if (mode === null) {
const sure = await showDialog({ title: 'replace_all', message: 'Alle bestehenden Daten werden ersetzt!', danger: true, okLabel: 'replace' });
if (!sure) { e.target.value = ''; return; }
}
try {
const url = mode ? 'api/import?merge=1' : 'api/import';
const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const data = JSON.parse(await res.text());
if (data.error) throw new Error(data.error);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
} catch (err) { console.error('Import failed:', err); }
e.target.value = '';
});
const importLogseq = document.getElementById('import-logseq-file');
document.getElementById('import-logseq-btn')?.addEventListener('click', () => importLogseq?.click());
importLogseq?.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
const form = new FormData(); form.append('file', file);
try {
const res = await fetch('api/import-logseq', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const data = JSON.parse(await res.text());
if (data.error) throw new Error(data.error);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
await showDialog({ title: 'logseq import', message: `${data.imported} cards, ${data.assets} images imported`, okLabel: 'ok' });
} catch (err) { console.error('Logseq import failed:', err); }
e.target.value = '';
});
// ── Auth ──
document.getElementById('logout-btn')?.addEventListener('click', async () => {
const ok = await showDialog({ title: 'logout', message: 'Abmelden?', okLabel: 'logout', danger: true });
if (!ok) return;
await fetch('api/auth/logout', { method: 'POST' });
location.reload();
});
async function checkAuth() {
const res = await fetch('api/auth/status');
const data = await res.json();
if (data.csrf) setCsrf(data.csrf);
if (data.setup) {
let pw = null;
while (!pw || pw.length < 4) {
pw = await showDialog({ title: 'setup', message: 'Willkommen bei hyprcrd. Setze ein Passwort:', input: 'password', placeholder: '> passwort (min. 4 zeichen)...', okLabel: 'set', cancelLabel: '' });
if (pw && pw.length < 4) pw = null;
}
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
const d = await r.json();
if (d.error) { location.reload(); return; }
if (d.csrf) setCsrf(d.csrf);
} else if (!data.logged_in) {
let ok = false;
while (!ok) {
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> passwort...', okLabel: 'login', cancelLabel: '' });
if (!pw) continue;
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
const d = await r.json();
if (d.ok) { ok = true; if (d.csrf) setCsrf(d.csrf); }
}
}
loadBuckets();
loadCards();
startBackgroundSync();
}
// ── Global keyboard ──
document.addEventListener('keydown', e => {
const modal = document.getElementById('modal');
const detail = document.getElementById('detail');
const search = document.getElementById('search');
const tasks = document.getElementById('tasks');
if (isDialogOpen()) return;
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.classList.contains('open') ? closeSearch() : openSearch(); return; }
if (e.key === 'Escape') {
if (detail.classList.contains('open')) closeDetail();
else if (modal.classList.contains('open')) closeModal();
else if (search.classList.contains('open')) closeSearch();
else if (tasks?.classList.contains('open')) closeTasks();
else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
return;
}
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; }
if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
// Arrow keys navigate group in detail view
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) {
const gc = getGroupCards(state.viewingCard);
if (!gc || gc.length < 2) return;
const idx = gc.findIndex(c => c.id == state.viewingCard.id);
if (idx === -1) return;
e.preventDefault();
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
}
});
// ── Boot ──
checkAuth();