From e85b5203cd4c4da1b3a5e20c1a3557706ee98d05 Mon Sep 17 00:00:00 2001 From: th Date: Mon, 6 Apr 2026 18:09:04 +0200 Subject: [PATCH] bak --- .codex | 0 .dockerignore | 4 + .gitignore | 8 + Dockerfile | 24 + config.php | 14 + data/.gitkeep | 0 docker-compose.yml | 10 + docker-entrypoint.sh | 6 + public/.htaccess | 4 + public/css/style.css | 264 ++++++ public/index.php | 555 ++++++++++++ public/js/app.js | 1806 +++++++++++++++++++++++++++++++++++++++ public/uploads/.gitkeep | 0 src/Database.php | 72 ++ src/bootstrap.php | 12 + 15 files changed, 2779 insertions(+) create mode 100644 .codex create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 config.php create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100755 docker-entrypoint.sh create mode 100644 public/.htaccess create mode 100644 public/css/style.css create mode 100644 public/index.php create mode 100644 public/js/app.js create mode 100644 public/uploads/.gitkeep create mode 100644 src/Database.php create mode 100644 src/bootstrap.php diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..283f8f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +data/*.db +.env +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d79f80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/data/*.db +/public/uploads/* +!/public/uploads/.gitkeep +/vendor/ +.env +*.swp +*.swo +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..59cdc3d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM php:8.3-apache + +# Enable mod_rewrite +RUN a2enmod rewrite + +# Install SQLite dev libs + PDO extension +RUN apt-get update && apt-get install -y libsqlite3-dev libzip-dev && rm -rf /var/lib/apt/lists/* +RUN docker-php-ext-install pdo_sqlite zip + +# Set document root to public/ +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public +RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ + /etc/apache2/sites-available/*.conf \ + /etc/apache2/apache2.conf + +# Allow .htaccess overrides +RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' \ + /etc/apache2/apache2.conf + +COPY . /var/www/html + +COPY docker-entrypoint.sh /usr/local/bin/ +EXPOSE 80 +CMD ["docker-entrypoint.sh"] diff --git a/config.php b/config.php new file mode 100644 index 0000000..970f34b --- /dev/null +++ b/config.php @@ -0,0 +1,14 @@ + div { + transition: transform 0.2s ease, opacity 0.2s ease; +} + +/* Overlays */ +.overlay { transition: opacity 0.15s; } +.overlay:not(.open) { opacity: 0; pointer-events: none; } +.overlay.open { opacity: 1; } + +/* Theme picker */ +.theme-picker { position: relative; } +.theme-menu { display: none; position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 200; } +.theme-picker.open .theme-menu { display: block; } + +/* Buckets */ +.bucket-tab.active { border-color: var(--t-accent); color: var(--t-accent); } +.bucket-tab.drag-over { border-color: var(--t-accent); background: var(--t-raised); } + +/* Toolbar */ +.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; } +.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); } + +/* Editor */ +#editor-body { tab-size: 4; resize: none; } +#editor-body:focus { outline: none; } + +/* Search */ +#search { backdrop-filter: blur(4px); } +#search-input { caret-color: var(--t-accent); } +#search-input:focus { outline: none; } +.search-hit { scroll-margin: 8px; } +.search-hit.active { background: var(--t-raised); } +.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; } + +/* Context menu */ +#ctx-menu .ctx-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 11px; + color: var(--t-text); + cursor: pointer; + transition: background 0.1s; + width: 100%; + background: none; + border: none; + font-family: inherit; + text-align: left; +} +#ctx-menu .ctx-item:hover { background: var(--t-raised); } +#ctx-menu .ctx-item.danger { color: var(--t-danger); } +#ctx-menu .ctx-item.danger:hover { background: var(--t-danger); color: var(--t-bg); } +#ctx-menu .ctx-sep { height: 1px; background: var(--t-border); margin: 2px 0; } + +/* Card links */ +.card-link { color: var(--t-accent2); cursor: pointer; border-bottom: 1px dashed var(--t-accent2); transition: color 0.1s; } +.card-link:hover { color: var(--t-accent); border-color: var(--t-accent); } +.card-link.broken { color: var(--t-danger); border-color: var(--t-danger); opacity: 0.6; } + +/* Autocomplete */ +#link-ac .ac-item { padding: 6px 12px; cursor: pointer; transition: background 0.1s; } +#link-ac .ac-item:hover, #link-ac .ac-item.active { background: var(--t-raised); } +#link-ac .ac-item .ac-title { font-size: 12px; color: var(--t-bright); } +#link-ac .ac-item .ac-bucket { font-size: 9px; color: var(--t-dim); margin-left: 6px; } + +/* Prose (markdown) */ +.prose { line-height: 1.7; } +.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-pixel, 'Silkscreen', cursive); margin: 1.25em 0 0.5em; } +.prose h1 { font-size: 1.4em; } +.prose h2 { font-size: 1.15em; } +.prose h3 { font-size: 1em; } +.prose p { margin: 0.6em 0; } +.prose a { color: var(--t-accent); text-decoration: underline; } +.prose strong { color: var(--t-bright); font-weight: 700; } +.prose em { font-style: italic; } +.prose code { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.88em; } +.prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; } +.prose pre code { background: none; border: none; padding: 0; font-size: 0.85em; } +.prose ul, .prose ol { padding-left: 1.5em; margin: 0.6em 0; } +.prose li { margin: 0.25em 0; } +.prose li::marker { color: var(--t-dim); } +.prose blockquote { border-left: 3px solid var(--t-accent); padding-left: 1em; margin: 0.8em 0; color: var(--t-muted); } +.prose hr { border: none; border-top: 1px solid var(--t-border); margin: 1.2em 0; } +/* Todo checkboxes */ +.prose input[type="checkbox"] { + appearance: none; + width: 30px; + height: 30px; + border: 1px solid var(--t-dim); + border-radius: 4px; + background: var(--t-bg); + cursor: pointer; + vertical-align: middle; + margin-right: 8px; + position: relative; + box-sizing: border-box; +} +.prose input[type="checkbox"]:checked { + background: var(--t-accent); + border-color: var(--t-accent); +} +.prose input[type="checkbox"]::after { + content: ''; + position: absolute; + left: 9px; + top: 4px; + width: 8px; + height: 14px; + border: solid transparent; + border-width: 0 3px 3px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.12s ease, border-color 0.12s ease; +} +.prose input[type="checkbox"]:checked::after { + border-color: var(--t-bg); + opacity: 1; +} +.prose input[type="checkbox"]:not(:checked):hover::after { + border-color: var(--t-accent); + opacity: 0.75; +} +.prose li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.5em; } + +.prose img { + max-width: 100%; + border-radius: 4px; + border: 1px solid var(--t-border); + margin: 0.8em 0; + cursor: nwse-resize; +} +.prose img.resizing { outline: 2px solid var(--t-accent); } +.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; } +.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; } +.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; } diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..b9797b3 --- /dev/null +++ b/public/index.php @@ -0,0 +1,555 @@ +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]); + 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['hyprcrd_auth'] = true; + echo json_encode(['ok' => true]); + exit; + } + + // POST /api/auth/login + if ($route === '/api/auth/login' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + $pw = $input['password'] ?? ''; + if ($pwHash && password_verify($pw, $pwHash)) { + $_SESSION['hyprcrd_auth'] = true; + echo json_encode(['ok' => true]); + } else { + 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; + } + + 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'); + + // 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; + $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position) VALUES (:title, :body, :color, :bucket_id, :position)')->execute([ + 'title' => $title, 'body' => $input['body'] ?? '', 'color' => $input['color'] ?? '#ffffff', 'bucket_id' => $input['bucket_id'] ?? null, 'position' => $pos, + ]); + 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') { + 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 (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') { + $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]); + echo json_encode(['ok' => true]); exit; + } + + if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') { + $input = json_decode(file_get_contents('php://input'), true); + $fields = []; $params = ['id' => $m[1]]; + foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id'] as $f) { + if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } + } + 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; + } + + // 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; } + $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); + $fields = []; $params = ['id' => $m[1]]; + foreach (['name', 'position'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$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') { + $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]); + $db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]); + echo json_encode(['ok' => true]); exit; + } + + // Upload + if ($route === '/api/upload' && $method === 'POST') { + $uploadDir = __DIR__ . '/uploads'; + 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', 'files' => array_keys($_FILES), 'err' => $_FILES['file']['error'] ?? 'missing']); + exit; + } + + $file = $_FILES['file']; + $allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/svg+xml' => 'svg']; + $ext = $allowed[$file['type']] ?? null; + if (!$ext) { + http_response_code(400); + echo json_encode(['error' => 'invalid file type: ' . $file['type']]); + exit; + } + + $name = bin2hex(random_bytes(8)) . '.' . $ext; + $dest = $uploadDir . '/' . $name; + if (!move_uploaded_file($file['tmp_name'], $dest)) { + http_response_code(500); + echo json_encode(['error' => 'failed to save, check permissions on ' . $uploadDir]); + exit; + } + + http_response_code(201); + echo json_encode(['url' => 'uploads/' . $name, 'name' => $name]); + exit; + } + + // Export — ZIP with JSON dump + uploads + if ($route === '/api/export' && $method === 'GET') { + $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], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + $uploadDir = __DIR__ . '/uploads'; + if (is_dir($uploadDir)) { + foreach (glob($uploadDir . '/*') as $f) { + if (is_file($f)) $zip->addFile($f, 'uploads/' . basename($f)); + } + } + $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; + } + + // Clear existing data + $db->exec('DELETE FROM cards'); + $db->exec('DELETE FROM buckets'); + + // Import buckets + $bucketMap = []; + foreach ($data['buckets'] as $b) { + $oldId = $b['id']; + $stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)'); + $stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]); + $bucketMap[$oldId] = $db->lastInsertId(); + } + + // Import cards + foreach ($data['cards'] as $c) { + $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, created_at, updated_at) VALUES (:title, :body, :color, :bucket_id, :position, :created_at, :updated_at)'); + $stmt->execute([ + 'title' => $c['title'], 'body' => $c['body'], 'color' => $c['color'], + 'bucket_id' => $bucketId, 'position' => $c['position'] ?? 0, + 'created_at' => $c['created_at'] ?? date('Y-m-d H:i:s'), + 'updated_at' => $c['updated_at'] ?? date('Y-m-d H:i:s'), + ]); + } + + // Extract uploads + $uploadDir = __DIR__ . '/uploads'; + 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; + } + + http_response_code(404); echo json_encode(['error' => 'not found']); exit; +} + +// ── HTML Shell ── +?> + + + + + + hyprcrd + + + + + + + + + + + + +
+
+ +

