From 0d4d369870cc2d2e3dc77b2fa0af91b5086d26e1 Mon Sep 17 00:00:00 2001 From: th Date: Mon, 6 Apr 2026 20:27:19 +0200 Subject: [PATCH] bak --- public/css/style.css | 73 +- public/index.php | 203 +++- public/js/app.js | 1848 ++--------------------------- public/js/modules/board.js | 276 +++++ public/js/modules/buckets.js | 233 ++++ public/js/modules/context-menu.js | 92 ++ public/js/modules/detail.js | 326 +++++ public/js/modules/dialog.js | 57 + public/js/modules/editor.js | 260 ++++ public/js/modules/gallery.js | 134 +++ public/js/modules/markdown.js | 62 + public/js/modules/search.js | 155 +++ public/js/modules/state.js | 58 + public/js/modules/tasks.js | 114 ++ public/js/modules/theme.js | 25 + public/js/modules/undo.js | 82 ++ public/js/modules/util.js | 65 + src/Database.php | 7 + src/bootstrap.php | 2 +- 19 files changed, 2258 insertions(+), 1814 deletions(-) create mode 100644 public/js/modules/board.js create mode 100644 public/js/modules/buckets.js create mode 100644 public/js/modules/context-menu.js create mode 100644 public/js/modules/detail.js create mode 100644 public/js/modules/dialog.js create mode 100644 public/js/modules/editor.js create mode 100644 public/js/modules/gallery.js create mode 100644 public/js/modules/markdown.js create mode 100644 public/js/modules/search.js create mode 100644 public/js/modules/state.js create mode 100644 public/js/modules/tasks.js create mode 100644 public/js/modules/theme.js create mode 100644 public/js/modules/undo.js create mode 100644 public/js/modules/util.js diff --git a/public/css/style.css b/public/css/style.css index f6c7728..4a6072a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -107,7 +107,7 @@ body::after { } .group:hover .card-thumb { border-color: var(--t-muted); } .card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); } -.card-dragging { opacity: 0.3; transform: scale(0.95); } +.card-dragging { opacity: 0.15; transform: scale(0.95); } /* Drop indicators — thick glowing bar */ .card-drop-before::before, @@ -130,6 +130,56 @@ body::after { transition: transform 0.2s ease, opacity 0.2s ease; } +/* Zen mode */ +html.detail-zen-open, +body.detail-zen-open { overflow: hidden; } +#detail.zen > div { inset: 0 !important; border: none !important; border-radius: 0; overflow-y: auto; } +#detail.zen #detail-sidebar { display: none !important; } +#detail.zen .card-bar { display: none; } +#detail.zen #detail-scroll { + max-width: 680px; + margin: 0 auto; + padding: 3rem 2rem; + overflow: visible !important; +} +#detail.zen #detail-scroll #detail-title { font-size: 1.6rem; margin-bottom: 1.5rem; } +#detail.zen #detail-scroll #detail-body { font-size: 1rem; line-height: 1.85; } +#detail.zen #detail-scroll #detail-meta { display: none; } +#detail.zen #detail-backlinks { display: none; } +#detail.zen #detail-controls { + position: fixed !important; + top: 1rem !important; + right: 1rem !important; + z-index: 120; + opacity: 0.15; + transition: opacity 0.2s; +} +#detail.zen #detail-controls:hover { opacity: 1; } + +/* Detail close morph */ +.detail-close-frame { + z-index: 130; + pointer-events: none; + border: 3px solid var(--t-accent2); + border-radius: 10px; + background: transparent; + opacity: 0.95; + box-sizing: border-box; + will-change: left, top, width, height, opacity, border-radius; + transition: left 0.34s cubic-bezier(0.22, 1, 0.36, 1), top 0.34s cubic-bezier(0.22, 1, 0.36, 1), width 0.34s cubic-bezier(0.22, 1, 0.36, 1), height 0.34s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.34s ease, border-radius 0.34s ease; +} +.card-morph-target { + animation: cardMorphTarget 0.34s ease; +} +@keyframes cardMorphTarget { + 0% { transform: scale(1); box-shadow: 0 0 0 0 transparent; } + 45% { transform: scale(1.015); box-shadow: 0 0 0 2px var(--t-accent2); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; } +} + +/* Undo hint */ +#undo-hint { opacity: 0; transition: opacity 0.3s, transform 0.3s; pointer-events: none; border-radius: 4px; } + /* Overlays */ .overlay { transition: opacity 0.15s; } .overlay:not(.open) { opacity: 0; pointer-events: none; } @@ -142,7 +192,17 @@ body::after { /* 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); } +.bucket-tab.drag-over { + background: var(--t-accent) !important; + color: var(--t-bg) !important; + border-color: var(--t-accent) !important; + transform: scale(1.05); + transition: all 0.15s ease; +} +.bucket-tab.drag-over i, +.bucket-tab.drag-over span { color: inherit !important; } +.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; } +.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; } /* 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; } @@ -161,7 +221,7 @@ body::after { .search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; } /* Context menu */ -#ctx-menu .ctx-item { +#ctx-menu .ctx-item, #bucket-ctx .ctx-item { display: flex; align-items: center; gap: 8px; @@ -176,10 +236,9 @@ body::after { 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; } +#ctx-menu .ctx-item:hover, #bucket-ctx .ctx-item:hover { background: var(--t-raised); } +#ctx-menu .ctx-item.danger, #bucket-ctx .ctx-item.danger { color: var(--t-danger); } +#ctx-menu .ctx-item.danger:hover, #bucket-ctx .ctx-item.danger:hover { background: var(--t-danger); color: var(--t-bg); } /* Card links */ .card-link { color: var(--t-accent2); cursor: pointer; border-bottom: 1px dashed var(--t-accent2); transition: color 0.1s; } diff --git a/public/index.php b/public/index.php index b9797b3..f23ab5a 100644 --- a/public/index.php +++ b/public/index.php @@ -3,8 +3,29 @@ require_once __DIR__ . '/../src/bootstrap.php'; $db = Database::connect(); + +// ── Security headers ── +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 ── +session_set_cookie_params([ + 'httponly' => true, + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'samesite' => 'Strict', +]); session_start(); +// CSRF token +if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); +} + // ── Auth ── $method = $_SERVER['REQUEST_METHOD']; @@ -23,7 +44,7 @@ if (str_starts_with($route, '/api/auth')) { // GET /api/auth/status if ($route === '/api/auth/status' && $method === 'GET') { - echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn]); + echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token']]); exit; } @@ -38,19 +59,34 @@ if (str_starts_with($route, '/api/auth')) { } $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; - echo json_encode(['ok' => true]); + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]); exit; } - // POST /api/auth/login + // 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; - echo json_encode(['ok' => 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']); } @@ -85,6 +121,20 @@ 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; } + // Cards if ($route === '/api/cards' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); @@ -92,7 +142,7 @@ if (str_starts_with($route, '/api/')) { 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, + 'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'position' => validInt($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; @@ -135,7 +185,7 @@ if (str_starts_with($route, '/api/')) { 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]; } } + foreach (['name', 'position', 'color'] 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; } @@ -146,31 +196,68 @@ if (str_starts_with($route, '/api/')) { echo json_encode(['ok' => true]); exit; } - // Upload + // List all upload files + if ($route === '/api/uploads' && $method === 'GET') { + $dir = __DIR__ . '/uploads'; + $files = []; + if (is_dir($dir)) { + foreach (glob($dir . '/*.{png,jpg,gif,webp}', 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 = __DIR__ . '/uploads/' . 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 = __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']); + echo json_encode(['error' => 'no file', 'debug_files' => array_keys($_FILES), 'debug_method' => $method, 'debug_ct' => $_SERVER['CONTENT_TYPE'] ?? 'none']); 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) { + if ($file['size'] > 10 * 1024 * 1024) { http_response_code(400); - echo json_encode(['error' => 'invalid file type: ' . $file['type']]); + echo json_encode(['error' => 'file too large (max 10MB)']); exit; } - $name = bin2hex(random_bytes(8)) . '.' . $ext; + // Check real MIME via finfo, not client-supplied type + $allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp']; + $finfo = new finfo(FILEINFO_MIME_TYPE); + $realMime = $finfo->file($file['tmp_name']); + $ext = $allowed[$realMime] ?? null; + if (!$ext) { + http_response_code(400); + echo json_encode(['error' => 'invalid image type']); + 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' => 'failed to save, check permissions on ' . $uploadDir]); + echo json_encode(['error' => 'upload failed']); exit; } @@ -289,6 +376,7 @@ if (str_starts_with($route, '/api/')) { + @@ -299,14 +387,14 @@ if (str_starts_with($route, '/api/')) { }}} + diff --git a/public/js/app.js b/public/js/app.js index 3fb9b78..e0b1805 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,1806 +1,134 @@ /* ══════════════════════════════════════ - hyprcrd — app.js + hyprcrd — app.js (orchestrator) ══════════════════════════════════════ */ -// ── Utilities ── +import { api, setCsrf, getCsrf } from './modules/util.js'; +import { state, getGroupCards } from './modules/state.js'; +import { showDialog, isDialogOpen } from './modules/dialog.js'; +import './modules/theme.js'; +import { loadBuckets } from './modules/buckets.js'; +import { renderBoard, updateSortUI } from './modules/board.js'; +import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js'; +import { openModal, closeModal, saveCard } from './modules/editor.js'; +import { openSearch, closeSearch } from './modules/search.js'; +import { showContextMenu } from './modules/context-menu.js'; +import { undo, canUndo } from './modules/undo.js'; +import { closeGallery } from './modules/gallery.js'; +import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js'; -function esc(s) { - const d = document.createElement('div'); - d.textContent = s; - return d.innerHTML; -} +// ── Global API (for inline onclick handlers and cross-module calls) ── -function escRe(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} +window._openDetail = openDetail; +window._openModal = openModal; +window._closeSearch = closeSearch; +window._closeTasks = closeTasks; +window._renderBoard = renderBoard; +window._showContextMenu = showContextMenu; +window._ungroupCard = ungroupCard; +window._refreshTasks = refreshTasksView; +window._reloadAll = async () => { state.cards = await api('api/cards'); renderBoard(); }; -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(); -}); +// ── Data loading ── async function loadCards() { - currentCards = await api('api/cards'); + state.cards = 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'; - }); -} +document.getElementById('export-btn')?.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 = ''; - }); -} +document.getElementById('import-btn')?.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, headers: { 'X-CSRF-Token': getCsrf() } }); + const data = JSON.parse(await res.text()); + if (data.error) throw new Error(data.error); + await loadBuckets(); state.cards = await api('api/cards'); renderBoard(); + } 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(); - }); -} +document.getElementById('logout-btn')?.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.csrf) setCsrf(data.csrf); 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: '', - }); + 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 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; } + if (d.csrf) setCsrf(d.csrf); } 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: '', - }); + 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 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; } + if (d.ok) { ok = true; if (d.csrf) setCsrf(d.csrf); } } } - // Authenticated — load app loadBuckets(); loadCards(); } +// ── Global keyboard ── + +document.addEventListener('keydown', e => { + const modal = document.getElementById('modal'); + const detail = document.getElementById('detail'); + const search = document.getElementById('search'); + const tasks = document.getElementById('tasks'); + if (isDialogOpen()) return; + + if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.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 (search.classList.contains('open')) closeSearch(); + else if (tasks?.classList.contains('open')) closeTasks(); + else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery(); + return; + } + if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; } + if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; } + if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && 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') && state.viewingCard) { + const gc = getGroupCards(state.viewingCard); + if (!gc || gc.length < 2) return; + const idx = gc.findIndex(c => c.id == state.viewingCard.id); + if (idx === -1) return; + e.preventDefault(); + showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id); + } +}); + // ── Boot ── checkAuth(); diff --git a/public/js/modules/board.js b/public/js/modules/board.js new file mode 100644 index 0000000..4729957 --- /dev/null +++ b/public/js/modules/board.js @@ -0,0 +1,276 @@ +// ── Board rendering & card interactions ── + +import { $, esc, api } from './util.js'; +import { state, sortedCards, getChildren, getRecent, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js'; +import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js'; +import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js'; + +const board = $('board'); +let activeDragEl = null; +let dragGhost = null; + +// Custom drag ghost that fades near header +document.addEventListener('dragover', e => { + if (!dragGhost) return; + const header = document.querySelector('header'); + if (!header) return; + const headerBottom = header.getBoundingClientRect().bottom; + const dist = e.clientY - headerBottom; + const near = dist < 80; + const op = near ? Math.max(0.08, dist / 80 * 0.5) : 0.5; + dragGhost.style.opacity = op.toFixed(2); + dragGhost.style.borderColor = near ? 'var(--t-accent)' : ''; + dragGhost.style.borderWidth = near ? '2px' : ''; + dragGhost.style.boxShadow = near ? '0 0 12px var(--t-accent)' : ''; + dragGhost.style.left = (e.clientX - dragGhost._offsetX) + 'px'; + dragGhost.style.top = (e.clientY - dragGhost._offsetY) + 'px'; +}); + +// ── Sort toggle ── + +export function updateSortUI() { + const label = $('sort-label'); + const icon = document.querySelector('#sort-toggle i'); + if (label) label.textContent = state.sortMode; + if (icon) icon.className = `ph ${SORT_ICONS[state.sortMode]} text-xs`; +} + +$('sort-toggle')?.addEventListener('click', () => { + state.sortMode = SORT_MODES[(SORT_MODES.indexOf(state.sortMode) + 1) % SORT_MODES.length]; + localStorage.setItem('hyprcrd-sort', state.sortMode); + updateSortUI(); + renderBoard(); +}); + +// ── Render ── + +export function renderBoard() { + let cards; + if (state.activeGroup) { + const parent = state.cards.find(c => c.id == state.activeGroup); + cards = parent ? [parent, ...getChildren(parent.id)] : []; + } else { + cards = state.cards.filter(c => !c.parent_id); + if (state.activeBucket === 'none') cards = cards.filter(c => !c.bucket_id); + else if (state.activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == state.activeBucket); + } + cards = sortedCards(cards); + window._refreshTasks?.(); + + renderGroupBar(cards); + + if (!cards.length) { + board.innerHTML = `
+ +
`; + return; + } + + const recent = getRecent(); + const inGroup = !!state.activeGroup; + + board.innerHTML = cards.map(c => { + const coverImg = leadingImage(c.body); + const thumbImg = coverImg ? null : firstImage(c.body); + 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; + const hasCover = !!coverImg; + + return ` +
+ ${hasCover ? '
' : ''} +
+ ${isGroup ? `
${children.length + 1}
` : ''} + ${thumbImg ? `
` : ''} +
+
+ ${todos ? `` : ''} +

${esc(c.title)}

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

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

+
+ ${(c.updated_at || c.created_at || '').substring(0, 10)} + ${c.bucket_name ? `${esc(c.bucket_name)}` : ''} +
+
+
`; + }).join(''); + + bindCardEvents(); +} + +// ── Group bar ── + +function renderGroupBar(cards) { + const bar = $('group-bar'); + if (!bar) return; + if (state.activeGroup) { + const parent = state.cards.find(c => c.id == state.activeGroup); + bar.classList.remove('hidden'); + bar.innerHTML = ` + + + ${esc(parent?.title || 'group')} + ${cards.length} cards`; + $('group-back')?.addEventListener('click', () => { state.activeGroup = null; renderBoard(); }); + } else { + bar.classList.add('hidden'); + } +} + +// ── Card event binding ── + +function bindCardEvents() { + board.querySelectorAll('[data-group]').forEach(badge => { + badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); }); + }); + + board.querySelectorAll('[data-id]').forEach(el => { + const id = el.dataset.id; + let clickTimer = null; + + el.addEventListener('click', () => { + if (clickTimer) return; + clickTimer = setTimeout(() => { clickTimer = null; window._openDetail(id); }, 200); + }); + el.addEventListener('dblclick', e => { + e.preventDefault(); + if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } + const card = state.cards.find(c => c.id == id); + if (card) window._openModal(card); + }); + el.addEventListener('contextmenu', e => { + e.preventDefault(); e.stopPropagation(); + const card = state.cards.find(c => c.id == id); + if (card) window._showContextMenu(e.clientX, e.clientY, card); + }); + + // Drag & drop + el.addEventListener('dragstart', e => { + e.dataTransfer.setData('text/plain', id); + e.dataTransfer.effectAllowed = 'move'; + el.classList.add('card-dragging'); + activeDragEl = el; + + // Hide native drag image + const empty = document.createElement('div'); + empty.style.width = '1px'; empty.style.height = '1px'; empty.style.opacity = '0.01'; + document.body.appendChild(empty); + e.dataTransfer.setDragImage(empty, 0, 0); + setTimeout(() => empty.remove(), 0); + + // Create custom ghost + const rect = el.getBoundingClientRect(); + const ghost = el.cloneNode(true); + ghost.style.cssText = `position:fixed;z-index:9998;width:${rect.width}px;pointer-events:none;opacity:0.5;transition:opacity 0.12s;`; + ghost._offsetX = e.clientX - rect.left; + ghost._offsetY = e.clientY - rect.top; + ghost.style.left = (e.clientX - ghost._offsetX) + 'px'; + ghost.style.top = (e.clientY - ghost._offsetY) + 'px'; + document.body.appendChild(ghost); + dragGhost = ghost; + }); + el.addEventListener('dragend', () => { + el.classList.remove('card-dragging'); + activeDragEl = null; + if (dragGhost) { dragGhost.remove(); dragGhost = null; } + 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 edge = rect.width * 0.25, x = e.clientX - rect.left; + el.classList.toggle('card-drop-before', x < edge); + el.classList.toggle('card-drop-after', x > rect.width - edge); + el.classList.toggle('card-drop-group', x >= edge && x <= rect.width - edge); + }); + el.addEventListener('dragleave', () => el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group')); + el.addEventListener('drop', e => handleCardDrop(e, el, id)); + }); +} + +// ── Card drop handler ── + +async function handleCardDrop(e, el, targetId) { + 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 === targetId) return; + + if (wasGroup) { + const prev = snapshotCard(state.cards.find(c => c.id == dragId)); + pushCardEdit('group', dragId, { parent_id: prev.parent_id || null }); + await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: targetId } }); + await window._reloadAll(); + return; + } + + // Compute new order + let filtered; + if (state.activeGroup) { + const parent = state.cards.find(c => c.id == state.activeGroup); + filtered = parent ? sortedCards([parent, ...getChildren(parent.id)]) : []; + } else { + filtered = sortedCards(state.cards.filter(c => { + if (c.parent_id) return false; + if (state.activeBucket === 'none') return !c.bucket_id; + if (state.activeBucket !== 'all') return c.bucket_id == state.activeBucket; + return true; + })); + } + const ids = filtered.map(c => String(c.id)).filter(i => i !== String(dragId)); + ids.splice(wasBefore ? ids.indexOf(String(targetId)) : ids.indexOf(String(targetId)) + 1, 0, String(dragId)); + + // Ungroup if dragging child to main board + const dragCard = state.cards.find(c => c.id == dragId); + if (!state.activeGroup && dragCard?.parent_id) { + await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: null } }); + } + + // Undo snapshot before reorder + pushReorder('reorder', filtered); + + // FLIP capture + const oldRects = {}; + board.querySelectorAll('[data-id]').forEach(c => { oldRects[c.dataset.id] = c.getBoundingClientRect(); }); + + await Promise.all(ids.map((cid, i) => api(`api/cards/${cid}`, { method: 'PATCH', body: { position: i } }))); + + // Swap group parent if needed + if (state.activeGroup && ids[0] !== String(state.activeGroup)) { + const newP = ids[0], oldP = String(state.activeGroup); + await api(`api/cards/${newP}`, { method: 'PATCH', body: { parent_id: null } }); + await api(`api/cards/${oldP}`, { method: 'PATCH', body: { parent_id: newP } }); + for (const cid of ids.slice(1)) { if (cid !== oldP) await api(`api/cards/${cid}`, { method: 'PATCH', body: { parent_id: newP } }); } + state.activeGroup = newP; + } + + state.sortMode = 'manual'; + localStorage.setItem('hyprcrd-sort', 'manual'); + updateSortUI(); + await window._reloadAll(); + + // FLIP animate + board.querySelectorAll('[data-id]').forEach(card => { + const old = oldRects[card.dataset.id]; + if (!old) return; + const nw = card.getBoundingClientRect(); + const dx = old.left - nw.left, dy = old.top - nw.top; + if (!dx && !dy) return; + card.style.transition = 'none'; + card.style.transform = `translate(${dx}px,${dy}px)`; + requestAnimationFrame(() => { card.style.transition = 'transform 0.25s ease'; card.style.transform = ''; }); + }); +} diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js new file mode 100644 index 0000000..e73c6ac --- /dev/null +++ b/public/js/modules/buckets.js @@ -0,0 +1,233 @@ +// ── Bucket management ── + +import { $, esc, api } from './util.js'; +import { state, CARD_COLORS } from './state.js'; +import { showDialog } from './dialog.js'; + +// ── Bucket context menu ── + +const ctxMenu = document.createElement('div'); +ctxMenu.id = 'bucket-ctx'; +ctxMenu.className = 'fixed z-[85] bg-t-surface border border-t-border shadow-2xl hidden'; +ctxMenu.style.minWidth = '170px'; +document.body.appendChild(ctxMenu); + +let ctxBucketId = null; + +function hideBucketCtx() { ctxMenu.classList.add('hidden'); ctxBucketId = null; } +document.addEventListener('click', hideBucketCtx); + +async function showBucketCtx(x, y, bucket) { + ctxBucketId = bucket.id; + const idx = state.buckets.findIndex(b => b.id == bucket.id); + const canLeft = idx > 0; + const canRight = idx < state.buckets.length - 1; + + ctxMenu.innerHTML = `
+ + +
+ + +
+ + +
+ `; + + ctxMenu.style.left = Math.min(x, innerWidth - 178) + 'px'; + ctxMenu.style.top = Math.min(y, innerHeight - 250) + 'px'; + ctxMenu.classList.remove('hidden'); + + ctxMenu.querySelectorAll('[data-baction]').forEach(btn => btn.addEventListener('click', async e => { + e.stopPropagation(); + const a = btn.dataset.baction; + + if (a === 'color') { + e.stopPropagation(); + document.getElementById('bctx-colors').classList.toggle('hidden'); + return; + } + else if (a === 'rename') { + hideBucketCtx(); + const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name }); + if (!name || !name.trim()) return; + await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } }); + await loadBuckets(); window._renderBoard(); + } + else if (a === 'move-left' || a === 'move-right') { + hideBucketCtx(); + const swapIdx = a === 'move-left' ? idx - 1 : idx + 1; + const other = state.buckets[swapIdx]; + if (!other) return; + await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: swapIdx } }); + await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: idx } }); + await loadBuckets(); window._renderBoard(); + } + else if (a === 'delete-keep') { + hideBucketCtx(); + const ok = await showDialog({ title: 'delete_bucket', message: `"${bucket.name}" loeschen? Karten bleiben erhalten.`, danger: true, okLabel: 'delete' }); + if (!ok) return; + if (state.activeBucket == bucket.id) state.activeBucket = 'all'; + await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); + await loadBuckets(); await window._reloadAll(); + } + else if (a === 'delete-all') { + hideBucketCtx(); + const cards = state.cards.filter(c => c.bucket_id == bucket.id); + const ok = await showDialog({ title: 'delete_bucket', message: `"${bucket.name}" und ${cards.length} Karte(n) loeschen?`, danger: true, okLabel: 'delete all' }); + if (!ok) return; + for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' }); + if (state.activeBucket == bucket.id) state.activeBucket = 'all'; + await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); + await loadBuckets(); await window._reloadAll(); + } + })); + + // Bucket color clicks + ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => { + e.stopPropagation(); + hideBucketCtx(); + await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } }); + await loadBuckets(); + window._renderBoard(); + })); +} + +const bar = $('bucket-bar'); +const inpBucket = $('inp-bucket'); +const searchFilter = $('search-bucket-filter'); +const tasksFilter = $('tasks-bucket-filter'); + +export async function loadBuckets() { + state.buckets = await api('api/buckets'); + renderBar(); + populateSelects(); +} + +export function populateSelects() { + const bucketOpts = state.buckets.map(b => ``).join(''); + if (inpBucket) inpBucket.innerHTML = `` + bucketOpts; + if (searchFilter) searchFilter.innerHTML = `` + bucketOpts; + if (tasksFilter) { + const cur = tasksFilter.value; + tasksFilter.innerHTML = `` + bucketOpts; + const valid = cur === '' || cur === 'none' || state.buckets.some(b => String(b.id) === cur); + tasksFilter.value = valid ? cur : ''; + } +} + +function updateHeaderTint() { + const tint = $('header-tint'); + if (!tint) return; + const bucket = state.buckets.find(b => b.id == state.activeBucket); + if (bucket && bucket.color) { + tint.style.background = `linear-gradient(135deg, ${bucket.color}30 0%, ${bucket.color}12 100%)`; + tint.style.opacity = '1'; + } else { + tint.style.opacity = '0'; + } +} + +function renderBar() { + if (!bar) return; + const tab = (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`; + + bar.innerHTML = ` + + + ${state.buckets.map(b => { + const c = b.color || '#64748b'; + const active = state.activeBucket == b.id; + return ``; + }).join('')} + `; + + updateHeaderTint(); + + let draggingBucketId = null; + + bar.querySelectorAll('.bucket-tab').forEach(t => { + const bid = t.dataset.bucket; + const isUser = bid !== 'all' && bid !== 'none'; + + t.addEventListener('click', () => { state.activeBucket = bid; renderBar(); window._renderBoard(); }); + + if (isUser) { + t.addEventListener('contextmenu', e => { + e.preventDefault(); e.stopPropagation(); + const bucket = state.buckets.find(b => b.id == bid); + if (bucket) showBucketCtx(e.clientX, e.clientY, bucket); + }); + + // Bucket drag + t.addEventListener('dragstart', e => { + draggingBucketId = bid; + e.dataTransfer.setData('application/x-bucket', bid); + e.dataTransfer.effectAllowed = 'move'; + t.style.opacity = '0.3'; + }); + t.addEventListener('dragend', () => { + t.style.opacity = ''; + draggingBucketId = null; + bar.querySelectorAll('.bucket-drop-left,.bucket-drop-right').forEach(x => x.classList.remove('bucket-drop-left', 'bucket-drop-right')); + }); + } + + t.addEventListener('dragover', e => { + e.preventDefault(); + if (draggingBucketId && isUser && draggingBucketId !== bid) { + // Bucket reorder indicator + const rect = t.getBoundingClientRect(); + const left = e.clientX < rect.left + rect.width / 2; + t.classList.toggle('bucket-drop-left', left); + t.classList.toggle('bucket-drop-right', !left); + t.classList.remove('drag-over'); + } else if (!draggingBucketId) { + // Card drop on bucket + t.classList.add('drag-over'); + } + }); + t.addEventListener('dragleave', () => { + t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right'); + }); + t.addEventListener('drop', async e => { + e.preventDefault(); + t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right'); + + // Bucket reorder + const dragBid = e.dataTransfer.getData('application/x-bucket'); + if (dragBid && isUser && dragBid !== bid) { + const rect = t.getBoundingClientRect(); + const before = e.clientX < rect.left + rect.width / 2; + const ids = state.buckets.map(b => String(b.id)).filter(i => i !== dragBid); + const targetIdx = ids.indexOf(String(bid)); + ids.splice(before ? targetIdx : targetIdx + 1, 0, dragBid); + await Promise.all(ids.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } }))); + await loadBuckets(); + window._renderBoard(); + return; + } + + // Card drop on bucket + const cardId = e.dataTransfer.getData('text/plain'); + if (!cardId) return; + const bucketId = bid === 'none' ? null : (bid === 'all' ? undefined : bid); + if (bucketId === undefined) return; + await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId } }); + await window._reloadAll(); + }); + }); + + $('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(); + window._renderBoard(); + }); +} diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js new file mode 100644 index 0000000..f2174af --- /dev/null +++ b/public/js/modules/context-menu.js @@ -0,0 +1,92 @@ +// ── Context menu ── + +import { $, esc, api } from './util.js'; +import { state, getChildren, CARD_COLORS } from './state.js'; +import { showDialog } from './dialog.js'; +import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js'; + +const menu = $('ctx-menu'); +const items = $('ctx-items'); +const colors = $('ctx-colors'); +const buckets = $('ctx-buckets'); +const members = $('ctx-members'); + +export function hideContextMenu() { menu.classList.add('hidden'); } + +document.addEventListener('click', hideContextMenu); +document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); }); + +export function showContextMenu(x, y, card) { + const children = getChildren(card.id); + const isParent = children.length > 0; + const isChild = !!card.parent_id; + + items.innerHTML = ` + + + +
+ + + ${isParent ? `
+ + + ` : ''} + ${isChild ? `
+ + ` : ''} +
+ `; + + // Color sub-panel + const grid = $('ctx-color-grid'); + grid.innerHTML = CARD_COLORS.map(c => ``).join(''); + colors.classList.add('hidden'); + + // Members sub-panel + if (isParent) { + members.innerHTML = [card, ...children].map(gc => ` + `).join(''); + } + members.classList.add('hidden'); + + // Bucket sub-panel + buckets.innerHTML = `` + + state.buckets.map(b => ``).join(''); + buckets.classList.add('hidden'); + + menu.style.left = Math.min(x, innerWidth - 188) + 'px'; + menu.style.top = Math.min(y, innerHeight - 300) + 'px'; + menu.classList.remove('hidden'); + + const toggle = (panel) => { [colors, buckets, members].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); }; + + // Actions + items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => { + e.stopPropagation(); + const a = btn.dataset.action; + if (a === 'open') { hideContextMenu(); window._openDetail(card.id); } + else if (a === 'edit') { hideContextMenu(); window._openModal(card); } + else if (a === '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 window._reloadAll(); } + else if (a === 'color') toggle(colors); + else if (a === 'bucket') toggle(buckets); + else if (a === 'group-members') toggle(members); + else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); } + else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } } + else if (a === 'dissolve') { + hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Gruppe "${card.title}" aufloesen?`, danger: true, okLabel: 'dissolve' }); + if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); } + } + else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); } + else if (a === 'delete') { hideContextMenu(); const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); if (ok) { pushCardDelete('delete', snapshotCard(card)); await api(`api/cards/${card.id}`, { method: 'DELETE' }); await window._reloadAll(); } } + })); + + grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); })); + buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); await window._reloadAll(); })); + members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); })); + members.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 window._reloadAll(); })); +} diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js new file mode 100644 index 0000000..c52ff01 --- /dev/null +++ b/public/js/modules/detail.js @@ -0,0 +1,326 @@ +// ── Detail view ── + +import { $, esc, api } from './util.js'; +import { state, getChildren, getGroupCards, getRecent, markRecent } from './state.js'; +import { renderMd, firstImage } from './markdown.js'; +import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js'; +import { showDialog } from './dialog.js'; + +const detail = $('detail'); +const scroll = $('detail-scroll'); +const titleEl = $('detail-title'); +const bodyEl = $('detail-body'); +const metaEl = $('detail-meta'); +let morphTimer = null; +let morphGhost = null; + +// ── Zoom ── + +const ZOOM_KEY = 'hyprcrd-detail-zoom'; +let zoom = parseFloat(localStorage.getItem(ZOOM_KEY) || '1') || 1; + +function applyZoom() { + if (!titleEl) return; + titleEl.style.fontSize = `${(1.125 * zoom).toFixed(3)}rem`; + bodyEl.style.fontSize = `${(0.875 * zoom).toFixed(3)}rem`; + metaEl.style.fontSize = `${Math.max(0.625, 0.6875 * zoom).toFixed(3)}rem`; + titleEl.style.lineHeight = `${Math.max(1.35, 1.55 + (zoom - 1) * 0.3).toFixed(3)}`; + bodyEl.style.lineHeight = `${Math.max(1.5, 1.7 + (zoom - 1) * 0.35).toFixed(3)}`; + metaEl.style.lineHeight = `${Math.max(1.2, 1.35 + (zoom - 1) * 0.25).toFixed(3)}`; +} +applyZoom(); + +scroll?.addEventListener('wheel', e => { + if (!detail.classList.contains('open') || !e.shiftKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return; + e.preventDefault(); + zoom = Math.min(2.2, Math.max(0.75, zoom + (e.deltaY < 0 ? 0.08 : -0.08))); + zoom = Math.round(zoom * 100) / 100; + localStorage.setItem(ZOOM_KEY, String(zoom)); + applyZoom(); +}, { passive: false }); + +// ── Open / close ── + +export function openDetail(id) { + const card = state.cards.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')) { + state.openedFromSearch = $('search')?.classList.contains('open') || false; + } + state.viewingCard = card; + + renderSidebar(card); + showContent(card); + detail.classList.add('open'); +} + +function clearMorphGhost() { + if (morphTimer) { + clearTimeout(morphTimer); + morphTimer = null; + } + if (morphGhost) { + morphGhost.remove(); + morphGhost = null; + } +} + +function resolveMorphTarget(cardId) { + const board = $('board'); + if (!board || !cardId) return null; + + const candidates = []; + const pushCandidate = id => { + if (!id) return; + const s = String(id); + if (!candidates.includes(s)) candidates.push(s); + }; + + // 1) direct card + pushCandidate(cardId); + + const card = state.cards.find(c => c.id == cardId); + if (card) { + if (card.parent_id) { + // 2) group parent (typical board representation for grouped cards) + pushCandidate(card.parent_id); + const parent = state.cards.find(c => c.id == card.parent_id); + if (parent) { + // 3) any visible member in expanded group view + pushCandidate(parent.id); + getChildren(parent.id).forEach(ch => pushCandidate(ch.id)); + } + } else { + const children = getChildren(card.id); + if (children.length) { + // parent with children: include visible members for expanded group view + pushCandidate(card.id); + children.forEach(ch => pushCandidate(ch.id)); + } + } + } + + for (const id of candidates) { + const el = board.querySelector(`[data-id="${id}"]`); + if (el) return el; + } + return null; +} + +function startCloseMorph(cardId) { + if (!cardId) return; + if ($('search')?.classList.contains('open')) return; + + const card = state.cards.find(c => c.id == cardId); + const panel = detail?.firstElementChild; + const target = resolveMorphTarget(cardId); + if (!panel || !target) return; + + const from = panel.getBoundingClientRect(); + const to = target.getBoundingClientRect(); + if (from.width < 40 || from.height < 40 || to.width < 20 || to.height < 20) return; + + // Skip if target card is off-screen; morph would look broken. + const visible = to.bottom > 0 && to.top < window.innerHeight && to.right > 0 && to.left < window.innerWidth; + if (!visible) return; + + clearMorphGhost(); + + const frame = document.createElement('div'); + frame.className = 'detail-close-frame'; + const targetCard = state.cards.find(c => c.id == target.dataset.id); + Object.assign(frame.style, { + left: `${from.left}px`, + top: `${from.top}px`, + width: `${from.width}px`, + height: `${from.height}px`, + borderColor: targetCard?.color || card?.color || 'var(--t-accent2)', + position: 'fixed', + inset: 'auto', + margin: '0', + }); + + document.body.appendChild(frame); + morphGhost = frame; + + requestAnimationFrame(() => { + frame.style.left = `${to.left}px`; + frame.style.top = `${to.top}px`; + frame.style.width = `${to.width}px`; + frame.style.height = `${to.height}px`; + frame.style.opacity = '0.32'; + frame.style.borderRadius = '4px'; + }); + + target.classList.add('card-morph-target'); + setTimeout(() => target.classList.remove('card-morph-target'), 360); + + const done = () => { + if (frame === morphGhost) morphGhost = null; + frame.remove(); + }; + frame.addEventListener('transitionend', done, { once: true }); + morphTimer = setTimeout(done, 430); +} + +export function closeDetail(options = {}) { + const animate = options?.animate !== false; + const cardId = state.viewingCard?.id; + + if (animate) startCloseMorph(cardId); + else clearMorphGhost(); + + detail.classList.remove('open', 'zen'); + document.documentElement.classList.remove('detail-zen-open'); + document.body.classList.remove('detail-zen-open'); + const icon = $('detail-zen')?.querySelector('i'); + if (icon) icon.className = 'ph ph-corners-out text-sm'; + state.viewingCard = null; + if (state.openedFromSearch) { + state.openedFromSearch = false; + $('search-input')?.focus(); + } +} + +export function showDetailCard(id) { + const card = state.cards.find(c => c.id == id); + if (!card) return; + state.viewingCard = card; + markRecent(id); + + 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); + }); + showContent(card); +} + +// ── Sidebar ── + +function renderSidebar(card) { + const sidebar = $('detail-sidebar'); + const list = $('detail-sidebar-list'); + const groupCards = getGroupCards(card); + + if (groupCards && groupCards.length > 1) { + sidebar.classList.remove('hidden'); + $('detail-sidebar-count').textContent = groupCards.length; + list.innerHTML = groupCards.map(gc => { + const active = gc.id == card.id; + const img = firstImage(gc.body); + return ` + `; + }).join(''); + + list.querySelectorAll('.sidebar-card').forEach(sc => { + sc.addEventListener('click', () => showDetailCard(sc.dataset.sid)); + }); + } else { + sidebar.classList.add('hidden'); + } +} + +// ── Content ── + +function showContent(card) { + $('detail-color').style.background = card.color; + titleEl.textContent = card.title; + bodyEl.innerHTML = renderMd(card.body); + + // Interactive checkboxes + bodyEl.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => { + cb.disabled = false; + cb.addEventListener('change', async () => { + let n = 0; + const body = card.body.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m); + card.body = body; + await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); + const c = state.cards.find(x => x.id == card.id); + if (c) c.body = body; + window._renderBoard(); + }); + }); + + metaEl.textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); + applyZoom(); + + // Backlinks + const pattern = `[[${card.title}]]`.toLowerCase(); + const backlinks = state.cards.filter(c => c.id != card.id && (c.body || '').toLowerCase().includes(pattern)); + const blBox = $('detail-backlinks'); + const blList = $('detail-backlinks-list'); + if (backlinks.length) { + blBox.classList.remove('hidden'); + blList.innerHTML = backlinks.map(b => ` + `).join(''); + blList.querySelectorAll('[data-open-card]').forEach(btn => { + btn.addEventListener('click', () => openDetail(btn.dataset.openCard)); + }); + } else { + blBox.classList.add('hidden'); + } +} + +// ── Event listeners ── + +// Delegated card-link clicks (replaces inline onclick) +detail.addEventListener('click', e => { + const link = e.target.closest('.card-link[data-card-id]'); + if (link) { openDetail(link.dataset.cardId); return; } + if (e.target === detail) closeDetail(); +}); +$('detail-close')?.addEventListener('click', closeDetail); + +// Zen mode +$('detail-zen')?.addEventListener('click', () => { + detail.classList.toggle('zen'); + const isZen = detail.classList.contains('zen'); + document.documentElement.classList.toggle('detail-zen-open', isZen); + document.body.classList.toggle('detail-zen-open', isZen); + const icon = $('detail-zen')?.querySelector('i'); + if (icon) icon.className = isZen ? 'ph ph-corners-in text-sm' : 'ph ph-corners-out text-sm'; +}); + +$('detail-edit')?.addEventListener('click', () => { + if (!state.viewingCard) return; + const card = state.viewingCard; + state.openedFromSearch = false; + closeDetail({ animate: false }); + window._closeSearch(); + window._openModal(card); +}); + +$('detail-delete')?.addEventListener('click', async () => { + if (!state.viewingCard) return; + const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); + if (!ok) return; + pushCardDelete('delete', snapshotCard(state.viewingCard)); + await api(`api/cards/${state.viewingCard.id}`, { method: 'DELETE' }); + closeDetail({ animate: false }); + await window._reloadAll(); +}); + +export async function ungroupCard(id) { + await api(`api/cards/${id}`, { method: 'PATCH', body: { parent_id: null } }); + await window._reloadAll(); + if (state.viewingCard) openDetail(state.viewingCard.id == id ? id : state.viewingCard.id); +} diff --git a/public/js/modules/dialog.js b/public/js/modules/dialog.js new file mode 100644 index 0000000..88030cb --- /dev/null +++ b/public/js/modules/dialog.js @@ -0,0 +1,57 @@ +// ── Custom dialog system ── + +import { $ } from './util.js'; + +const overlay = $('dialog'); +const titleEl = $('dialog-title'); +const msgEl = $('dialog-msg'); +const inputEl = $('dialog-input'); +const cancelBtn = $('dialog-cancel'); +const okBtn = $('dialog-ok'); +const closeBtn = $('dialog-close'); +let resolve = null; + +function canCancel() { return !cancelBtn.classList.contains('hidden'); } + +function close(result) { + overlay.classList.remove('open'); + if (resolve) { resolve(result); resolve = null; } +} + +function submit() { + close(inputEl.classList.contains('hidden') ? true : inputEl.value); +} + +export function showDialog({ title, message = '', input = false, placeholder = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) { + return new Promise(res => { + resolve = res; + titleEl.textContent = title; + msgEl.textContent = message; + msgEl.classList.toggle('hidden', !message); + inputEl.classList.toggle('hidden', input === false); + inputEl.value = ''; + inputEl.placeholder = placeholder; + inputEl.type = input === 'password' ? 'password' : 'text'; + okBtn.textContent = okLabel; + okBtn.classList.toggle('border-t-danger', danger); + okBtn.classList.toggle('text-t-danger', danger); + okBtn.classList.toggle('border-t-accent', !danger); + okBtn.classList.toggle('text-t-accent', !danger); + cancelBtn.textContent = cancelLabel; + cancelBtn.classList.toggle('hidden', !cancelLabel); + if (closeBtn) closeBtn.classList.toggle('hidden', !cancelLabel); + overlay.classList.add('open'); + if (input) inputEl.focus(); else okBtn.focus(); + }); +} + +export function isDialogOpen() { return overlay.classList.contains('open'); } + +okBtn.addEventListener('click', submit); +cancelBtn.addEventListener('click', () => { if (canCancel()) close(null); }); +if (closeBtn) closeBtn.addEventListener('click', () => { if (canCancel()) close(null); }); +overlay.addEventListener('click', e => { if (e.target === overlay && canCancel()) close(null); }); +overlay.addEventListener('keydown', e => { + if (e.key === 'Escape' && canCancel()) close(null); + if (e.key === 'Enter') submit(); +}); diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js new file mode 100644 index 0000000..3531a7d --- /dev/null +++ b/public/js/modules/editor.js @@ -0,0 +1,260 @@ +// ── Editor (card create/edit) ── + +import { $, api, escRe, getCsrf } from './util.js'; +import { state, CARD_COLORS } from './state.js'; +import { renderMd } from './markdown.js'; +import { pushCardEdit, snapshotCard } from './undo.js'; +import { fuzzyMatch } from './util.js'; +import { esc } from './util.js'; + +const modal = $('modal'); +const body = $('editor-body'); +const preview = $('editor-preview'); +const previewContent = $('preview-content'); +const inpTitle = $('inp-title'); +const inpColor = $('inp-color'); +const inpBucket = $('inp-bucket'); +const presets = $('color-presets'); +const linkAc = $('link-ac'); + +let linkAcVisible = false, linkAcItems = [], linkAcActive = 0; + +// ── Color presets ── + +CARD_COLORS.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)); + presets.appendChild(btn); +}); + +function selectColor(hex) { + inpColor.value = hex; + presets.querySelectorAll('.color-swatch').forEach(el => { + const a = el.dataset.color === hex; + el.classList.toggle('border-t-bright', a); + el.classList.toggle('border-transparent', !a); + el.classList.toggle('scale-125', a); + }); +} + +// ── Open / close ── + +export function openModal(card = null) { + state.editingId = card ? card.id : null; + $('modal-title').textContent = card ? 'edit_card' : 'new_card'; + inpTitle.value = card ? card.title : ''; + body.value = card ? card.body : ''; + inpBucket.value = card?.bucket_id ? card.bucket_id + : (state.activeBucket !== 'all' && state.activeBucket !== 'none' ? state.activeBucket : ''); + selectColor(card ? card.color : '#38bdf8'); + state.previewVisible = true; + preview.classList.remove('hidden'); + $('toggle-preview').classList.add('text-t-accent'); + updatePreview(); + modal.classList.add('open'); + inpTitle.focus(); +} + +export function closeModal() { modal.classList.remove('open'); state.editingId = null; } + +function updatePreview() { + if (state.previewVisible) previewContent.innerHTML = renderMd(body.value); +} + +export async function saveCard() { + const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null }; + if (!data.title.trim()) return; + if (state.editingId) { + const prev = snapshotCard(state.cards.find(c => c.id == state.editingId)); + if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id }); + await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data }); + } else { + data.position = -1; + await api('api/cards', { method: 'POST', body: data }); + } + closeModal(); + await window._reloadAll(); +} + +// ── Markdown toolbar ── + +function insertMd(before, after = '', placeholder = '') { + body.focus(); + const s = body.selectionStart, e = body.selectionEnd; + const sel = body.value.substring(s, e) || placeholder; + body.setRangeText(before + sel + after, s, e, 'select'); + body.selectionStart = s + before.length; + body.selectionEnd = s + before.length + sel.length; + updatePreview(); +} + +$('md-toolbar')?.addEventListener('click', e => { + const btn = e.target.closest('[data-md]'); + if (!btn) return; + const cmds = { + 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'), + }; + cmds[btn.dataset.md]?.(); +}); + +$('toggle-preview')?.addEventListener('click', () => { + state.previewVisible = !state.previewVisible; + preview.classList.toggle('hidden', !state.previewVisible); + $('toggle-preview').classList.toggle('text-t-accent', state.previewVisible); + updatePreview(); +}); + +$('add-btn')?.addEventListener('click', () => openModal()); +$('modal-cancel')?.addEventListener('click', closeModal); +$('modal-save')?.addEventListener('click', saveCard); + +// ── Link autocomplete ([[) ── + +function getLinkQuery() { + const before = body.value.substring(0, body.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 = state.cards.filter(c => c.id != state.editingId).map(c => { + const t = c.title.toLowerCase(); + let score = t.startsWith(q) ? 100 : t.includes(q) ? 50 : (fuzzyMatch(q, t) !== null ? 10 : (!q ? 1 : -1)); + return score >= 0 ? { card: c, score } : null; + }).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)); }); + }); + const rect = body.getBoundingClientRect(); + const lines = body.value.substring(0, body.selectionStart).split('\n'); + linkAc.style.top = Math.min(rect.top + (lines.length - 1) * 22 - body.scrollTop + 26, innerHeight - 200) + 'px'; + linkAc.style.left = Math.min(rect.left + 40, innerWidth - 380) + '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 = body.value.substring(0, info.start); + let after = body.value.substring(body.selectionStart); + if (after.startsWith(']]')) after = after.substring(2); + else if (after.startsWith(']')) after = after.substring(1); + const ins = `[[${card.title}]]`; + body.value = before + ins + after; + body.selectionStart = body.selectionEnd = info.start + ins.length; + hideLinkAc(); updatePreview(); body.focus(); +} + +body.addEventListener('input', () => { + const info = getLinkQuery(); + info ? showLinkAc(info.query) : hideLinkAc(); + updatePreview(); +}); + +body.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(' '); } +}); + +body.addEventListener('blur', () => setTimeout(hideLinkAc, 150)); + +// ── Clipboard image paste ── + +body.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; + const placeholder = '![uploading...]()'; + body.setRangeText(placeholder, body.selectionStart, body.selectionEnd, 'end'); + updatePreview(); + 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, headers: { 'X-CSRF-Token': getCsrf() } }); + const text = await res.text(); + const data = JSON.parse(text); + if (data.error) throw new Error(data.error); + if (data.url) { + const md = `![${data.name}](${data.url})`; + const idx = body.value.indexOf(placeholder); + if (idx !== -1) { body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length); body.selectionStart = body.selectionEnd = idx + md.length; } + } + } catch (err) { + console.error('Upload failed:', err); + const idx = body.value.indexOf(placeholder); + if (idx !== -1) { body.value = body.value.substring(0, idx) + body.value.substring(idx + placeholder.length); body.selectionStart = body.selectionEnd = idx; } + } + updatePreview(); + return; + } +}); + +// ── Image resize ── + +document.addEventListener('mousedown', e => { + const img = e.target.closest('.prose img'); + if (!img) return; + e.preventDefault(); + img.classList.add('resizing'); + const startX = e.clientX, startW = img.offsetWidth; + const onMove = ev => { img.style.width = Math.max(50, startW + ev.clientX - startX) + 'px'; img.style.maxWidth = 'none'; }; + const onUp = () => { + img.classList.remove('resizing'); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + const width = Math.round(img.offsetWidth), src = img.getAttribute('src'); + if (src && modal.classList.contains('open')) { + const val = body.value; + const mdRe = new RegExp('!\\[([^\\]]*)\\]\\(' + escRe(src) + '\\)'); + const mdMatch = val.match(mdRe); + if (mdMatch) { body.value = val.replace(mdMatch[0], `${mdMatch[1]}`); } + else { + const tagRe = new RegExp('(]*src="' + escRe(src) + '"[^>]*?)(?:\\swidth="[^"]*")?([^>]*>)'); + const tagMatch = val.match(tagRe); + if (tagMatch) body.value = val.replace(tagMatch[0], tagMatch[1] + ` width="${width}"` + tagMatch[2]); + } + updatePreview(); + } + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +}); diff --git a/public/js/modules/gallery.js b/public/js/modules/gallery.js new file mode 100644 index 0000000..05c548c --- /dev/null +++ b/public/js/modules/gallery.js @@ -0,0 +1,134 @@ +// ── Image gallery ── + +import { $, esc, api, getCsrf } from './util.js'; +import { state } from './state.js'; +import { showDialog } from './dialog.js'; + +function getReferencedUrls() { + const urls = new Set(); + for (const card of state.cards) { + const body = card.body || ''; + let m; + const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g; + while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]); + const htmlRe = /]+src=["']([^"']+)["'][^>]*>/gi; + while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]); + } + return urls; +} + +function findCardForUrl(url) { + for (const card of state.cards) { + const body = card.body || ''; + if (body.includes(url)) return card; + } + return null; +} + +export async function openGallery() { + const el = $('gallery'); + if (!el) return; + + const files = await api('api/uploads'); + const referenced = getReferencedUrls(); + const grid = $('gallery-grid'); + const count = $('gallery-count'); + + const linked = []; + const orphaned = []; + for (const f of files) { + const card = findCardForUrl(f.url); + if (card) { + linked.push({ ...f, cardId: card.id, cardTitle: card.title, cardColor: card.color }); + } else { + orphaned.push(f); + } + } + + const total = linked.length + orphaned.length; + if (count) count.textContent = `${total} image${total !== 1 ? 's' : ''}${orphaned.length ? ` · ${orphaned.length} orphaned` : ''}`; + + if (!total) { + grid.innerHTML = `
+ +

