bak
This commit is contained in:
1 parent
e85b5203cd
commit
0d4d369870
19 files changed
+2258
-1814
No files matched your search
+66
-7
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 = ''; });
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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(); }));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 = ``;
|
||||
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);
|
||||
});
|
||||
@@ -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: ``, 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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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'); });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user