Files
hyprcrd/public/index.php
T
thandClaude Opus 4.6 b7f450e41f autosync toggle, configurable sync interval, 30-day session
- header Sync button now has a paired autosync toggle to its right —
  reads as one button with no inner seam (outer border only). Dot inside
  pulses once per actual sync (bursts on every backgroundSync /
  triggerSync / collab pollOnce via a new sync.pulse hook) so the user
  can see polls happen. Dot + data-autosync attribute persist the
  enabled state in localStorage; paused state stops both the
  CalDAV/relay background timer and the collab poll via a broadcast
  autosync.changed hook.
- hover polish: Sync regains a full accent border (the missing inner
  edge re-appears only on its hover); the toggle fills with solid
  accent as hover treatment so the pair never shows a half-outline.
- sync interval moved out of hardcode into a settings field under
  Settings → Relay: number input, 5s..3600s, default 15s (was 45s on
  caldav / 30s on collab). Changes broadcast via sync.interval.changed
  and restart both timers without reload.
- session cookie lifetime raised to 30 days with matching gc_maxlifetime
  and SameSite=Lax (was 0 / Strict — some users got kicked to login on
  every reload when gc cleaned up the /tmp/sess file or a cross-link
  return blocked the cookie).
- search button's kbd element was pushing the button ~3px taller than
  Sync; leading-none + tighter padding puts them on the same height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:57:24 +02:00