no images

`; + } else { + let html = ''; + + if (orphaned.length) { + html += `
orphaned (not used by any card)
`; + html += orphaned.map(f => ` + `).join(''); + } + + if (linked.length) { + if (orphaned.length) html += `
linked to cards
`; + html += linked.map(img => ` + `).join(''); + } + + grid.innerHTML = html; + + // Linked image clicks → open card + grid.querySelectorAll('[data-card-id]').forEach(item => { + item.addEventListener('click', () => { closeGallery(); window._openDetail(item.dataset.cardId); }); + }); + + // Orphan: create card from image + grid.querySelectorAll('[data-create]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const url = btn.dataset.create; + const name = url.split('/').pop().replace(/\.[^.]+$/, ''); + await api('api/cards', { method: 'POST', body: { title: name, body: `![${name}](${url})`, color: '#38bdf8', position: -1 } }); + await window._reloadAll(); + openGallery(); // refresh + }); + }); + + // Orphan: delete + grid.querySelectorAll('[data-del]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const name = btn.dataset.del; + const ok = await showDialog({ title: 'delete_image', message: `"${name}" loeschen?`, danger: true, okLabel: 'delete' }); + if (!ok) return; + await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } }); + openGallery(); // refresh + }); + }); + } + + el.classList.add('open'); +} + +export function closeGallery() { + $('gallery')?.classList.remove('open'); +} + +$('gallery-close')?.addEventListener('click', closeGallery); +$('gallery')?.addEventListener('click', e => { if (e.target === $('gallery')) closeGallery(); }); +$('gallery-btn')?.addEventListener('click', openGallery); diff --git a/public/js/modules/markdown.js b/public/js/modules/markdown.js new file mode 100644 index 0000000..1422a39 --- /dev/null +++ b/public/js/modules/markdown.js @@ -0,0 +1,62 @@ +// ── Markdown rendering ── + +import { esc } from './util.js'; +import { state } from './state.js'; + +marked.use({ breaks: true, gfm: true }); + +const purifyConfig = { + ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div'], + ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title'], + ALLOW_DATA_ATTR: true, +}; + +export function renderMd(src) { + const linked = (src || '').replace(/\[\[([^\]]+)\]\]/g, (_, title) => { + const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase()); + if (card) return `${esc(title)}`; + return `${esc(title)}`; + }); + const raw = marked.parse(linked); + return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, purifyConfig) : raw; +} + +export 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(); +} + +export function getTodos(src) { + const s = src || ''; + const total = (s.match(/- \[([ x])\]/g) || []).length; + if (!total) return null; + return { total, done: (s.match(/- \[x\]/g) || []).length }; +} + +export 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; +} + +export function leadingImage(src) { + const s = (src || '').trimStart(); + if (!s) return null; + const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/); + if (md) return md[1].split(/\s+/)[0].replace(/^<(.+)>$/, '$1') || null; + const html = s.match(/^]+src=(['"])(.*?)\1/i); + return html ? html[2] : null; +} diff --git a/public/js/modules/search.js b/public/js/modules/search.js new file mode 100644 index 0000000..dddacf8 --- /dev/null +++ b/public/js/modules/search.js @@ -0,0 +1,155 @@ +// ── Search ── + +import { $, esc, escRe, fuzzyMatch, countSubstring, highlight } from './util.js'; +import { state } from './state.js'; +import { renderMd, stripMd } from './markdown.js'; + +const overlay = $('search'); +const input = $('search-input'); +const results = $('search-results'); +const footer = $('search-footer'); +const previewEmpty = $('search-preview-empty'); +const previewContent = $('search-preview-content'); +const bucketFilter = $('search-bucket-filter'); +let active = -1, hits = []; + +export function openSearch() { + window._closeTasks?.(); + input.value = ''; + results.innerHTML = ''; + footer.classList.add('hidden'); + active = -1; + overlay.classList.add('open'); + input.focus(); + clearPreview(); + renderHome(); +} + +export function closeSearch() { overlay.classList.remove('open'); } + +function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); } + +function getPool() { + const bv = bucketFilter.value; + if (!bv) return state.cards; + if (bv === 'none') return state.cards.filter(c => !c.bucket_id); + return state.cards.filter(c => c.bucket_id == bv); +} + +function updatePreview() { + const card = hits[active]; + if (!card) { clearPreview(); return; } + previewEmpty.classList.add('hidden'); + previewContent.classList.remove('hidden'); + $('search-preview-color').style.background = card.color; + const q = input.value.trim(); + const words = q ? q.toLowerCase().split(/\s+/).filter(Boolean) : []; + $('search-preview-title').innerHTML = words.length ? highlight(esc(card.title), words) : esc(card.title); + let html = renderMd(card.body); + if (words.length) { + for (const w of words) html = html.replace(new RegExp('(>)([^<]*?)(' + escRe(w) + ')', 'gi'), '$1$2$3'); + } + $('search-preview-body').innerHTML = html; + $('search-preview-meta').textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); +} + +function renderHome() { + const recent = [...getPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6); + if (!recent.length) { results.innerHTML = `
no cards yet
`; return; } + results.innerHTML = `
recent
` + + recent.map((c, i) => hitHtml(c, i, null)).join(''); + hits = recent; active = recent.length ? 0 : -1; + footer.classList.remove('hidden'); + bindHits(); updateActive(); updatePreview(); +} + +function search(query) { + const q = query.toLowerCase().trim(); + if (!q) { renderHome(); return; } + const words = q.split(/\s+/).filter(Boolean); + const scored = getPool().map(c => { + const title = (c.title || '').toLowerCase(), 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 allWords = true; + for (const w of words) { + const inT = title.includes(w), inB = body.includes(w); + if (inT) { score += 30; if (new RegExp('(^|[\\s\\-_./])' + escRe(w)).test(title)) score += 20; if (title.startsWith(w)) score += 15; score += countSubstring(title, w) * 5; } + if (inB) score += 8 + countSubstring(body, w) * 3; + if (!inT && !inB) allWords = false; + } + if (allWords) { 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; + return { card: c, score: score - title.length * 0.1 }; + }).filter(Boolean).sort((a, b) => b.score - a.score); + + hits = scored.map(s => s.card); active = scored.length ? 0 : -1; + if (!scored.length) { results.innerHTML = `

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

