bak
This commit is contained in:
1 parent
e85b5203cd
commit
0d4d369870
19 files changed
+2228
-1784
No files matched your search
+66
-7
@@ -107,7 +107,7 @@ body::after {
|
|||||||
}
|
}
|
||||||
.group:hover .card-thumb { border-color: var(--t-muted); }
|
.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-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 */
|
/* Drop indicators — thick glowing bar */
|
||||||
.card-drop-before::before,
|
.card-drop-before::before,
|
||||||
@@ -130,6 +130,56 @@ body::after {
|
|||||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
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 */
|
/* Overlays */
|
||||||
.overlay { transition: opacity 0.15s; }
|
.overlay { transition: opacity 0.15s; }
|
||||||
.overlay:not(.open) { opacity: 0; pointer-events: none; }
|
.overlay:not(.open) { opacity: 0; pointer-events: none; }
|
||||||
@@ -142,7 +192,17 @@ body::after {
|
|||||||
|
|
||||||
/* Buckets */
|
/* Buckets */
|
||||||
.bucket-tab.active { border-color: var(--t-accent); color: var(--t-accent); }
|
.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 */
|
||||||
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
|
.toolbar-btn { 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; }
|
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
|
||||||
|
|
||||||
/* Context menu */
|
/* Context menu */
|
||||||
#ctx-menu .ctx-item {
|
#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -176,10 +236,9 @@ body::after {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
#ctx-menu .ctx-item:hover { background: var(--t-raised); }
|
#ctx-menu .ctx-item:hover, #bucket-ctx .ctx-item:hover { background: var(--t-raised); }
|
||||||
#ctx-menu .ctx-item.danger { color: var(--t-danger); }
|
#ctx-menu .ctx-item.danger, #bucket-ctx .ctx-item.danger { color: var(--t-danger); }
|
||||||
#ctx-menu .ctx-item.danger:hover { background: var(--t-danger); color: var(--t-bg); }
|
#ctx-menu .ctx-item.danger:hover, #bucket-ctx .ctx-item.danger:hover { background: var(--t-danger); color: var(--t-bg); }
|
||||||
#ctx-menu .ctx-sep { height: 1px; background: var(--t-border); margin: 2px 0; }
|
|
||||||
|
|
||||||
/* Card links */
|
/* Card links */
|
||||||
.card-link { color: var(--t-accent2); cursor: pointer; border-bottom: 1px dashed var(--t-accent2); transition: color 0.1s; }
|
.card-link { color: var(--t-accent2); cursor: pointer; border-bottom: 1px dashed var(--t-accent2); transition: color 0.1s; }
|
||||||
|
|||||||
+139
-28
@@ -3,8 +3,29 @@
|
|||||||
require_once __DIR__ . '/../src/bootstrap.php';
|
require_once __DIR__ . '/../src/bootstrap.php';
|
||||||
|
|
||||||
$db = Database::connect();
|
$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();
|
session_start();
|
||||||
|
|
||||||
|
// CSRF token
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth ──
|
// ── Auth ──
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
@@ -23,7 +44,7 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
|
|
||||||
// GET /api/auth/status
|
// GET /api/auth/status
|
||||||
if ($route === '/api/auth/status' && $method === 'GET') {
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,19 +59,34 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
}
|
}
|
||||||
$hash = password_hash($pw, PASSWORD_BCRYPT);
|
$hash = password_hash($pw, PASSWORD_BCRYPT);
|
||||||
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('password_hash', :hash)")->execute(['hash' => $hash]);
|
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('password_hash', :hash)")->execute(['hash' => $hash]);
|
||||||
|
session_regenerate_id(true);
|
||||||
$_SESSION['hyprcrd_auth'] = true;
|
$_SESSION['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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login (rate limited)
|
||||||
if ($route === '/api/auth/login' && $method === 'POST') {
|
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);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$pw = $input['password'] ?? '';
|
$pw = $input['password'] ?? '';
|
||||||
if ($pwHash && password_verify($pw, $pwHash)) {
|
if ($pwHash && password_verify($pw, $pwHash)) {
|
||||||
|
session_regenerate_id(true);
|
||||||
$_SESSION['hyprcrd_auth'] = 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 {
|
} else {
|
||||||
|
$_SESSION['login_attempts'] = $attempts + 1;
|
||||||
|
$_SESSION['login_last'] = time();
|
||||||
|
usleep(300000); // 300ms delay against timing attacks
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'wrong password']);
|
echo json_encode(['error' => 'wrong password']);
|
||||||
}
|
}
|
||||||
@@ -85,6 +121,20 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
error_reporting(0);
|
error_reporting(0);
|
||||||
header('Content-Type: application/json');
|
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
|
// Cards
|
||||||
if ($route === '/api/cards' && $method === 'POST') {
|
if ($route === '/api/cards' && $method === 'POST') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$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; }
|
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
||||||
$pos = $input['position'] ?? 0;
|
$pos = $input['position'] ?? 0;
|
||||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position) VALUES (:title, :body, :color, :bucket_id, :position)')->execute([
|
$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());
|
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;
|
exit;
|
||||||
@@ -135,7 +185,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$fields = []; $params = ['id' => $m[1]];
|
$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);
|
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;
|
$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;
|
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') {
|
if ($route === '/api/upload' && $method === 'POST') {
|
||||||
$uploadDir = __DIR__ . '/uploads';
|
$uploadDir = __DIR__ . '/uploads';
|
||||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||||
|
|
||||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
http_response_code(400);
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$file = $_FILES['file'];
|
$file = $_FILES['file'];
|
||||||
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/svg+xml' => 'svg'];
|
if ($file['size'] > 10 * 1024 * 1024) {
|
||||||
$ext = $allowed[$file['type']] ?? null;
|
|
||||||
if (!$ext) {
|
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'invalid file type: ' . $file['type']]);
|
echo json_encode(['error' => 'file too large (max 10MB)']);
|
||||||
exit;
|
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;
|
$dest = $uploadDir . '/' . $name;
|
||||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['error' => 'failed to save, check permissions on ' . $uploadDir]);
|
echo json_encode(['error' => 'upload failed']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +376,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/@phosphor-icons/web"></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/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 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 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() ?>">
|
<link rel="stylesheet" href="css/style.css?v=<?= time() ?>">
|
||||||
@@ -299,14 +387,14 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}}}
|
}}}
|
||||||
</script>
|
</script>
|
||||||
<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' }},
|
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' }},
|
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' }},
|
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' }},
|
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' }},
|
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;
|
const t = themes[name] || themes.slate;
|
||||||
for (const [k,v] of Object.entries(t.vars)) document.documentElement.style.setProperty(k,v);
|
for (const [k,v] of Object.entries(t.vars)) document.documentElement.style.setProperty(k,v);
|
||||||
localStorage.setItem('hyprcrd-theme', name);
|
localStorage.setItem('hyprcrd-theme', name);
|
||||||
@@ -321,7 +409,9 @@ 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 -->
|
<!-- 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">
|
<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">
|
<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">
|
<div class="flex items-center gap-3">
|
||||||
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
|
<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>
|
<h1 class="font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
|
||||||
@@ -340,25 +430,27 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<i class="ph ph-plus-square text-sm"></i><span class="font-pixel text-[11px]">new_card</span>
|
<i class="ph ph-plus-square text-sm"></i><span class="font-pixel text-[11px]">new_card</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
<div class="flex items-center px-6 pb-2 relative z-[1]">
|
||||||
<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 gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
|
||||||
<div class="flex items-center justify-between px-6 pb-2">
|
<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">
|
||||||
<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>
|
<i class="ph ph-sort-ascending text-xs"></i>
|
||||||
<span id="sort-label">manual</span>
|
<span id="sort-label">manual</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="group-bar" class="hidden flex items-center gap-2 px-6 pb-2"></div>
|
<div id="group-bar" class="hidden flex items-center gap-2 px-6 pb-2 relative z-[1]"></div>
|
||||||
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pb-24"></div>
|
</header>
|
||||||
|
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 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">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<input type="file" id="import-file" accept=".zip,application/zip" class="hidden">
|
<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">
|
<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>
|
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">import</span>
|
||||||
</button>
|
</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">
|
<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>
|
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -471,13 +563,16 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<div class="flex-1 flex flex-col min-w-0">
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
<div class="card-bar w-full shrink-0" id="detail-color"></div>
|
<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">
|
<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>
|
<i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span>
|
||||||
</button>
|
</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">
|
<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>
|
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">del</span>
|
||||||
</button>
|
</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">
|
<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>
|
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
|
||||||
</button>
|
</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>
|
<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 -->
|
<!-- 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-menu" class="fixed z-[85] bg-t-surface border border-t-border shadow-2xl hidden" style="min-width:180px;">
|
||||||
<div id="ctx-items" class="py-1"></div>
|
<div id="ctx-items" class="py-1"></div>
|
||||||
@@ -550,6 +661,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+76
-1748
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)) {
|
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');
|
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);
|
$dataDir = dirname(DB_PATH);
|
||||||
if (!is_writable($dataDir) && is_dir($dataDir)) {
|
if (!is_writable($dataDir) && is_dir($dataDir)) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo 'Data directory is not writable: ' . $dataDir;
|
echo 'Data directory is not writable';
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user