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

+391 -48
View File
@@ -3,10 +3,12 @@
require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/CalDavClient.php';
require_once __DIR__ . '/../src/CalDavSync.php';
require_once __DIR__ . '/../src/WebDavNotes.php';
$db = Database::connect();
// ── Security headers ──
// ── Cache & Security headers ──
header('Cache-Control: no-cache, must-revalidate');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
@@ -46,7 +48,7 @@ if (str_starts_with($route, '/api/auth')) {
// GET /api/auth/status
if ($route === '/api/auth/status' && $method === 'GET') {
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token']]);
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]);
exit;
}
@@ -103,6 +105,68 @@ if (str_starts_with($route, '/api/auth')) {
exit;
}
// POST /api/auth/password — change password (requires current password)
if ($route === '/api/auth/password' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$current = $input['current'] ?? '';
$newPass = $input['new'] ?? '';
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
if (!password_verify($current, $hash)) {
http_response_code(403);
echo json_encode(['error' => 'wrong password']);
exit;
}
if (strlen($newPass) < 4) {
http_response_code(400);
echo json_encode(['error' => 'min 4 characters']);
exit;
}
// Re-encrypt CalDAV credentials with new key (atomic — rollback on failure)
$oldKey = substr(hash('sha256', $hash), 0, 32);
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
$newKey = substr(hash('sha256', $newHash), 0, 32);
$credKeys = ['caldav_url', 'caldav_username', 'caldav_password'];
$reEncrypted = [];
$rekeyOk = true;
foreach ($credKeys as $ck) {
$enc = $db->query("SELECT value FROM settings WHERE key = '{$ck}'")->fetchColumn();
if (!$enc) continue;
$raw = base64_decode($enc);
if (strlen($raw) < 17) continue;
$iv = substr($raw, 0, 16);
$cipher = base64_encode(substr($raw, 16));
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $oldKey, 0, $iv);
if ($plain === false) { $rekeyOk = false; break; }
$newIv = random_bytes(16);
$newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKey, 0, $newIv);
$reEncrypted[$ck] = base64_encode($newIv . base64_decode($newCipher));
}
if (!$rekeyOk) {
http_response_code(500);
echo json_encode(['error' => 'could not re-encrypt credentials — password NOT changed. Please re-enter CalDAV credentials first.']);
exit;
}
// All re-encryption succeeded — commit everything
$db->beginTransaction();
// Save old hash for re-key recovery
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('prev_password_hash', :h)")->execute(['h' => $hash]);
foreach ($reEncrypted as $ck => $val) {
$db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => $val, 'k' => $ck]);
}
$db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]);
$db->commit();
// Mark all notes for re-push (re-encrypt with new key)
$db->exec("UPDATE cards SET caldav_etag = NULL, caldav_synced_at = NULL WHERE caldav_uid LIKE 'note:%'");
echo json_encode(['ok' => true, 'rekey' => true]);
exit;
}
http_response_code(404);
echo json_encode(['error' => 'not found']);
exit;
@@ -160,10 +224,25 @@ if (str_starts_with($route, '/api/')) {
}
if ($route === '/api/cards' && $method === 'GET') {
if (!empty($_GET['ids'])) {
$ids = array_filter(array_map('intval', explode(',', $_GET['ids'])));
if ($ids) {
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id IN ($ph)");
$stmt->execute($ids);
echo json_encode($stmt->fetchAll());
exit;
}
}
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
exit;
}
if ($route === '/api/cards/meta' && $method === 'GET') {
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
exit;
}
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') {
// CalDAV tombstone for synced cards
$card = $db->prepare('SELECT caldav_uid FROM cards WHERE id = :id');
@@ -179,7 +258,7 @@ if (str_starts_with($route, '/api/')) {
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
$fields = []; $params = ['id' => $m[1]];
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id'] as $f) {
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) {
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
}
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
@@ -537,6 +616,133 @@ if (str_starts_with($route, '/api/')) {
echo json_encode($db->query('SELECT * FROM caldav_sync_log ORDER BY id DESC LIMIT 50')->fetchAll()); exit;
}
// WebDAV Notes sync
if ($route === '/api/notes/sync' && $method === 'POST') {
$notes = new WebDavNotes($db);
echo json_encode($notes->sync()); exit;
}
// Re-key notes: decrypt with old key, re-encrypt with current key
if ($route === '/api/notes/rekey' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$oldPass = $input['old_password'] ?? '';
if (!$oldPass) { http_response_code(400); echo json_encode(['error' => 'old password required']); exit; }
$currentHash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
$currentKey = substr(hash('sha256', $currentHash), 0, 32);
$prevHash = $db->query("SELECT value FROM settings WHERE key = 'prev_password_hash'")->fetchColumn();
// Build candidate old keys: try prev hash first, then verify password against it
$candidateKeys = [];
if ($prevHash) {
$candidateKeys[] = substr(hash('sha256', $prevHash), 0, 32);
// Maybe password was changed multiple times — prev_hash might not match oldPass
if (password_verify($oldPass, $prevHash)) {
// Confirmed: prev hash matches old password
array_unshift($candidateKeys, substr(hash('sha256', $prevHash), 0, 32));
}
}
// Also try current key (notes might already be on current key)
$candidateKeys[] = $currentKey;
// Get WebDAV client
$caldavSync = new CalDavSync($db);
$settings = $caldavSync->getSettings();
if (empty($settings['url'])) { echo json_encode(['ok' => false, 'error' => 'no CalDAV credentials']); exit; }
$url = $settings['url'];
$user = $settings['username'];
$passEnc = $db->query("SELECT value FROM settings WHERE key = 'caldav_password'")->fetchColumn();
$passRaw = base64_decode($passEnc);
$passIv = substr($passRaw, 0, 16);
$passCipher = base64_encode(substr($passRaw, 16));
$pass = openssl_decrypt($passCipher, 'aes-256-cbc', $currentKey, 0, $passIv) ?: '';
if (!$pass) { echo json_encode(['ok' => false, 'error' => 'cannot decrypt CalDAV password']); exit; }
if (preg_match('#(https?://[^/]+)/remote\.php/dav/calendars/([^/]+)#', $url, $m)) {
$notesUrl = $m[1] . '/remote.php/dav/files/' . $m[2] . '/Notes/';
} else { echo json_encode(['ok' => false, 'error' => 'cannot derive notes URL']); exit; }
$client = new CalDavClient($notesUrl, $user, $pass);
$notes = new WebDavNotes($db);
$remoteNotes = $notes->listRemoteNotes();
if (empty($remoteNotes)) { echo json_encode(['ok' => false, 'error' => 'no remote notes']); exit; }
// Find working key by testing first encrypted note
$parsed = parse_url($notesUrl);
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
$workingKey = null;
foreach ($remoteNotes as $testNote) {
$testRes = $client->request('GET', $base . $testNote['href']);
$testBody = $testRes['body'] ?? '';
if (!str_starts_with($testBody, 'HYPRCRD-ENCRYPTED-V1')) continue;
$encData = trim(explode("\n", $testBody, 2)[1] ?? '');
$raw = base64_decode($encData);
if (strlen($raw) < 17) continue;
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
foreach ($candidateKeys as $tryKey) {
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $tryKey, OPENSSL_RAW_DATA, $iv);
if ($plain !== false && (str_contains($plain, '#') || str_contains($plain, "\n"))) {
$workingKey = $tryKey;
break 2;
}
}
}
if (!$workingKey) {
echo json_encode(['ok' => false, 'error' => 'could not decrypt — old key not found']);
exit;
}
if ($workingKey === $currentKey) {
echo json_encode(['ok' => true, 'rekeyed' => 0, 'message' => 'notes already on current key']);
exit;
}
// Re-encrypt all notes
$count = 0;
foreach ($remoteNotes as $note) {
$res = $client->request('GET', $base . $note['href']);
if ($res['status'] !== 200) continue;
$body = $res['body'];
if (!str_starts_with($body, 'HYPRCRD-ENCRYPTED-V1')) continue;
$encData = trim(explode("\n", $body, 2)[1] ?? '');
$raw = base64_decode($encData);
if (strlen($raw) < 17) continue;
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $workingKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($plain === false) continue;
$newIv = random_bytes(16);
$newCipher = openssl_encrypt($plain, 'aes-256-cbc', $currentKey, OPENSSL_RAW_DATA, $newIv);
$client->request('PUT', $base . $note['href'], "HYPRCRD-ENCRYPTED-V1\n" . base64_encode($newIv . $newCipher), ['Content-Type: text/markdown; charset=utf-8']);
$count++;
}
// Clear and re-pull
$db->exec("DELETE FROM cards WHERE caldav_uid LIKE 'note:%'");
$db->exec("DELETE FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'");
$db->exec("DELETE FROM settings WHERE key = 'prev_password_hash'");
echo json_encode(['ok' => true, 'rekeyed' => $count]);
exit;
}
// Force re-sync: clear all notes state and re-pull
if ($route === '/api/notes/reset' && $method === 'POST') {
$deleted = $db->exec("DELETE FROM cards WHERE caldav_uid LIKE 'note:%'");
$db->exec("DELETE FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'");
$notes = new WebDavNotes($db);
$result = $notes->sync();
$result['reset'] = $deleted;
echo json_encode($result); exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
}
@@ -551,7 +757,7 @@ if (str_starts_with($route, '/api/')) {
<link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/vendor/phosphor-regular.css">
<link rel="stylesheet" href="css/vendor/phosphor-fill.css">
<link rel="stylesheet" href="css/tailwind.out.css">
<link rel="stylesheet" href="css/tailwind.out.css?v=<?= APP_VERSION ?>">
<script src="js/vendor/marked.min.js"></script>
<script src="js/vendor/purify.min.js"></script>
<link rel="stylesheet" href="css/style.css?v=<?= time() ?>">
@@ -584,7 +790,7 @@ if (str_starts_with($route, '/api/')) {
<div class="flex items-center gap-3">
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
<h1 class="font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
<span class="text-[10px] text-t-dim font-pixel ml-1">v0.1</span>
<span class="text-[10px] text-t-dim font-pixel ml-1">v<?= APP_VERSION ?></span>
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
@@ -614,6 +820,9 @@ if (str_starts_with($route, '/api/')) {
<button id="superview-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="compact list view">
<i class="ph ph-list text-xs"></i>
</button>
<button id="hide-done" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="toggle done">
<i class="ph ph-check-circle text-xs"></i>
</button>
<button id="hide-termine" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="toggle appointments">
<i class="ph ph-calendar-x text-xs"></i>
</button>
@@ -624,12 +833,14 @@ if (str_starts_with($route, '/api/')) {
</div>
<div id="group-bar" class="hidden flex items-center gap-2 px-6 pb-2 relative z-[1]"></div>
</header>
<div id="upcoming-banner" class="hidden shrink-0"></div>
<div id="board-wrapper" class="flex flex-1 min-h-0 overflow-hidden">
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 pb-6 flex-1 overflow-y-auto content-start"></div>
<div id="board-preview" class="hidden border-l border-t-border bg-t-surface flex flex-col" style="width:40%;min-width:300px;max-width:500px;">
<div id="board-preview-header" class="flex items-center gap-2 px-4 py-2.5 border-b border-t-border shrink-0">
<div id="board-preview-header" class="flex items-center gap-2 px-4 py-2.5 border-b border-t-border shrink-0 relative">
<div class="card-bar w-8 rounded-sm" id="board-preview-color"></div>
<h3 id="board-preview-title" class="font-pixel text-xs text-t-bright flex-1 truncate"></h3>
<span id="board-preview-done-badge" class="hidden"><i class="ph ph-check-circle text-sm text-green-400"></i></span>
<button id="board-preview-edit" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-pencil-simple text-sm"></i></button>
<button id="board-preview-close" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
</div>
@@ -660,15 +871,9 @@ if (str_starts_with($route, '/api/')) {
<button id="logout-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
</button>
<div id="theme-picker" class="theme-picker">
<button id="theme-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-sliders-horizontal text-sm"></i><span class="font-pixel text-[10px]">settings</span><i class="ph ph-caret-down text-[10px]"></i>
</button>
<div class="theme-menu bg-t-surface border border-t-border shadow-2xl p-2 min-w-[170px]">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim px-2 pb-1">theme</div>
<div id="theme-list" class="space-y-1"></div>
</div>
</div>
<button id="settings-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">settings</span>
</button>
</div>
<div id="footer-count" class="font-pixel text-[10px] text-t-dim whitespace-nowrap absolute left-1/2 -translate-x-1/2"></div>
<div class="flex items-center gap-2">
@@ -868,6 +1073,150 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<!-- Settings -->
<div class="overlay fixed inset-0 z-[88] flex justify-center items-center px-6" style="background:var(--t-overlay)" id="settings-overlay">
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-2xl flex" style="border-radius:4px;height:90vh;">
<div class="w-44 shrink-0 border-r border-t-border py-4 flex flex-col gap-1" id="settings-nav">
<button class="settings-tab active" data-tab="branding"><i class="ph ph-terminal-window text-sm"></i><span class="font-pixel text-[10px]">hyprcrd</span></button>
<button class="settings-tab" data-tab="account"><i class="ph ph-user text-sm"></i><span class="font-pixel text-[10px]">account</span></button>
<button class="settings-tab" data-tab="theme"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">themes</span></button>
<button class="settings-tab" data-tab="sync"><i class="ph ph-cloud-arrow-up text-sm"></i><span class="font-pixel text-[10px]">sync</span></button>
<button class="settings-tab" data-tab="behavior"><i class="ph ph-sliders text-sm"></i><span class="font-pixel text-[10px]">behavior</span></button>
<button class="settings-tab" data-tab="notifications"><i class="ph ph-bell text-sm"></i><span class="font-pixel text-[10px]">notifications</span></button>
</div>
<div class="flex-1 flex flex-col min-h-0">
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
<i class="ph ph-gear text-t-accent text-base"></i>
<h2 id="settings-title" class="font-pixel text-xs text-t-accent">settings</h2>
<div class="flex-1"></div>
<button id="settings-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
</div>
<div class="flex-1 overflow-y-auto p-5">
<div id="settings-panel-branding">
<div class="flex flex-col items-center justify-center py-12 text-center">
<i class="ph ph-terminal-window text-t-accent text-5xl mb-4"></i>
<h1 class="font-pixel text-2xl text-t-accent tracking-wider mb-2">hyprcrd</h1>
<p class="font-pixel text-[10px] text-t-dim mb-6">v<?= APP_VERSION ?></p>
<p class="text-xs text-t-muted leading-relaxed max-w-sm">Self-hosted card manager with markdown, tags, calendar, CalDAV sync, encrypted WebDAV notes, and Obsidian-compatible export.</p>
<div class="flex items-center gap-4 mt-8 text-t-dim">
<div class="text-center"><div id="stats-cards" class="font-pixel text-lg text-t-bright">—</div><div class="font-pixel text-[8px] uppercase">cards</div></div>
<div class="w-px h-8 bg-t-border"></div>
<div class="text-center"><div id="stats-buckets" class="font-pixel text-lg text-t-bright">—</div><div class="font-pixel text-[8px] uppercase">buckets</div></div>
<div class="w-px h-8 bg-t-border"></div>
<div class="text-center"><div id="stats-tags" class="font-pixel text-lg text-t-bright">—</div><div class="font-pixel text-[8px] uppercase">tags</div></div>
</div>
<p class="text-[10px] text-t-dim mt-8">Press <kbd class="px-1 py-0.5 border border-t-border rounded text-[9px] font-pixel">F1</kbd> for help</p>
</div>
</div>
<div id="settings-panel-theme" class="hidden">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3">color theme</div>
<div id="settings-theme-list" class="grid grid-cols-2 gap-2"></div>
</div>
<div id="settings-panel-sync" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2">CalDAV / WebDAV</div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">server url</label><input id="caldav-url" type="text" name="caldav_url_x" placeholder="https://cloud.example.com/remote.php/dav/calendars/user/" autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div class="flex gap-3">
<div class="flex-1"><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">username</label><input id="caldav-user" type="text" name="caldav_user_x" autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
<div class="flex-1"><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">password</label><input id="caldav-pass" type="text" name="caldav_pass_x" placeholder="app password" autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" style="-webkit-text-security:disc;" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
</div>
<div class="flex items-center gap-3">
<button id="caldav-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
<span id="caldav-test-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
<div id="caldav-calendars" class="hidden border-t border-t-border pt-2">
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-2">calendars</label>
<div id="caldav-cal-list" class="space-y-2"></div>
</div>
<div class="flex flex-wrap gap-2 pt-2 border-t border-t-border">
<button id="caldav-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="caldav-sync-now" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-arrows-clockwise text-sm"></i><span class="font-pixel text-[10px]">sync now</span></button>
<button id="notes-reset" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-arrow-counter-clockwise text-sm"></i><span class="font-pixel text-[10px]">reset notes</span></button>
<button id="notes-rekey" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-key text-sm"></i><span class="font-pixel text-[10px]">re-key notes</span></button>
</div>
<div class="flex items-center gap-2">
<span id="caldav-sync-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
<div id="caldav-log" class="text-[10px] text-t-dim font-mono max-h-32 overflow-y-auto hidden border-t border-t-border pt-2"></div>
</div>
<div id="settings-panel-behavior" class="hidden space-y-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2">current view</div>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted">Show cards touched within</label>
<select id="setting-current-days" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer">
<option value="1">1 day</option>
<option value="2">2 days</option>
<option value="3">3 days</option>
<option value="7">1 week</option>
<option value="14">2 weeks</option>
</select>
</div>
<p class="text-[10px] text-t-dim leading-relaxed"><i class="ph ph-info text-xs"></i> "Current" shows cards you recently opened or edited. Sync-only updates (CalDAV/WebDAV) are excluded.</p>
</div>
<div id="settings-panel-notifications" class="hidden space-y-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2">lookahead</div>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted">Show upcoming events</label>
<select id="notif-lookahead" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer">
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="7">1 week</option>
<option value="14">2 weeks</option>
<option value="30">1 month</option>
</select>
<span class="text-xs text-t-dim">ahead</span>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted">Show at</label>
<select id="notif-time" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer">
<option value="0">midnight</option>
<option value="6">06:00</option>
<option value="8">08:00</option>
<option value="9">09:00</option>
</select>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex items-center gap-2">
<input type="checkbox" id="notif-birthdays" checked class="accent-t-accent"> Birthdays
</label>
<label class="text-xs text-t-muted flex items-center gap-2">
<input type="checkbox" id="notif-termine" checked class="accent-t-accent"> Appointments
</label>
<label class="text-xs text-t-muted flex items-center gap-2">
<input type="checkbox" id="notif-overdue" checked class="accent-t-accent"> Overdue tasks
</label>
</div>
<div class="border-t border-t-border pt-3 mt-2">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2">notification stream</div>
<div class="flex items-center gap-2 mb-2">
<button id="notif-clear-dismissed" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-arrow-counter-clockwise text-sm"></i><span class="font-pixel text-[10px]">restore dismissed</span></button>
</div>
<div id="notif-stream" class="max-h-64 overflow-y-auto space-y-1"></div>
</div>
</div>
<div id="settings-panel-account" class="hidden space-y-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3">change password</div>
<div>
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">current password</label>
<input id="settings-pw-current" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
</div>
<div>
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">new password</label>
<input id="settings-pw-new" type="password" autocomplete="new-password" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
</div>
<div>
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">confirm new password</label>
<input id="settings-pw-confirm" type="password" autocomplete="new-password" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
</div>
<div class="flex items-center gap-3">
<button id="settings-pw-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">change password</span></button>
<span id="settings-pw-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Dialog -->
<div class="overlay fixed inset-0 z-[90] flex justify-center items-center px-4" style="background:var(--t-overlay)" id="dialog">
<div class="bg-t-surface border border-t-border w-full max-w-sm">
@@ -960,7 +1309,7 @@ if (str_starts_with($route, '/api/')) {
<div id="detail-sidebar-list" class="flex-1 overflow-y-auto"></div>
</div>
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0">
<div class="flex-1 flex flex-col min-w-0 relative">
<div class="card-bar w-full shrink-0" id="detail-color"></div>
<div id="detail-controls" class="flex items-center gap-2 px-4 py-2 border-b border-t-border shrink-0">
<button id="detail-edit" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
@@ -969,6 +1318,9 @@ if (str_starts_with($route, '/api/')) {
<button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">del</span>
</button>
<button id="detail-done" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="mark done">
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]" id="detail-done-label">done</span>
</button>
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="zen mode">
<i class="ph ph-corners-out text-sm"></i>
</button>
@@ -990,6 +1342,7 @@ if (str_starts_with($route, '/api/')) {
</div>
<div id="detail-meta" class="mt-8 pt-4 border-t border-t-border text-[11px] text-t-dim font-pixel"></div>
</div>
<div id="detail-done-corner" class="card-done-badge hidden"><i class="ph ph-check"></i></div>
</div>
<!-- Split right pane -->
<div id="split-right" class="hidden w-2/5 shrink-0 border-l border-t-border flex flex-col min-w-0">
@@ -1028,38 +1381,6 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<!-- CalDAV Settings -->
<div class="overlay fixed inset-0 z-[74] flex justify-center items-start pt-16 px-4" style="background:var(--t-overlay)" id="caldav-settings">
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-lg" style="border-radius:4px;">
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border">
<i class="ph ph-cloud-arrow-up text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">CalDAV Sync</h2>
<div class="flex-1"></div>
<button id="caldav-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
</div>
<div class="p-5 space-y-3">
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">server url</label><input id="caldav-url" type="text" placeholder="https://cloud.example.com/remote.php/dav/calendars/user/personal/" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div class="flex gap-3">
<div class="flex-1"><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">username</label><input id="caldav-user" type="text" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
<div class="flex-1"><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">password</label><input id="caldav-pass" type="password" placeholder="app password" autocomplete="new-password" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
</div>
<div class="flex items-center gap-3">
<button id="caldav-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
<span id="caldav-test-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
<div id="caldav-calendars" class="hidden border-t border-t-border pt-2">
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-2">calendars</label>
<div id="caldav-cal-list" class="space-y-2"></div>
</div>
<div class="flex gap-3 pt-2 border-t border-t-border">
<button id="caldav-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="caldav-sync-now" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-arrows-clockwise text-sm"></i><span class="font-pixel text-[10px]">sync now</span></button>
<span id="caldav-sync-status" class="font-pixel text-[9px] text-t-dim ml-auto self-center"></span>
</div>
<div id="caldav-log" class="text-[10px] text-t-dim font-mono max-h-32 overflow-y-auto hidden border-t border-t-border pt-2 mt-2"></div>
</div>
</div>
</div>
<!-- Gallery -->
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
@@ -1080,9 +1401,24 @@ if (str_starts_with($route, '/api/')) {
<!-- Selection action bar -->
<div id="select-bar" class="fixed bottom-16 left-1/2 -translate-x-1/2 z-[45] bg-t-surface border border-t-border shadow-2xl px-4 py-2 flex items-center gap-3 hidden" style="border-radius:6px;">
<span id="select-count" class="font-pixel text-[10px] text-t-accent"></span>
<button id="select-group" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">group</span>
</button>
<button id="select-bucket" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">bucket</span>
</button>
<button id="select-color" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span>
</button>
<button id="select-tag" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">tag</span>
</button>
<button id="select-export" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
</button>
<button id="select-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span>
</button>
<button id="select-clear" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">clear</span>
</button>
@@ -1117,6 +1453,13 @@ if (str_starts_with($route, '/api/')) {
</div>
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
<input type="text" id="inp-title" placeholder="> title..." class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
<button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" title="set due date">
<i class="ph ph-clock-countdown text-xs"></i><span id="inp-due-label">no due</span>
</button>
<button id="inp-done-toggle" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" title="mark done">
<i class="ph ph-check-circle text-xs"></i><span id="inp-done-label">done</span>
</button>
<input type="hidden" id="inp-due-value" value="">
<select id="inp-bucket" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">no bucket</option></select>
<div id="color-presets" class="flex gap-1.5 shrink-0"></div>
<input type="hidden" id="inp-color" value="#38bdf8">