This commit is contained in:
th committed 2026-04-06 20:27:19 +02:00
1 parent e85b5203cd
commit 0d4d369870
19 files changed
+2258 -1814

No files matched your search

+66 -7
View File
@@ -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; }
+157 -46
View File
@@ -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/')) {
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Silkscreen:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=<?= time() ?>">
@@ -299,14 +387,14 @@ if (str_starts_with($route, '/api/')) {
}}}
</script>
<script>
const themes = {
window.themes = {
slate: { label:'Slate', vars:{ '--t-bg':'#1e293b','--t-surface':'#273349','--t-raised':'#2e3d56','--t-border':'#384a64','--t-dim':'#64748b','--t-muted':'#94a3b8','--t-text':'#cbd5e1','--t-bright':'#f1f5f9','--t-accent':'#38bdf8','--t-accent2':'#818cf8','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.04)','--t-overlay':'rgba(15,23,42,0.75)','--t-font-pixel':'"Silkscreen", cursive' }},
phosphor: { label:'Phosphor', vars:{ '--t-bg':'#0a0f0a','--t-surface':'#0f1a0f','--t-raised':'#142214','--t-border':'#1a3a1a','--t-dim':'#2d5a2d','--t-muted':'#4a8a4a','--t-text':'#7bc67b','--t-bright':'#b8f0b8','--t-accent':'#4ade80','--t-accent2':'#22d3ee','--t-danger':'#f87171','--t-scanline':'rgba(0,0,0,0.1)','--t-overlay':'rgba(5,10,5,0.85)','--t-font-pixel':'"Silkscreen", cursive' }},
midnight: { label:'Midnight', vars:{ '--t-bg':'#0f172a','--t-surface':'#162033','--t-raised':'#1c2940','--t-border':'#263650','--t-dim':'#475882','--t-muted':'#7088b0','--t-text':'#a8bdd8','--t-bright':'#e2e8f0','--t-accent':'#a78bfa','--t-accent2':'#f472b6','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.06)','--t-overlay':'rgba(10,15,30,0.8)','--t-font-pixel':'"Silkscreen", cursive' }},
amber: { label:'Amber', vars:{ '--t-bg':'#1a1408','--t-surface':'#231c0e','--t-raised':'#2c2414','--t-border':'#3d3520','--t-dim':'#6b5c38','--t-muted':'#a08c5a','--t-text':'#d4c088','--t-bright':'#fbe8b0','--t-accent':'#fbbf24','--t-accent2':'#fb923c','--t-danger':'#ef4444','--t-scanline':'rgba(0,0,0,0.08)','--t-overlay':'rgba(20,16,5,0.8)','--t-font-pixel':'"Silkscreen", cursive' }},
paper: { label:'Paper', vars:{ '--t-bg':'#f5f1eb','--t-surface':'#ffffff','--t-raised':'#eae5dd','--t-border':'#d6d0c4','--t-dim':'#a8a08e','--t-muted':'#7a7468','--t-text':'#3d3832','--t-bright':'#1a1714','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0)','--t-overlay':'rgba(245,241,235,0.8)','--t-font-pixel':'"JetBrains Mono", monospace' }},
};
function applyTheme(name) {
window.applyTheme = function(name) {
const t = themes[name] || themes.slate;
for (const [k,v] of Object.entries(t.vars)) document.documentElement.style.setProperty(k,v);
localStorage.setItem('hyprcrd-theme', name);
@@ -321,44 +409,48 @@ if (str_starts_with($route, '/api/')) {
<!-- tailwind safelist: border-t-bright border-transparent scale-125 text-t-accent line-clamp-2 border-t-danger text-t-danger pr-16 -->
<body class="bg-t-bg text-t-text min-h-screen transition-colors duration-200 flex flex-col">
<header class="flex items-center justify-between px-6 py-4 border-b border-t-border">
<div class="flex items-center gap-3">
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
<h1 class="font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
<span class="text-[10px] text-t-dim font-pixel ml-1">v0.1</span>
<header id="main-header" class="sticky top-0 z-40 bg-t-bg border-b border-t-border shadow-[0_4px_20px_-4px_rgba(0,0,0,0.3)]" style="overflow:hidden;">
<div id="header-tint" class="absolute inset-0 pointer-events-none" style="opacity:0;transition:opacity 0.3s,background 0.3s;z-index:0;"></div>
<div class="flex items-center justify-between px-6 py-3 relative z-[1]">
<div class="flex items-center gap-3">
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
<h1 class="font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
<span class="text-[10px] text-t-dim font-pixel ml-1">v0.1</span>
</div>
<div class="flex items-center gap-3">
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
</button>
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">tasks</span>
<span id="tasks-open-count" class="font-pixel text-[9px] px-1.5 py-0.5 border border-t-border rounded text-t-dim">0</span>
</button>
<button id="add-btn" class="flex items-center gap-2 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
<i class="ph ph-plus-square text-sm"></i><span class="font-pixel text-[11px]">new_card</span>
</button>
</div>
</div>
<div class="flex items-center gap-3">
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
</button>
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">tasks</span>
<span id="tasks-open-count" class="font-pixel text-[9px] px-1.5 py-0.5 border border-t-border rounded text-t-dim">0</span>
</button>
<button id="add-btn" class="flex items-center gap-2 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
<i class="ph ph-plus-square text-sm"></i><span class="font-pixel text-[11px]">new_card</span>
<div class="flex items-center px-6 pb-2 relative z-[1]">
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2">
<i class="ph ph-sort-ascending text-xs"></i>
<span id="sort-label">manual</span>
</button>
</div>
<div id="group-bar" class="hidden flex items-center gap-2 px-6 pb-2 relative z-[1]"></div>
</header>
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 pb-24"></div>
<div class="flex items-center gap-1 px-6 pt-4 pb-2 overflow-x-auto" id="bucket-bar"></div>
<div class="flex items-center justify-between px-6 pb-2">
<div></div>
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors">
<i class="ph ph-sort-ascending text-xs"></i>
<span id="sort-label">manual</span>
</button>
</div>
<div id="group-bar" class="hidden flex items-center gap-2 px-6 pb-2"></div>
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pb-24"></div>
<footer class="fixed inset-x-0 bottom-0 z-40 border-t border-t-border bg-t-bg/95 backdrop-blur-sm px-6 py-2 flex items-center justify-between gap-4">
<footer class="fixed inset-x-0 bottom-0 z-40 border-t border-t-border bg-t-bg shadow-[0_-4px_20px_-4px_rgba(0,0,0,0.3)] px-6 py-2 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-wrap">
<input type="file" id="import-file" accept=".zip,application/zip" class="hidden">
<button id="import-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">import</span>
</button>
<button id="gallery-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-images text-sm"></i><span class="font-pixel text-[10px]">images</span>
</button>
<button id="export-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
</button>
@@ -471,13 +563,16 @@ if (str_starts_with($route, '/api/')) {
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0">
<div class="card-bar w-full shrink-0" id="detail-color"></div>
<div class="absolute top-4 right-4 flex gap-2 z-10">
<div id="detail-controls" class="absolute top-4 right-4 flex gap-2 z-10">
<button id="detail-edit" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span>
</button>
<button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">del</span>
</button>
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="zen mode">
<i class="ph ph-corners-out text-sm"></i>
</button>
<button id="detail-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
@@ -497,6 +592,22 @@ if (str_starts_with($route, '/api/')) {
<div id="link-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:220px;max-width:360px;"></div>
<!-- Gallery -->
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
<i class="ph ph-images text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">images</h2>
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
<div class="flex-1"></div>
<button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<div id="gallery-grid" class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 content-start"></div>
</div>
</div>
<!-- Context menu -->
<div id="ctx-menu" class="fixed z-[85] bg-t-surface border border-t-border shadow-2xl hidden" style="min-width:180px;">
<div id="ctx-items" class="py-1"></div>
@@ -550,6 +661,6 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<script>document.write('<script src="js/app.js?v=' + Date.now() + '"><\/script>');</script>
<script type="module" src="js/app.js?v=<?= time() ?>"></script>
</body>
</html>
+88 -1760
View File
File diff suppressed because it is too large. Load diff
+276
View File
@@ -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 = `<div class="col-span-full text-center py-20">
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
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 `
<div class="${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
style="--glow-color:${c.color}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + c.color : ''}" draggable="true" data-id="${c.id}">
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
${isGroup ? `<div class="card-group-badge font-pixel cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
<div class="p-4 card-layer ${thumbImg ? 'pr-16' : ''}">
<div class="flex items-start gap-2 mb-2">
${todos ? `<i class="ph ${todos.done === todos.total ? 'ph-check-square text-t-accent' : 'ph-squares-four text-t-muted'} text-sm mt-0.5 shrink-0"></i>` : ''}
<h3 class="font-pixel text-xs text-t-bright leading-relaxed">${esc(c.title)}</h3>
</div>
${todos ? `<div class="mb-2"><div class="flex items-center gap-2 mb-1">
<div class="flex-1 h-1.5 bg-t-border rounded-full overflow-hidden"><div class="h-full rounded-full transition-all" style="width:${todoPct}%;background:${c.color}"></div></div>
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
</div></div>` : ''}
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-3">${esc(stripMd(c.body))}</p>
<div class="mt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
</div>
</div>
</div>`;
}).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 = `
<button id="group-back" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">back</span>
</button>
<i class="ph ph-stack text-t-accent text-sm"></i>
<span class="font-pixel text-[10px] text-t-accent">${esc(parent?.title || 'group')}</span>
<span class="font-pixel text-[9px] text-t-dim">${cards.length} cards</span>`;
$('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 = ''; });
});
}
+233
View File
@@ -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 = `<div class="py-1">
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">rename</span></button>
<button class="ctx-item" data-baction="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">move left</span></button>
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">move right</span></button>
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
<button class="ctx-item" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete (keep cards)</span></button>
<button class="ctx-item danger" data-baction="delete-all"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete + cards</span></button>
</div>
<div id="bctx-colors" class="hidden px-3 py-2 border-t border-t-border">
<div class="flex flex-wrap gap-1.5">
${CARD_COLORS.map(c => `<button class="w-5 h-5 rounded-sm border-2 transition-all hover:scale-125 ${c.hex === (bucket.color || '#64748b') ? 'border-t-bright' : 'border-transparent'}" style="background:${c.hex}" data-bctx-color="${c.hex}"></button>`).join('')}
</div>
</div>`;
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 => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
if (inpBucket) inpBucket.innerHTML = `<option value="">no bucket</option>` + bucketOpts;
if (searchFilter) searchFilter.innerHTML = `<option value="">all buckets</option><option value="none">unsorted</option>` + bucketOpts;
if (tasksFilter) {
const cur = tasksFilter.value;
tasksFilter.innerHTML = `<option value="">all buckets</option><option value="none">unsorted</option>` + 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 = `
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all"><i class="ph ph-squares-four text-sm"></i><span class="font-pixel text-[10px]">all</span></button>
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none"><i class="ph ph-tray text-sm"></i><span class="font-pixel text-[10px]">unsorted</span></button>
${state.buckets.map(b => {
const c = b.color || '#64748b';
const active = state.activeBucket == b.id;
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `border-color:${c};color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
}).join('')}
<button id="add-bucket-btn" class="flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">bucket</span></button>`;
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();
});
}
+92
View File
@@ -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 = `
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">open</span></button>
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span></button>
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">duplicate</span></button>
<div class="ctx-sep"></div>
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
<button class="ctx-item" data-action="bucket"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">bucket</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
${isParent ? `<div class="ctx-sep"></div>
<button class="ctx-item" data-action="expand-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">open group</span></button>
<button class="ctx-item" data-action="group-members"><i class="ph ph-list text-sm"></i><span class="font-pixel text-[10px]">members (${children.length + 1})</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
<button class="ctx-item" data-action="dissolve"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">dissolve group</span></button>` : ''}
${isChild ? `<div class="ctx-sep"></div>
<button class="ctx-item" data-action="go-to-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">go to group</span></button>
<button class="ctx-item" data-action="ungroup"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">ungroup</span></button>` : ''}
<div class="ctx-sep"></div>
<button class="ctx-item danger" data-action="delete"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span></button>`;
// Color sub-panel
const grid = $('ctx-color-grid');
grid.innerHTML = CARD_COLORS.map(c => `<button class="w-5 h-5 rounded-sm border-2 transition-all hover:scale-125 ${c.hex === card.color ? 'border-t-bright' : 'border-transparent'}" style="background:${c.hex}" data-ctx-color="${c.hex}"></button>`).join('');
colors.classList.add('hidden');
// Members sub-panel
if (isParent) {
members.innerHTML = [card, ...children].map(gc => `
<button class="ctx-item" data-ctx-member="${gc.id}">
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
<span class="font-pixel text-[10px] truncate flex-1">${esc(gc.title)}</span>
${gc.id !== card.id ? `<i class="ph ph-arrow-line-up text-[10px] text-t-dim hover:text-t-danger" data-ctx-kick="${gc.id}"></i>` : ''}
</button>`).join('');
}
members.classList.add('hidden');
// Bucket sub-panel
buckets.innerHTML = `<button class="ctx-item ${!card.bucket_id ? 'text-t-accent' : ''}" data-ctx-bucket=""><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">no bucket</span></button>` +
state.buckets.map(b => `<button class="ctx-item ${card.bucket_id == b.id ? 'text-t-accent' : ''}" data-ctx-bucket="${b.id}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`).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(); }));
}
+326
View File
@@ -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 `
<div class="sidebar-card flex items-center gap-2 px-3 py-2.5 cursor-pointer border-l-2 transition-colors
${active ? 'border-t-accent bg-t-raised' : 'border-transparent hover:bg-t-raised/50'}" data-sid="${gc.id}">
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
${img ? `<span class="w-6 h-6 rounded-full shrink-0 bg-cover bg-center border border-t-border" style="background-image:url('${img}')"></span>` : ''}
<span class="font-pixel text-[10px] ${active ? 'text-t-bright' : 'text-t-muted'} truncate flex-1">${esc(gc.title)}</span>
<button onclick="event.stopPropagation();window._ungroupCard(${gc.id})" class="text-t-dim hover:text-t-danger transition-colors shrink-0 ${gc.id == groupCards[0]?.id ? 'invisible' : ''}" title="ungroup">
<i class="ph ph-x text-[10px]"></i>
</button>
</div>`;
}).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 => `
<button data-open-card="${b.id}" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<span class="w-1.5 h-1.5 rounded-full shrink-0" style="background:${b.color}"></span>
<span class="font-pixel text-[10px]">${esc(b.title)}</span>
</button>`).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);
}
+57
View File
@@ -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();
});
+260
View File
@@ -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) => `
<div class="ac-item ${i === 0 ? 'active' : ''}" data-idx="${i}">
<span class="ac-title font-pixel">${esc(c.title)}</span>
${c.bucket_name ? `<span class="ac-bucket font-pixel">${esc(c.bucket_name)}</span>` : ''}
</div>`).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], `<img src="${src}" alt="${mdMatch[1]}" width="${width}">`); }
else {
const tagRe = new RegExp('(<img[^>]*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);
});
+134
View File
@@ -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 = /<img[^>]+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 = `<div class="col-span-full text-center py-16">
<i class="ph ph-image text-4xl text-t-dim mb-2 block"></i>
<p class="font-pixel text-[10px] text-t-dim">no images</p></div>`;
} else {
let html = '';
if (orphaned.length) {
html += `<div class="col-span-full font-pixel text-[10px] text-t-danger flex items-center gap-1.5 pt-2"><i class="ph ph-warning text-xs"></i>orphaned (not used by any card)</div>`;
html += orphaned.map(f => `
<div class="gallery-item group relative" data-orphan="${f.name}">
<div class="aspect-square bg-t-bg border border-t-danger/30 rounded overflow-hidden">
<img src="${f.url}" class="w-full h-full object-cover opacity-60" loading="lazy">
</div>
<div class="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="w-6 h-6 rounded bg-t-accent text-t-bg flex items-center justify-center text-xs" data-create="${f.url}" title="create card">
<i class="ph ph-plus"></i>
</button>
<button class="w-6 h-6 rounded bg-t-danger text-t-bg flex items-center justify-center text-xs" data-del="${f.name}" title="delete">
<i class="ph ph-trash"></i>
</button>
</div>
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
</div>`).join('');
}
if (linked.length) {
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
html += linked.map(img => `
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}">
<div class="aspect-square bg-t-bg border border-t-border rounded overflow-hidden">
<img src="${img.url}" class="w-full h-full object-cover" loading="lazy">
</div>
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded flex flex-col justify-end p-2">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full shrink-0" style="background:${img.cardColor}"></span>
<span class="font-pixel text-[9px] text-white truncate">${esc(img.cardTitle)}</span>
</div>
</div>
</div>`).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);
+62
View File
@@ -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 `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`;
return `<span class="card-link broken" title="card not found">${esc(title)}</span>`;
});
const raw = marked.parse(linked);
return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, purifyConfig) : raw;
}
export function stripMd(src) {
return (src || '')
.replace(/<img[^>]*>/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(/<img[^>]+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(/^<img[^>]+src=(['"])(.*?)\1/i);
return html ? html[2] : null;
}
+155
View File
@@ -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<mark>$3</mark>');
}
$('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 = `<div class="px-4 py-8 text-center text-t-dim text-xs font-pixel">no cards yet</div>`; return; }
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">recent</span></div>` +
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 = `<div class="px-4 py-8 text-center"><i class="ph ph-magnifying-glass text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel">no results for "${esc(query)}"</p></div>`; footer.classList.add('hidden'); clearPreview(); return; }
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${scored.length} result${scored.length > 1 ? 's' : ''}</span></div>` +
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 `<div class="search-hit flex items-start gap-3 px-4 py-2.5 cursor-pointer transition-colors hover:bg-t-raised" data-idx="${idx}">
<span class="w-2 h-2 rounded-full shrink-0 mt-1.5" style="background:${card.color}"></span>
<div class="min-w-0 flex-1"><div class="text-xs text-t-bright font-pixel leading-relaxed truncate">${title}</div>
<div class="text-[11px] text-t-muted leading-relaxed mt-0.5 line-clamp-2">${snippet}</div></div></div>`;
}
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);
+58
View File
@@ -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)));
}
+114
View File
@@ -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 = `<div class="px-4 py-10 text-center text-t-dim"><i class="ph ph-check-circle text-3xl mb-2 block"></i><p class="font-pixel text-[10px]">no tasks in this filter</p></div>`;
return;
}
list.innerHTML = filtered.map(t => `
<div class="px-4 py-3 border-b border-t-border flex items-start gap-3 hover:bg-t-raised/40">
<button data-task-toggle="${t.cardId}:${t.todoIndex}" class="group mt-0.5 shrink-0 border rounded-sm flex items-center justify-center transition-colors ${t.done ? 'border-t-accent bg-t-accent' : 'border-t-border bg-transparent hover:border-t-accent'}" style="width:30px;height:30px;">
<i class="ph ph-check text-[18px] transition-opacity ${t.done ? 'opacity-100 text-t-bg' : 'opacity-0 text-t-accent group-hover:opacity-75'}"></i>
</button>
<div class="min-w-0 flex-1">
<div class="text-xs leading-relaxed ${t.done ? 'text-t-dim line-through' : 'text-t-text'}">${esc(t.text)}</div>
<button data-task-open="${t.cardId}" class="mt-1 inline-flex items-center gap-1.5 text-[10px] font-pixel text-t-muted hover:text-t-accent2 transition-colors">
<span class="w-1.5 h-1.5 rounded-full" style="background:${t.cardColor}"></span>
<span>${esc(t.cardTitle)}</span><span class="text-t-dim">/ ${t.bucketName ? esc(t.bucketName) : 'unsorted'}</span>
</button>
</div>
</div>`).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);
+25
View File
@@ -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 = `<span class="w-3 h-3 rounded-full shrink-0 border border-t-border" style="background:${t.vars['--t-accent']}"></span>
<span class="font-pixel text-[10px]">${t.label}</span>`;
btn.onclick = () => window.applyTheme(key);
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'); });
}
+82
View File
@@ -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);
}
+65
View File
@@ -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'), '<mark>$1</mark>');
if (!result.includes('<mark>')) {
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 += '<mark>' + html[i] + '</mark>'; fi++; } else { out += html[i]; }
}
out += html.substring(out.replace(/<\/?mark>/g, '').length);
result = out;
}
return result;
}
+7
View File
@@ -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'");
}
}
}
+1 -1
View File
@@ -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);
}