2369 lines
177 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/CalDavClient.php';
require_once __DIR__ . '/../src/CalDavSync.php';
$db = Database::connect();
// ── 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');
header('Referrer-Policy: strict-origin-when-cross-origin');
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
// ── Session ──
//
// 30-day persistence: single-user local-first app, no reason to log the
// user out every 24 minutes (PHP's gc_maxlifetime default). Cookie +
// server-side GC are set to the same duration so the session survives
// reboots AND page reloads, up to a month of inactivity.
$sessionLifetime = 60 * 60 * 24 * 30; // 30 days
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
ini_set('session.cookie_lifetime', (string)$sessionLifetime);
session_set_cookie_params([
'lifetime' => $sessionLifetime,
'path' => '/',
'httponly' => true,
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Lax', // Strict was blocking some cross-link returns; Lax still blocks CSRF
]);
session_start();
// CSRF token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// ── Auth ──
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$basePath = dirname($_SERVER['SCRIPT_NAME']);
$route = ($basePath === '/' || $basePath === '\\') ? $path : str_replace($basePath, '', $path);
$pwHash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
$isSetup = !$pwHash;
$isLoggedIn = !empty($_SESSION['hyprcrd_auth']);
// Auth API endpoints (always accessible)
if (str_starts_with($route, '/api/auth')) {
error_reporting(0);
header('Content-Type: application/json');
// GET /api/auth/status
if ($route === '/api/auth/status' && $method === 'GET') {
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]);
exit;
}
// POST /api/auth/setup — set initial password
if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) {
$input = json_decode(file_get_contents('php://input'), true);
$pw = $input['password'] ?? '';
if (strlen($pw) < 4) {
http_response_code(400);
echo json_encode(['error' => 'password too short (min 4)']);
exit;
}
$hash = password_hash($pw, PASSWORD_BCRYPT);
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('password_hash', :hash)")->execute(['hash' => $hash]);
session_regenerate_id(true);
$_SESSION['hyprcrd_auth'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
exit;
}
// POST /api/auth/login (rate limited)
if ($route === '/api/auth/login' && $method === 'POST') {
$attempts = $_SESSION['login_attempts'] ?? 0;
$lastAttempt = $_SESSION['login_last'] ?? 0;
if ($attempts >= 5 && time() - $lastAttempt < 60) {
http_response_code(429);
echo json_encode(['error' => 'too many attempts, wait 60s']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$pw = $input['password'] ?? '';
if ($pwHash && password_verify($pw, $pwHash)) {
session_regenerate_id(true);
$_SESSION['hyprcrd_auth'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$_SESSION['login_attempts'] = 0;
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
} else {
$_SESSION['login_attempts'] = $attempts + 1;
$_SESSION['login_last'] = time();
usleep(300000); // 300ms delay against timing attacks
http_response_code(401);
echo json_encode(['error' => 'wrong password']);
}
exit;
}
// POST /api/auth/logout
if ($route === '/api/auth/logout' && $method === 'POST') {
$_SESSION = [];
session_destroy();
echo json_encode(['ok' => true]);
exit;
}
// GET/POST /api/auth/vault-key
if ($route === '/api/auth/vault-key' && $method === 'GET') {
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
echo json_encode(['has_key' => !empty($vk)]); exit;
}
if ($route === '/api/auth/vault-key' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$newKey = $input['key'] ?? '';
if (strlen($newKey) < 8) { http_response_code(400); echo json_encode(['error' => 'min 8 characters']); exit; }
$oldVk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
$oldKey = $oldVk ? substr(hash('sha256', $oldVk), 0, 32) : null;
if (!$oldKey) {
// Fallback: try old password-hash-based key
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
$oldKey = substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
$newKeyDerived = substr(hash('sha256', $newKey), 0, 32);
// Re-encrypt CalDAV credentials
$credKeys = ['caldav_url', 'caldav_username', 'caldav_password'];
foreach ($credKeys as $ck) {
$enc = $db->query("SELECT value FROM settings WHERE key = '{$ck}'")->fetchColumn();
if ($enc) {
$raw = base64_decode($enc);
if (strlen($raw) >= 17) {
$iv = substr($raw, 0, 16);
$cipher = base64_encode(substr($raw, 16));
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $oldKey, 0, $iv);
if ($plain !== false) {
$newIv = random_bytes(16);
$newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKeyDerived, 0, $newIv);
$db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => base64_encode($newIv . base64_decode($newCipher)), 'k' => $ck]);
}
}
}
}
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('vault_key', :v)")->execute(['v' => $newKey]);
echo json_encode(['ok' => true, 'rekey' => true]); 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;
}
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
$hasVaultKey = !empty($db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn());
if ($hasVaultKey) {
// Vault key handles encryption — just change the password
$db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]);
echo json_encode(['ok' => true]);
} else {
// Legacy: re-encrypt credentials with new password hash
$oldKey = substr(hash('sha256', $hash), 0, 32);
$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;
$plain = openssl_decrypt(base64_encode(substr($raw, 16)), 'aes-256-cbc', $oldKey, 0, substr($raw, 0, 16));
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' => 'set a vault key first to avoid encryption issues']); exit; }
$db->beginTransaction();
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();
echo json_encode(['ok' => true, 'rekey' => true]);
}
exit;
}
http_response_code(404);
echo json_encode(['error' => 'not found']);
exit;
}
// All other API/page requests require auth (unless setup mode)
if (!$isSetup && !$isLoggedIn) {
if (str_starts_with($route, '/api/')) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'unauthorized']);
exit;
}
// For page requests, still serve the HTML shell — JS will show login dialog
}
if (str_starts_with($route, '/api/')) {
error_reporting(0);
header('Content-Type: application/json');
// CSRF check on all state-changing requests
if (in_array($method, ['POST', 'PATCH', 'DELETE'])) {
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) {
http_response_code(403);
echo json_encode(['error' => 'invalid csrf token']);
exit;
}
}
// Input sanitization
function validColor($c) { return preg_match('/^#[0-9a-fA-F]{6}$/', $c ?? '') ? $c : '#ffffff'; }
function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; }
function extractTags($body) {
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches);
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
}
// Plugin route handlers (registered by plugin backend.php files).
// Each handler is responsible for calling exit() on a match.
PluginManager::instance()->dispatchRoute($route, $method);
// Cards
if ($route === '/api/cards' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$title = trim($input['title'] ?? '');
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
$pos = $input['position'] ?? 0;
$body = $input['body'] ?? '';
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
'parent_id' => validInt($input['parent_id'] ?? null, null),
'position' => validInt($pos),
'tags' => extractTags($body),
'due_date' => $input['due_date'] ?? null,
'scratchpad' => $input['scratchpad'] ?? null,
]);
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 WHERE c.id = ' . $db->lastInsertId())->fetch());
exit;
}
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, c.relay_uid, 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');
$card->execute(['id' => $m[1]]);
$uid = $card->fetchColumn();
if ($uid) {
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$uid, $m[1]]);
}
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
}
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
$stmt = $db->prepare('SELECT field, old_value, new_value, changed_at FROM card_history WHERE card_id = :id ORDER BY changed_at DESC LIMIT 100');
$stmt->execute(['id' => $m[1]]);
echo json_encode($stmt->fetchAll()); exit;
}
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
// Snapshot changed fields before update
$tracked = ['title', 'body', 'color', 'bucket_id', 'due_date'];
$changedTracked = array_intersect($tracked, array_keys($input));
if ($changedTracked) {
$old = $db->prepare('SELECT * FROM cards WHERE id = :id'); $old->execute(['id' => $m[1]]); $old = $old->fetch();
if ($old) {
$histStmt = $db->prepare('INSERT INTO card_history (card_id, field, old_value, new_value) VALUES (:cid, :f, :o, :n)');
foreach ($changedTracked as $f) {
$oldVal = $old[$f] ?? null; $newVal = $input[$f] ?? null;
if ($f === 'body') { $oldVal = mb_substr($oldVal ?? '', 0, 500); $newVal = mb_substr($newVal ?? '', 0, 500); }
if ((string)$oldVal !== (string)$newVal) {
$histStmt->execute(['cid' => $m[1], 'f' => $f, 'o' => $oldVal, 'n' => $newVal]);
}
}
}
}
$fields = []; $params = ['id' => $m[1]];
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] 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']); }
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
$card = $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 = :id');
$card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit;
}
// Plugins
if ($route === '/api/plugins' && $method === 'GET') {
echo json_encode(PluginManager::instance()->pluginsForApi()); exit;
}
if ($route === '/api/plugins/toggle' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$name = trim((string)($input['name'] ?? ''));
$enabled = !empty($input['enabled']);
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
$pm = PluginManager::instance();
$known = array_keys($pm->plugins());
if (!in_array($name, $known, true)) {
http_response_code(404); echo json_encode(['error' => 'plugin not found']); exit;
}
$disabled = $pm->disabledList();
if ($enabled) {
$disabled = array_values(array_diff($disabled, [$name]));
} else if (!in_array($name, $disabled, true)) {
$disabled[] = $name;
}
if (!$pm->setDisabled($disabled)) {
http_response_code(500); echo json_encode(['error' => 'failed to write state']); exit;
}
echo json_encode(['ok' => true, 'plugins' => $pm->pluginsForApi()]); exit;
}
// Buckets
if ($route === '/api/buckets' && $method === 'GET') {
echo json_encode($db->query('SELECT * FROM buckets ORDER BY position ASC, created_at ASC')->fetchAll()); exit;
}
if ($route === '/api/buckets' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$name = trim($input['name'] ?? '');
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; }
// Normal POST creates a local bucket. Collab buckets go through
// /api/collab-buckets (plugin-owned) and can't be created here.
$db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]);
echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit;
}
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
// Collab buckets: name and color must go through the relay plugin so
// all members see the update. Only position (local reorder) is allowed.
$b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id');
$b->execute(['id' => $m[1]]);
$existing = $b->fetch();
$isCollab = $existing && !empty($existing['collab_uid']);
$allowed = $isCollab ? ['position'] : ['name', 'position', 'color', 'parent_id'];
// Super-bucket depth + cycle enforcement: a bucket may only become a
// child if (a) it has no children itself, (b) the prospective parent
// is top-level (parent_id IS NULL), and (c) self-parent is forbidden.
if (!$isCollab && array_key_exists('parent_id', $input)) {
$newParent = $input['parent_id'];
if ($newParent !== null && (int)$newParent !== 0) {
$newParent = (int)$newParent;
if ($newParent === (int)$m[1]) {
http_response_code(400); echo json_encode(['error' => 'cannot parent to self']); exit;
}
$h = $db->prepare('SELECT COUNT(*) FROM buckets WHERE parent_id = :id');
$h->execute(['id' => $m[1]]);
if ((int)$h->fetchColumn() > 0) {
http_response_code(400); echo json_encode(['error' => 'this bucket has children — flatten it first']); exit;
}
$p = $db->prepare('SELECT parent_id FROM buckets WHERE id = :id');
$p->execute(['id' => $newParent]);
$parentRow = $p->fetch();
if (!$parentRow) { http_response_code(400); echo json_encode(['error' => 'parent not found']); exit; }
if ($parentRow['parent_id'] !== null) {
http_response_code(400); echo json_encode(['error' => 'cannot nest under a child bucket']); exit;
}
} else {
$input['parent_id'] = null;
}
}
$fields = []; $params = ['id' => $m[1]];
foreach ($allowed as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
$b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit;
}
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') {
// Collab buckets must go through /api/collab-buckets/{uid} so the relay
// and plugin mirror stay consistent. Refuse deletion here.
$b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id');
$b->execute(['id' => $m[1]]);
$existing = $b->fetch();
if ($existing && !empty($existing['collab_uid'])) {
http_response_code(400);
echo json_encode(['error' => 'collab buckets must be deleted via /api/collab-buckets/' . $existing['collab_uid']]);
exit;
}
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]);
// If this was a super-bucket parent, un-nest its children back to top-level
// instead of cascade-deleting them.
$db->prepare('UPDATE buckets SET parent_id = NULL WHERE parent_id = :id')->execute(['id' => $m[1]]);
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
}
// List all upload files
if ($route === '/api/uploads' && $method === 'GET') {
$dir = UPLOAD_DIR;
$files = [];
if (is_dir($dir)) {
foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) {
$name = basename($f);
$files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)];
}
}
usort($files, fn($a, $b) => $b['modified'] - $a['modified']);
echo json_encode($files);
exit;
}
// Delete an upload file
if (preg_match('#^/api/uploads/([a-f0-9]+\.\w+)$#', $route, $m) && $method === 'DELETE') {
$file = UPLOAD_DIR . '/' . basename($m[1]);
if (is_file($file)) {
unlink($file);
echo json_encode(['ok' => true]);
} else {
http_response_code(404);
echo json_encode(['error' => 'file not found']);
}
exit;
}
// Upload (max 10MB, real MIME check)
if ($route === '/api/upload' && $method === 'POST') {
$uploadDir = UPLOAD_DIR;
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'no file', 'debug_files' => array_keys($_FILES), 'debug_method' => $method, 'debug_ct' => $_SERVER['CONTENT_TYPE'] ?? 'none']);
exit;
}
$file = $_FILES['file'];
if ($file['size'] > 10 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['error' => 'file too large (max 10MB)']);
exit;
}
// Check MIME — finfo first, fallback to extension for audio
$mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg', 'application/pdf' => 'pdf'];
$extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a', 'pdf' => 'pdf'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file['tmp_name']);
$ext = $mimeMap[$realMime] ?? null;
// Fallback: trust extension for audio when finfo returns octet-stream
if (!$ext && ($realMime === 'application/octet-stream' || str_starts_with($realMime, 'audio/'))) {
$origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$ext = $extMap[$origExt] ?? null;
}
if (!$ext) {
http_response_code(400);
echo json_encode(['error' => 'invalid file type', 'detected' => $realMime]);
exit;
}
$name = bin2hex(random_bytes(16)) . '.' . $ext;
$dest = $uploadDir . '/' . $name;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['error' => 'upload failed']);
exit;
}
http_response_code(201);
echo json_encode(['url' => 'uploads/' . $name, 'name' => $name]);
exit;
}
// Export — Markdown vault (Obsidian / Logseq compatible)
if ($route === '/api/export-md' && $method === 'GET') {
$ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null;
if ($ids) {
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id");
$stmt->execute($ids);
$cards = $stmt->fetchAll();
} else {
$cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll();
}
$allCards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll();
$buckets = $db->query('SELECT * FROM buckets ORDER BY position, id')->fetchAll();
$bucketMap = [];
foreach ($buckets as $b) $bucketMap[$b['id']] = $b['name'];
$cardMap = [];
foreach ($allCards as $c) $cardMap[$c['id']] = $c;
// Sanitise filename
$safe = function ($name) {
$name = preg_replace('/[<>:"\/\\\\|?*]/', '_', $name);
return trim($name) ?: 'untitled';
};
$tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_md_');
$zip = new ZipArchive();
$zip->open($tmp, ZipArchive::OVERWRITE);
$usedNames = [];
$allBodies = '';
foreach ($cards as $c) {
// Build folder path: bucket/group/ (skip folders for single-card export)
$folder = '';
if (!$ids || count($ids) > 1) {
if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) {
$folder = $safe($bucketMap[$c['bucket_id']]) . '/';
}
if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) {
$folder .= $safe($cardMap[$c['parent_id']]['title']) . '/';
}
}
$baseName = $safe($c['title']);
$fileName = $folder . $baseName . '.md';
// Deduplicate
$n = 1;
while (isset($usedNames[$fileName])) {
$fileName = $folder . $baseName . ' ' . (++$n) . '.md';
}
$usedNames[$fileName] = true;
// YAML frontmatter
$fm = "---\n";
$fm .= 'id: ' . $c['id'] . "\n";
$fm .= 'color: "' . $c['color'] . "\"\n";
if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) {
$fm .= 'bucket: "' . addcslashes($bucketMap[$c['bucket_id']], '"') . "\"\n";
}
if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) {
$fm .= 'parent: "' . addcslashes($cardMap[$c['parent_id']]['title'], '"') . "\"\n";
}
$fm .= 'created: ' . $c['created_at'] . "\n";
$fm .= 'updated: ' . $c['updated_at'] . "\n";
if (!empty($c['tags'])) {
$fm .= 'tags: [' . implode(', ', array_map(fn($t) => '"' . trim($t) . '"', explode(',', $c['tags']))) . "]\n";
}
$fm .= "---\n\n";
// Body: rewrite upload paths to attachments/
$body = $c['body'] ?? '';
$allBodies .= "\n" . $body;
$body = preg_replace('#uploads/#', 'attachments/', $body);
$zip->addFromString($fileName, $fm . '# ' . $c['title'] . "\n\n" . $body);
}
// Copy referenced uploads → attachments/
$uploadDir = UPLOAD_DIR;
if (is_dir($uploadDir)) {
foreach (glob($uploadDir . '/*') as $f) {
$name = basename($f);
if (is_file($f) && (!$ids || str_contains($allBodies, $name))) {
$zip->addFile($f, 'attachments/' . $name);
}
}
}
$zip->close();
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="hyprcrd-vault-' . date('Y-m-d') . '.zip"');
header('Content-Length: ' . filesize($tmp));
readfile($tmp);
unlink($tmp);
exit;
}
// Export — full or selective
if ($route === '/api/export' && $method === 'GET') {
$ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null;
if ($ids) {
$ph = implode(',', array_fill(0, count($ids), '?'));
$cards = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id")->execute($ids) ? $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id") : null;
// Re-query properly
$stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id");
$stmt->execute($ids);
$cards = $stmt->fetchAll();
$bucketIds = array_unique(array_filter(array_column($cards, 'bucket_id')));
$buckets = [];
if ($bucketIds) {
$bph = implode(',', array_fill(0, count($bucketIds), '?'));
$bstmt = $db->prepare("SELECT * FROM buckets WHERE id IN ($bph)");
$bstmt->execute(array_values($bucketIds));
$buckets = $bstmt->fetchAll();
}
} else {
$cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll();
$buckets = $db->query('SELECT * FROM buckets ORDER BY id')->fetchAll();
}
$tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_');
$zip = new ZipArchive();
$zip->open($tmp, ZipArchive::OVERWRITE);
$zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Only include referenced uploads
$uploadDir = UPLOAD_DIR;
$allBodies = implode("\n", array_column($cards, 'body'));
if (is_dir($uploadDir)) {
foreach (glob($uploadDir . '/*') as $f) {
$name = basename($f);
if (is_file($f) && (!$ids || str_contains($allBodies, $name))) {
$zip->addFile($f, 'uploads/' . $name);
}
}
}
$zip->close();
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="hyprcrd-export-' . date('Y-m-d') . '.zip"');
header('Content-Length: ' . filesize($tmp));
readfile($tmp);
unlink($tmp);
exit;
}
// Import — ZIP with JSON dump + uploads
if ($route === '/api/import' && $method === 'POST') {
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'no file']); exit;
}
$zip = new ZipArchive();
if ($zip->open($_FILES['file']['tmp_name']) !== true) {
http_response_code(400);
echo json_encode(['error' => 'invalid zip']); exit;
}
$json = $zip->getFromName('data.json');
if (!$json) {
$zip->close();
http_response_code(400);
echo json_encode(['error' => 'no data.json in archive']); exit;
}
$data = json_decode($json, true);
if (!$data || !isset($data['buckets']) || !isset($data['cards'])) {
$zip->close();
http_response_code(400);
echo json_encode(['error' => 'invalid data.json']); exit;
}
$merge = !empty($data['selective']) || !empty($_GET['merge']);
if (!$merge) {
$db->exec('DELETE FROM cards');
$db->exec('DELETE FROM buckets');
}
// Import buckets (merge: match by name, create if missing)
$bucketMap = [];
foreach ($data['buckets'] as $b) {
$oldId = $b['id'];
if ($merge) {
$existing = $db->prepare('SELECT id FROM buckets WHERE name = :name');
$existing->execute(['name' => $b['name']]);
$found = $existing->fetchColumn();
if ($found) { $bucketMap[$oldId] = $found; continue; }
}
$stmt = $db->prepare('INSERT INTO buckets (name, position, color, collab_uid, created_at) VALUES (:name, :position, :color, :collab_uid, :created_at)');
$stmt->execute([
'name' => $b['name'],
'position' => $b['position'] ?? 0,
'color' => $b['color'] ?? '#64748b',
'collab_uid' => $b['collab_uid'] ?? null,
'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s'),
]);
$bucketMap[$oldId] = $db->lastInsertId();
}
// Second pass: resolve parent_id (old id → new id) now that all
// buckets have been inserted and the map is complete.
foreach ($data['buckets'] as $b) {
if (!empty($b['parent_id']) && isset($bucketMap[$b['id']]) && isset($bucketMap[$b['parent_id']])) {
$db->prepare('UPDATE buckets SET parent_id = :p WHERE id = :id')
->execute(['p' => $bucketMap[$b['parent_id']], 'id' => $bucketMap[$b['id']]]);
}
}
// Import cards (preserve all fields including CalDAV sync state)
$cardMap = []; // old id → new id
foreach ($data['cards'] as $c) {
$oldId = $c['id'] ?? null;
$bucketId = ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) ? $bucketMap[$c['bucket_id']] : null;
$stmt = $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags, due_date, caldav_uid, caldav_etag, caldav_href, caldav_synced_at, caldav_rrule, relay_uid, created_at, updated_at) VALUES (:title, :body, :color, :bucket_id, :position, :tags, :due_date, :caldav_uid, :caldav_etag, :caldav_href, :caldav_synced_at, :caldav_rrule, :relay_uid, :created_at, :updated_at)');
$stmt->execute([
'title' => $c['title'], 'body' => $c['body'], 'color' => $c['color'],
'bucket_id' => $bucketId, 'position' => $c['position'] ?? 0,
'tags' => $c['tags'] ?? '', 'due_date' => $c['due_date'] ?? null,
'caldav_uid' => $c['caldav_uid'] ?? null, 'caldav_etag' => $c['caldav_etag'] ?? null,
'caldav_href' => $c['caldav_href'] ?? null, 'caldav_synced_at' => $c['caldav_synced_at'] ?? null,
'caldav_rrule' => $c['caldav_rrule'] ?? null, 'relay_uid' => $c['relay_uid'] ?? null,
'created_at' => $c['created_at'] ?? date('Y-m-d H:i:s'),
'updated_at' => $c['updated_at'] ?? date('Y-m-d H:i:s'),
]);
if ($oldId) $cardMap[$oldId] = $db->lastInsertId();
}
// Resolve parent_id references (old id → new id)
foreach ($data['cards'] as $c) {
if (!empty($c['parent_id']) && isset($cardMap[$c['id']]) && isset($cardMap[$c['parent_id']])) {
$db->prepare('UPDATE cards SET parent_id = ? WHERE id = ?')
->execute([$cardMap[$c['parent_id']], $cardMap[$c['id']]]);
}
}
// Extract uploads
$uploadDir = UPLOAD_DIR;
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (str_starts_with($name, 'uploads/') && $name !== 'uploads/') {
$content = $zip->getFromIndex($i);
if ($content !== false) {
file_put_contents($uploadDir . '/' . basename($name), $content);
}
}
}
$zip->close();
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards'])]);
exit;
}
// CalDAV endpoints
$caldav = new CalDavSync($db);
if ($route === '/api/caldav/settings' && $method === 'GET') {
echo json_encode($caldav->getSettings()); exit;
}
if ($route === '/api/caldav/settings' && $method === 'POST') {
$caldav->saveSettings(json_decode(file_get_contents('php://input'), true) ?? []);
echo json_encode(['ok' => true]); exit;
}
if ($route === '/api/caldav/test' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
echo json_encode($caldav->testConnection($input['url'] ?? null, $input['username'] ?? null, $input['password'] ?? null)); exit;
}
if ($route === '/api/caldav/sync' && $method === 'POST') {
echo json_encode($caldav->sync()); exit;
}
// ── Relay ──
if ($route === '/api/relay/settings' && $method === 'GET') {
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
$username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '';
$key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '';
// Single-user local app — echo the stored secret back so the user can
// see and edit it directly without guessing if it's still saved.
echo json_encode(['url' => $url, 'username' => $username, 'key' => $key]); exit;
}
if ($route === '/api/relay/settings' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]);
// Always overwrite key when present (even empty) — single-user local
// app, the input is now pre-populated so an empty submit is intentional
if (array_key_exists('key', $input)) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
echo json_encode(['ok' => true]); exit;
}
if ($route === '/api/relay/poll' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$strict = !empty($input['strict']); // test button sets this; background poll doesn't
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) {
if ($strict) {
$missing = [];
if (!$relayUrl) $missing[] = 'url';
if (!$relayUser) $missing[] = 'username';
if (!$relayKey) $missing[] = 'secret';
echo json_encode(['ok' => false, 'error' => 'relay not configured: missing ' . implode(', ', $missing)]); exit;
}
echo json_encode(['ok' => true, 'pulled' => 0]); exit;
}
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
$relayMsg = '';
$decoded = json_decode($response, true);
if (is_array($decoded) && !empty($decoded['error'])) {
$relayMsg = ' — ' . $decoded['error'];
} elseif (is_string($response) && $response !== '') {
$relayMsg = ' — ' . substr($response, 0, 200);
}
echo json_encode(['ok' => false, 'error' => "HTTP $status$relayMsg", 'sent_username' => $relayUser]);
exit;
}
$messages = json_decode($response, true);
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
$pulled = 0;
$encKey = substr(hash('sha256', $relayKey), 0, 32);
$decryptField = function($text) use ($encKey) {
if (!str_starts_with($text, 'HYPRCRD-RELAY-V1')) return $text;
$raw = base64_decode(trim(explode("\n", $text, 2)[1] ?? ''));
if (strlen($raw) < 17) return $text;
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
return $plain !== false ? $plain : $text;
};
// Load IDs we pushed ourselves
$ownIds = array_column($db->query('SELECT relay_msg_id FROM relay_pushed')->fetchAll(), 'relay_msg_id');
foreach ($messages as $msg) {
if ($msg['fetched'] ?? false) continue;
if (in_array($msg['id'] ?? 0, $ownIds)) {
$db->prepare('DELETE FROM relay_pushed WHERE relay_msg_id = ?')->execute([$msg['id']]);
continue;
}
$title = $decryptField($msg['title'] ?? '');
$body = $decryptField($msg['body'] ?? '');
if (!$title) $title = substr(strip_tags($body), 0, 60) ?: 'Relay message';
$body = trim($body);
$relayUid = $msg['relay_uid'] ?? null;
// Check for existing card with this relay_uid
$existingCard = null;
if ($relayUid) {
$stmt = $db->prepare('SELECT id FROM cards WHERE relay_uid = ?');
$stmt->execute([$relayUid]);
$existingCard = $stmt->fetch();
}
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
if ($existingCard) {
// Update existing card
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
$db->prepare('UPDATE cards SET title = ?, body = ?, color = ?, tags = ?, updated_at = datetime("now") WHERE id = ?')
->execute([$title, $body, $msg['color'] ?? '#38bdf8', $tags, $existingCard['id']]);
} else {
// New card
if (!str_contains($body, '#inbox')) $body .= "\n\n#inbox";
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
$db->prepare('INSERT INTO cards (title, body, color, tags, position, relay_uid) VALUES (?, ?, ?, ?, -1, ?)')
->execute([$title, $body, $msg['color'] ?? '#38bdf8', $tags, $relayUid]);
}
$pulled++;
// Fire a sync-event notification to the sender so their hyprcrd
// surfaces a toast. Best-effort — an older relay may not have
// /api/sync-events, and we don't want to fail the whole poll.
$senderName = $msg['sender_username'] ?? '';
if ($senderName && $senderName !== $relayUser) {
$eventBody = $relayUser . ' synced: ' . $title;
$bch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
curl_setopt_array($bch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $eventBody]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $relayUser,
'X-Relay-Key: ' . $relayKey,
],
CURLOPT_TIMEOUT => 5,
]);
$bRes = curl_exec($bch);
$bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE);
curl_close($bch);
@error_log('[hyprcrd] sync-event POST → ' . $senderName
. ' status=' . $bStat
. ' body=' . substr((string)$bRes, 0, 120));
}
// Download attachments
if ($relayUid) {
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
curl_setopt_array($ach, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
$attResponse = curl_exec($ach);
$attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE);
curl_close($ach);
if ($attStatus === 200) {
$attachments = json_decode($attResponse, true);
$uploadDir = UPLOAD_DIR;
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
foreach ($attachments ?? [] as $att) {
$fn = basename($att['filename']);
if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue;
$dest = $uploadDir . '/' . $fn;
if (file_exists($dest)) continue;
// New format: HYPRCRD-RELAY-V1 envelope (decrypt-reencrypted by relay)
// Old format: raw base64(iv || cipher)
$payload = $att['data'] ?? '';
if (str_starts_with($payload, 'HYPRCRD-RELAY-V1')) {
$b64 = trim(substr($payload, strlen('HYPRCRD-RELAY-V1') + 1));
$raw = base64_decode($b64);
} else {
$raw = base64_decode($payload);
}
if (strlen($raw) >= 17) {
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($decrypted !== false) {
$fileBytes = base64_decode($decrypted);
if ($fileBytes !== false) $decrypted = $fileBytes;
file_put_contents($dest, $decrypted);
}
}
}
}
}
}
// Drain pending sync-event notifications so the JS caller can toast
// them. GET /api/sync-events on the relay auto-marks fetched events.
// Best-effort — older relays won't have the endpoint.
$syncEvents = [];
$syncDebug = ['requested' => true];
$sch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
curl_setopt_array($sch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 5,
]);
$sResp = curl_exec($sch);
$sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE);
curl_close($sch);
$syncDebug['status'] = $sStatus;
if ($sStatus === 200) {
$decoded = json_decode($sResp, true);
if (is_array($decoded) && isset($decoded['events'])) {
$syncEvents = $decoded['events'];
$syncDebug['count'] = count($syncEvents);
} else {
$syncDebug['parse_error'] = substr((string)$sResp, 0, 200);
}
} else {
$syncDebug['body'] = substr((string)$sResp, 0, 200);
}
echo json_encode(['ok' => true, 'pulled' => $pulled, 'sync_events' => $syncEvents, 'sync_debug' => $syncDebug]); exit;
}
// List relay tenants (for the recipient picker)
if ($route === '/api/relay/tenants' && $method === 'GET') {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) {
echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit;
}
$ch = curl_init(rtrim($relayUrl, '/') . '/api/tenants');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
echo json_encode(['ok' => false, 'error' => "relay returned HTTP $status"]); exit;
}
$data = json_decode($response, true);
echo json_encode([
'ok' => true,
'tenants' => $data['tenants'] ?? [],
'self' => $data['self'] ?? $relayUser,
]);
exit;
}
// Push a card to relay
if ($route === '/api/relay/push' && $method === 'POST') {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) { echo json_encode(['ok' => false, 'error' => 'relay not configured (need url, username, and secret)']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$cardId = $input['card_id'] ?? null;
$recipient = isset($input['recipient']) ? trim((string)$input['recipient']) : null;
if (!$cardId) { http_response_code(400); echo json_encode(['error' => 'card_id required']); exit; }
$card = $db->prepare('SELECT * FROM cards WHERE id = ?');
$card->execute([$cardId]);
$card = $card->fetch();
if (!$card) { http_response_code(404); echo json_encode(['error' => 'card not found']); exit; }
// Generate relay_uid if not set
$relayUid = $card['relay_uid'] ?? null;
if (!$relayUid) {
$relayUid = bin2hex(random_bytes(16));
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]);
}
$encKey = substr(hash('sha256', $relayKey), 0, 32);
$encryptField = function($text) use ($encKey) {
$iv = random_bytes(16);
$cipher = openssl_encrypt($text, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
};
$payloadData = [
'title' => $encryptField($card['title'] ?? ''),
'body' => $encryptField($card['body'] ?? ''),
'color' => $card['color'] ?? '#38bdf8',
'tags' => $card['tags'] ?? '',
'relay_uid' => $relayUid,
];
if ($recipient) $payloadData['recipient'] = $recipient;
$payload = json_encode($payloadData);
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
$debugStream = fopen('php://temp', 'w+');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
CURLOPT_VERBOSE => true,
CURLOPT_STDERR => $debugStream,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
rewind($debugStream);
$curlDebug = stream_get_contents($debugStream);
fclose($debugStream);
curl_close($ch);
if ($status === 200 || $status === 201) {
$resData = json_decode($response, true);
if (!empty($resData['id'])) {
$db->prepare("INSERT OR IGNORE INTO relay_pushed (relay_msg_id) VALUES (?)")->execute([$resData['id']]);
}
// Upload attachments (images referenced in body).
// Same envelope format as title/body so the relay can decrypt with
// the sender's secret and re-encrypt with the recipient's secret.
$cardBody = $card['body'] ?? '';
preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $cardBody, $attMatches);
$uploadDir = UPLOAD_DIR;
foreach (array_unique($attMatches[1] ?? []) as $filename) {
$filePath = $uploadDir . '/' . $filename;
if (!file_exists($filePath)) continue;
$raw = file_get_contents($filePath);
$envelope = $encryptField($raw);
$attData = [
'relay_uid' => $relayUid,
'filename' => $filename,
'data' => $envelope,
];
if ($recipient) $attData['recipient'] = $recipient;
$attPayload = json_encode($attData);
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments');
curl_setopt_array($ach, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $attPayload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ach);
curl_close($ach);
}
echo json_encode(['ok' => true]); exit;
}
// Surface the relay's actual error message so the user can see why
$relayMsg = '';
$decoded = json_decode($response, true);
if (is_array($decoded) && !empty($decoded['error'])) {
$relayMsg = ' — ' . $decoded['error'];
} elseif (is_string($response) && $response !== '') {
$relayMsg = ' — ' . substr($response, 0, 200);
}
// Strip the X-Relay-Key value from the debug log so we don't leak the secret
$safeDebug = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
echo json_encode([
'ok' => false,
'error' => "relay returned HTTP $status$relayMsg",
'sent_username' => $relayUser,
'relay_url' => rtrim($relayUrl, '/') . '/api/messages',
'response_body' => is_string($response) ? substr($response, 0, 500) : null,
'curl_debug' => $safeDebug,
]);
exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
}
// ── HTML Shell ──
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hyprcrd</title>
<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?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() ?>">
<?= PluginManager::instance()->cssLinks() ?>
<script>
window.themes = {
slate: { label:'Slate', vars:{ '--t-bg':'#1e293b','--t-surface':'#273349','--t-raised':'#2e3d56','--t-border':'#384a64','--t-dim':'#64748b','--t-muted':'#94a3b8','--t-text':'#cbd5e1','--t-bright':'#f1f5f9','--t-accent':'#38bdf8','--t-accent2':'#818cf8','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.04)','--t-overlay':'rgba(15,23,42,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(15,23,42,0.22) 0%, rgba(15,23,42,0.7) 62%, rgba(15,23,42,0.86) 100%)' }},
phosphor: { label:'Phosphor', vars:{ '--t-bg':'#0a0f0a','--t-surface':'#0f1a0f','--t-raised':'#142214','--t-border':'#1a3a1a','--t-dim':'#2d5a2d','--t-muted':'#4a8a4a','--t-text':'#7bc67b','--t-bright':'#b8f0b8','--t-accent':'#4ade80','--t-accent2':'#22d3ee','--t-danger':'#f87171','--t-scanline':'rgba(0,0,0,0.1)','--t-overlay':'rgba(5,10,5,0.85)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(10,15,10,0.22) 0%, rgba(10,15,10,0.7) 62%, rgba(10,15,10,0.86) 100%)' }},
midnight: { label:'Midnight', vars:{ '--t-bg':'#0f172a','--t-surface':'#162033','--t-raised':'#1c2940','--t-border':'#263650','--t-dim':'#475882','--t-muted':'#7088b0','--t-text':'#a8bdd8','--t-bright':'#e2e8f0','--t-accent':'#a78bfa','--t-accent2':'#f472b6','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.06)','--t-overlay':'rgba(10,15,30,0.8)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(15,23,42,0.22) 0%, rgba(15,23,42,0.7) 62%, rgba(15,23,42,0.86) 100%)' }},
amber: { label:'Amber', vars:{ '--t-bg':'#1a1408','--t-surface':'#231c0e','--t-raised':'#2c2414','--t-border':'#3d3520','--t-dim':'#6b5c38','--t-muted':'#a08c5a','--t-text':'#d4c088','--t-bright':'#fbe8b0','--t-accent':'#fbbf24','--t-accent2':'#fb923c','--t-danger':'#ef4444','--t-scanline':'rgba(0,0,0,0.08)','--t-overlay':'rgba(20,16,5,0.8)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(26,20,8,0.22) 0%, rgba(26,20,8,0.7) 62%, rgba(26,20,8,0.86) 100%)' }},
void: { label:'Void', vars:{ '--t-bg':'#000000','--t-surface':'#0a0a0a','--t-raised':'#141414','--t-border':'#222222','--t-dim':'#444444','--t-muted':'#666666','--t-text':'#a0a0a0','--t-bright':'#e0e0e0','--t-accent':'#ffffff','--t-accent2':'#888888','--t-danger':'#ff4444','--t-scanline':'rgba(255,255,255,0.04)','--t-overlay':'rgba(0,0,0,0.85)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.75) 62%, rgba(0,0,0,0.9) 100%)' }},
paper: { label:'Bright Mind', vars:{ '--t-bg':'#f4f4f5','--t-surface':'#ffffff','--t-raised':'#e8e8eb','--t-border':'#d4d4d8','--t-dim':'#a1a1aa','--t-muted':'#71717a','--t-text':'#3f3f46','--t-bright':'#18181b','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0.05)','--t-overlay':'rgba(244,244,245,0.8)','--t-font-pixel':'"JetBrains Mono", monospace','--t-cover-gradient':'linear-gradient(180deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.75) 40%, rgba(255,255,255,0.3) 100%)' }},
sand: { label:'Sand', vars:{ '--t-bg':'#d6c5a3','--t-surface':'#e2d5b8','--t-raised':'#ede2c5','--t-border':'#a89572','--t-dim':'#8a7654','--t-muted':'#5d4f36','--t-text':'#3a3120','--t-bright':'#1a1408','--t-accent':'#b91c1c','--t-accent2':'#15803d','--t-danger':'#7c2d12','--t-scanline':'rgba(60,40,20,0.06)','--t-overlay':'rgba(60,50,30,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(214,196,160,0.22) 0%, rgba(214,196,160,0.7) 62%, rgba(214,196,160,0.86) 100%)' }},
stone: { label:'Stone', vars:{ '--t-bg':'#94a3b8','--t-surface':'#a8b6c8','--t-raised':'#bcc7d5','--t-border':'#64748b','--t-dim':'#475569','--t-muted':'#334155','--t-text':'#1e293b','--t-bright':'#0f172a','--t-accent':'#1d4ed8','--t-accent2':'#7e22ce','--t-danger':'#991b1b','--t-scanline':'rgba(15,23,42,0.06)','--t-overlay':'rgba(50,60,80,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(148,163,184,0.22) 0%, rgba(148,163,184,0.7) 62%, rgba(148,163,184,0.86) 100%)' }},
};
window.applyTheme = function(name) {
const t = themes[name] || themes.slate;
for (const [k,v] of Object.entries(t.vars)) document.documentElement.style.setProperty(k,v);
localStorage.setItem('hyprcrd-theme', name);
document.querySelectorAll('[data-theme-dot]').forEach(el => {
const a = el.dataset.themeDot === name;
el.classList.toggle('ring-2',a); el.classList.toggle('ring-t-accent',a); el.classList.toggle('ring-offset-1',a); el.classList.toggle('ring-offset-t-bg',a);
});
}
applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
window.themeFonts = {
pixel: '"Silkscreen", cursive',
mono: '"JetBrains Mono", monospace',
sans: 'system-ui, -apple-system, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
};
window.themeFontLabels = { pixel: 'Pixel (Silkscreen)', mono: 'Mono (JetBrains)', sans: 'Sans', serif: 'Serif' };
window.designShadows = {
none: '0 0 0 0 transparent',
soft: '0 2px 8px rgba(0,0,0,0.18)',
medium: '0 6px 20px -4px rgba(0,0,0,0.35)',
heavy: '0 12px 40px -8px rgba(0,0,0,0.55)',
};
// Header drops down, footer drops up — same intensity scale.
window.designUiShadows = {
header: {
none: '0 0 0 0 transparent',
soft: '0 2px 8px -2px rgba(0,0,0,0.18)',
medium: '0 4px 20px -4px rgba(0,0,0,0.3)',
heavy: '0 8px 30px -4px rgba(0,0,0,0.5)',
},
footer: {
none: '0 0 0 0 transparent',
soft: '0 -2px 8px -2px rgba(0,0,0,0.18)',
medium: '0 -4px 20px -4px rgba(0,0,0,0.3)',
heavy: '0 -8px 30px -4px rgba(0,0,0,0.5)',
},
};
window.designDefaults = {
// color theme
theme: 'slate',
// typography
fontHeadline: 'pixel', fontContent: 'mono', fontUi: 'pixel',
// geometry
radiusCard: 0, radiusButton: 0, radiusInput: 0, radiusModal: 0, radiusTag: 0, radiusImage: 0,
// borders
borderCard: 1, borderStyle: 'solid', barHeight: 3,
// spacing
padCard: 10, gapBoard: 17,
// effects
shadowCard: 'none', shadowModal: 'medium', shadowUi: 'medium', liftCard: 1, blurOverlay: 0, animSpeed: 'fast',
};
window.builtinPresets = {
Terminal: { ...window.designDefaults },
Modern: { theme: 'paper',
fontHeadline: 'mono', fontContent: 'mono', fontUi: 'mono',
radiusCard: 10, radiusButton: 24, radiusInput: 5, radiusModal: 15, radiusTag: 3, radiusImage: 8,
borderCard: 1, borderStyle: 'solid', barHeight: 3,
padCard: 10, gapBoard: 17,
shadowCard: 'none', shadowModal: 'none', shadowUi: 'none',
liftCard: 2, blurOverlay: 5, animSpeed: 'normal' },
};
window.applyDesign = function(d) {
const root = document.documentElement.style;
// color theme
if (d.theme && window.themes?.[d.theme]) window.applyTheme(d.theme);
// fonts
root.setProperty('--t-font-headline', window.themeFonts[d.fontHeadline] || window.themeFonts.pixel);
root.setProperty('--t-font-content', window.themeFonts[d.fontContent] || window.themeFonts.mono);
root.setProperty('--t-font-ui', window.themeFonts[d.fontUi] || window.themeFonts.pixel);
// geometry
root.setProperty('--ui-radius-card', d.radiusCard + 'px');
root.setProperty('--ui-radius-button', d.radiusButton + 'px');
root.setProperty('--ui-radius-input', d.radiusInput + 'px');
root.setProperty('--ui-radius-modal', d.radiusModal + 'px');
root.setProperty('--ui-radius-tag', d.radiusTag + 'px');
root.setProperty('--ui-radius-image', d.radiusImage + 'px');
// borders
root.setProperty('--ui-border-card', d.borderCard + 'px');
root.setProperty('--ui-border-style', d.borderStyle);
root.setProperty('--ui-bar-height', d.barHeight + 'px');
// spacing
root.setProperty('--ui-pad-card', d.padCard + 'px');
root.setProperty('--ui-gap-board', d.gapBoard + 'px');
// effects
root.setProperty('--ui-shadow-card', window.designShadows[d.shadowCard] || 'none');
root.setProperty('--ui-shadow-modal', window.designShadows[d.shadowModal] || window.designShadows.medium);
root.setProperty('--ui-shadow-header', window.designUiShadows.header[d.shadowUi] || window.designUiShadows.header.medium);
root.setProperty('--ui-shadow-footer', window.designUiShadows.footer[d.shadowUi] || window.designUiShadows.footer.medium);
root.setProperty('--ui-lift-card', d.liftCard + 'px');
root.setProperty('--ui-blur-overlay', d.blurOverlay + 'px');
// body classes
document.body.classList.remove('anim-off', 'anim-fast', 'anim-slow');
if (d.animSpeed === 'off') document.body.classList.add('anim-off');
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
if (d.animSpeed === 'slow') document.body.classList.add('anim-slow');
};
window.loadDesign = function() {
let saved = {};
try { saved = JSON.parse(localStorage.getItem('hyprcrd-design') || '{}'); } catch {}
// Migrate legacy individual keys (only if no JSON blob exists yet)
if (!Object.keys(saved).length) {
const legacy = {};
const fh = localStorage.getItem('hyprcrd-font-headline'); if (fh) legacy.fontHeadline = fh;
const fc = localStorage.getItem('hyprcrd-font-content'); if (fc) legacy.fontContent = fc;
const fu = localStorage.getItem('hyprcrd-font-ui'); if (fu) legacy.fontUi = fu;
saved = legacy;
}
// Always pull the legacy theme key as a fallback so existing users keep their selected theme
if (!saved.theme) {
const t = localStorage.getItem('hyprcrd-theme');
if (t) saved.theme = t;
}
return { ...window.designDefaults, ...saved };
};
window.saveDesign = function(d) { localStorage.setItem('hyprcrd-design', JSON.stringify(d)); };
// Apply once body exists
const _applySaved = () => window.applyDesign(window.loadDesign());
if (document.body) _applySaved(); else document.addEventListener('DOMContentLoaded', _applySaved);
</script>
</head>
<!-- tailwind safelist: border-t-bright border-transparent scale-125 text-t-accent line-clamp-2 line-clamp-5 border-t-danger text-t-danger pr-16 text-amber-400 -->
<body class="bg-t-bg text-t-text h-screen transition-colors duration-200 flex flex-col overflow-hidden">
<header id="main-header" class="shrink-0 z-40 bg-t-bg border-b border-t-border" style="overflow:hidden;">
<div class="flex items-center justify-between px-6 py-3 relative z-[1]">
<div class="flex items-center gap-3">
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
<h1 class="brand font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
<span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>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 ml-1 font-pixel leading-none" style="padding:1px 4px;">ctrl+k</kbd>
</button>
<div class="header-sync-group flex items-center">
<button id="header-sync" 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" data-tip="sync now">
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">Sync</span>
</button>
<button id="header-autosync" class="flex items-center justify-center px-2 py-1.5 text-xs border border-t-border transition-colors" data-tip="auto-sync">
<span id="header-autosync-dot" class="header-autosync-dot"></span>
</button>
</div>
</div>
<div class="flex items-center gap-3">
<button id="calendar-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">
<i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span>
</button>
<button id="gallery-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">
<i class="ph ph-folder-open text-sm"></i><span class="font-pixel text-[10px]">Assets</span>
</button>
<button id="tasks-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-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">Tasks</span>
<span id="tasks-open-count" class="font-pixel text-[9px] px-1.5 py-0.5 border border-t-border rounded text-t-dim">0</span>
</button>
<button id="add-btn" class="flex items-center gap-2 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
<i class="ph ph-plus-square text-sm"></i><span class="font-pixel text-[11px]">New_card</span>
</button>
</div>
</div>
<div id="quick-capture" class="hidden px-6 py-2 border-t border-t-border bg-t-bg">
<div class="flex items-center gap-2">
<i class="ph ph-lightning text-t-accent text-sm"></i>
<input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono">
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 font-pixel">esc</kbd>
</div>
</div>
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
<div class="flex items-center gap-1 overflow-x-auto flex-1 relative" id="bucket-bar"></div>
<button id="board-split-toggle" class="toolbar-toggle" data-help="Split preview\nShows a side panel with card preview,\nrelated cards, and backlinks.">
<i class="ph ph-split-horizontal text-sm"></i>
</button>
<button id="superview-toggle" class="toolbar-toggle" data-help="Superview\nCompact multi-column list with\nhover cards. Great for overviews.">
<i class="ph ph-list text-sm"></i>
</button>
<button id="hide-done" class="toolbar-toggle" data-tip="toggle done">
<i class="ph ph-check-circle text-sm"></i>
</button>
<button id="hide-termine" class="toolbar-toggle" data-tip="toggle appointments">
<i class="ph ph-calendar-x text-sm"></i>
</button>
<button id="hide-tagged" class="toolbar-toggle" data-tip="toggle tagged cards">
<i class="ph ph-hash text-sm"></i>
</button>
<button id="sort-toggle" class="toolbar-toggle">
<i class="ph ph-sort-ascending text-sm"></i>
<span id="sort-label">manual</span>
</button>
</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="relative 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 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>
<div id="board-preview-empty" class="flex-1 flex flex-col items-center justify-center text-t-dim">
<i class="ph ph-article text-3xl mb-2"></i>
<p class="font-pixel text-[10px] cursor-blink">click a card to preview</p>
</div>
<div id="board-preview-content" class="hidden flex-1 flex flex-col min-h-0">
<div id="board-preview-body" class="flex-1 overflow-y-auto p-5 prose text-sm text-t-text"></div>
<div id="board-preview-tags" class="hidden flex flex-wrap gap-1.5 px-5 pb-3"></div>
<div id="board-preview-meta" class="px-5 py-2 border-t border-t-border text-[10px] text-t-dim font-pixel shrink-0"></div>
</div>
</div>
</div>
<footer id="main-footer" class="shrink-0 z-40 border-t border-t-border bg-t-bg px-6 py-2 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-wrap">
<input type="file" id="import-file" accept=".zip,application/zip" class="hidden">
<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>
<button id="import-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-accent hover:text-t-accent transition-colors">
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">Import</span>
</button>
<button id="export-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-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="export-md-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-accent hover:text-t-accent transition-colors">
<i class="ph ph-vault text-sm"></i><span class="font-pixel text-[10px]">Export vault</span>
</button>
<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"></div>
<div class="flex items-center gap-2">
<button id="shortcuts-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-keyboard text-sm"></i><span class="font-pixel text-[10px]">Keys</span></button>
<button id="help-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-question text-sm"></i><span class="font-pixel text-[10px]">Help</span></button>
</div>
</footer>
<!-- Shortcuts (?) -->
<div class="overlay fixed inset-0 z-[95] flex justify-center items-start pt-12 pb-12 px-8" style="background:var(--t-overlay)" id="shortcuts">
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-4xl max-h-full overflow-y-auto">
<div class="flex items-center gap-3 px-6 py-4 border-b border-t-border sticky top-0 bg-t-surface z-[1]">
<i class="ph ph-keyboard text-t-accent text-base"></i>
<h2 class="font-pixel text-sm text-t-accent">keyboard shortcuts</h2>
<div class="flex-1"></div>
<button id="shortcuts-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>
<!-- Filled by shortcuts.js renderShortcutHelp() from the registry.
Layout: 3 columns on desktop, 2 on tablet, 1 on mobile.
The CSS grid is set on .shortcut-help-content. -->
<div class="shortcut-help-content p-6 text-sm text-t-text" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;"></div>
</div>
</div>
<!-- Help -->
<div class="overlay fixed inset-0 z-[95] flex justify-center items-start pt-12 pb-12 px-8" style="background:var(--t-overlay)" id="help">
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-3xl max-h-full overflow-y-auto">
<div class="flex items-center gap-3 px-6 py-4 border-b border-t-border sticky top-0 bg-t-surface z-[1]">
<i class="ph ph-question text-t-accent text-base"></i>
<h2 class="font-pixel text-sm text-t-accent">help</h2>
<div class="flex-1"></div>
<button id="help-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-6 text-sm text-t-text space-y-6" id="help-content">
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-info text-sm"></i>concepts</h3>
<div class="text-xs leading-relaxed space-y-3 text-t-muted">
<p><i class="ph ph-cards text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">cards</span> are the basic unit — each card has a title, a markdown body, a color, and optional tags. Cards appear as tiles on the board. Click to preview, double-click to edit, right-click for actions. Cards can contain images, checklists, links to other cards, and any markdown content.</p>
<p><i class="ph ph-folder text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">buckets</span> organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: <i class="ph ph-squares-four text-xs"></i> <span class="text-t-dim font-pixel">all</span>, <i class="ph ph-lightning text-xs"></i> <span class="text-t-dim font-pixel">current</span>, <i class="ph ph-star text-xs"></i> <span class="text-t-dim font-pixel">favorites</span>, <i class="ph ph-notebook text-xs"></i> <span class="text-t-dim font-pixel">journal</span>, <i class="ph ph-tray text-xs"></i> <span class="text-t-dim font-pixel">unsorted</span>. Drag a card onto a bucket tab to move it. Drag to <i class="ph ph-plus text-xs"></i> to create a new bucket. Buckets can be nested one level deep — drag a bucket onto another to create a <span class="text-t-bright font-pixel">super-bucket</span> with a dropdown for its children.</p>
<p><i class="ph ph-stack text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">groups</span> let you nest cards. Drag a card to the center of another card to group them. The parent card shows a badge with the member count. Click the badge or use <i class="ph ph-stack text-xs"></i> "open group" in the context menu to see all members. Inside a group you can reorder, drag cards out to ungroup, or drag the first card to promote a new parent. Use <i class="ph ph-arrow-line-up text-xs"></i> "dissolve group" to ungroup all at once.</p>
<p><i class="ph ph-hash text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">tags</span> are written as <span class="text-t-accent font-pixel">#tagname</span> in the card body and extracted automatically. They support hierarchy: <span class="text-t-accent font-pixel">#project/frontend</span>. Click a tag to filter — Ctrl+click to combine multiple tags (AND). Assign via right-click > <i class="ph ph-hash text-xs"></i> tags. Each segment of a hierarchical tag is independently clickable.</p>
<p><i class="ph ph-link text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">links</span> connect cards. Write <span class="text-t-accent font-pixel">[[Card Title]]</span> in the body to create a link. Linked cards are clickable in preview and show up as <i class="ph ph-arrow-bend-left-up text-xs"></i> backlinks on the target card. The <i class="ph ph-split-horizontal text-xs"></i> split view shows related cards based on shared tags, links, and content overlap.</p>
<p><i class="ph ph-calendar-dots text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">Calendar</span> shows cards on their dates. Journal cards (<span class="text-t-dim font-pixel">YYYY-MM-DD</span>) and appointments (<span class="text-t-dim font-pixel">YYYY-MM-DD — Subject</span>) appear on their date. <i class="ph ph-cake text-xs" style="color:#fb7185"></i> Birthdays (<span class="text-t-accent font-pixel">#geburtstag</span>) repeat yearly. Sync with ownCloud/Nextcloud via <i class="ph ph-cloud-arrow-up text-xs"></i> CalDAV.</p>
</div>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-keyboard text-sm"></i>shortcuts</h3>
<p class="text-xs text-t-muted mb-2">Press <kbd class="px-1 py-0.5 border border-t-border rounded text-[9px] font-pixel">?</kbd> for the full keyboard reference.</p>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-squares-four text-sm"></i>board</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel">click card</td><td><i class="ph ph-eye text-xs"></i> open preview (or side panel in split mode)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">double-click</td><td><i class="ph ph-pencil-simple text-xs"></i> open editor</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">right-click</td><td><i class="ph ph-list text-xs"></i> context menu (color, bucket, tags, copy text, mark done, join, ...)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+click</td><td><i class="ph ph-check-square text-xs"></i> multi-select cards</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">drag card</td><td><i class="ph ph-arrows-out-cardinal text-xs"></i> reorder, move to bucket, or group</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrow-up text-xs"></i><i class="ph ph-arrow-down text-xs"></i><i class="ph ph-arrow-left text-xs"></i><i class="ph ph-arrow-right text-xs"></i></td><td>navigate cards with arrow keys, Enter to open</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">click tag</td><td><i class="ph ph-funnel text-xs"></i> filter by tag</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+click tag</td><td><i class="ph ph-plus-circle text-xs"></i> add/remove tag filter (AND)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">group badge</td><td><i class="ph ph-stack text-xs"></i> expand group</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+N</td><td><i class="ph ph-lightning text-xs"></i> quick capture — create card in active bucket</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">1–9</td><td><i class="ph ph-folder text-xs"></i> switch to bucket by number</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-list text-sm"></i>context menu</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-palette text-xs"></i> color</td><td>change card color</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-folder text-xs"></i> move</td><td>move to a different bucket</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-hash text-xs"></i> tags</td><td>assign tags via picker</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-copy text-xs"></i> copy text</td><td>copy card body as plain text (strips markdown)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-check-circle text-xs"></i> mark done</td><td>toggle #done tag on any card</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-unite text-xs"></i> join cards</td><td>merge multi-selected cards into one</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-copy text-xs"></i> duplicate</td><td>duplicate card</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-file-md text-xs"></i> save .md</td><td>download as markdown + images</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-trash text-xs"></i> delete</td><td>delete card (with undo)</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-eye text-sm"></i>preview</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel">double-click image</td><td><i class="ph ph-arrows-out text-xs"></i> fullscreen (scroll to zoom, drag to pan)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-caret-left text-xs"></i> <i class="ph ph-caret-right text-xs"></i></td><td>prev/next image in gallery</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">click <span class="text-t-accent">[[link]]</span></td><td><i class="ph ph-arrow-square-out text-xs"></i> open linked card</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">click checkbox</td><td><i class="ph ph-check-square text-xs"></i> toggle task completion</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-corners-out text-xs"></i> zen mode</td><td>full-screen reading (drag edges to resize)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-split-horizontal text-xs"></i> split view</td><td>show related / tagged cards side-by-side</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-pencil-simple text-sm"></i>editor</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel">Ctrl+S</td><td><i class="ph ph-floppy-disk text-xs"></i> save</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Shift+S</td><td><i class="ph ph-floppy-disk text-xs"></i> save and close</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+H</td><td><i class="ph ph-magnifying-glass text-xs"></i> find + replace in card</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+F</td><td><i class="ph ph-magnifying-glass text-xs"></i> find in card</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Alt+<i class="ph ph-arrow-up text-xs"></i>/<i class="ph ph-arrow-down text-xs"></i></td><td>move line up/down</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Shift+Alt+<i class="ph ph-arrow-up text-xs"></i>/<i class="ph ph-arrow-down text-xs"></i></td><td>duplicate line</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Tab</td><td>indent (4 spaces)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">paste / drag</td><td><i class="ph ph-upload-simple text-xs"></i> upload image, audio, or PDF</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-scissors text-xs"></i> extract</td><td>selection to new card</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-file-pdf text-xs"></i> PDF</td><td>upload + link a PDF from toolbar</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">drag divider</td><td>resize editor / preview split</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-lightning text-sm"></i>autocomplete</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><span class="text-t-accent">[[</span></td><td><i class="ph ph-link text-xs"></i> link to card (search by title)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#</span></td><td><i class="ph ph-hash text-xs"></i> tag autocomplete (existing tags)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">?</span></td><td><i class="ph ph-magic-wand text-xs"></i> snippet menu (see below)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrow-up text-xs"></i> <i class="ph ph-arrow-down text-xs"></i></td><td>navigate suggestions</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Enter / Tab</td><td>accept suggestion</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-magic-wand text-sm"></i>snippets <span class="text-t-dim font-pixel" style="font-weight:400">type ? + keyword + space</span></h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel">?dat</td><td><i class="ph ph-calendar text-xs"></i> current date</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">?time</td><td><i class="ph ph-clock text-xs"></i> current time</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">?now</td><td><i class="ph ph-clock-countdown text-xs"></i> date + time</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">?week</td><td><i class="ph ph-calendar-blank text-xs"></i> calendar week (KW15)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">?todo</td><td><i class="ph ph-check-square text-xs"></i> checkbox - [ ]</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">?hr</td><td><i class="ph ph-minus text-xs"></i> horizontal rule ---</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">?h1 ?h2 ?h3</td><td><i class="ph ph-text-h text-xs"></i> headings</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">?table</td><td><i class="ph ph-table text-xs"></i> markdown table template</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">?code</td><td><i class="ph ph-code text-xs"></i> fenced code block</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-calculator text-sm"></i>inline calculator</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><span class="text-t-accent">?</span>3+5 Space</td><td><i class="ph ph-equals text-xs"></i> replaces with 8</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">34+56<span class="text-t-accent">?</span> Space</td><td><i class="ph ph-equals text-xs"></i> becomes 34+56=90</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">operators</td><td>+ - * / () % ^</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-hash text-sm"></i>tags</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><span class="text-t-accent">#tagname</span></td><td>write tags in card body</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#parent/child</span></td><td><i class="ph ph-tree-structure text-xs"></i> hierarchical tags</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">right-click > <i class="ph ph-hash text-xs"></i> tags</td><td>assign tags via context menu</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">click segment</td><td><i class="ph ph-funnel text-xs"></i> each path level is clickable</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-magnifying-glass text-sm"></i>search</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel">text</td><td><i class="ph ph-magnifying-glass text-xs"></i> search titles + body</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag</span></td><td><i class="ph ph-funnel text-xs"></i> filter by tag</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag</span> word</td><td>tag filter + text search combined</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag1 #tag2</span></td><td><i class="ph ph-intersect text-xs"></i> multiple tags (AND)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">bucket filter</td><td><i class="ph ph-folder text-xs"></i> dropdown to restrict scope</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-swap text-xs"></i> Replace</td><td>toggle replace mode — bulk find + replace across all matching cards</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Shift+H</td><td><i class="ph ph-swap text-xs"></i> open search directly in replace mode</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">drag result</td><td><i class="ph ph-stack text-xs"></i> drag onto another card to group</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-article text-sm"></i>markdown</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><span class="text-t-accent">[[title]]</span></td><td><i class="ph ph-link text-xs"></i> card link</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">```gallery</span></td><td><i class="ph ph-images text-xs"></i> image gallery grid (click to fullscreen)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">```tree</span></td><td><i class="ph ph-tree-structure text-xs"></i> tree-style list</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">```bullets</span></td><td><i class="ph ph-list-bullets text-xs"></i> bullet-style list</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">- [ ] / - [x]</td><td><i class="ph ph-check-square text-xs"></i> interactive checkboxes</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">http://...</td><td><i class="ph ph-arrow-square-out text-xs"></i> external links open in new tab</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-calendar-dots text-sm"></i>calendar</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel">click day</td><td><i class="ph ph-caret-down text-xs"></i> open day popup with all cards</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">click item</td><td><i class="ph ph-eye text-xs"></i> open card preview</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">double-click item</td><td><i class="ph ph-pencil-simple text-xs"></i> open card editor</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">right-click item</td><td><i class="ph ph-list text-xs"></i> context menu</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">+ termin</td><td><i class="ph ph-calendar-plus text-xs"></i> new appointment (choose calendar)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel">+ journal</td><td><i class="ph ph-notebook text-xs"></i> new/open journal for that day</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-cake text-xs" style="color:#fb7185;"></i> geb.</td><td>new birthday (repeats yearly)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-caret-left text-xs"></i> <i class="ph ph-caret-right text-xs"></i></td><td>navigate months</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-cloud-arrow-up text-sm"></i>CalDAV sync</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-gear text-xs"></i> gear icon</td><td>CalDAV settings (URL, credentials, calendar colors)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-cloud-arrow-up text-xs"></i> sync</td><td>manual sync (also auto every 2 min)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#termin</span></td><td><i class="ph ph-calendar-dots text-xs"></i> card syncs as calendar event</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#geburtstag</span></td><td><i class="ph ph-cake text-xs"></i> card syncs as yearly birthday</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#termin #privat</span></td><td><i class="ph ph-folder text-xs"></i> pushes to "Privat" calendar</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrows-clockwise text-xs"></i> recurring</td><td>birthdays + RRULE events repeat in view</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-layout text-sm"></i>views &amp; toolbar</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-split-horizontal text-xs"></i> split</td><td>side-by-side board + preview panel</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-list text-xs"></i> superview</td><td>compact multi-column title list with hover cards</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-check-circle text-xs"></i> hide done</td><td>toggle cards tagged #done on/off</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-calendar-x text-xs"></i> hide termine</td><td>toggle appointment cards on/off</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-hash text-xs"></i> hide tagged</td><td>toggle tagged cards on/off</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-sort-ascending text-xs"></i> sort</td><td>manual / newest / oldest / alpha / tag</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-images text-xs"></i> assets</td><td>gallery of all images, audio, and PDFs across cards — with orphan detection</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-broadcast text-sm"></i>relay</h3>
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
<p>The <span class="text-t-bright font-pixel">relay</span> is an optional self-hosted server that connects multiple hyprcrd instances. Configure the relay URL and your client secret in <i class="ph ph-gear text-xs"></i> Settings > Relay. The relay has its own admin UI for user management.</p>
</div>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-folders text-xs"></i> collab-buckets</td><td>shared buckets between tenants — exclusive card locking, unread indicators, E2E encrypted sync</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-chat-circle-dots text-xs"></i> chat</td><td>encrypted chat between tenants — footer flyout, delivery + read receipts</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-paper-plane-tilt text-xs"></i> compose</td><td>send messages to other tenants via the relay inbox</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-tray text-xs"></i> inbox</td><td>read received messages — synced badge shows delivery status</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-lock text-xs"></i> card locking</td><td>editing a collab card locks it for all others — takeover on first keystroke</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-key text-xs"></i> client secret</td><td>per-tenant E2E key — set in relay account settings, shared across devices</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-download-simple text-sm"></i>import / export</h3>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-download-simple text-xs"></i> export</td><td>JSON + media ZIP (re-importable)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-vault text-xs"></i> vault</td><td>Obsidian / Logseq markdown vault</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-file-md text-xs"></i> save .md</td><td>single card as markdown + images (right-click)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-upload-simple text-xs"></i> import</td><td>restore from exported ZIP</td></tr>
</table>
</section>
<div class="pt-4 border-t border-t-border text-center">
<span class="font-pixel text-[10px] text-t-dim">© <?= date('Y') ?> hyprcrd · <span data-app-version>v<?= APP_VERSION ?></span></span>
</div>
</div>
</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="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="brand 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="caldav"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span></button>
<button class="settings-tab" data-tab="relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">Relay</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>
<button class="settings-tab" data-tab="plugins"><i class="ph ph-puzzle-piece text-sm"></i><span class="font-pixel text-[10px]">Plugins</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 relative">
<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="brand font-pixel text-2xl text-t-accent tracking-wider mb-2">hyprcrd</h1>
<p class="font-pixel text-[10px] text-t-dim mb-6" data-app-version>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 absolute inset-0 p-5 flex flex-col">
<div class="shrink-0 pb-3 border-b border-t-border">
<div class="flex items-center justify-between mb-2">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim">preview</div>
<div class="flex items-center gap-2 cursor-pointer select-none" id="design-peek-row">
<span class="font-pixel text-[10px] text-t-muted">Peek Board</span>
<div class="toggle-switch" id="design-peek-toggle"></div>
</div>
</div>
<div id="theme-preview" class="theme-preview">
<div class="tp-row">
<div class="tp-card">
<div class="tp-card-bar"></div>
<div class="tp-card-body">
<div class="tp-headline">Headline</div>
<div class="tp-content">Sample content text rendered in the chosen content font. 0123456789</div>
<div class="tp-meta">
<span class="tp-tag">tag</span>
<span class="tp-ui">ui_label</span>
</div>
</div>
</div>
<div class="tp-side">
<button class="tp-btn" type="button">Button</button>
<input class="tp-input" type="text" placeholder="input">
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto pt-4 space-y-5" style="margin-right:-1.25rem;padding-right:1.25rem;">
<div>
<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 class="border-t border-t-border pt-4">
<div class="design-section-label">presets</div>
<div class="design-row">
<select id="design-preset-select" 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 flex-1"></select>
<button id="design-preset-delete" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors" data-tip="delete user preset"><i class="ph ph-trash text-xs"></i></button>
</div>
<div class="design-row">
<input id="design-preset-name" type="text" placeholder="new preset name..." class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 flex-1 focus:border-t-accent focus:outline-none">
<button id="design-preset-save" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-xs"></i><span class="font-pixel">Save</span></button>
</div>
<div class="design-row">
<button id="design-preset-export" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex-1 justify-center"><i class="ph ph-export text-xs"></i><span class="font-pixel">Export JSON</span></button>
<button id="design-preset-import" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex-1 justify-center"><i class="ph ph-upload-simple text-xs"></i><span class="font-pixel">Import JSON</span></button>
</div>
</div>
<details id="design-advanced" class="border-t border-t-border pt-4">
<summary class="cursor-pointer select-none flex items-center gap-2 font-pixel text-[9px] uppercase tracking-wider text-t-dim">
<i class="ph ph-caret-right text-xs design-advanced-caret"></i>
<span>advanced</span>
</summary>
<div class="mt-2">
<div class="design-section-label">typography</div>
<div class="design-row"><label>Headline</label><select id="setting-font-headline" 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"></select></div>
<div class="design-row"><label>Content</label><select id="setting-font-content" 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"></select></div>
<div class="design-row"><label>UI</label><select id="setting-font-ui" 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"></select></div>
<div class="design-section-label">corners</div>
<div class="design-row"><label>Card</label><input type="range" id="setting-radius-card" min="0" max="24" step="1" class="design-slider"><span class="design-val" id="val-radius-card">0</span></div>
<div class="design-row"><label>Button</label><input type="range" id="setting-radius-button" min="0" max="24" step="1" class="design-slider"><span class="design-val" id="val-radius-button">0</span></div>
<div class="design-row"><label>Input</label><input type="range" id="setting-radius-input" min="0" max="24" step="1" class="design-slider"><span class="design-val" id="val-radius-input">0</span></div>
<div class="design-row"><label>Modal</label><input type="range" id="setting-radius-modal" min="0" max="24" step="1" class="design-slider"><span class="design-val" id="val-radius-modal">0</span></div>
<div class="design-row"><label>Tag</label><input type="range" id="setting-radius-tag" min="0" max="16" step="1" class="design-slider"><span class="design-val" id="val-radius-tag">0</span></div>
<div class="design-row"><label>Image</label><input type="range" id="setting-radius-image" min="0" max="24" step="1" class="design-slider"><span class="design-val" id="val-radius-image">0</span></div>
<div class="design-section-label">borders</div>
<div class="design-row"><label>Card width</label><input type="range" id="setting-border-card" min="0" max="4" step="1" class="design-slider"><span class="design-val" id="val-border-card">1</span></div>
<div class="design-row"><label>Style</label><select id="setting-border-style" 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="solid">solid</option><option value="dashed">dashed</option><option value="dotted">dotted</option><option value="double">double</option></select></div>
<div class="design-row"><label>Bar height</label><input type="range" id="setting-bar-height" min="0" max="10" step="1" class="design-slider"><span class="design-val" id="val-bar-height">3</span></div>
<div class="design-section-label">spacing</div>
<div class="design-row"><label>Card padding</label><input type="range" id="setting-pad-card" min="4" max="32" step="1" class="design-slider"><span class="design-val" id="val-pad-card">16</span></div>
<div class="design-row"><label>Board gap</label><input type="range" id="setting-gap-board" min="0" max="24" step="1" class="design-slider"><span class="design-val" id="val-gap-board">0</span></div>
<div class="design-section-label">effects</div>
<div class="design-row"><label>Card shadow</label><select id="setting-shadow-card" 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="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>Modal shadow</label><select id="setting-shadow-modal" 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="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>UI shadow</label><select id="setting-shadow-ui" 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" data-tip="header & footer shadow"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>Card lift</label><input type="range" id="setting-lift-card" min="0" max="8" step="1" class="design-slider"><span class="design-val" id="val-lift-card">0</span></div>
<div class="design-row"><label>Overlay blur</label><input type="range" id="setting-blur-overlay" min="0" max="16" step="1" class="design-slider"><span class="design-val" id="val-blur-overlay">0</span></div>
<div class="design-row"><label>Animation</label><select id="setting-anim-speed" 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="off">off</option><option value="fast">fast</option><option value="normal">normal</option><option value="slow">slow</option></select></div>
<div class="flex items-center gap-2 mt-4">
<button id="design-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 to defaults</span></button>
<span id="design-status" class="font-pixel text-[9px] text-t-dim ml-2">auto-saving</span>
</div>
</div>
</details>
</div>
</div>
<div id="settings-panel-caldav" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-calendar-dots text-xs"></i> CalDAV sync</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>
</div>
<div id="caldav-test-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></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>
</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-relay" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-broadcast text-xs"></i> relay</div>
<p class="text-[10px] text-t-dim leading-relaxed">The relay is multi-tenant: each user has their own account on the relay server. Ask the relay admin to create a user for you, then enter the credentials below.</p>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">relay url</label><input id="relay-url" type="text" placeholder="https://relay.example.com" autocomplete="off" 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><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">username</label><input id="relay-username" type="text" placeholder="your relay username" autocomplete="off" 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><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">shared secret</label><input id="relay-key" type="text" placeholder="shared secret" autocomplete="off" 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 class="flex gap-2">
<button id="relay-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="relay-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>
</div>
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
<div class="border-t border-t-border pt-4 mt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-timer text-xs"></i> sync frequency</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-2">How often the background sync polls the relay + CalDAV for new messages, collab updates and sync events. Lower = snappier, higher = less network traffic.</p>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex-1" for="setting-sync-interval">Poll every</label>
<input id="setting-sync-interval" type="number" min="5" max="3600" step="1" class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none" style="width:72px;text-align:right;">
<span class="text-xs text-t-dim">sec</span>
</div>
</div>
</div>
<div id="settings-panel-behavior" class="hidden space-y-5">
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-lightning text-xs"></i> current view</div>
<div class="space-y-2">
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex-1">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>
<div class="toggle-row" data-toggle="setting-current-include-synced"><span class="toggle-label">Include sync-only updates</span><div class="toggle-switch" id="setting-current-include-synced"></div></div>
</div>
</div>
<div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-squares-four text-xs"></i> board cards</div>
<div class="space-y-2">
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex-1">Card min height</label>
<select id="setting-card-height" 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="140">compact</option>
<option value="180">normal</option>
<option value="220">tall</option>
<option value="280">large</option>
</select>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex-1">Card width</label>
<select id="setting-card-width" 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="220">narrow</option>
<option value="280">normal</option>
<option value="340">wide</option>
<option value="400">extra wide</option>
</select>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex-1">Body preview lines</label>
<select id="setting-body-lines" 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="3">3 lines</option>
<option value="5">5 lines</option>
<option value="8">8 lines</option>
<option value="0">hide body</option>
</select>
</div>
<div class="toggle-row" data-toggle="setting-show-tags"><span class="toggle-label">Show tag chips on cards</span><div class="toggle-switch" id="setting-show-tags"></div></div>
<div class="toggle-row" data-toggle="setting-show-thumbnails"><span class="toggle-label">Show image thumbnails</span><div class="toggle-switch" id="setting-show-thumbnails"></div></div>
<div class="toggle-row" data-toggle="setting-show-done-badge"><span class="toggle-label">Show done badges</span><div class="toggle-switch" id="setting-show-done-badge"></div></div>
</div>
</div>
<div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-cursor-click text-xs"></i> interactions</div>
<div class="space-y-2">
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex-1">Single click opens</label>
<select id="setting-click-action" 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="preview">preview</option>
<option value="edit">editor</option>
</select>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex-1">ESC in detail view</label>
<select id="setting-esc-action" 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="close">close card</option>
<option value="exitZen">exit zen first</option>
</select>
</div>
<div class="toggle-row" data-toggle="setting-confirm-delete"><span class="toggle-label">Confirm before delete</span><div class="toggle-switch" id="setting-confirm-delete"></div></div>
<div class="toggle-row" data-toggle="setting-auto-save"><span class="toggle-label">Auto-save on close (editor)</span><div class="toggle-switch" id="setting-auto-save"></div></div>
</div>
</div>
</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-plugins" class="hidden space-y-4">
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggle multiple plugins and apply them together — the page reloads once when you click Apply.</p>
<div id="plugins-list" class="space-y-2"></div>
<div id="plugins-apply-bar" class="hidden mt-3 flex items-center gap-3 px-3 py-2 border border-t-accent bg-t-accent/10">
<i class="ph ph-warning text-sm text-t-accent"></i>
<span id="plugins-apply-count" class="flex-1 font-pixel text-[10px] text-t-accent"></span>
<button id="plugins-discard-btn" class="font-pixel text-[10px] text-t-muted hover:text-t-danger transition-colors">discard</button>
<button id="plugins-apply-btn" class="font-pixel text-[10px] px-3 py-1 bg-t-accent text-t-bg hover:brightness-110 transition-all">Apply</button>
</div>
</div>
</div>
<div id="settings-panel-account" class="hidden space-y-4">
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-key text-xs"></i> vault key</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-2">Shared encryption key for Notes and CalDAV credentials. Must be identical on all connected hyprcrd instances.</p>
<div class="flex gap-2">
<input id="settings-vault-key" type="text" placeholder="enter vault key" autocomplete="off" style="-webkit-text-security:disc;" class="flex-1 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">
<button id="settings-vault-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>
</div>
<span id="settings-vault-status" class="font-pixel text-[9px] text-t-dim mt-1 block"></span>
</div>
<div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><i class="ph ph-lock text-xs"></i> change password</div>
<form id="settings-pw-form" onsubmit="return false;">
<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" type="button" 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>
</form>
</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">
<div class="flex items-center gap-2 px-4 py-3 border-b border-t-border">
<i class="ph ph-warning text-t-accent text-sm"></i>
<h2 id="dialog-title" class="font-pixel text-xs text-t-accent"></h2>
<div class="flex-1"></div>
<button id="dialog-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-4 space-y-3">
<p id="dialog-msg" class="text-xs text-t-text"></p>
<input type="text" id="dialog-input" class="hidden w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-sm focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono">
<div id="dialog-datepicker" class="hidden"></div>
</div>
<div class="flex justify-end gap-2 px-4 py-3 border-t border-t-border">
<button id="dialog-cancel" class="font-pixel text-[10px] px-3 py-1.5 text-xs border border-t-border text-t-muted hover:text-t-bright transition-colors">Cancel</button>
<button id="dialog-ok" class="font-pixel text-[10px] px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">Ok</button>
</div>
</div>
</div>
<!-- Search -->
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
<div class="flex flex-col border-b border-t-border">
<div class="flex items-center gap-3 px-4 py-3">
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
<select id="search-bucket-filter" 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="">all buckets</option></select>
<button id="search-replace-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-accent hover:text-t-accent transition-colors" data-help="Replace mode\nSearch and replace text across all\nmatching cards. Ctrl+Shift+H to open.">
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
</button>
<button id="search-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 id="search-replace-row" class="hidden flex items-center gap-3 px-4 pb-3">
<i class="ph ph-swap text-t-dim text-base"></i>
<input type="text" id="search-replace-input" placeholder="replace with..." class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1.5 font-mono focus:border-t-accent focus:outline-none rounded-sm" autocomplete="off" spellcheck="false">
<span id="search-replace-status" class="font-pixel text-[9px] text-t-dim"></span>
<button id="search-replace-all" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors font-pixel text-[10px]" disabled>
<i class="ph ph-swap text-sm"></i> Replace all
</button>
</div>
</div>
<div class="flex flex-1 min-h-0">
<div id="search-results" class="overflow-y-auto w-2/5 min-w-[220px] border-r border-t-border"></div>
<div class="flex-1 overflow-y-auto p-5">
<div id="search-preview-empty" class="flex flex-col items-center justify-center h-full text-t-dim"><i class="ph ph-article text-3xl mb-2"></i><p class="font-pixel text-[10px] cursor-blink">select a card</p></div>
<div id="search-preview-content" class="hidden">
<div class="card-bar w-16 mb-4 rounded-sm" id="search-preview-color"></div>
<h2 id="search-preview-title" class="font-pixel text-t-bright text-sm mb-4 leading-relaxed"></h2>
<div id="search-preview-body" class="prose text-xs text-t-text"></div>
<div id="search-preview-tags" class="hidden flex flex-wrap gap-1.5 mt-4"></div>
<div id="search-preview-meta" class="mt-6 pt-3 border-t border-t-border text-[10px] text-t-dim font-pixel"></div>
</div>
</div>
</div>
<div id="search-footer" class="hidden px-4 py-2 border-t border-t-border flex items-center gap-4 text-[10px] text-t-dim font-pixel">
<span><i class="ph ph-arrow-up text-xs"></i><i class="ph ph-arrow-down text-xs"></i> navigate</span>
<span><i class="ph ph-arrow-elbow-down-left text-xs"></i> open</span><span>esc close</span>
</div>
</div>
</div>
<!-- Tasks -->
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="tasks">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:54px;">
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
<i class="ph ph-check-square-offset text-t-accent2 text-base"></i>
<h2 class="font-pixel text-xs text-t-accent2">global_tasks</h2>
<div class="flex-1"></div>
<select id="tasks-status-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent2 focus:outline-none cursor-pointer">
<option value="open">open</option>
<option value="done">done</option>
<option value="all">all</option>
</select>
<select id="tasks-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent2 focus:outline-none cursor-pointer">
<option value="">all buckets</option>
<option value="none">unsorted</option>
</select>
<button id="tasks-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 id="tasks-summary" class="px-4 py-2 border-b border-t-border text-[10px] text-t-dim font-pixel"></div>
<div id="tasks-list" class="flex-1 overflow-y-auto"></div>
</div>
</div>
<!-- Detail -->
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="detail">
<div class="bg-t-surface border border-t-border flex absolute" style="inset:40px;">
<!-- Group sidebar (hidden for single cards) -->
<div id="detail-sidebar" class="hidden w-56 shrink-0 border-r border-t-border flex flex-col">
<div class="px-3 py-3 border-b border-t-border flex items-center gap-2">
<i class="ph ph-stack text-t-accent text-sm"></i>
<span class="font-pixel text-[10px] text-t-accent">group</span>
<span id="detail-sidebar-count" class="font-pixel text-[9px] text-t-dim ml-auto"></span>
</div>
<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 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">
<i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Edit</span>
</button>
<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" data-tip="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" data-help="Zen mode\nFull-screen distraction-free reading.\nDrag edges to resize.">
<i class="ph ph-corners-out text-sm"></i>
</button>
<div class="flex-1"></div>
<button id="split-right-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-accent hover:text-t-accent transition-colors" data-help="Split view\nShows cards sharing tags, links,\nor content with this card.">
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span>
</button>
<button id="detail-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 id="detail-scroll" class="p-6 pt-5 flex-1 overflow-y-auto">
<h2 id="detail-title" class="font-pixel text-t-bright text-lg mb-4 leading-relaxed"></h2>
<div id="detail-body" class="prose text-sm text-t-text"></div>
<div id="detail-tags" class="hidden flex flex-wrap gap-1.5 mt-4"></div>
<div id="detail-backlinks" class="hidden mt-6 pt-4 border-t border-t-border">
<div class="font-pixel text-[10px] text-t-dim mb-2 flex items-center gap-1.5"><i class="ph ph-arrow-bend-left-up text-xs"></i> referenced_by</div>
<div id="detail-backlinks-list" class="flex flex-wrap gap-2"></div>
</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">
<div class="flex items-center gap-2 px-3 py-2 border-b border-t-border shrink-0">
<i class="ph ph-magnifying-glass text-t-dim text-xs"></i>
<input type="text" id="split-search" class="flex-1 bg-transparent text-xs text-t-bright placeholder:text-t-dim font-mono focus:outline-none" placeholder="search all cards..." autocomplete="off">
<button id="split-close" class="text-t-dim hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
</div>
<div id="split-picks" class="overflow-y-auto border-b border-t-border"></div>
<div id="split-content" class="flex-1 overflow-y-auto p-5"></div>
</div>
</div>
</div>
<div id="link-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:220px;max-width:360px;"></div>
<div id="tag-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:180px;max-width:300px;"></div>
<div id="snippet-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-56 overflow-y-auto hidden" style="min-width:240px;max-width:360px;"></div>
<!-- Calendar -->
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-2 px-5 py-3 border-b border-t-border shrink-0 relative">
<i class="ph ph-calendar-dots text-t-accent text-base"></i>
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
<button id="cal-prev" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="previous"><i class="ph ph-caret-left text-base"></i></button>
<h2 id="cal-title" class="font-pixel text-sm text-t-accent text-center" style="width:280px;flex-shrink:0;"></h2>
<button id="cal-next" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="next"><i class="ph ph-caret-right text-base"></i></button>
</div>
<div class="flex-1"></div>
<button id="cal-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 id="cal-grid" class="flex-1 flex flex-col overflow-hidden"></div>
</div>
</div>
<!-- Assets (was Gallery) -->
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
<i class="ph ph-folder-open text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">assets</h2>
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
<div id="gallery-tabs" class="flex items-center gap-1 ml-3">
<button class="gallery-tab active font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="all">All</button>
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="image">Images</button>
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="audio">Audio</button>
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="pdf">PDF</button>
</div>
<div class="flex-1"></div>
<button id="gallery-upload" 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-upload-simple text-sm"></i><span class="font-pixel text-[10px]">Upload</span>
</button>
<input type="file" id="gallery-upload-file" accept="image/*,audio/*,.pdf,application/pdf" multiple class="hidden">
<button id="gallery-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 id="gallery-grid" class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 content-start select-none"></div>
</div>
</div>
<!-- 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-join" 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-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join</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-unbucket" 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-minus text-sm"></i><span class="font-pixel text-[10px]">Unsort</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>
</div>
<!-- Context menu -->
<div id="ctx-menu" class="fixed z-[85] bg-t-surface border border-t-border shadow-2xl hidden" style="min-width:180px;">
<div id="ctx-items" class="py-1"></div>
<div id="ctx-colors" class="hidden px-3 py-2 border-t border-t-border">
<div class="flex flex-wrap gap-1.5" id="ctx-color-grid"></div>
</div>
<div id="ctx-buckets" class="hidden border-t border-t-border py-1"></div>
<div id="ctx-members" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
<div id="ctx-relay-tenants" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
<div id="ctx-tags" class="hidden border-t border-t-border py-1">
<div class="px-3 py-1.5"><input id="ctx-tag-input" type="text" placeholder="#tag..." class="w-full bg-t-bg border border-t-border text-t-text text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none"></div>
<div id="ctx-tag-list" class="max-h-36 overflow-y-auto"></div>
</div>
</div>
<!-- Editor -->
<div class="overlay fixed inset-0 z-[60]" style="background:var(--t-overlay)" id="modal">
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
<div class="card-bar w-full shrink-0" id="editor-color"></div>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-t-border shrink-0">
<div class="flex items-center gap-3"><i class="ph ph-file-plus text-t-accent text-sm"></i><h2 id="modal-title" class="font-pixel text-xs text-t-accent">new_card</h2></div>
<div class="flex items-center gap-2">
<button id="modal-cancel" 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>
<button id="modal-save" 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-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
<button id="modal-save-view" 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-eye text-sm"></i><span class="font-pixel text-[10px]">Save+view</span></button>
<button id="modal-save-close" class="flex items-center gap-1.5 px-2.5 py-1 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+close</span></button>
</div>
</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..." spellcheck="false" autocorrect="off" autocapitalize="off" 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" data-tip="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" data-tip="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">
</div>
<div class="flex items-center gap-1 px-4 py-1.5 border-b border-t-border shrink-0" id="md-toolbar">
<button data-md="heading" data-tip="heading" class="toolbar-btn"><i class="ph ph-text-h text-sm"></i></button>
<button data-md="bold" data-tip="bold" class="toolbar-btn"><i class="ph ph-text-bolder text-sm"></i></button>
<button data-md="italic" data-tip="italic" class="toolbar-btn"><i class="ph ph-text-italic text-sm"></i></button>
<button data-md="strikethrough" data-tip="strikethrough" class="toolbar-btn"><i class="ph ph-text-strikethrough text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="code" data-tip="code" class="toolbar-btn"><i class="ph ph-code text-sm"></i></button>
<button data-md="codeblock" data-tip="code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="ul" data-tip="list" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button>
<button data-md="tree" data-tip="tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button>
<button data-md="ol" data-tip="numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button>
<button data-md="quote" data-tip="quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button>
<button data-md="todo" data-tip="todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="link" data-tip="link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button>
<button data-md="cardlink" data-tip="card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
<button data-md="hr" data-tip="divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="extract" data-help="Extract\nMoves selected text into a new card\nand inserts a [[link]] in its place." class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
<button id="md-upload-pdf" data-help="Upload PDF\nSelect a PDF file to upload.\nInserts a download link into the card." class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
<button id="md-asset-picker" data-help="Insert from assets\nBrowse uploaded images, audio, and PDFs.\nClick a tile to insert into the editor." class="toolbar-btn" style="position:relative;"><i class="ph ph-images text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button id="md-find-replace" data-help="Find & replace\nSearch and replace text within this card.\nCtrl+H to open, Ctrl+F for find-only." class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
<?= PluginManager::instance()->slot('md-toolbar') ?>
<div class="flex-1"></div>
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" data-tip="line numbers"><i class="ph ph-hash text-sm"></i></button>
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">Preview</span></button>
<button id="toggle-scratchpad" class="toolbar-btn flex items-center gap-1.5 px-2" data-help="Scratchpad\nPrivate notes about this card.\nSaved separately, not part of the content."><i class="ph ph-notepad text-sm"></i><span class="font-pixel text-[9px]">Scratch</span></button>
</div>
<div class="flex flex-1 min-h-0 overflow-hidden">
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden flex flex-col" style="flex:1 1 0%;min-width:0;">
<!-- Find / Replace bar (Ctrl+H) -->
<div id="editor-find-bar" class="hidden shrink-0 border-b border-t-border bg-t-surface px-3 py-2" style="z-index:5;">
<div class="flex items-center gap-2 mb-1.5">
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
<input id="find-input" type="text" placeholder="find…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
<span id="find-count" class="font-pixel text-[9px] text-t-dim" style="min-width:50px;text-align:center;"></span>
<button id="find-prev" class="toolbar-btn" data-tip="previous"><i class="ph ph-caret-up text-xs"></i></button>
<button id="find-next" class="toolbar-btn" data-tip="next"><i class="ph ph-caret-down text-xs"></i></button>
<button id="find-close" class="toolbar-btn" data-tip="close"><i class="ph ph-x text-xs"></i></button>
</div>
<div id="replace-row" class="flex items-center gap-2">
<i class="ph ph-swap text-xs text-t-dim"></i>
<input id="replace-input" type="text" placeholder="replace…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
<button id="replace-one" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Replace</button>
<button id="replace-all" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">All</button>
</div>
</div>
<div class="relative flex-1 min-h-0">
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
<textarea id="editor-body" placeholder="> write markdown..." spellcheck="false" autocorrect="off" autocapitalize="off" class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
</div>
</div>
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
<div id="editor-scratchpad" class="bg-t-bg flex flex-col hidden" style="flex:1 1 0%;min-width:0;">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-t-border shrink-0">
<i class="ph ph-notepad text-xs text-t-dim"></i>
<span class="font-pixel text-[9px] text-t-dim flex-1">scratchpad</span>
<button id="scratchpad-clear" class="font-pixel text-[8px] text-t-muted hover:text-t-danger transition-colors">clear</button>
</div>
<textarea id="scratchpad-body" placeholder="private notes..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 text-t-muted font-mono placeholder:text-t-dim border-none overflow-y-auto bg-transparent" style="padding:1rem;font-size:11px;line-height:1.625;resize:none;outline:none;"></textarea>
</div>
</div>
</div>
</div>
<!-- Plugin HTML fragments (overlays, modals, etc.) -->
<?= PluginManager::instance()->htmlFragments() ?>
<!-- Plugin API: must load BEFORE any ES module so window.hyprcrd exists -->
<script src="js/hyprcrd.js?v=<?= time() ?>"></script>
<!-- Plugin entry points (load before app.js so they self-register first) -->
<?= PluginManager::instance()->scriptTags() ?>
<script type="module" src="js/app.js?v=<?= time() ?>"></script>
</body>
</html>