haufen sachen
This commit is contained in:
1 parent
416ca9e854
commit
e6243701a3
20 files changed
+1562
-116
No files matched your search
+22
-1
@@ -76,6 +76,27 @@ body::after {
|
||||
|
||||
.board-card.preview-active { border-color: var(--t-accent) !important; box-shadow: 0 0 0 1px var(--t-accent), 0 0 12px -2px color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
||||
|
||||
/* Upcoming banner */
|
||||
.upcoming-item { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; font-size: 11px; border-radius: 3px; border: 1px solid var(--t-border); cursor: pointer; transition: border-color 0.12s; }
|
||||
.upcoming-item:hover { border-color: var(--t-accent); }
|
||||
.upcoming-dismiss { background: none; border: none; color: var(--t-dim); cursor: pointer; padding: 2px; transition: color 0.12s; }
|
||||
.upcoming-dismiss:hover { color: var(--t-danger); }
|
||||
|
||||
/* Settings */
|
||||
.settings-tab { display: flex; align-items: center; gap: 8px; padding: 6px 16px; font-size: 11px; color: var(--t-muted); cursor: pointer; border: none; background: none; width: 100%; text-align: left; transition: color 0.1s, background 0.1s; border-left: 2px solid transparent; }
|
||||
.settings-tab:hover { color: var(--t-bright); background: var(--t-raised); }
|
||||
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
||||
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
||||
.due-pill { display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border-radius: 8px; font-size: 9px; line-height: 16px; color: var(--t-bg); font-family: var(--t-font-pixel); }
|
||||
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
||||
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
||||
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
||||
.card-spinner { animation: spin 0.8s linear infinite; }
|
||||
|
||||
/* Cards */
|
||||
.card-bar { image-rendering: pixelated; height: 3px; }
|
||||
.board-card { min-height: 180px; }
|
||||
@@ -631,7 +652,7 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
/* Markdown gallery */
|
||||
.md-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px; margin: 0.8em 0; }
|
||||
.md-gallery-item { aspect-ratio: 1; overflow: hidden; border-radius: 4px; border: 1px solid var(--t-border); cursor: pointer; }
|
||||
.md-gallery-item img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.15s; }
|
||||
.md-gallery-item img { width: 100% !important; height: 100% !important; object-fit: cover !important; display: block; max-width: none !important; margin: 0 !important; border: none !important; border-radius: 0 !important; cursor: pointer !important; }
|
||||
.md-gallery-item:hover img { transform: scale(1.05); }
|
||||
|
||||
/* Image fullscreen prev/next */
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkLine truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::fiLine truncated
|
||||
+391
-48
@@ -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">
|
||||
|
||||
+43
-3
@@ -16,7 +16,7 @@ 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 } from './modules/caldav.js';
|
||||
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings } from './modules/caldav.js';
|
||||
|
||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||
|
||||
@@ -28,13 +28,14 @@ window._closeTasks = closeTasks;
|
||||
window._renderBoard = renderBoard;
|
||||
window._openCalendar = openCalendar;
|
||||
window._triggerSync = triggerSync;
|
||||
window._loadCaldavSettings = loadCaldavSettings;
|
||||
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._reloadAll = async () => { state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
||||
window._extractToCard = async (text) => {
|
||||
if (!text?.trim()) return;
|
||||
const lines = text.trim().split('\n');
|
||||
@@ -52,10 +53,49 @@ window._extractToCard = async (text) => {
|
||||
|
||||
// ── 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() {
|
||||
state.cards = await api('api/cards');
|
||||
// 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 ──
|
||||
|
||||
+244
-12
@@ -6,7 +6,8 @@ import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js';
|
||||
import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
|
||||
import { getCalConfig } from './caldav.js';
|
||||
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||
import { bindCheckboxes } from './detail.js';
|
||||
import { addProvider as addNotifProvider, render as renderNotifs } from './notifications.js';
|
||||
import { bindCheckboxes, bindDetailImageFullscreen } from './detail.js';
|
||||
|
||||
const board = $('board');
|
||||
const boardPreview = $('board-preview');
|
||||
@@ -76,6 +77,78 @@ $('select-export')?.addEventListener('click', () => {
|
||||
if (!selectedIds.size) return;
|
||||
window.location.href = 'api/export?ids=' + [...selectedIds].join(',');
|
||||
});
|
||||
|
||||
$('select-group')?.addEventListener('click', async () => {
|
||||
if (selectedIds.size < 2) return;
|
||||
const ids = [...selectedIds];
|
||||
const parentId = ids[0]; // First selected becomes parent
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
await api(`api/cards/${ids[i]}`, { method: 'PATCH', body: { parent_id: parentId } });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-bucket')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const bucketOpts = state.buckets.map(b => b.name);
|
||||
const name = await showDialog({ title: 'move to bucket', input: true, placeholder: 'bucket name', okLabel: 'move' });
|
||||
if (!name) return;
|
||||
let bucket = state.buckets.find(b => b.name.toLowerCase() === name.trim().toLowerCase());
|
||||
if (!bucket) {
|
||||
const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
||||
if (created?.id) bucket = created;
|
||||
}
|
||||
if (!bucket) return;
|
||||
for (const id of selectedIds) {
|
||||
await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucket.id } });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-color')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const color = await showDialog({ title: 'set color', input: true, placeholder: '#38bdf8', okLabel: 'apply' });
|
||||
if (!color || !/^#[0-9a-fA-F]{6}$/.test(color.trim())) return;
|
||||
for (const id of selectedIds) {
|
||||
await api(`api/cards/${id}`, { method: 'PATCH', body: { color: color.trim() } });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-tag')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const tag = await showDialog({ title: 'add tag', input: true, placeholder: '#tagname', okLabel: 'add' });
|
||||
if (!tag) return;
|
||||
const cleanTag = tag.trim().replace(/^#/, '');
|
||||
if (!cleanTag) return;
|
||||
for (const id of selectedIds) {
|
||||
const card = state.cards.find(c => c.id == id);
|
||||
if (!card) continue;
|
||||
const body = (card.body || '').trimEnd() + ' #' + cleanTag;
|
||||
await api(`api/cards/${id}`, { method: 'PATCH', body: { body } });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-delete')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
for (const id of selectedIds) {
|
||||
await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
$('select-clear')?.addEventListener('click', clearSelection);
|
||||
|
||||
// Custom drag ghost that fades near header
|
||||
@@ -117,10 +190,13 @@ function showBoardPreview(cardId) {
|
||||
$('board-preview-content')?.classList.remove('hidden');
|
||||
if (boardPreviewColor) boardPreviewColor.style.background = card.color;
|
||||
if (boardPreviewTitle) boardPreviewTitle.textContent = card.title;
|
||||
const doneBadge = $('board-preview-done-badge');
|
||||
if (doneBadge) doneBadge.classList.toggle('hidden', !parseTags(card).includes('done'));
|
||||
if (boardPreviewBody) {
|
||||
boardPreviewBody.innerHTML = renderMd(card.body);
|
||||
addCodeCopyButtons(boardPreviewBody);
|
||||
formatTodoItems(boardPreviewBody);
|
||||
bindDetailImageFullscreen(boardPreviewBody);
|
||||
bindCheckboxes(boardPreviewBody, card);
|
||||
}
|
||||
const tags = parseTags(card);
|
||||
@@ -199,6 +275,22 @@ if (state.superView) {
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
}
|
||||
|
||||
$('hide-done')?.addEventListener('click', () => {
|
||||
state.hideDone = !state.hideDone;
|
||||
localStorage.setItem('hyprcrd-hide-done', state.hideDone ? '1' : '0');
|
||||
const btn = $('hide-done');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = `ph ${state.hideDone ? 'ph-check-square' : 'ph-check-circle'} text-xs`;
|
||||
if (btn) btn.classList.toggle('text-t-accent', state.hideDone);
|
||||
renderBoard();
|
||||
});
|
||||
if (state.hideDone) {
|
||||
const btn = $('hide-done');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = 'ph ph-check-square text-xs';
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
}
|
||||
|
||||
$('hide-termine')?.addEventListener('click', () => {
|
||||
state.hideTermine = !state.hideTermine;
|
||||
localStorage.setItem('hyprcrd-hide-termine', state.hideTermine ? '1' : '0');
|
||||
@@ -224,11 +316,12 @@ $('sort-toggle')?.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
const coverImg = leadingImage(c.body);
|
||||
const thumbImg = coverImg ? null : firstImage(c.body);
|
||||
const loaded = c.body != null;
|
||||
const coverImg = loaded ? leadingImage(c.body) : null;
|
||||
const thumbImg = coverImg ? null : (loaded ? firstImage(c.body) : null);
|
||||
const children = getChildren(c.id);
|
||||
const isGroup = !inGroup && children.length > 0;
|
||||
const todos = getTodos(c.body);
|
||||
const todos = loaded ? getTodos(c.body) : null;
|
||||
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
|
||||
const hasCover = !!coverImg;
|
||||
const tags = parseTags(c);
|
||||
@@ -237,12 +330,15 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
if (isTermin) { for (const t of tags) { if (t && t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) { calCfg = cfg; break; } } } }
|
||||
const cardColor = calCfg?.color || c.color;
|
||||
const calIcon = isTermin ? (calCfg?.icon || 'calendar-dots') : null;
|
||||
const overdue = c.due_date && c.due_date < new Date().toISOString().substring(0, 10);
|
||||
const barColor = overdue ? 'var(--t-danger)' : cardColor;
|
||||
|
||||
return `
|
||||
<div class="board-card ${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
|
||||
style="--glow-color:${cardColor}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + cardColor : ''}" draggable="true" data-id="${c.id}">
|
||||
style="--glow-color:${cardColor}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${overdue ? 'border-bottom:3px solid var(--t-danger)' : recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + cardColor : ''}" draggable="true" data-id="${c.id}">
|
||||
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
||||
<div class="card-bar w-full card-layer" style="background:${cardColor}"></div>
|
||||
${tags.includes('done') ? '<div class="card-done-badge"><i class="ph ph-check"></i></div>' : ''}
|
||||
<div class="card-bar w-full card-layer ${overdue ? 'card-bar-pulse' : ''}" style="background:${barColor}"></div>
|
||||
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${cardColor};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
||||
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
|
||||
<div class="p-4 card-layer flex flex-col h-full ${thumbImg ? 'pr-16' : ''}">
|
||||
@@ -255,10 +351,12 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
${todos ? `<div class="mb-2">
|
||||
<div class="todo-progress">${Array.from({length: todos.total}, (_, i) => `<div class="todo-seg" style="background:${i < todos.done ? cardColor : 'var(--t-border)'}"></div>`).join('')}</div>
|
||||
</div>` : ''}
|
||||
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>
|
||||
${loaded
|
||||
? `<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>`
|
||||
: `<div class="flex-1 flex items-center justify-center py-2"><i class="ph ph-spinner card-spinner text-t-dim text-lg"></i></div>`}
|
||||
${tags.length ? `<div class="flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||
<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>
|
||||
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,13 +372,20 @@ export function renderBoard() {
|
||||
cards = parent ? [parent, ...getChildren(parent.id)] : [];
|
||||
} else {
|
||||
if (state.activeBucket === 'current') {
|
||||
// Current shows ALL recently touched cards, including group children
|
||||
// Current: recently opened/edited cards (not just synced)
|
||||
const recent = getRecent();
|
||||
const now = Date.now();
|
||||
const freshMs = 48 * 60 * 60 * 1000;
|
||||
const currentDays = parseInt(localStorage.getItem('hyprcrd-current-days') || '2');
|
||||
const freshMs = currentDays * 24 * 60 * 60 * 1000;
|
||||
cards = state.cards.filter(c => {
|
||||
const tags = (c.tags || '').split(',');
|
||||
if (tags.includes('geburtstag')) return false;
|
||||
// Recently opened (tracked by markRecent)
|
||||
if (recent.includes(String(c.id))) return true;
|
||||
// Recently edited locally (not just synced — check synced_at vs updated_at)
|
||||
const updated = new Date(c.updated_at || c.created_at || 0).getTime();
|
||||
const synced = c.caldav_synced_at ? new Date(c.caldav_synced_at).getTime() : 0;
|
||||
if (synced && Math.abs(updated - synced) < 3000) return false; // sync-only update
|
||||
return (now - updated) < freshMs;
|
||||
});
|
||||
cards.sort((a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''));
|
||||
@@ -300,9 +405,13 @@ export function renderBoard() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hide appointments
|
||||
// Hide appointments + birthdays
|
||||
if (state.hideTermine) {
|
||||
cards = cards.filter(c => !parseTags(c).includes('termin'));
|
||||
cards = cards.filter(c => { const t = parseTags(c); return !t.includes('termin') && !t.includes('geburtstag'); });
|
||||
}
|
||||
// Hide done
|
||||
if (state.hideDone) {
|
||||
cards = cards.filter(c => !parseTags(c).includes('done'));
|
||||
}
|
||||
// Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel
|
||||
if (state.activeTags.length) {
|
||||
@@ -454,8 +563,131 @@ export function renderBoard() {
|
||||
|
||||
// Refresh split preview if open
|
||||
if (boardSplitOpen && boardPreviewCardId) showBoardPreview(boardPreviewCardId);
|
||||
|
||||
// Notification banner
|
||||
renderNotifs();
|
||||
}
|
||||
|
||||
// ── Notification providers ──
|
||||
|
||||
// Upcoming events (birthdays + appointments)
|
||||
addNotifProvider((settings) => {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().substring(0, 10);
|
||||
const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const lookahead = settings.lookahead || 3;
|
||||
const aheadStr = new Date(todayLocal.getTime() + lookahead * 86400000).toISOString().substring(0, 10);
|
||||
const items = [];
|
||||
|
||||
for (const c of state.cards) {
|
||||
const tags = (c.tags || '').split(',');
|
||||
const isGeb = tags.includes('geburtstag');
|
||||
const isTermin = tags.includes('termin');
|
||||
if (isGeb && !settings.birthdays) continue;
|
||||
if (isTermin && !isGeb && !settings.termine) continue;
|
||||
if (!isGeb && !isTermin) continue;
|
||||
|
||||
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (!m) continue;
|
||||
|
||||
let eventDate = m[1];
|
||||
if (isGeb) eventDate = todayStr.substring(0, 4) + eventDate.substring(4);
|
||||
if (eventDate < todayStr || eventDate > aheadStr) continue;
|
||||
|
||||
const name = c.title.replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
|
||||
const evParts = eventDate.split('-').map(Number);
|
||||
const evLocal = new Date(evParts[0], evParts[1] - 1, evParts[2]);
|
||||
const daysAway = Math.round((evLocal - todayLocal) / 86400000);
|
||||
const when = daysAway === 0 ? 'today' : daysAway === 1 ? 'tomorrow' : `in ${daysAway}d`;
|
||||
|
||||
items.push({
|
||||
id: String(c.id),
|
||||
icon: isGeb ? 'cake' : 'calendar-dots',
|
||||
iconColor: isGeb ? '#fb7185' : 'var(--t-text)',
|
||||
label: name,
|
||||
when,
|
||||
sort: eventDate,
|
||||
type: isGeb ? 'birthday' : 'appointment',
|
||||
onClick: () => window._openDetail(c.id),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// Due dates approaching or overdue
|
||||
addNotifProvider((settings) => {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().substring(0, 10);
|
||||
const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const lookahead = settings.lookahead || 3;
|
||||
const aheadStr = new Date(todayLocal.getTime() + lookahead * 86400000).toISOString().substring(0, 10);
|
||||
const items = [];
|
||||
|
||||
for (const c of state.cards) {
|
||||
if (!c.due_date) continue;
|
||||
|
||||
// Overdue
|
||||
if (c.due_date < todayStr) {
|
||||
if (!settings.overdue) continue;
|
||||
items.push({
|
||||
id: 'due-' + c.id,
|
||||
icon: 'warning',
|
||||
iconColor: 'var(--t-danger)',
|
||||
label: c.title,
|
||||
when: 'overdue',
|
||||
sort: '0' + c.due_date,
|
||||
type: 'overdue',
|
||||
onClick: () => window._openDetail(c.id),
|
||||
});
|
||||
}
|
||||
// Upcoming
|
||||
else if (c.due_date <= aheadStr) {
|
||||
const evParts = c.due_date.split('-').map(Number);
|
||||
const evLocal = new Date(evParts[0], evParts[1] - 1, evParts[2]);
|
||||
const daysAway = Math.round((evLocal - todayLocal) / 86400000);
|
||||
const when = daysAway === 0 ? 'today' : daysAway === 1 ? 'tomorrow' : `in ${daysAway}d`;
|
||||
items.push({
|
||||
id: 'due-' + c.id,
|
||||
icon: 'clock-countdown',
|
||||
iconColor: daysAway === 0 ? 'var(--t-danger)' : 'var(--t-accent)',
|
||||
label: c.title,
|
||||
when,
|
||||
sort: c.due_date,
|
||||
type: 'due',
|
||||
onClick: () => window._openDetail(c.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// Overdue tasks (cards with unchecked todos past their date, no due_date set)
|
||||
addNotifProvider((settings) => {
|
||||
if (!settings.overdue) return [];
|
||||
const todayStr = new Date().toISOString().substring(0, 10);
|
||||
const items = [];
|
||||
|
||||
for (const c of state.cards) {
|
||||
if (c.due_date) continue; // handled by due-date provider above
|
||||
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (!m || m[1] >= todayStr) continue;
|
||||
if (!c.body || !/- \[ \]/.test(c.body)) continue;
|
||||
|
||||
const name = c.title.replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title;
|
||||
items.push({
|
||||
id: 'overdue-' + c.id,
|
||||
icon: 'warning',
|
||||
iconColor: 'var(--t-danger)',
|
||||
label: name,
|
||||
when: 'overdue',
|
||||
sort: '0' + m[1],
|
||||
type: 'overdue',
|
||||
onClick: () => window._openDetail(c.id),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// ── Group bar ──
|
||||
|
||||
function renderGroupBar(cards) {
|
||||
|
||||
+70
-22
@@ -21,7 +21,7 @@ const CAL_COLORS = ['#38bdf8', '#8b5cf6', '#fb7185', '#fbbf24', '#4ade80', '#fb9
|
||||
let calendarConfigs = {}; // slug → { color, icon }
|
||||
const CAL_CONFIG_KEY = 'hyprcrd-cal-configs';
|
||||
|
||||
const overlay = $('caldav-settings');
|
||||
const settingsOverlay = $('settings-overlay');
|
||||
const urlInput = $('caldav-url');
|
||||
const userInput = $('caldav-user');
|
||||
const passInput = $('caldav-pass');
|
||||
@@ -30,12 +30,15 @@ const syncStatus = $('caldav-sync-status');
|
||||
const logEl = $('caldav-log');
|
||||
|
||||
export function openCaldavSettings() {
|
||||
overlay?.classList.add('open');
|
||||
// Open settings overlay on sync tab
|
||||
settingsOverlay?.classList.add('open');
|
||||
settingsOverlay?.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'sync'));
|
||||
settingsOverlay?.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.toggle('hidden', p.id !== 'settings-panel-sync'));
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
export function closeCaldavSettings() {
|
||||
overlay?.classList.remove('open');
|
||||
settingsOverlay?.classList.remove('open');
|
||||
}
|
||||
|
||||
function loadCalConfigs() {
|
||||
@@ -97,7 +100,7 @@ function renderCalList(calendars) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
export async function loadSettings() {
|
||||
const s = await api('api/caldav/settings');
|
||||
if (!s) return;
|
||||
if (urlInput) urlInput.value = s.url || '';
|
||||
@@ -115,8 +118,6 @@ async function loadSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
$('caldav-close')?.addEventListener('click', closeCaldavSettings);
|
||||
overlay?.addEventListener('click', e => { if (e.target === overlay) closeCaldavSettings(); });
|
||||
|
||||
$('caldav-test')?.addEventListener('click', async () => {
|
||||
testStatus.textContent = 'testing...';
|
||||
@@ -149,6 +150,19 @@ $('caldav-save')?.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
||||
$('notes-reset')?.addEventListener('click', async () => {
|
||||
if (syncStatus) { syncStatus.textContent = 'resetting notes...'; syncStatus.style.color = 'var(--t-dim)'; }
|
||||
const res = await fetch('api/notes/reset', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const s = res.stats || {};
|
||||
if (syncStatus) { syncStatus.textContent = `reset: ${res.reset || 0} cleared, ${s.pulled || 0} re-pulled`; syncStatus.style.color = 'var(--t-accent)'; }
|
||||
await window._reloadAll?.();
|
||||
} else {
|
||||
if (syncStatus) { syncStatus.textContent = res?.error || 'reset failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||
}
|
||||
});
|
||||
|
||||
export async function triggerSync() {
|
||||
const btn = $('cal-sync');
|
||||
@@ -161,14 +175,22 @@ export async function triggerSync() {
|
||||
|
||||
if (btn) btn.style.opacity = '';
|
||||
|
||||
// Also sync WebDAV Notes
|
||||
const notesRes = await fetch('api/notes/sync', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
const s = res.stats || {};
|
||||
const hasErrors = (res.log || []).some(l => l.status === 'error');
|
||||
const msg = `pushed:${s.pushed || 0} pulled:${s.pulled || 0} del:${s.deleted || 0}${s.conflicts ? ' conflicts:' + s.conflicts : ''}`;
|
||||
const ns = notesRes?.stats || {};
|
||||
const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) };
|
||||
const allLog = [...(res.log || []), ...(notesRes?.log || [])];
|
||||
const hasErrors = allLog.some(l => l.status === 'error');
|
||||
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${ns.pulled ? ' notes:' + ns.pulled : ''}`;
|
||||
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
||||
await window._reloadAll?.();
|
||||
showLog(res.log);
|
||||
showSyncNotification(s);
|
||||
showLog(allLog);
|
||||
showSyncNotification(total);
|
||||
} else {
|
||||
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||
}
|
||||
@@ -190,6 +212,25 @@ function showConnectionLog(lines) {
|
||||
).join('');
|
||||
}
|
||||
|
||||
$('notes-rekey')?.addEventListener('click', async () => {
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const oldPass = await showDialog({ title: 're-key notes', input: 'password', placeholder: 'old password', okLabel: 're-key' });
|
||||
if (!oldPass) return;
|
||||
if (syncStatus) { syncStatus.textContent = 're-keying notes...'; syncStatus.style.color = 'var(--t-dim)'; }
|
||||
const res = await fetch('api/notes/rekey', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify({ old_password: oldPass }),
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
if (res?.ok) {
|
||||
if (syncStatus) { syncStatus.textContent = `re-keyed ${res.rekeyed} notes — syncing...`; syncStatus.style.color = 'var(--t-accent)'; }
|
||||
// Re-pull with correct key
|
||||
const pullRes = await fetch('api/notes/reset', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
|
||||
await window._reloadAll?.();
|
||||
if (syncStatus) syncStatus.textContent = `re-keyed ${res.rekeyed} notes, ${pullRes?.stats?.pulled || 0} re-pulled`;
|
||||
} else {
|
||||
if (syncStatus) { syncStatus.textContent = res?.error || 're-key failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||
}
|
||||
});
|
||||
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
||||
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
||||
|
||||
@@ -218,25 +259,32 @@ function showSyncNotification(stats) {
|
||||
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
|
||||
}
|
||||
|
||||
let bgSyncRunning = false;
|
||||
async function backgroundSync() {
|
||||
if (bgSyncRunning) return;
|
||||
bgSyncRunning = true;
|
||||
try {
|
||||
const res = await fetch('api/caldav/sync', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const s = res.stats || {};
|
||||
if (s.pulled > 0 || s.pushed > 0 || s.deleted > 0) {
|
||||
await window._reloadAll?.();
|
||||
showSyncNotification(s);
|
||||
}
|
||||
const headers = { 'X-CSRF-Token': getCsrf() };
|
||||
const [res, notesRes] = await Promise.all([
|
||||
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetch('api/notes/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
]);
|
||||
const s = res?.stats || {};
|
||||
const ns = notesRes?.stats || {};
|
||||
const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) };
|
||||
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
|
||||
await window._reloadAll?.();
|
||||
showSyncNotification(total);
|
||||
}
|
||||
} catch {}
|
||||
} catch {} finally {
|
||||
bgSyncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start background sync after app is ready
|
||||
let bgSyncInterval = null;
|
||||
export function startBackgroundSync() {
|
||||
if (bgSyncInterval) return;
|
||||
backgroundSync(); // first sync immediately
|
||||
bgSyncInterval = setInterval(backgroundSync, 120000); // then every 2 minutes
|
||||
backgroundSync();
|
||||
bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export function showContextMenu(x, y, card) {
|
||||
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span></button>
|
||||
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">duplicate</span></button>
|
||||
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `<button class="ctx-item" data-action="move-date"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">move to date</span></button>` : ''}
|
||||
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'due: ' + card.due_date : 'set due date'}</span></button>
|
||||
${card.due_date ? `<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm text-t-accent"></i><span class="font-pixel text-[10px]">mark done</span></button>` : ''}
|
||||
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
|
||||
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
|
||||
<div class="ctx-sep"></div>
|
||||
@@ -188,6 +190,20 @@ export function showContextMenu(x, y, card) {
|
||||
await window._reloadAll();
|
||||
if (card.caldav_uid) window._triggerSync?.();
|
||||
}
|
||||
else if (a === 'due-done') {
|
||||
hideContextMenu();
|
||||
// Clear due date and mark as done
|
||||
const bodyWithDone = (card.body || '').includes('#done') ? card.body : (card.body || '').trimEnd() + ' #done';
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: null, body: bodyWithDone } });
|
||||
await window._reloadAll();
|
||||
}
|
||||
else if (a === 'due-date') {
|
||||
hideContextMenu();
|
||||
const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'update' : 'set' });
|
||||
if (newDate === null) return;
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: newDate || null } });
|
||||
await window._reloadAll();
|
||||
}
|
||||
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
||||
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
|
||||
else if (a === 'color') toggle(colors);
|
||||
|
||||
@@ -444,9 +444,23 @@ function renderSidebar(card) {
|
||||
|
||||
// ── Content ──
|
||||
|
||||
function updateDoneButton(card) {
|
||||
const isDone = (card.tags || '').split(',').includes('done');
|
||||
const btn = $('detail-done');
|
||||
const label = $('detail-done-label');
|
||||
if (btn) {
|
||||
btn.classList.toggle('text-t-accent', isDone);
|
||||
btn.classList.toggle('border-t-accent', isDone);
|
||||
}
|
||||
if (label) label.textContent = isDone ? 'undone' : 'done';
|
||||
}
|
||||
|
||||
function showContent(card) {
|
||||
$('detail-color').style.background = card.color;
|
||||
titleEl.textContent = card.title;
|
||||
updateDoneButton(card);
|
||||
const doneCorner = $('detail-done-corner');
|
||||
if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
|
||||
bodyEl.innerHTML = renderMd(card.body);
|
||||
addCodeCopyButtons(bodyEl);
|
||||
formatTodoItems(bodyEl);
|
||||
@@ -575,7 +589,7 @@ function applyReadingResizeHandles(root) {
|
||||
});
|
||||
}
|
||||
|
||||
function bindDetailImageFullscreen(root) {
|
||||
export function bindDetailImageFullscreen(root) {
|
||||
if (!root) return;
|
||||
|
||||
// Gallery grids: single click, with prev/next
|
||||
@@ -978,6 +992,27 @@ $('detail-edit')?.addEventListener('click', () => {
|
||||
window._openModal(card);
|
||||
});
|
||||
|
||||
$('detail-done')?.addEventListener('click', async () => {
|
||||
const card = state.viewingCard;
|
||||
if (!card) return;
|
||||
const isDone = (card.tags || '').split(',').includes('done');
|
||||
let newBody;
|
||||
if (isDone) {
|
||||
newBody = card.body.replace(/\s*#done\b/g, '');
|
||||
} else {
|
||||
newBody = (card.body || '').trimEnd() + ' #done';
|
||||
}
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody, due_date: isDone ? card.due_date : null } });
|
||||
card.body = newBody;
|
||||
if (!isDone) card.due_date = null;
|
||||
const c = state.cards.find(x => x.id == card.id);
|
||||
if (c) { c.body = newBody; c.tags = newBody.match(/#[a-z0-9_/-]+/gi)?.map(t => t.substring(1).toLowerCase()).join(',') || ''; if (!isDone) c.due_date = null; }
|
||||
showContent(card);
|
||||
window._renderBoard();
|
||||
const doneCorner = $('detail-done-corner');
|
||||
if (doneCorner) doneCorner.classList.toggle('hidden', !newBody.includes('#done'));
|
||||
});
|
||||
|
||||
$('detail-delete')?.addEventListener('click', async () => {
|
||||
if (!state.viewingCard) return;
|
||||
const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
||||
|
||||
@@ -112,6 +112,58 @@ CARD_COLORS.forEach(c => {
|
||||
presets.appendChild(btn);
|
||||
});
|
||||
|
||||
const inpDueValue = $('inp-due-value');
|
||||
const inpDueLabel = $('inp-due-label');
|
||||
const inpDueBtn = $('inp-due-date');
|
||||
|
||||
function setDueDate(date) {
|
||||
if (inpDueValue) inpDueValue.value = date || '';
|
||||
if (inpDueLabel) inpDueLabel.textContent = date || 'no due';
|
||||
if (inpDueBtn) {
|
||||
inpDueBtn.classList.toggle('text-t-accent', !!date);
|
||||
inpDueBtn.classList.toggle('border-t-accent', !!date);
|
||||
inpDueBtn.classList.toggle('text-t-dim', !date);
|
||||
}
|
||||
}
|
||||
|
||||
inpDueBtn?.addEventListener('click', async () => {
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const current = inpDueValue?.value || '';
|
||||
const result = await showDialog({
|
||||
title: current ? 'change due date' : 'set due date',
|
||||
input: 'date',
|
||||
defaultValue: current || new Date().toISOString().substring(0, 10),
|
||||
okLabel: current ? 'update' : 'set',
|
||||
});
|
||||
if (result === null) return;
|
||||
setDueDate(result || '');
|
||||
});
|
||||
|
||||
const inpDoneToggle = $('inp-done-toggle');
|
||||
const inpDoneLabel = $('inp-done-label');
|
||||
|
||||
function updateDoneToggle() {
|
||||
const isDone = body.value.includes('#done');
|
||||
if (inpDoneToggle) {
|
||||
inpDoneToggle.classList.toggle('text-t-accent', isDone);
|
||||
inpDoneToggle.classList.toggle('border-t-accent', isDone);
|
||||
inpDoneToggle.classList.toggle('text-t-dim', !isDone);
|
||||
}
|
||||
if (inpDoneLabel) inpDoneLabel.textContent = isDone ? 'undone' : 'done';
|
||||
}
|
||||
|
||||
inpDoneToggle?.addEventListener('click', () => {
|
||||
const isDone = body.value.includes('#done');
|
||||
if (isDone) {
|
||||
body.value = body.value.replace(/\s*#done\b/g, '');
|
||||
} else {
|
||||
body.value = body.value.trimEnd() + ' #done';
|
||||
setDueDate('');
|
||||
}
|
||||
updateDoneToggle();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
function selectColor(hex) {
|
||||
inpColor.value = hex;
|
||||
const bar = $('editor-color');
|
||||
@@ -137,6 +189,8 @@ export function openModal(card = null) {
|
||||
inpBucket.value = card?.bucket_id ? card.bucket_id
|
||||
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
|
||||
selectColor(card ? card.color : '#38bdf8');
|
||||
setDueDate(card?.due_date || '');
|
||||
updateDoneToggle();
|
||||
state.previewVisible = true;
|
||||
preview.classList.remove('hidden');
|
||||
editorDivider?.classList.remove('hidden');
|
||||
@@ -328,7 +382,7 @@ function updatePreview() {
|
||||
}
|
||||
|
||||
async function doSave(close, { animate = true } = {}) {
|
||||
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null };
|
||||
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null };
|
||||
if (!data.title.trim()) return;
|
||||
const returnToGallery = state.returnToGalleryAfterEdit;
|
||||
if (state.editingId) {
|
||||
@@ -975,7 +1029,7 @@ body.addEventListener('drop', e => {
|
||||
|
||||
document.addEventListener('mousedown', e => {
|
||||
const img = e.target.closest('.prose img');
|
||||
if (!img) return;
|
||||
if (!img || img.closest('.md-gallery-item')) return;
|
||||
e.preventDefault();
|
||||
img.classList.add('resizing');
|
||||
const startX = e.clientX, startW = img.offsetWidth;
|
||||
|
||||
@@ -13,7 +13,7 @@ function mediaTile(url, extraClass = '') {
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden">
|
||||
<img src="${url}" class="w-full h-full object-cover${extraClass.includes('danger') ? ' opacity-60' : ''}" loading="lazy">
|
||||
<img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
|
||||
</div>`;
|
||||
}
|
||||
import { state } from './state.js';
|
||||
|
||||
@@ -13,7 +13,7 @@ marked.use({ breaks: true, gfm: true, renderer: {
|
||||
|
||||
const purifyConfig = {
|
||||
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
|
||||
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel'],
|
||||
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'],
|
||||
ALLOW_DATA_ATTR: true,
|
||||
};
|
||||
|
||||
@@ -38,7 +38,48 @@ function normalizeListBlock(block) {
|
||||
}
|
||||
|
||||
export function renderMd(src) {
|
||||
let processed = String(src || '').replace(/\r\n?/g, '\n')
|
||||
let processed = String(src || '').replace(/\r\n?/g, '\n');
|
||||
|
||||
// Convert Unicode box-drawing tables to HTML
|
||||
processed = processed.replace(/([ \t]*[┌╔][\s\S]*?[┘╝][ \t]*)/g, (block) => {
|
||||
const lines = block.split('\n').filter(l => l.trim());
|
||||
const rows = [];
|
||||
let currentRow = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Skip border lines (┌─┬─┐, ├─┼─┤, └─┴─┘, ╔═╦═╗, etc.)
|
||||
if (/^[┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║\s]+$/.test(trimmed)) {
|
||||
if (currentRow) { rows.push(currentRow); currentRow = null; }
|
||||
continue;
|
||||
}
|
||||
// Data row: │ cell │ cell │ or ║ cell ║
|
||||
if (/^[│║]/.test(trimmed)) {
|
||||
const cells = trimmed.split(/[│║]/).slice(1, -1).map(c => c.trim());
|
||||
if (currentRow && currentRow.length === cells.length) {
|
||||
// Continuation line — append to previous cells
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (cells[i]) currentRow[i] = ((currentRow[i] || '') + ' ' + cells[i]).trim();
|
||||
}
|
||||
} else {
|
||||
if (currentRow) rows.push(currentRow);
|
||||
currentRow = cells;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentRow) rows.push(currentRow);
|
||||
if (rows.length < 2) return block;
|
||||
|
||||
const header = rows[0];
|
||||
const body = rows.slice(1);
|
||||
return '\n<table><thead><tr>' +
|
||||
header.map(c => `<th>${esc(c)}</th>`).join('') +
|
||||
'</tr></thead><tbody>' +
|
||||
body.map(r => '<tr>' + r.map(c => `<td>${esc(c)}</td>`).join('') + '</tr>').join('') +
|
||||
'</tbody></table>\n';
|
||||
});
|
||||
|
||||
processed = processed
|
||||
// Gallery fenced block: ```gallery
|
||||
.replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
|
||||
const imgs = [];
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// ── Notification banner system ──
|
||||
// Providers register via addProvider(fn). Each fn(settings) returns an array of items.
|
||||
|
||||
import { $, esc } from './util.js';
|
||||
|
||||
const banner = $('upcoming-banner');
|
||||
const providers = [];
|
||||
const DISMISS_KEY = 'hyprcrd-dismissed-notifs';
|
||||
const DISMISS_DATE_KEY = 'hyprcrd-dismissed-date';
|
||||
const SETTINGS_KEY = 'hyprcrd-notif-settings';
|
||||
|
||||
// Clear dismissed list daily
|
||||
if (localStorage.getItem(DISMISS_DATE_KEY) !== new Date().toISOString().substring(0, 10)) {
|
||||
localStorage.removeItem(DISMISS_KEY);
|
||||
localStorage.setItem(DISMISS_DATE_KEY, new Date().toISOString().substring(0, 10));
|
||||
}
|
||||
|
||||
function getDismissed() {
|
||||
try { return JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
|
||||
function dismiss(id) {
|
||||
const d = getDismissed();
|
||||
if (!d.includes(id)) d.push(id);
|
||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(d));
|
||||
render();
|
||||
}
|
||||
|
||||
export function getSettings() {
|
||||
const defaults = { lookahead: 3, time: 0, birthdays: true, termine: true, overdue: true };
|
||||
try { return { ...defaults, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') }; } catch { return defaults; }
|
||||
}
|
||||
|
||||
export function saveSettings(s) {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a notification provider.
|
||||
* @param {function} fn - Receives settings, returns array of { id, icon, iconColor, label, when, sort, onClick?, dismissable?, type? }
|
||||
*/
|
||||
export function addProvider(fn) {
|
||||
providers.push(fn);
|
||||
}
|
||||
|
||||
function getAllItems() {
|
||||
const settings = getSettings();
|
||||
const items = [];
|
||||
for (const fn of providers) {
|
||||
try {
|
||||
const result = fn(settings);
|
||||
if (Array.isArray(result)) items.push(...result);
|
||||
} catch {}
|
||||
}
|
||||
items.sort((a, b) => (a.sort || '').localeCompare(b.sort || ''));
|
||||
return items;
|
||||
}
|
||||
|
||||
export function render() {
|
||||
if (!banner) return;
|
||||
const dismissed = getDismissed();
|
||||
const visible = getAllItems().filter(i => !dismissed.includes(String(i.id)));
|
||||
|
||||
if (!visible.length) { banner.classList.add('hidden'); banner.innerHTML = ''; return; }
|
||||
|
||||
banner.classList.remove('hidden');
|
||||
banner.innerHTML = `<div class="flex items-center gap-2 px-6 py-2 bg-t-raised border-b border-t-border overflow-x-auto">
|
||||
<i class="ph ph-bell-ringing text-t-accent text-sm shrink-0"></i>
|
||||
${visible.map(i => `<div class="upcoming-item" data-notif-id="${i.id}" style="color:${i.iconColor || 'var(--t-text)'}">
|
||||
<i class="ph ph-${i.icon || 'info'}" style="font-size:11px;"></i>
|
||||
${i.when ? `<span class="font-pixel text-[10px]">${esc(i.when)}</span>` : ''}
|
||||
<span>${esc(i.label)}</span>
|
||||
${i.dismissable !== false ? `<button class="upcoming-dismiss" data-dismiss="${i.id}"><i class="ph ph-x text-xs"></i></button>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
|
||||
banner.querySelectorAll('[data-notif-id]').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
if (e.target.closest('.upcoming-dismiss')) return;
|
||||
const item = visible.find(i => String(i.id) === el.dataset.notifId);
|
||||
if (item?.onClick) item.onClick();
|
||||
});
|
||||
});
|
||||
banner.querySelectorAll('.upcoming-dismiss').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
dismiss(btn.dataset.dismiss);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Settings panel integration ──
|
||||
|
||||
export function renderSettingsPanel() {
|
||||
const s = getSettings();
|
||||
|
||||
// Populate controls
|
||||
const lookahead = $('notif-lookahead');
|
||||
if (lookahead) lookahead.value = String(s.lookahead);
|
||||
const time = $('notif-time');
|
||||
if (time) time.value = String(s.time);
|
||||
const cbBday = $('notif-birthdays');
|
||||
if (cbBday) cbBday.checked = s.birthdays;
|
||||
const cbTermin = $('notif-termine');
|
||||
if (cbTermin) cbTermin.checked = s.termine;
|
||||
const cbOverdue = $('notif-overdue');
|
||||
if (cbOverdue) cbOverdue.checked = s.overdue;
|
||||
|
||||
// Auto-save on change (use onchange to avoid stacking listeners)
|
||||
const save = () => saveSettings({
|
||||
lookahead: parseInt(lookahead?.value || '3'),
|
||||
time: parseInt(time?.value || '0'),
|
||||
birthdays: cbBday?.checked ?? true,
|
||||
termine: cbTermin?.checked ?? true,
|
||||
overdue: cbOverdue?.checked ?? true,
|
||||
});
|
||||
if (lookahead) lookahead.onchange = save;
|
||||
if (time) time.onchange = save;
|
||||
if (cbBday) cbBday.onchange = save;
|
||||
if (cbTermin) cbTermin.onchange = save;
|
||||
if (cbOverdue) cbOverdue.onchange = save;
|
||||
|
||||
// Restore dismissed — replace element to avoid duplicate listeners
|
||||
const restoreBtn = $('notif-clear-dismissed');
|
||||
if (restoreBtn) {
|
||||
const fresh = restoreBtn.cloneNode(true);
|
||||
restoreBtn.parentNode.replaceChild(fresh, restoreBtn);
|
||||
fresh.addEventListener('click', () => {
|
||||
localStorage.removeItem(DISMISS_KEY);
|
||||
render();
|
||||
renderStream();
|
||||
});
|
||||
}
|
||||
|
||||
renderStream();
|
||||
}
|
||||
|
||||
function renderStream() {
|
||||
const stream = $('notif-stream');
|
||||
if (!stream) return;
|
||||
const dismissed = getDismissed();
|
||||
const all = getAllItems();
|
||||
|
||||
if (!all.length) {
|
||||
stream.innerHTML = '<div class="text-xs text-t-dim font-pixel text-center py-4">no notifications</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
stream.innerHTML = all.map(i => {
|
||||
const isDismissed = dismissed.includes(String(i.id));
|
||||
return `<div class="flex items-center gap-2 px-2 py-1.5 rounded text-xs ${isDismissed ? 'opacity-40' : ''}" style="color:${i.iconColor || 'var(--t-text)'}">
|
||||
<i class="ph ph-${i.icon || 'info'}" style="font-size:11px;flex-shrink:0;"></i>
|
||||
${i.when ? `<span class="font-pixel text-[9px] text-t-dim" style="min-width:55px;">${esc(i.when)}</span>` : ''}
|
||||
<span class="flex-1 truncate">${esc(i.label)}</span>
|
||||
${isDismissed ? '<span class="font-pixel text-[8px] text-t-dim">dismissed</span>' : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const state = {
|
||||
activeBucket: 'all',
|
||||
activeTags: [],
|
||||
hideTermine: localStorage.getItem('hyprcrd-hide-termine') === '1',
|
||||
hideDone: localStorage.getItem('hyprcrd-hide-done') === '1',
|
||||
superView: localStorage.getItem('hyprcrd-superview') === '1',
|
||||
activeGroup: null,
|
||||
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
||||
@@ -45,6 +46,7 @@ export function tagChipHtml(tag) {
|
||||
}
|
||||
|
||||
function effectiveDate(c) {
|
||||
if (c.due_date) return c.due_date;
|
||||
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (m) return m[1];
|
||||
return (c.created_at || '').substring(0, 10);
|
||||
|
||||
+91
-15
@@ -1,25 +1,101 @@
|
||||
// ── Theme picker ──
|
||||
// ── Theme + Settings ──
|
||||
|
||||
import { $ } from './util.js';
|
||||
import { $, api, getCsrf } from './util.js';
|
||||
import { state } from './state.js';
|
||||
import { renderSettingsPanel as renderNotifSettings } from './notifications.js';
|
||||
|
||||
const picker = $('theme-picker');
|
||||
const list = $('theme-list');
|
||||
const overlay = $('settings-overlay');
|
||||
const themeList = $('settings-theme-list');
|
||||
|
||||
if (list) {
|
||||
// Render theme list in settings
|
||||
function renderThemes() {
|
||||
if (!themeList) return;
|
||||
const current = localStorage.getItem('hyprcrd-theme') || 'slate';
|
||||
themeList.innerHTML = '';
|
||||
for (const [key, t] of Object.entries(window.themes)) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'flex items-center gap-2.5 w-full px-2 py-1.5 text-left text-xs text-t-muted hover:text-t-bright transition-colors rounded';
|
||||
btn.dataset.themeDot = key;
|
||||
btn.innerHTML = `<span class="w-3 h-3 rounded-full shrink-0 border border-t-border" style="background:${t.vars['--t-accent']}"></span>
|
||||
<span class="font-pixel text-[10px]">${t.label}</span>`;
|
||||
btn.onclick = () => { window.applyTheme(key); window._renderBoard?.(); };
|
||||
list.appendChild(btn);
|
||||
const active = key === current;
|
||||
btn.className = `flex items-center gap-3 px-3 py-2.5 text-left text-xs border transition-colors rounded ${active ? 'border-t-accent text-t-bright' : 'border-t-border text-t-muted hover:border-t-accent hover:text-t-bright'}`;
|
||||
btn.innerHTML = `<span class="w-4 h-4 rounded-full shrink-0" style="background:${t.vars['--t-accent']}"></span>
|
||||
<span class="font-pixel text-[10px] flex-1">${t.label}</span>
|
||||
${active ? '<i class="ph ph-check text-t-accent text-sm"></i>' : ''}`;
|
||||
btn.onclick = () => { window.applyTheme(key); renderThemes(); window._renderBoard?.(); };
|
||||
themeList.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
window.applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
|
||||
// Tab switching
|
||||
overlay?.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
overlay.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
const panel = tab.dataset.tab;
|
||||
overlay.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
|
||||
$(`settings-panel-${panel}`)?.classList.remove('hidden');
|
||||
if (panel === 'sync') window._loadCaldavSettings?.();
|
||||
if (panel === 'notifications') renderNotifSettings();
|
||||
if (panel === 'behavior') initBehaviorSettings();
|
||||
});
|
||||
});
|
||||
|
||||
if (picker) {
|
||||
$('theme-toggle')?.addEventListener('click', () => picker.classList.toggle('open'));
|
||||
document.addEventListener('click', e => { if (!picker.contains(e.target)) picker.classList.remove('open'); });
|
||||
// Open/Close
|
||||
function updateStats() {
|
||||
const sc = $('stats-cards'); if (sc) sc.textContent = state.cards?.length || 0;
|
||||
const sb = $('stats-buckets'); if (sb) sb.textContent = state.buckets?.length || 0;
|
||||
const tags = new Set(); (state.cards || []).forEach(c => (c.tags || '').split(',').forEach(t => { if (t) tags.add(t); }));
|
||||
const st = $('stats-tags'); if (st) st.textContent = tags.size;
|
||||
}
|
||||
|
||||
$('settings-btn')?.addEventListener('click', () => {
|
||||
renderThemes();
|
||||
updateStats();
|
||||
overlay?.classList.add('open');
|
||||
});
|
||||
$('settings-close')?.addEventListener('click', () => overlay?.classList.remove('open'));
|
||||
overlay?.addEventListener('click', e => { if (e.target === overlay) overlay?.classList.remove('open'); });
|
||||
|
||||
// Password change
|
||||
$('settings-pw-save')?.addEventListener('click', async () => {
|
||||
const current = $('settings-pw-current')?.value;
|
||||
const newPw = $('settings-pw-new')?.value;
|
||||
const confirm = $('settings-pw-confirm')?.value;
|
||||
const status = $('settings-pw-status');
|
||||
|
||||
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
||||
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
||||
if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
||||
|
||||
const res = await fetch('api/auth/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify({ current, new: newPw }),
|
||||
}).then(r => r.json()).catch(() => ({ error: 'network error' }));
|
||||
|
||||
if (res.ok) {
|
||||
if (status) { status.textContent = res.rekey ? 'password changed — re-encrypting notes...' : 'password changed'; status.style.color = 'var(--t-accent)'; }
|
||||
$('settings-pw-current').value = '';
|
||||
$('settings-pw-new').value = '';
|
||||
$('settings-pw-confirm').value = '';
|
||||
// Trigger sync to re-push notes with new encryption key
|
||||
if (res.rekey) {
|
||||
window._triggerSync?.();
|
||||
setTimeout(() => { if (status) status.textContent = 'password changed, notes re-encrypted'; }, 3000);
|
||||
}
|
||||
} else {
|
||||
if (status) { status.textContent = res.error || 'failed'; status.style.color = 'var(--t-danger)'; }
|
||||
}
|
||||
});
|
||||
|
||||
function initBehaviorSettings() {
|
||||
const sel = $('setting-current-days');
|
||||
if (sel) {
|
||||
sel.value = localStorage.getItem('hyprcrd-current-days') || '2';
|
||||
sel.onchange = () => {
|
||||
localStorage.setItem('hyprcrd-current-days', sel.value);
|
||||
window._renderBoard?.();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Apply saved theme on load
|
||||
window.applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
|
||||
Reference in new issue
Block a user