`; footer.classList.add('hidden'); clearPreview(); return; } + results.innerHTML = `
${scored.length} result${scored.length > 1 ? 's' : ''}
` + + scored.map((s, i) => hitHtml(s.card, i, words)).join(''); + footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); +} + +function hitHtml(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(), fullQ = words.join(' '), winSize = 160; + let best = 0, bestScore = -1; + for (let i = 0; i < lower.length; i += 10) { + const win = lower.substring(i, i + winSize); + let ws = win.includes(fullQ) ? 50 : 0; + let fh = -1, lh = -1; + for (const w of words) { let p = 0, h = 0; while ((p = win.indexOf(w, p)) !== -1) { h++; if (fh === -1 || p < fh) fh = p; if (p > lh) lh = p + w.length; p += w.length; } ws += h * 8; } + if (fh !== -1 && lh !== -1) ws += Math.max(0, 30 - (lh - fh) / 2); + if (ws > bestScore) { bestScore = ws; best = i; } + } + 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 bindHits() { + results.querySelectorAll('.search-hit').forEach(el => { + el.addEventListener('click', () => { + const i = parseInt(el.dataset.idx); + if (active === i && hits[i]) window._openDetail(hits[i].id); + else { active = i; updateActive(); } + }); + el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); }); + }); +} + +function updateActive() { + results.querySelectorAll('.search-hit').forEach((el, i) => { + el.classList.toggle('active', i === active); + if (i === active) el.scrollIntoView({ block: 'nearest' }); + }); + updatePreview(); +} + +input.addEventListener('input', () => search(input.value)); +bucketFilter?.addEventListener('change', () => search(input.value)); +input.addEventListener('keydown', e => { + const n = results.querySelectorAll('.search-hit').length; + if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, n - 1); updateActive(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); updateActive(); } + else if (e.key === 'Enter' && active >= 0 && hits[active]) { e.preventDefault(); window._openDetail(hits[active].id); } +}); +overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); }); +$('search-btn')?.addEventListener('click', openSearch); +$('search-close')?.addEventListener('click', closeSearch); diff --git a/public/js/modules/state.js b/public/js/modules/state.js new file mode 100644 index 0000000..39a2c23 --- /dev/null +++ b/public/js/modules/state.js @@ -0,0 +1,58 @@ +// ── Shared app state ── + +export const state = { + cards: [], + buckets: [], + activeBucket: 'all', + activeGroup: null, + sortMode: localStorage.getItem('hyprcrd-sort') || 'manual', + editingId: null, + viewingCard: null, + openedFromSearch: false, + previewVisible: false, +}; + +export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha']; +export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa' }; + +export const CARD_COLORS = [ + { 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' }, +]; + +export function sortedCards(cards) { + const arr = [...cards]; + switch (state.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)); + } +} + +export function getChildren(parentId) { + return state.cards.filter(c => c.parent_id == parentId); +} + +export function getGroupCards(card) { + if (card.parent_id) { + const parent = state.cards.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; +} + +// Recently opened +export function getRecent() { + try { return JSON.parse(localStorage.getItem('hyprcrd-recent') || '[]'); } catch { return []; } +} +export function markRecent(id) { + const r = getRecent().filter(i => i != id); + r.unshift(String(id)); + localStorage.setItem('hyprcrd-recent', JSON.stringify(r.slice(0, 10))); +} diff --git a/public/js/modules/tasks.js b/public/js/modules/tasks.js new file mode 100644 index 0000000..3f69d2f --- /dev/null +++ b/public/js/modules/tasks.js @@ -0,0 +1,114 @@ +// ── Global tasks view ── + +import { $, esc, api } from './util.js'; +import { state } from './state.js'; + +const overlay = $('tasks'); +const btn = $('tasks-btn'); +const closeBtn = $('tasks-close'); +const statusFilter = $('tasks-status-filter'); +const bucketFilter = $('tasks-bucket-filter'); +const list = $('tasks-list'); +const summary = $('tasks-summary'); +const countBadge = $('tasks-open-count'); + +function collectTasks() { + const all = []; + for (const card of state.cards) { + const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g; + let m, idx = 0; + while ((m = re.exec(card.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++ }); + } + } + return all; +} + +function filter(tasks) { + const s = statusFilter?.value || 'open', b = bucketFilter?.value || ''; + return tasks.filter(t => { + if (s === 'open' && t.done) return false; + if (s === 'done' && !t.done) return false; + if (b === 'none') return !t.bucketId; + if (b && t.bucketId != b) return false; + return true; + }); +} + +export function refreshTasksView() { + if (!countBadge) return; + const tasks = collectTasks(); + const open = tasks.filter(t => !t.done).length; + countBadge.textContent = String(open); + + if (!overlay?.classList.contains('open') || !list) return; + const filtered = filter(tasks); + if (summary) summary.textContent = `open: ${open} | done: ${tasks.length - open} | showing: ${filtered.length}`; + + if (!filtered.length) { + list.innerHTML = `