hyprcrd

+ v0.1 +
+
+ + + +
+
+ +
+
+
+ +
+ +
+ + + + +
+
+
+ +

+
+ +
+
+

+ +
+
+ + +
+
+
+ + + + + +
+
+
+ +

global_tasks

+
+ + + +
+
+
+
+
+ + +
+
+ + + +
+
+
+ + + +
+
+

+
+ +
+
+
+
+
+ + + + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..3fb9b78 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,1806 @@ +/* ══════════════════════════════════════ + hyprcrd — app.js + ══════════════════════════════════════ */ + +// ── Utilities ── + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +function escRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function api(path, opts = {}) { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json' }, + ...opts, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + return res.json(); +} + +// ── Custom dialogs ── + +const dialogOverlay = document.getElementById('dialog'); +const dialogTitle = document.getElementById('dialog-title'); +const dialogMsg = document.getElementById('dialog-msg'); +const dialogInput = document.getElementById('dialog-input'); +const dialogCancel = document.getElementById('dialog-cancel'); +const dialogOk = document.getElementById('dialog-ok'); +const dialogClose = document.getElementById('dialog-close'); +let dialogResolve = null; + +function showDialog({ title, message = '', input = false, placeholder = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) { + return new Promise(resolve => { + dialogResolve = resolve; + dialogTitle.textContent = title; + dialogMsg.textContent = message; + dialogMsg.classList.toggle('hidden', !message); + dialogInput.classList.toggle('hidden', input === false); + dialogInput.value = ''; + dialogInput.placeholder = placeholder; + dialogOk.textContent = okLabel; + dialogOk.classList.toggle('border-t-danger', danger); + dialogOk.classList.toggle('text-t-danger', danger); + dialogOk.classList.toggle('border-t-accent', !danger); + dialogOk.classList.toggle('text-t-accent', !danger); + dialogCancel.textContent = cancelLabel; + dialogCancel.classList.toggle('hidden', !cancelLabel); + dialogClose.classList.toggle('hidden', !cancelLabel); + dialogInput.type = input === 'password' ? 'password' : 'text'; + dialogOverlay.classList.add('open'); + if (input) dialogInput.focus(); else dialogOk.focus(); + }); +} + +function closeDialog(result) { + dialogOverlay.classList.remove('open'); + if (dialogResolve) { dialogResolve(result); dialogResolve = null; } +} + +dialogOk.addEventListener('click', () => { + const val = dialogInput.classList.contains('hidden') ? true : dialogInput.value; + closeDialog(val); +}); +dialogCancel.addEventListener('click', () => { if (!dialogCancel.classList.contains('hidden')) closeDialog(null); }); +dialogClose.addEventListener('click', () => { if (!dialogClose.classList.contains('hidden')) closeDialog(null); }); +dialogOverlay.addEventListener('click', e => { if (e.target === dialogOverlay && !dialogCancel.classList.contains('hidden')) closeDialog(null); }); +dialogOverlay.addEventListener('keydown', e => { + if (e.key === 'Escape' && !dialogCancel.classList.contains('hidden')) closeDialog(null); + if (e.key === 'Enter') { + const val = dialogInput.classList.contains('hidden') ? true : dialogInput.value; + closeDialog(val); + } +}); + +// ── Markdown ── + +marked.use({ breaks: true, gfm: true }); + +let currentCards = []; + +function renderMd(src) { + const linked = (src || '').replace(/\[\[([^\]]+)\]\]/g, (_, title) => { + const card = currentCards.find(c => c.title.toLowerCase() === title.toLowerCase()); + if (card) return `${esc(title)}`; + return `${esc(title)}`; + }); + return marked.parse(linked); +} + +function getTodos(src) { + const s = src || ''; + const total = (s.match(/- \[([ x])\]/g) || []).length; + if (!total) return null; + const done = (s.match(/- \[x\]/g) || []).length; + return { total, done }; +} + +function firstImage(src) { + const s = src || ''; + const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/); + if (md) return md[1]; + const html = s.match(/]+src="([^"]+)"/i); + return html ? html[1] : null; +} + +function leadingImage(src) { + const s = (src || '').trimStart(); + if (!s) return null; + + const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/); + if (md) { + const raw = md[1].trim(); + const withoutTitle = raw.split(/\s+/)[0]; + const url = withoutTitle.replace(/^<(.+)>$/, '$1'); + return url || null; + } + + const html = s.match(/^]+src=(['"])(.*?)\1/i); + return html ? html[2] : null; +} + +function stripMd(src) { + return (src || '') + .replace(/]*>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[\[([^\]]+)\]\]/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/(\*{1,3}|_{1,3}|~~)(.*?)\1/g, '$2') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/`{1,3}[^`]*`{1,3}/g, '') + .replace(/^[-*>]\s+/gm, '') + .replace(/^\d+\.\s+/gm, '') + .replace(/\n{2,}/g, '\n') + .trim(); +} + +// ── Theme picker ── + +const picker = document.getElementById('theme-picker'); +const themeList = document.getElementById('theme-list'); +const themeToggle = document.getElementById('theme-toggle'); + +if (themeList) { + for (const [key, t] of Object.entries(themes)) { + const btn = document.createElement('button'); + btn.className = 'flex items-center gap-2.5 w-full px-2 py-1.5 text-left text-xs text-t-muted hover:text-t-bright transition-colors rounded'; + btn.dataset.themeDot = key; + btn.innerHTML = ` + ${t.label}`; + btn.onclick = () => applyTheme(key); + themeList.appendChild(btn); + } +} +applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate'); + +if (picker && themeToggle) { + themeToggle.addEventListener('click', () => picker.classList.toggle('open')); + document.addEventListener('click', e => { if (!picker.contains(e.target)) picker.classList.remove('open'); }); +} + +// ── Color presets ── + +const cardColors = [ + { name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' }, + { name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' }, + { name: 'green', hex: '#4ade80' }, { name: 'orange', hex: '#fb923c' }, + { name: 'cyan', hex: '#22d3ee' }, { name: 'pink', hex: '#f472b6' }, + { name: 'lime', hex: '#a3e635' }, { name: 'slate', hex: '#94a3b8' }, +]; + +const presetContainer = document.getElementById('color-presets'); +const inpColor = document.getElementById('inp-color'); + +cardColors.forEach(c => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.title = c.name; + btn.dataset.color = c.hex; + btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch'; + btn.style.background = c.hex; + btn.addEventListener('click', () => selectColor(c.hex)); + presetContainer.appendChild(btn); +}); + +function selectColor(hex) { + inpColor.value = hex; + presetContainer.querySelectorAll('.color-swatch').forEach(el => { + const active = el.dataset.color === hex; + el.classList.toggle('border-t-bright', active); + el.classList.toggle('border-transparent', !active); + el.classList.toggle('scale-125', active); + }); +} + +// ── Buckets ── + +const bucketBar = document.getElementById('bucket-bar'); +const inpBucket = document.getElementById('inp-bucket'); +const searchBucketFilter = document.getElementById('search-bucket-filter'); +const tasksBucketFilter = document.getElementById('tasks-bucket-filter'); +let currentBuckets = []; +let activeBucket = 'all'; + +async function loadBuckets() { + currentBuckets = await api('api/buckets'); + renderBucketBar(); + populateBucketSelects(); +} + +function renderBucketBar() { + const tabClass = (active) => `bucket-tab ${active ? 'active' : ''} 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 shrink-0`; + + bucketBar.innerHTML = ` + + + ${currentBuckets.map(b => ` + `).join('')} + `; + + bucketBar.querySelectorAll('.bucket-tab').forEach(tab => { + tab.addEventListener('click', () => { activeBucket = tab.dataset.bucket; renderBucketBar(); renderBoard(); }); + tab.addEventListener('dragover', e => { e.preventDefault(); tab.classList.add('drag-over'); }); + tab.addEventListener('dragleave', () => tab.classList.remove('drag-over')); + tab.addEventListener('drop', async e => { + e.preventDefault(); + tab.classList.remove('drag-over'); + const cardId = e.dataTransfer.getData('text/plain'); + if (!cardId) return; + const bucketId = tab.dataset.bucket === 'none' ? null : (tab.dataset.bucket === 'all' ? undefined : tab.dataset.bucket); + if (bucketId === undefined) return; + await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId } }); + await reloadAll(); + }); + }); + + document.getElementById('add-bucket-btn').addEventListener('click', async () => { + const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' }); + if (!name || !name.trim()) return; + await api('api/buckets', { method: 'POST', body: { name: name.trim() } }); + await loadBuckets(); + renderBoard(); + }); +} + +function populateBucketSelects() { + inpBucket.innerHTML = `` + + currentBuckets.map(b => ``).join(''); + searchBucketFilter.innerHTML = `` + + currentBuckets.map(b => ``).join(''); + if (tasksBucketFilter) { + const current = tasksBucketFilter.value; + tasksBucketFilter.innerHTML = `` + + currentBuckets.map(b => ``).join(''); + const stillExists = current === '' || current === 'none' || currentBuckets.some(b => String(b.id) === current); + tasksBucketFilter.value = stillExists ? current : ''; + } +} + +// ── Board ── + +const board = document.getElementById('board'); +let activeGroup = null; // card id of group parent, or null +let sortMode = localStorage.getItem('hyprcrd-sort') || 'manual'; // 'manual', 'newest', 'oldest', 'alpha' +const sortModes = ['manual', 'newest', 'oldest', 'alpha']; +const sortIcons = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa' }; + +function updateSortUI() { + document.getElementById('sort-label').textContent = sortMode; + const icon = document.querySelector('#sort-toggle i'); + icon.className = `ph ${sortIcons[sortMode]} text-xs`; +} + +document.getElementById('sort-toggle').addEventListener('click', () => { + sortMode = sortModes[(sortModes.indexOf(sortMode) + 1) % sortModes.length]; + localStorage.setItem('hyprcrd-sort', sortMode); + updateSortUI(); + renderBoard(); +}); + +async function loadCards() { + currentCards = await api('api/cards'); + updateSortUI(); + renderBoard(); +} + +async function reloadAll() { + currentCards = await api('api/cards'); + renderBoard(); +} + +function sortedCards(cards) { + const arr = [...cards]; + switch (sortMode) { + case 'newest': return arr.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')); + case 'oldest': return arr.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || '')); + case 'alpha': return arr.sort((a, b) => (a.title || '').localeCompare(b.title || '')); + default: return arr.sort((a, b) => (a.position || 0) - (b.position || 0)); + } +} + +function getChildren(parentId) { + return currentCards.filter(c => c.parent_id == parentId); +} + +function isGroupParent(id) { + return currentCards.some(c => c.parent_id == id); +} + +function renderBoard() { + let cards; + if (activeGroup) { + const parent = currentCards.find(c => c.id == activeGroup); + cards = parent ? [parent, ...getChildren(parent.id)] : []; + } else { + cards = currentCards.filter(c => !c.parent_id); + if (activeBucket === 'none') cards = cards.filter(c => !c.bucket_id); + else if (activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == activeBucket); + } + cards = sortedCards(cards); + refreshTasksView(); + + // Group breadcrumb bar + const groupBar = document.getElementById('group-bar'); + if (activeGroup) { + const parent = currentCards.find(c => c.id == activeGroup); + groupBar.classList.remove('hidden'); + groupBar.innerHTML = ` + + + ${esc(parent?.title || 'group')} + ${cards.length} cards`; + groupBar.querySelector('#group-back').addEventListener('click', () => { activeGroup = null; renderBoard(); }); + } else { + groupBar.classList.add('hidden'); + } + + if (!cards.length) { + board.innerHTML = `
+ +
`; + return; + } + + const recent = getRecent(); + const inGroup = !!activeGroup; + board.innerHTML = cards.map(c => { + const img = firstImage(c.body); + const coverImg = leadingImage(c.body); + const hasCover = !!coverImg; + const thumbImg = hasCover ? null : img; + const children = getChildren(c.id); + const isGroup = !inGroup && children.length > 0; + const todos = getTodos(c.body); + const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0; + return ` +
+ ${hasCover ? '
' : ''} +
+ ${isGroup ? `
${children.length + 1}
` : ''} + ${thumbImg ? `
` : ''} +
+
+ ${todos ? `` : ''} +

${esc(c.title)}

+
+ ${todos ? ` +
+
+
+
+
+ ${todos.done}/${todos.total} +
+
` : ''} +

${esc(stripMd(c.body))}

+ ${c.bucket_name ? `
${esc(c.bucket_name)}
` : ''} +
+
`; + }).join(''); + + // Group badge clicks + board.querySelectorAll('[data-group]').forEach(badge => { + badge.addEventListener('click', e => { + e.stopPropagation(); + activeGroup = badge.dataset.group; + renderBoard(); + }); + }); + + // Events + board.querySelectorAll('[data-id]').forEach(el => { + const id = el.dataset.id; + + // Click = detail (delayed), dblclick = edit + let clickTimer = null; + el.addEventListener('click', () => { + if (clickTimer) return; + clickTimer = setTimeout(() => { clickTimer = null; openDetail(id); }, 200); + }); + el.addEventListener('dblclick', e => { + e.preventDefault(); + if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } + const card = currentCards.find(c => c.id == id); + if (card) openModal(card); + }); + + // Right-click context menu + el.addEventListener('contextmenu', e => { + e.preventDefault(); + e.stopPropagation(); + const card = currentCards.find(c => c.id == id); + if (card) showContextMenu(e.clientX, e.clientY, card); + }); + + el.addEventListener('dragstart', e => { + e.dataTransfer.setData('text/plain', id); + e.dataTransfer.effectAllowed = 'move'; + el.classList.add('card-dragging'); + }); + el.addEventListener('dragend', () => { + el.classList.remove('card-dragging'); + board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x => + x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group')); + }); + + el.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = el.getBoundingClientRect(); + const edgeZone = rect.width * 0.25; + const x = e.clientX - rect.left; + const isLeft = x < edgeZone; + const isRight = x > rect.width - edgeZone; + const isCenter = !isLeft && !isRight; + + el.classList.toggle('card-drop-before', isLeft); + el.classList.toggle('card-drop-after', isRight); + el.classList.toggle('card-drop-group', isCenter); + }); + el.addEventListener('dragleave', () => { + el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'); + }); + el.addEventListener('drop', async e => { + e.preventDefault(); + const wasGroup = el.classList.contains('card-drop-group'); + const wasBefore = el.classList.contains('card-drop-before'); + el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'); + const dragId = e.dataTransfer.getData('text/plain'); + if (!dragId || dragId === id) return; + + if (wasGroup) { + // Group: set dragged card's parent_id to target + await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: id } }); + await reloadAll(); + return; + } + + // Reorder + let filtered; + if (activeGroup) { + const parent = currentCards.find(c => c.id == activeGroup); + filtered = parent ? sortedCards([parent, ...getChildren(parent.id)]) : []; + } else { + filtered = sortedCards(currentCards.filter(c => { + if (c.parent_id) return false; + if (activeBucket === 'none') return !c.bucket_id; + if (activeBucket !== 'all') return c.bucket_id == activeBucket; + return true; + })); + } + const ids = filtered.map(c => String(c.id)).filter(i => i !== String(dragId)); + const targetIdx = ids.indexOf(String(id)); + const insertIdx = wasBefore ? targetIdx : targetIdx + 1; + ids.splice(insertIdx, 0, String(dragId)); + + // Ungroup dragged card if it was a child (when dropping on main board) + const dragCard = currentCards.find(c => c.id == dragId); + if (!activeGroup && dragCard && dragCard.parent_id) { + await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: null } }); + } + + // FLIP: capture old positions + const oldRects = {}; + board.querySelectorAll('[data-id]').forEach(card => { + oldRects[card.dataset.id] = card.getBoundingClientRect(); + }); + + const updates = ids.map((cid, i) => api(`api/cards/${cid}`, { method: 'PATCH', body: { position: i } })); + await Promise.all(updates); + + // If in group view and first card changed, swap parent + if (activeGroup && ids[0] !== String(activeGroup)) { + const newParentId = ids[0]; + const oldParentId = String(activeGroup); + // New first card becomes the parent: clear its parent_id + await api(`api/cards/${newParentId}`, { method: 'PATCH', body: { parent_id: null } }); + // Old parent becomes a child of the new parent + await api(`api/cards/${oldParentId}`, { method: 'PATCH', body: { parent_id: newParentId } }); + // All other children point to new parent + for (const cid of ids.slice(1)) { + if (cid !== oldParentId) { + await api(`api/cards/${cid}`, { method: 'PATCH', body: { parent_id: newParentId } }); + } + } + activeGroup = newParentId; + } + + sortMode = 'manual'; + localStorage.setItem('hyprcrd-sort', 'manual'); + updateSortUI(); + await reloadAll(); + + // FLIP: animate + board.querySelectorAll('[data-id]').forEach(card => { + const oldRect = oldRects[card.dataset.id]; + if (!oldRect) return; + const newRect = card.getBoundingClientRect(); + const dx = oldRect.left - newRect.left; + const dy = oldRect.top - newRect.top; + if (dx === 0 && dy === 0) return; + card.style.transition = 'none'; + card.style.transform = `translate(${dx}px, ${dy}px)`; + requestAnimationFrame(() => { + card.style.transition = 'transform 0.25s ease'; + card.style.transform = ''; + }); + }); + }); + }); +} + +// ── Detail view ── + +const detail = document.getElementById('detail'); +const detailScroll = document.getElementById('detail-scroll'); +const detailTitle = document.getElementById('detail-title'); +const detailBody = document.getElementById('detail-body'); +const detailMeta = document.getElementById('detail-meta'); +let viewingCard = null; +let openedFromSearch = false; + +const DETAIL_ZOOM_KEY = 'hyprcrd-detail-zoom'; +const DETAIL_ZOOM_STEP = 0.08; +const DETAIL_ZOOM_MIN = 0.75; +const DETAIL_ZOOM_MAX = 2.2; +let detailZoom = Number.parseFloat(localStorage.getItem(DETAIL_ZOOM_KEY) || '1'); +if (!Number.isFinite(detailZoom)) detailZoom = 1; + +function setDetailZoom(nextZoom) { + detailZoom = Math.min(DETAIL_ZOOM_MAX, Math.max(DETAIL_ZOOM_MIN, nextZoom)); + detailZoom = Math.round(detailZoom * 100) / 100; + localStorage.setItem(DETAIL_ZOOM_KEY, String(detailZoom)); + applyDetailZoom(); +} + +function applyDetailZoom() { + if (!detailTitle || !detailBody || !detailMeta) return; + detailTitle.style.fontSize = `${(1.125 * detailZoom).toFixed(3)}rem`; + detailBody.style.fontSize = `${(0.875 * detailZoom).toFixed(3)}rem`; + detailMeta.style.fontSize = `${Math.max(0.625, 0.6875 * detailZoom).toFixed(3)}rem`; + detailTitle.style.lineHeight = `${Math.max(1.35, 1.55 + (detailZoom - 1) * 0.3).toFixed(3)}`; + detailBody.style.lineHeight = `${Math.max(1.5, 1.7 + (detailZoom - 1) * 0.35).toFixed(3)}`; + detailMeta.style.lineHeight = `${Math.max(1.2, 1.35 + (detailZoom - 1) * 0.25).toFixed(3)}`; +} + +applyDetailZoom(); + +// Recently opened tracking +function getRecent() { + try { return JSON.parse(localStorage.getItem('hyprcrd-recent') || '[]'); } catch { return []; } +} +function markRecent(id) { + const r = getRecent().filter(i => i != id); + r.unshift(String(id)); + localStorage.setItem('hyprcrd-recent', JSON.stringify(r.slice(0, 10))); +} +function isRecent(id) { return getRecent().includes(String(id)); } + +// Resolve the group context: if card is a child, find parent + siblings +function getGroupCards(card) { + if (card.parent_id) { + const parent = currentCards.find(c => c.id == card.parent_id); + if (parent) return [parent, ...getChildren(parent.id)]; + } + const children = getChildren(card.id); + if (children.length) return [card, ...children]; + return null; // not a group +} + +function openDetail(id) { + const card = currentCards.find(c => c.id == id); + if (!card) return; + markRecent(id); + const el = board.querySelector(`[data-id="${id}"]`); + if (el) el.style.borderBottom = `3px solid ${card.color}`; + if (!detail.classList.contains('open')) { + openedFromSearch = searchOverlay.classList.contains('open'); + } + viewingCard = card; + + // Group sidebar + const sidebar = document.getElementById('detail-sidebar'); + const sidebarList = document.getElementById('detail-sidebar-list'); + const groupCards = getGroupCards(card); + + if (groupCards && groupCards.length > 1) { + sidebar.classList.remove('hidden'); + document.getElementById('detail-sidebar-count').textContent = groupCards.length; + sidebarList.innerHTML = groupCards.map(gc => { + const active = gc.id == card.id; + const img = firstImage(gc.body); + return ` + `; + }).join(''); + + sidebarList.querySelectorAll('.sidebar-card').forEach(sc => { + sc.addEventListener('click', () => showDetailCard(sc.dataset.sid)); + }); + } else { + sidebar.classList.add('hidden'); + } + + showDetailContent(card); + detail.classList.add('open'); +} + +function showDetailCard(id) { + const card = currentCards.find(c => c.id == id); + if (!card) return; + viewingCard = card; + markRecent(id); + + // Update sidebar active state + document.querySelectorAll('#detail-sidebar-list .sidebar-card').forEach(sc => { + const active = sc.dataset.sid == id; + sc.classList.toggle('border-t-accent', active); + sc.classList.toggle('bg-t-raised', active); + sc.classList.toggle('border-transparent', !active); + sc.querySelector('span:last-of-type')?.classList.toggle('text-t-bright', active); + sc.querySelector('span:last-of-type')?.classList.toggle('text-t-muted', !active); + }); + + showDetailContent(card); +} + +function showDetailContent(card) { + document.getElementById('detail-color').style.background = card.color; + detailTitle.textContent = card.title; + detailBody.innerHTML = renderMd(card.body); + + // Make checkboxes interactive + detailBody.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => { + cb.disabled = false; + cb.addEventListener('change', async () => { + // Toggle the nth checkbox in markdown source + let body = card.body; + let n = 0; + body = body.replace(/- \[([ x])\]/g, (match, state) => { + if (n++ === idx) return `- [${state === 'x' ? ' ' : 'x'}]`; + return match; + }); + card.body = body; + await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); + // Update card in local state + const c = currentCards.find(x => x.id == card.id); + if (c) c.body = body; + renderBoard(); + }); + }); + + const meta = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); + detailMeta.textContent = meta; + applyDetailZoom(); + + // Backlinks + const pattern = `[[${card.title}]]`.toLowerCase(); + const backlinks = currentCards.filter(c => c.id != card.id && (c.body || '').toLowerCase().includes(pattern)); + const blContainer = document.getElementById('detail-backlinks'); + const blList = document.getElementById('detail-backlinks-list'); + if (backlinks.length) { + blContainer.classList.remove('hidden'); + blList.innerHTML = backlinks.map(b => ` + `).join(''); + } else { + blContainer.classList.add('hidden'); + } +} + +function closeDetail() { + detail.classList.remove('open'); + viewingCard = null; + if (openedFromSearch) { openedFromSearch = false; searchInput.focus(); } +} + +detail.addEventListener('click', e => { if (e.target === detail) closeDetail(); }); + +if (detailScroll) { + detailScroll.addEventListener('wheel', e => { + if (!detail.classList.contains('open')) return; + if (!e.shiftKey) return; // zoom only with Shift+wheel + if (Math.abs(e.deltaY) < Math.abs(e.deltaX)) return; + e.preventDefault(); + const step = e.deltaY < 0 ? DETAIL_ZOOM_STEP : -DETAIL_ZOOM_STEP; + setDetailZoom(detailZoom + step); + }, { passive: false }); +} + +async function ungroupCard(id) { + await api(`api/cards/${id}`, { method: 'PATCH', body: { parent_id: null } }); + await reloadAll(); + if (viewingCard) { + // If we ungrouped the card we're viewing, or the parent, re-open + if (viewingCard.id == id) { + openDetail(id); + } else { + openDetail(viewingCard.id); + } + } +} + +document.getElementById('detail-edit').addEventListener('click', () => { + if (!viewingCard) return; + const card = viewingCard; + openedFromSearch = false; + closeDetail(); + closeSearch(); + openModal(card); +}); + +document.getElementById('detail-delete').addEventListener('click', async () => { + if (!viewingCard) return; + const ok = await showDialog({ title: 'delete_card', message: `"${viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); + if (!ok) return; + await api(`api/cards/${viewingCard.id}`, { method: 'DELETE' }); + closeDetail(); + await reloadAll(); +}); + +document.getElementById('detail-close').addEventListener('click', closeDetail); + +// ── Editor ── + +const modal = document.getElementById('modal'); +const editorBody = document.getElementById('editor-body'); +const previewPane = document.getElementById('editor-preview'); +const previewContent = document.getElementById('preview-content'); +let editingId = null; +let previewVisible = false; + +function openModal(card = null) { + editingId = card ? card.id : null; + document.getElementById('modal-title').textContent = card ? 'edit_card' : 'new_card'; + document.getElementById('inp-title').value = card ? card.title : ''; + editorBody.value = card ? card.body : ''; + inpBucket.value = card && card.bucket_id ? card.bucket_id + : (activeBucket !== 'all' && activeBucket !== 'none' ? activeBucket : ''); + selectColor(card ? card.color : '#38bdf8'); + previewVisible = true; + previewPane.classList.remove('hidden'); + document.getElementById('toggle-preview').classList.add('text-t-accent'); + updatePreview(); + modal.classList.add('open'); + document.getElementById('inp-title').focus(); +} + +function closeModal() { modal.classList.remove('open'); editingId = null; } + +function updatePreview() { + if (previewVisible) previewContent.innerHTML = renderMd(editorBody.value); +} + +async function saveCard() { + const data = { + title: document.getElementById('inp-title').value, + body: editorBody.value, + color: inpColor.value, + bucket_id: inpBucket.value || null, + }; + if (!data.title.trim()) return; + if (editingId) { + await api(`api/cards/${editingId}`, { method: 'PATCH', body: data }); + } else { + // New cards get position -1 so they sort before everything at position 0+ + data.position = -1; + await api('api/cards', { method: 'POST', body: data }); + } + closeModal(); + await reloadAll(); +} + +function insertMd(before, after = '', placeholder = '') { + editorBody.focus(); + const start = editorBody.selectionStart, end = editorBody.selectionEnd; + const sel = editorBody.value.substring(start, end) || placeholder; + editorBody.setRangeText(before + sel + after, start, end, 'select'); + editorBody.selectionStart = start + before.length; + editorBody.selectionEnd = start + before.length + sel.length; + updatePreview(); +} + +document.getElementById('md-toolbar').addEventListener('click', e => { + const btn = e.target.closest('[data-md]'); + if (!btn) return; + const actions = { + heading: () => insertMd('## ', '', 'Heading'), + bold: () => insertMd('**', '**', 'bold'), + italic: () => insertMd('*', '*', 'italic'), + strikethrough: () => insertMd('~~', '~~', 'text'), + code: () => insertMd('`', '`', 'code'), + codeblock: () => insertMd('```\n', '\n```', 'code'), + ul: () => insertMd('- ', '', 'item'), + ol: () => insertMd('1. ', '', 'item'), + quote: () => insertMd('> ', '', 'quote'), + todo: () => insertMd('- [ ] ', '', 'task'), + link: () => insertMd('[', '](url)', 'text'), + cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); }, + hr: () => insertMd('\n---\n'), + }; + if (actions[btn.dataset.md]) actions[btn.dataset.md](); +}); + +document.getElementById('toggle-preview').addEventListener('click', () => { + previewVisible = !previewVisible; + previewPane.classList.toggle('hidden', !previewVisible); + document.getElementById('toggle-preview').classList.toggle('text-t-accent', previewVisible); + updatePreview(); +}); + +document.getElementById('add-btn').addEventListener('click', () => openModal()); +document.getElementById('modal-cancel').addEventListener('click', closeModal); +document.getElementById('modal-save').addEventListener('click', saveCard); + +// ── Card link autocomplete ── + +const linkAc = document.getElementById('link-ac'); +let linkAcVisible = false, linkAcItems = [], linkAcActive = 0; + +function getLinkQuery() { + const before = editorBody.value.substring(0, editorBody.selectionStart); + const idx = before.lastIndexOf('[['); + if (idx === -1) return null; + const between = before.substring(idx + 2); + if (between.includes(']]')) return null; + return { start: idx, query: between }; +} + +function showLinkAc(query) { + const q = query.toLowerCase(); + linkAcItems = currentCards + .filter(c => c.id != editingId) + .map(c => { + const t = c.title.toLowerCase(); + let score = 0; + if (t.startsWith(q)) score = 100; + else if (t.includes(q)) score = 50; + else if (fuzzyMatch(q, t) !== null) score = 10; + else if (!q) score = 1; + else return null; + return { card: c, score }; + }) + .filter(Boolean).sort((a, b) => b.score - a.score).slice(0, 8).map(s => s.card); + + if (!linkAcItems.length) { hideLinkAc(); return; } + + linkAcActive = 0; + linkAc.innerHTML = linkAcItems.map((c, i) => ` +
+ ${esc(c.title)} + ${c.bucket_name ? `${esc(c.bucket_name)}` : ''} +
`).join(''); + + linkAc.querySelectorAll('.ac-item').forEach(el => { + el.addEventListener('mousedown', e => { e.preventDefault(); completeLinkAc(parseInt(el.dataset.idx)); }); + }); + + // Position + const rect = editorBody.getBoundingClientRect(); + const lines = editorBody.value.substring(0, editorBody.selectionStart).split('\n'); + let top = rect.top + (lines.length - 1) * 22 - editorBody.scrollTop + 26; + let left = rect.left + 40; + top = Math.min(top, window.innerHeight - 200); + left = Math.min(left, window.innerWidth - 380); + linkAc.style.top = top + 'px'; + linkAc.style.left = left + 'px'; + linkAc.classList.remove('hidden'); + linkAcVisible = true; +} + +function hideLinkAc() { linkAc.classList.add('hidden'); linkAcVisible = false; } + +function completeLinkAc(idx) { + const card = linkAcItems[idx]; + if (!card) return; + const info = getLinkQuery(); + if (!info) { hideLinkAc(); return; } + const before = editorBody.value.substring(0, info.start); + let after = editorBody.value.substring(editorBody.selectionStart); + if (after.startsWith(']]')) after = after.substring(2); + else if (after.startsWith(']')) after = after.substring(1); + const insert = `[[${card.title}]]`; + editorBody.value = before + insert + after; + editorBody.selectionStart = editorBody.selectionEnd = info.start + insert.length; + hideLinkAc(); + updatePreview(); + editorBody.focus(); +} + +editorBody.addEventListener('input', () => { + const info = getLinkQuery(); + if (info) showLinkAc(info.query); else hideLinkAc(); + updatePreview(); +}); + +editorBody.addEventListener('keydown', e => { + if (linkAcVisible) { + if (e.key === 'ArrowDown') { e.preventDefault(); linkAcActive = Math.min(linkAcActive + 1, linkAcItems.length - 1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); linkAcActive = Math.max(linkAcActive - 1, 0); } + else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeLinkAc(linkAcActive); return; } + else if (e.key === 'Escape') { e.preventDefault(); hideLinkAc(); return; } + linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive)); + return; + } + if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); } +}); + +editorBody.addEventListener('blur', () => setTimeout(hideLinkAc, 150)); + +// ── Context menu ── + +const ctxMenu = document.getElementById('ctx-menu'); +const ctxItems = document.getElementById('ctx-items'); +const ctxColors = document.getElementById('ctx-colors'); +const ctxBuckets = document.getElementById('ctx-buckets'); +let ctxCard = null; + +function hideContextMenu() { + ctxMenu.classList.add('hidden'); + ctxCard = null; +} + +document.addEventListener('click', () => hideContextMenu()); +document.addEventListener('contextmenu', e => { + if (!ctxMenu.contains(e.target)) hideContextMenu(); +}); + +function showContextMenu(x, y, card) { + ctxCard = card; + + // Group context + const children = getChildren(card.id); + const isGroupParent = children.length > 0; + const isChild = !!card.parent_id; + + // Main items + ctxItems.innerHTML = ` + + + +
+ + + ${isGroupParent ? ` +
+ + + + ` : ''} + ${isChild ? ` +
+ + + ` : ''} +
+ + `; + + // Color sub-panel + const ctxColorGrid = document.getElementById('ctx-color-grid'); + ctxColorGrid.innerHTML = cardColors.map(c => + `` + ).join(''); + ctxColors.classList.add('hidden'); + + // Members sub-panel + const ctxMembers = document.getElementById('ctx-members'); + if (isGroupParent) { + const allGroup = [card, ...children]; + ctxMembers.innerHTML = allGroup.map(gc => ` + `).join(''); + } + ctxMembers.classList.add('hidden'); + + // Bucket sub-panel + ctxBuckets.innerHTML = ` + + ${currentBuckets.map(b => ` + + `).join('')}`; + ctxBuckets.classList.add('hidden'); + + // Position + const mw = 180; + const mh = 300; + ctxMenu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px'; + ctxMenu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px'; + ctxMenu.classList.remove('hidden'); + + // Action handlers + ctxItems.querySelectorAll('[data-action]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const action = btn.dataset.action; + + if (action === 'open') { hideContextMenu(); openDetail(card.id); } + else if (action === 'edit') { hideContextMenu(); openModal(card); } + else if (action === 'duplicate') { + hideContextMenu(); + await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } }); + await reloadAll(); + } + else if (action === 'color') { + ctxColors.classList.toggle('hidden'); + ctxBuckets.classList.add('hidden'); + ctxMembers.classList.add('hidden'); + } + else if (action === 'bucket') { + ctxBuckets.classList.toggle('hidden'); + ctxColors.classList.add('hidden'); + ctxMembers.classList.add('hidden'); + } + else if (action === 'group-members') { + ctxMembers.classList.toggle('hidden'); + ctxColors.classList.add('hidden'); + ctxBuckets.classList.add('hidden'); + } + else if (action === 'expand-group') { + hideContextMenu(); + activeGroup = card.id; + renderBoard(); + } + else if (action === 'go-to-group') { + hideContextMenu(); + const parent = currentCards.find(c => c.id == card.parent_id); + if (parent) { activeGroup = parent.id; renderBoard(); } + } + else if (action === 'dissolve') { + hideContextMenu(); + const ok = await showDialog({ title: 'dissolve_group', message: `Gruppe "${card.title}" aufloesen? Alle Karten werden einzeln.`, danger: true, okLabel: 'dissolve' }); + if (!ok) return; + const ch = getChildren(card.id); + await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); + await reloadAll(); + } + else if (action === 'ungroup') { + hideContextMenu(); + await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); + await reloadAll(); + } + else if (action === 'delete') { + hideContextMenu(); + const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); + if (!ok) return; + await api(`api/cards/${card.id}`, { method: 'DELETE' }); + await reloadAll(); + } + }); + }); + + // Color clicks + ctxColorGrid.querySelectorAll('[data-ctx-color]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + hideContextMenu(); + await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); + await reloadAll(); + }); + }); + + // Bucket clicks + ctxBuckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + hideContextMenu(); + await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); + await reloadAll(); + }); + }); + + // Member clicks + ctxMembers.querySelectorAll('[data-ctx-member]').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + if (e.target.closest('[data-ctx-kick]')) return; // handled below + hideContextMenu(); + openDetail(btn.dataset.ctxMember); + }); + }); + ctxMembers.querySelectorAll('[data-ctx-kick]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + hideContextMenu(); + await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); + await reloadAll(); + }); + }); +} + +// ── Image resize in preview ── + +document.addEventListener('mousedown', e => { + const img = e.target.closest('.prose img'); + if (!img) return; + e.preventDefault(); + img.classList.add('resizing'); + const startX = e.clientX; + const startW = img.offsetWidth; + + function onMove(ev) { + const delta = ev.clientX - startX; + const newW = Math.max(50, startW + delta); + img.style.width = newW + 'px'; + img.style.maxWidth = 'none'; + } + + function onUp() { + img.classList.remove('resizing'); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + + // Persist width back into editor markdown if in editor preview + const width = Math.round(img.offsetWidth); + const src = img.getAttribute('src'); + if (src && modal.classList.contains('open')) { + const val = editorBody.value; + // Match ![...](src) or and set width via HTML img tag + const mdPattern = new RegExp('!\\[([^\\]]*)\\]\\(' + escRe(src) + '\\)'); + const match = val.match(mdPattern); + if (match) { + const replacement = `${match[1]}`; + editorBody.value = val.replace(match[0], replacement); + updatePreview(); + } else { + // Already an tag — update width + const imgTagRe = new RegExp('(]*src="' + escRe(src) + '"[^>]*?)(?:\\swidth="[^"]*")?([^>]*>)'); + const imgMatch = val.match(imgTagRe); + if (imgMatch) { + editorBody.value = val.replace(imgMatch[0], imgMatch[1] + ` width="${width}"` + imgMatch[2]); + updatePreview(); + } + } + } + } + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +}); + +// ── Clipboard image paste ── + +editorBody.addEventListener('paste', async e => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of items) { + if (!item.type.startsWith('image/')) continue; + e.preventDefault(); + + const file = item.getAsFile(); + if (!file) return; + + // Insert placeholder while uploading + const placeholder = '![uploading...]()'; + const pos = editorBody.selectionStart; + editorBody.setRangeText(placeholder, pos, editorBody.selectionEnd, 'end'); + updatePreview(); + + // Upload + const form = new FormData(); + form.append('file', file, 'paste.' + (file.type.split('/')[1] || 'png')); + try { + const res = await fetch('api/upload', { method: 'POST', body: form }); + const text = await res.text(); + console.log('Upload response:', res.status, text); + const data = JSON.parse(text); + if (data.error) throw new Error(data.error); + if (data.url) { + const md = `![${data.name}](${data.url})`; + const current = editorBody.value; + const idx = current.indexOf(placeholder); + if (idx !== -1) { + editorBody.value = current.substring(0, idx) + md + current.substring(idx + placeholder.length); + editorBody.selectionStart = editorBody.selectionEnd = idx + md.length; + } + } + } catch (err) { + console.error('Upload failed:', err); + // Replace placeholder with error hint + const current = editorBody.value; + const idx = current.indexOf(placeholder); + if (idx !== -1) { + editorBody.value = current.substring(0, idx) + current.substring(idx + placeholder.length); + editorBody.selectionStart = editorBody.selectionEnd = idx; + } + } + updatePreview(); + return; + } +}); + +// ── Search ── + +const searchOverlay = document.getElementById('search'); +const searchInput = document.getElementById('search-input'); +const searchResults = document.getElementById('search-results'); +const searchFooter = document.getElementById('search-footer'); +const searchPreviewEmpty = document.getElementById('search-preview-empty'); +const searchPreviewContent = document.getElementById('search-preview-content'); +const searchCloseBtn = document.getElementById('search-close'); +let searchActive = -1, searchHits = []; + +function openSearch() { + closeTasks(); + searchInput.value = ''; + searchResults.innerHTML = ''; + searchFooter.classList.add('hidden'); + searchActive = -1; + searchOverlay.classList.add('open'); + searchInput.focus(); + clearSearchPreview(); + renderSearchHome(); +} + +function closeSearch() { searchOverlay.classList.remove('open'); } + +function clearSearchPreview() { + searchPreviewEmpty.classList.remove('hidden'); + searchPreviewContent.classList.add('hidden'); +} + +function updateSearchPreview() { + const card = searchHits[searchActive]; + if (!card) { clearSearchPreview(); return; } + searchPreviewEmpty.classList.add('hidden'); + searchPreviewContent.classList.remove('hidden'); + document.getElementById('search-preview-color').style.background = card.color; + + const q = searchInput.value.trim(); + const words = q ? q.toLowerCase().split(/\s+/).filter(Boolean) : []; + + document.getElementById('search-preview-title').innerHTML = words.length + ? highlight(esc(card.title), words) + : esc(card.title); + + let bodyHtml = renderMd(card.body); + if (words.length) { + // Highlight search words in rendered HTML (skip inside tags) + for (const w of words) { + const re = new RegExp('(>)([^<]*?)(' + escRe(w) + ')', 'gi'); + bodyHtml = bodyHtml.replace(re, '$1$2$3'); + } + } + document.getElementById('search-preview-body').innerHTML = bodyHtml; + + document.getElementById('search-preview-meta').textContent = + '> created: ' + card.created_at + ' | modified: ' + card.updated_at + + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); +} + +function getSearchPool() { + const bv = searchBucketFilter.value; + if (!bv) return currentCards; + if (bv === 'none') return currentCards.filter(c => !c.bucket_id); + return currentCards.filter(c => c.bucket_id == bv); +} + +function renderSearchHome() { + const recent = [...getSearchPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6); + if (!recent.length) { + searchResults.innerHTML = `
no cards yet
`; + return; + } + searchResults.innerHTML = `
recent
+ ${recent.map((c, i) => searchHitHtml(c, i, null)).join('')}`; + searchHits = recent; + searchActive = recent.length ? 0 : -1; + searchFooter.classList.remove('hidden'); + bindSearchHits(); + updateSearchActive(); + updateSearchPreview(); +} + +// ── Fuzzy search engine ── + +function fuzzyMatch(needle, haystack) { + if (needle.length < 3) return null; // minimum 3 chars for fuzzy + let ni = 0, hi = 0, score = 0, streak = 0; + while (ni < needle.length && hi < haystack.length) { + if (needle[ni] === haystack[hi]) { + streak++; score += streak * 2; + if (hi === 0 || /[\s\-_./]/.test(haystack[hi - 1])) score += 10; + if (hi < 5) score += 5 - hi; + ni++; + } else { streak = 0; } + hi++; + } + if (ni < needle.length) return null; + score -= (haystack.length - needle.length) * 0.3; + return score; +} + +function countSubstring(str, sub) { + let count = 0, pos = 0; + while ((pos = str.indexOf(sub, pos)) !== -1) { count++; pos += sub.length; } + return count; +} + +function searchCards(query) { + const q = query.toLowerCase().trim(); + if (!q) { renderSearchHome(); return; } + const words = q.split(/\s+/).filter(Boolean); + const pool = getSearchPool(); + + const scored = pool.map(c => { + const title = (c.title || '').toLowerCase(); + const body = stripMd(c.body || '').toLowerCase(); + let score = 0, matched = false; + + if (title.includes(q)) { score += 150; matched = true; } + if (body.includes(q)) { score += 40 + countSubstring(body, q) * 10; matched = true; } + + let allWordsMatch = true; + for (const w of words) { + const inTitle = title.includes(w), inBody = body.includes(w); + if (inTitle) { score += 30; if (new RegExp('(^|[\\s\\-_./])' + escRe(w)).test(title)) score += 20; if (title.startsWith(w)) score += 15; score += countSubstring(title, w) * 5; } + if (inBody) { score += 8 + countSubstring(body, w) * 3; } + if (!inTitle && !inBody) allWordsMatch = false; + } + if (allWordsMatch) { score += 50; matched = true; } + + const tf = fuzzyMatch(q, title), bf = fuzzyMatch(q, body); + if (tf !== null) { score += 20 + tf * 2; matched = true; } + if (bf !== null) { score += 5 + bf * 0.5; matched = true; } + + if (!matched) { + let fh = 0; + for (const w of words) { if (fuzzyMatch(w, title) !== null) { score += 10; fh++; } if (fuzzyMatch(w, body) !== null) { score += 3; fh++; } } + if (fh > 0) matched = true; + } + if (!matched) return null; + score -= title.length * 0.1; + return { card: c, score }; + }).filter(Boolean).sort((a, b) => b.score - a.score); + + searchHits = scored.map(s => s.card); + searchActive = scored.length ? 0 : -1; + + if (!scored.length) { + searchResults.innerHTML = `

no results for "${esc(query)}"

`; + searchFooter.classList.add('hidden'); + clearSearchPreview(); + return; + } + + searchResults.innerHTML = `
${scored.length} result${scored.length > 1 ? 's' : ''}
+ ${scored.map((s, i) => searchHitHtml(s.card, i, words)).join('')}`; + searchFooter.classList.remove('hidden'); + bindSearchHits(); + updateSearchActive(); + updateSearchPreview(); +} + +function searchHitHtml(card, idx, words) { + const title = words ? highlight(esc(card.title), words) : esc(card.title); + const plain = stripMd(card.body || ''); + let snippet = ''; + + if (words && plain) { + const lower = plain.toLowerCase(); + const fullQ = words.join(' '); + const winSize = 160; + + // Score each position: exact phrase > multi-word density > single word + let best = 0, bestScore = -1; + for (let i = 0; i < lower.length; i += 10) { + const win = lower.substring(i, i + winSize); + let ws = 0; + + // Exact full query in window + if (win.includes(fullQ)) ws += 50; + + // Per-word: count occurrences, weight by proximity to each other + let firstHit = -1, lastHit = -1; + for (const w of words) { + let p = 0, hits = 0; + while ((p = win.indexOf(w, p)) !== -1) { + hits++; + if (firstHit === -1 || p < firstHit) firstHit = p; + if (p > lastHit) lastHit = p + w.length; + p += w.length; + } + ws += hits * 8; + } + // Proximity bonus: words close together score higher + if (firstHit !== -1 && lastHit !== -1) { + const span = lastHit - firstHit; + if (span > 0) ws += Math.max(0, 30 - span / 2); + } + + if (ws > bestScore) { bestScore = ws; best = i; } + } + + // Center the snippet around the best match + const center = Math.min(best + winSize / 2, lower.length); + const start = Math.max(0, Math.floor(center - winSize / 2)); + const end = Math.min(plain.length, start + winSize); + snippet = (start > 0 ? '...' : '') + plain.substring(start, end).trim() + (end < plain.length ? '...' : ''); + snippet = highlight(esc(snippet), words); + } else { + snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); + } + + return `
+ +
+
${title}
+
${snippet}
+
`; +} + +function highlight(html, words) { + let result = html; + for (const w of words) result = result.replace(new RegExp('(' + escRe(w) + ')', 'gi'), '$1'); + if (!result.includes('')) { + const full = words.join(''); + let fi = 0, out = ''; + for (let i = 0; i < html.length && fi < full.length; i++) { + if (html[i].toLowerCase() === full[fi]) { out += '' + html[i] + ''; fi++; } else { out += html[i]; } + } + out += html.substring(out.replace(/<\/?mark>/g, '').length); + result = out; + } + return result; +} + +function bindSearchHits() { + searchResults.querySelectorAll('.search-hit').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.idx); + if (searchActive === idx && searchHits[idx]) openDetail(searchHits[idx].id); + else { searchActive = idx; updateSearchActive(); } + }); + el.addEventListener('dblclick', () => { const idx = parseInt(el.dataset.idx); if (searchHits[idx]) openDetail(searchHits[idx].id); }); + }); +} + +function updateSearchActive() { + searchResults.querySelectorAll('.search-hit').forEach((el, i) => { + el.classList.toggle('active', i === searchActive); + if (i === searchActive) el.scrollIntoView({ block: 'nearest' }); + }); + updateSearchPreview(); +} + +searchInput.addEventListener('input', () => searchCards(searchInput.value)); +searchBucketFilter.addEventListener('change', () => searchCards(searchInput.value)); + +searchInput.addEventListener('keydown', e => { + const hits = searchResults.querySelectorAll('.search-hit'); + if (e.key === 'ArrowDown') { e.preventDefault(); searchActive = Math.min(searchActive + 1, hits.length - 1); updateSearchActive(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); searchActive = Math.max(searchActive - 1, 0); updateSearchActive(); } + else if (e.key === 'Enter' && searchActive >= 0 && searchHits[searchActive]) { e.preventDefault(); openDetail(searchHits[searchActive].id); } +}); + +searchOverlay.addEventListener('click', e => { if (e.target === searchOverlay) closeSearch(); }); +document.getElementById('search-btn').addEventListener('click', openSearch); +if (searchCloseBtn) searchCloseBtn.addEventListener('click', closeSearch); + +// ── Global tasks ── + +const tasksOverlay = document.getElementById('tasks'); +const tasksBtn = document.getElementById('tasks-btn'); +const tasksCloseBtn = document.getElementById('tasks-close'); +const tasksStatusFilter = document.getElementById('tasks-status-filter'); +const tasksList = document.getElementById('tasks-list'); +const tasksSummary = document.getElementById('tasks-summary'); +const tasksOpenCount = document.getElementById('tasks-open-count'); + +function collectTasks() { + const all = []; + for (const card of currentCards) { + const body = card.body || ''; + const taskRe = /- \[([ xX])\][ \t]*([^\n\r]*)/g; + let m; + let idx = 0; + while ((m = taskRe.exec(body)) !== null) { + all.push({ + cardId: card.id, + cardTitle: card.title || 'untitled', + cardColor: card.color || '#94a3b8', + bucketId: card.bucket_id, + bucketName: card.bucket_name || '', + done: m[1].toLowerCase() === 'x', + text: (m[2] || '').trim() || '(empty task)', + todoIndex: idx, + }); + idx++; + } + } + return all; +} + +function filterTasks(tasks) { + const status = tasksStatusFilter?.value || 'open'; + const bucket = tasksBucketFilter?.value || ''; + return tasks.filter(t => { + if (status === 'open' && t.done) return false; + if (status === 'done' && !t.done) return false; + if (bucket === 'none') return !t.bucketId; + if (bucket !== '' && t.bucketId != bucket) return false; + return true; + }); +} + +function refreshTasksView() { + if (!tasksOpenCount) return; + const tasks = collectTasks(); + const openCount = tasks.filter(t => !t.done).length; + tasksOpenCount.textContent = String(openCount); + + if (!tasksOverlay || !tasksOverlay.classList.contains('open') || !tasksList || !tasksSummary) return; + + const filtered = filterTasks(tasks); + const doneCount = tasks.length - openCount; + tasksSummary.textContent = `open: ${openCount} | done: ${doneCount} | showing: ${filtered.length}`; + + if (!filtered.length) { + tasksList.innerHTML = `
+ +

no tasks in this filter

+
`; + return; + } + + tasksList.innerHTML = filtered.map(t => { + const bucket = t.bucketName ? esc(t.bucketName) : 'unsorted'; + return `
+ +
+
${esc(t.text)}
+ +
+
`; + }).join(''); + + tasksList.querySelectorAll('[data-task-toggle]').forEach(btn => { + btn.addEventListener('click', async e => { + e.preventDefault(); + e.stopPropagation(); + const [cardId, todoIdx] = btn.dataset.taskToggle.split(':'); + await toggleTask(cardId, Number(todoIdx)); + }); + }); + + tasksList.querySelectorAll('[data-task-open]').forEach(btn => { + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + closeTasks(); + openDetail(btn.dataset.taskOpen); + }); + }); +} + +async function toggleTask(cardId, todoIdx) { + const card = currentCards.find(c => c.id == cardId); + if (!card) return; + + let idx = 0; + let changed = false; + const nextBody = (card.body || '').replace(/- \[([ xX])\]/g, (m, state) => { + if (idx++ !== todoIdx) return m; + changed = true; + return `- [${state.toLowerCase() === 'x' ? ' ' : 'x'}]`; + }); + if (!changed) return; + + const updated = await api(`api/cards/${cardId}`, { method: 'PATCH', body: { body: nextBody } }); + if (updated && updated.id) { + const pos = currentCards.findIndex(c => c.id == updated.id); + if (pos !== -1) currentCards[pos] = updated; + } else { + card.body = nextBody; + } + + if (viewingCard && viewingCard.id == cardId) { + const latest = currentCards.find(c => c.id == cardId); + if (latest) { + viewingCard = latest; + if (detail.classList.contains('open')) showDetailContent(latest); + } + } + + renderBoard(); +} + +function openTasks() { + if (!tasksOverlay) return; + if (searchOverlay.classList.contains('open')) closeSearch(); + tasksOverlay.classList.add('open'); + refreshTasksView(); +} + +function closeTasks() { + if (tasksOverlay) tasksOverlay.classList.remove('open'); +} + +if (tasksBtn) tasksBtn.addEventListener('click', openTasks); +if (tasksCloseBtn) tasksCloseBtn.addEventListener('click', closeTasks); +if (tasksOverlay) tasksOverlay.addEventListener('click', e => { if (e.target === tasksOverlay) closeTasks(); }); +if (tasksStatusFilter) tasksStatusFilter.addEventListener('change', refreshTasksView); +if (tasksBucketFilter) tasksBucketFilter.addEventListener('change', refreshTasksView); + +// ── Global keyboard ── + +document.addEventListener('keydown', e => { + const anyOpen = modal.classList.contains('open') || detail.classList.contains('open') || searchOverlay.classList.contains('open') || tasksOverlay?.classList.contains('open'); + const dialogOpen = dialogOverlay.classList.contains('open'); + if (dialogOpen) return; // dialog handles its own keys + + if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); searchOverlay.classList.contains('open') ? closeSearch() : openSearch(); return; } + if (e.key === 'Escape') { + if (detail.classList.contains('open')) closeDetail(); + else if (modal.classList.contains('open')) closeModal(); + else if (searchOverlay.classList.contains('open')) closeSearch(); + else if (tasksOverlay?.classList.contains('open')) closeTasks(); + return; + } + if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; } + if (e.key === '/' && !anyOpen && document.activeElement === document.body) { e.preventDefault(); openSearch(); } + + // Arrow keys navigate group in detail view + if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && viewingCard) { + const groupCards = getGroupCards(viewingCard); + if (!groupCards || groupCards.length < 2) return; + const idx = groupCards.findIndex(c => c.id == viewingCard.id); + if (idx === -1) return; + e.preventDefault(); + const next = e.key === 'ArrowRight' + ? groupCards[(idx + 1) % groupCards.length] + : groupCards[(idx - 1 + groupCards.length) % groupCards.length]; + showDetailCard(next.id); + } +}); + +// ── Export / Import ── + +const exportBtn = document.getElementById('export-btn'); +if (exportBtn) { + exportBtn.addEventListener('click', () => { + window.location.href = 'api/export'; + }); +} + +const importBtn = document.getElementById('import-btn'); +const importFile = document.getElementById('import-file'); +if (importBtn && importFile) { + importBtn.addEventListener('click', () => { + importFile.click(); + }); + + importFile.addEventListener('change', async e => { + const file = e.target.files?.[0]; + if (!file) return; + + const ok = await showDialog({ + title: 'import_data', + message: 'Alle bestehenden Daten werden ersetzt. Fortfahren?', + danger: true, + okLabel: 'import', + }); + if (!ok) { e.target.value = ''; return; } + + const form = new FormData(); + form.append('file', file); + try { + const res = await fetch('api/import', { method: 'POST', body: form }); + const text = await res.text(); + const data = JSON.parse(text); + if (data.error) throw new Error(data.error); + await loadBuckets(); + await reloadAll(); + } catch (err) { + console.error('Import failed:', err); + } + e.target.value = ''; + }); +} + +// ── Auth ── + +const logoutBtn = document.getElementById('logout-btn'); +if (logoutBtn) { + logoutBtn.addEventListener('click', async () => { + const ok = await showDialog({ title: 'logout', message: 'Abmelden?', okLabel: 'logout', danger: true }); + if (!ok) return; + await fetch('api/auth/logout', { method: 'POST' }); + location.reload(); + }); +} + +async function checkAuth() { + const res = await fetch('api/auth/status'); + const data = await res.json(); + + if (data.setup) { + // First-time setup + let pw = null; + while (!pw || pw.length < 4) { + pw = await showDialog({ + title: 'setup', + message: 'Willkommen bei hyprcrd. Setze ein Passwort:', + input: 'password', + placeholder: '> passwort (min. 4 zeichen)...', + okLabel: 'set', + cancelLabel: '', + }); + if (pw && pw.length < 4) pw = null; + } + const r = await fetch('api/auth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }), + }); + const d = await r.json(); + if (d.error) { location.reload(); return; } + } else if (!data.logged_in) { + // Login required + let ok = false; + while (!ok) { + const pw = await showDialog({ + title: 'login', + message: '', + input: 'password', + placeholder: '> passwort...', + okLabel: 'login', + cancelLabel: '', + }); + if (!pw) continue; + const r = await fetch('api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }), + }); + const d = await r.json(); + if (d.ok) { ok = true; } + } + } + + // Authenticated — load app + loadBuckets(); + loadCards(); +} + +// ── Boot ── + +checkAuth(); diff --git a/public/uploads/.gitkeep b/public/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Database.php b/src/Database.php new file mode 100644 index 0000000..13efc1e --- /dev/null +++ b/src/Database.php @@ -0,0 +1,72 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + self::$pdo->exec('PRAGMA journal_mode = WAL'); + self::$pdo->exec('PRAGMA foreign_keys = ON'); + + self::migrate(); + } + + return self::$pdo; + } + + private static function migrate(): void + { + self::$pdo->exec(' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + '); + + self::$pdo->exec(' + CREATE TABLE IF NOT EXISTS buckets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + '); + + self::$pdo->exec(' + CREATE TABLE IF NOT EXISTS cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT "", + color TEXT NOT NULL DEFAULT "#ffffff", + bucket_id INTEGER DEFAULT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (bucket_id) REFERENCES buckets(id) ON DELETE SET NULL + ) + '); + + // Migrations for existing installs + $cols = self::$pdo->query('PRAGMA table_info(cards)')->fetchAll(); + $colNames = array_column($cols, 'name'); + + if (!in_array('bucket_id', $colNames)) { + self::$pdo->exec('ALTER TABLE cards ADD COLUMN bucket_id INTEGER DEFAULT NULL REFERENCES buckets(id) ON DELETE SET NULL'); + } + if (!in_array('parent_id', $colNames)) { + self::$pdo->exec('ALTER TABLE cards ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES cards(id) ON DELETE SET NULL'); + } + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..ba8c203 --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,12 @@ +