Small bookmark icon in the search header bar opens a popup listing localStorage-persisted search strings. Clicking an entry fills the input and runs the search; the x button deletes it; "save current search" at the bottom saves the active input (no dupes). List persists across sessions, no server round-trip. Tests: 6 cases covering button/popup toggle, save + recall, delete, save-current-button, and duplicate rejection. All snapshot/restore localStorage in try/finally so they don't leak into user data or cross-test state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2393 lines
180 KiB
PHP
2393 lines
180 KiB
PHP
<?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 & 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-shopping-cart text-sm"></i>shopping list (phone sync)</h3>
|
||
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
|
||
<p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Click the header's <span class="text-t-bright font-pixel">Shopping</span> button to pick which one is the currently <span class="text-t-bright">active</span> list — only one at a time is mirrored to the phone.</p>
|
||
<p>The active card's body is pushed to the relay on every sync tick. On your phone, log into the relay UI (same credentials as everywhere else) and open <span class="text-t-bright font-pixel">shopping</span> from the menu. You see only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p>
|
||
<p>Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).</p>
|
||
</div>
|
||
<table class="w-full text-xs">
|
||
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-hash text-xs"></i> trigger tags</td><td><span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> — either counts</td></tr>
|
||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-shopping-cart text-xs"></i> pick active</td><td>header Shopping button → radio select → Save</td></tr>
|
||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-device-mobile text-xs"></i> phone access</td><td>relay UI → shopping tab (same login)</td></tr>
|
||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrows-clockwise text-xs"></i> sync cadence</td><td>piggybacks on the shared sync tick (see Settings → Relay)</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" for="setting-sync-interval">Poll every</label>
|
||
<input id="setting-sync-interval" type="range" min="0" max="14" step="1" class="design-slider">
|
||
<span id="setting-sync-interval-val" class="text-[11px] text-t-muted" style="min-width:52px;text-align:right;flex-shrink:0;">15s</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>
|
||
<div class="relative">
|
||
<button id="search-saved-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-tip="saved searches">
|
||
<i class="ph ph-bookmark-simple text-sm"></i>
|
||
</button>
|
||
<div id="search-saved-popup" class="hidden absolute right-0 top-full mt-1 min-w-[220px] max-h-[280px] bg-t-surface border border-t-border shadow-2xl z-50 flex flex-col" style="border-radius:var(--ui-radius-modal,4px)">
|
||
<div id="search-saved-list" class="overflow-y-auto flex-1"></div>
|
||
<button id="search-saved-add" class="w-full text-left px-3 py-2 text-[10px] font-pixel text-t-accent border-t border-t-border hover:bg-t-raised transition-colors"><i class="ph ph-plus text-xs"></i> save current search</button>
|
||
</div>
|
||
</div>
|
||
<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>
|