235 lines
11 KiB
JavaScript
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();
|