no tasks in this filter

`; + return; + } + + list.innerHTML = filtered.map(t => ` +
+ +
+
${esc(t.text)}
+ +
+
`).join(''); + + list.querySelectorAll('[data-task-toggle]').forEach(b => b.addEventListener('click', async e => { + e.preventDefault(); e.stopPropagation(); + const [cardId, idx] = b.dataset.taskToggle.split(':'); + await toggleTask(cardId, Number(idx)); + })); + list.querySelectorAll('[data-task-open]').forEach(b => b.addEventListener('click', e => { + e.preventDefault(); e.stopPropagation(); + closeTasks(); + window._openDetail(b.dataset.taskOpen); + })); +} + +async function toggleTask(cardId, todoIdx) { + const card = state.cards.find(c => c.id == cardId); + if (!card) return; + let idx = 0, changed = false; + const next = (card.body || '').replace(/- \[([ xX])\]/g, (m, s) => { + if (idx++ !== todoIdx) return m; + changed = true; + return `- [${s.toLowerCase() === 'x' ? ' ' : 'x'}]`; + }); + if (!changed) return; + const updated = await api(`api/cards/${cardId}`, { method: 'PATCH', body: { body: next } }); + if (updated?.id) { const i = state.cards.findIndex(c => c.id == updated.id); if (i !== -1) state.cards[i] = updated; } + else card.body = next; + if (state.viewingCard?.id == cardId) { + const latest = state.cards.find(c => c.id == cardId); + if (latest) { state.viewingCard = latest; /* detail re-render handled by board */ } + } + window._renderBoard(); +} + +export function openTasks() { + if (!overlay) return; + window._closeSearch?.(); + overlay.classList.add('open'); + refreshTasksView(); +} + +export function closeTasks() { overlay?.classList.remove('open'); } + +btn?.addEventListener('click', openTasks); +closeBtn?.addEventListener('click', closeTasks); +overlay?.addEventListener('click', e => { if (e.target === overlay) closeTasks(); }); +statusFilter?.addEventListener('change', refreshTasksView); +bucketFilter?.addEventListener('change', refreshTasksView); diff --git a/public/js/modules/theme.js b/public/js/modules/theme.js new file mode 100644 index 0000000..766082a --- /dev/null +++ b/public/js/modules/theme.js @@ -0,0 +1,25 @@ +// ── Theme picker ── + +import { $ } from './util.js'; + +const picker = $('theme-picker'); +const list = $('theme-list'); + +if (list) { + for (const [key, t] of Object.entries(window.themes)) { + const btn = document.createElement('button'); + btn.className = 'flex items-center gap-2.5 w-full px-2 py-1.5 text-left text-xs text-t-muted hover:text-t-bright transition-colors rounded'; + btn.dataset.themeDot = key; + btn.innerHTML = ` + ${t.label}`; + btn.onclick = () => window.applyTheme(key); + list.appendChild(btn); + } +} + +window.applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate'); + +if (picker) { + $('theme-toggle')?.addEventListener('click', () => picker.classList.toggle('open')); + document.addEventListener('click', e => { if (!picker.contains(e.target)) picker.classList.remove('open'); }); +} diff --git a/public/js/modules/undo.js b/public/js/modules/undo.js new file mode 100644 index 0000000..9d580aa --- /dev/null +++ b/public/js/modules/undo.js @@ -0,0 +1,82 @@ +// ── Undo system ── + +import { api } from './util.js'; + +const MAX_STACK = 30; +const stack = []; + +export function pushUndo(label, restoreFn) { + stack.push({ label, restore: restoreFn, time: Date.now() }); + if (stack.length > MAX_STACK) stack.shift(); + showHint(label); +} + +export async function undo() { + const entry = stack.pop(); + if (!entry) return; + await entry.restore(); + await window._reloadAll(); + showHint(`undo: ${entry.label}`); +} + +export function canUndo() { return stack.length > 0; } +export function lastLabel() { return stack.length ? stack[stack.length - 1].label : ''; } + +// Snapshot helpers + +export function snapshotCard(card) { + return { ...card }; +} + +export function snapshotCards(cards) { + return cards.map(c => ({ ...c })); +} + +export function pushCardEdit(label, cardId, prevData) { + pushUndo(label, async () => { + await api(`api/cards/${cardId}`, { method: 'PATCH', body: prevData }); + }); +} + +export function pushCardDelete(label, card) { + pushUndo(label, async () => { + await api('api/cards', { method: 'POST', body: { + title: card.title, body: card.body, color: card.color, + bucket_id: card.bucket_id, position: card.position, + }}); + }); +} + +export function pushReorder(label, cards) { + const prev = cards.map(c => ({ id: c.id, position: c.position })); + pushUndo(label, async () => { + await Promise.all(prev.map(p => api(`api/cards/${p.id}`, { method: 'PATCH', body: { position: p.position } }))); + }); +} + +export function pushBatch(label, patches) { + // patches = [{cardId, prevData}, ...] + pushUndo(label, async () => { + await Promise.all(patches.map(p => api(`api/cards/${p.cardId}`, { method: 'PATCH', body: p.prevData }))); + }); +} + +// Toast hint +let hintTimer = null; +function showHint(text) { + let el = document.getElementById('undo-hint'); + if (!el) { + el = document.createElement('div'); + el.id = 'undo-hint'; + el.className = 'fixed bottom-16 left-1/2 -translate-x-1/2 z-[95] bg-t-surface border border-t-border px-4 py-2 text-xs font-pixel text-t-muted shadow-lg transition-all'; + document.body.appendChild(el); + } + el.textContent = text + ' (ctrl+z undo)'; + el.style.opacity = '1'; + el.style.transform = 'translateX(-50%) translateY(0)'; + clearTimeout(hintTimer); + hintTimer = setTimeout(() => { + el.style.opacity = '0'; + el.style.transform = 'translateX(-50%) translateY(8px)'; + }, 2500); +} diff --git a/public/js/modules/util.js b/public/js/modules/util.js new file mode 100644 index 0000000..1497b2a --- /dev/null +++ b/public/js/modules/util.js @@ -0,0 +1,65 @@ +// ── Utilities ── + +export function esc(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +export function escRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +let csrfToken = ''; +export function setCsrf(token) { csrfToken = token; } +export function getCsrf() { return csrfToken; } + +export async function api(path, opts = {}) { + const headers = { 'Content-Type': 'application/json' }; + if (csrfToken) headers['X-CSRF-Token'] = csrfToken; + const res = await fetch(path, { + headers, + ...opts, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + return res.json(); +} + +export function $(id) { return document.getElementById(id); } + +export function fuzzyMatch(needle, haystack) { + if (needle.length < 3) return null; + 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; + return score - (haystack.length - needle.length) * 0.3; +} + +export function countSubstring(str, sub) { + let c = 0, p = 0; + while ((p = str.indexOf(sub, p)) !== -1) { c++; p += sub.length; } + return c; +} + +export 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; +} diff --git a/src/Database.php b/src/Database.php index 13efc1e..7c2cf4e 100644 --- a/src/Database.php +++ b/src/Database.php @@ -68,5 +68,12 @@ class Database 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'); } + + // Bucket color migration + $bcols = self::$pdo->query('PRAGMA table_info(buckets)')->fetchAll(); + $bcolNames = array_column($bcols, 'name'); + if (!in_array('color', $bcolNames)) { + self::$pdo->exec("ALTER TABLE buckets ADD COLUMN color TEXT NOT NULL DEFAULT '#64748b'"); + } } } diff --git a/src/bootstrap.php b/src/bootstrap.php index ba8c203..d09430d 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -7,6 +7,6 @@ require_once __DIR__ . '/Database.php'; $dataDir = dirname(DB_PATH); if (!is_writable($dataDir) && is_dir($dataDir)) { http_response_code(500); - echo 'Data directory is not writable: ' . $dataDir; + echo 'Data directory is not writable'; exit(1); }