From 719f9d51ffba2513fa561188eca1daf46bb52b81 Mon Sep 17 00:00:00 2001 From: th Date: Tue, 7 Apr 2026 21:58:45 +0200 Subject: [PATCH] tags --- public/css/style.css | 36 ++++++++- public/index.php | 124 ++++++++++++++++++++++++++++-- public/js/app.js | 4 +- public/js/modules/board.js | 27 ++++++- public/js/modules/buckets.js | 53 +++++++++++-- public/js/modules/context-menu.js | 70 ++++++++++++++++- public/js/modules/detail.js | 83 +++++++++++++++++++- public/js/modules/editor.js | 79 ++++++++++++++++++- public/js/modules/gallery.js | 83 ++++++++++++-------- public/js/modules/markdown.js | 21 ++++- public/js/modules/search.js | 76 +++++++++++++++--- public/js/modules/state.js | 25 +++++- src/Database.php | 14 ++++ 13 files changed, 625 insertions(+), 70 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 02a1c09..26c7947 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -369,7 +369,7 @@ body.detail-image-fs-open { overflow: hidden; } .theme-picker.open .theme-menu { display: block; } /* Buckets */ -.bucket-tab.active { border-color: var(--t-accent); color: var(--t-accent); } +.bucket-tab.active, .bucket-tab.active:hover { background: var(--t-accent); color: var(--t-bg); border-color: var(--t-accent); } .bucket-tab.drag-over { background: var(--t-accent) !important; color: var(--t-bg) !important; @@ -390,6 +390,21 @@ body.detail-image-fs-open { overflow: hidden; } .bucket-drop-left { border-left: 3px solid var(--t-accent) !important; } .bucket-drop-right { border-right: 3px solid var(--t-accent) !important; } +/* Tag chips */ +.tag-chip { + display: inline-flex; align-items: center; padding: 1px 6px; + font-size: 9px; font-family: var(--t-font-pixel, 'Silkscreen', cursive); + color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--t-accent) 25%, transparent); + border-radius: 2px; cursor: pointer; line-height: 1.4; + transition: background 0.12s, border-color 0.12s; +} +.tag-seg { cursor: pointer; border-radius: 1px; padding: 0 1px; } +.tag-seg:hover { text-decoration: underline; color: var(--t-bright); } +.tag-sep { color: var(--t-dim); margin: 0 0.5px; pointer-events: none; } +.tag-chip-more { color: var(--t-dim); background: transparent; border-color: var(--t-border); cursor: default; } +#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); } + /* Toolbar */ .toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; } .toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); } @@ -434,9 +449,9 @@ body.detail-image-fs-open { overflow: hidden; } .card-link.broken { color: var(--t-danger); border-color: var(--t-danger); opacity: 0.6; } /* Autocomplete */ -#link-ac .ac-item { padding: 6px 12px; cursor: pointer; transition: background 0.1s; } -#link-ac .ac-item:hover, #link-ac .ac-item.active { background: var(--t-raised); } -#link-ac .ac-item .ac-title { font-size: 12px; color: var(--t-bright); } +#link-ac .ac-item, #tag-ac .ac-item { padding: 6px 12px; cursor: pointer; transition: background 0.1s; } +#link-ac .ac-item:hover, #link-ac .ac-item.active, #tag-ac .ac-item:hover, #tag-ac .ac-item.active { background: var(--t-raised); } +#link-ac .ac-item .ac-title, #tag-ac .ac-item .ac-title { font-size: 12px; color: var(--t-bright); } #link-ac .ac-item .ac-bucket { font-size: 9px; color: var(--t-dim); margin-left: 6px; } /* Prose (markdown) */ @@ -574,6 +589,19 @@ body.detail-image-fs-open { overflow: hidden; } .prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; } .prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; } +/* Markdown gallery */ +.md-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px; margin: 0.8em 0; } +.md-gallery-item { aspect-ratio: 1; overflow: hidden; border-radius: 4px; border: 1px solid var(--t-border); cursor: pointer; } +.md-gallery-item img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.15s; } +.md-gallery-item:hover img { transform: scale(1.05); } + +/* Image fullscreen prev/next */ +.detail-image-fs-nav { position: fixed; top: 50%; transform: translateY(-50%); z-index: 172; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.7); font-size: 20px; cursor: pointer; border-radius: 50%; transition: background 0.15s, color 0.15s; } +.detail-image-fs-nav:hover { background: rgba(0,0,0,0.8); color: #fff; } +.detail-image-fs-prev { left: 16px; } +.detail-image-fs-next { right: 16px; } +.detail-image-fs-counter { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 172; font-family: var(--font-pixel, monospace); font-size: 10px; color: rgba(255,255,255,0.5); } + /* Temporary reading aid in open detail view (not persisted) */ #detail-body .detail-reading-resize { position: relative; diff --git a/public/index.php b/public/index.php index a2d3776..fa87a79 100644 --- a/public/index.php +++ b/public/index.php @@ -134,6 +134,10 @@ if (str_starts_with($route, '/api/')) { // 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; } + function extractTags($body) { + preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches); + return implode(',', array_unique(array_map('strtolower', $matches[1] ?? []))); + } // Cards if ($route === '/api/cards' && $method === 'POST') { @@ -141,11 +145,13 @@ if (str_starts_with($route, '/api/')) { $title = trim($input['title'] ?? ''); if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; } $pos = $input['position'] ?? 0; - $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position)')->execute([ - 'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null), + $body = $input['body'] ?? ''; + $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags)')->execute([ + 'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'parent_id' => validInt($input['parent_id'] ?? null, null), 'position' => validInt($pos), + 'tags' => extractTags($body), ]); 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; @@ -167,6 +173,7 @@ if (str_starts_with($route, '/api/')) { foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } } + if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); } if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); } $card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id'); $card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit; @@ -275,6 +282,103 @@ if (str_starts_with($route, '/api/')) { exit; } + // Export — Markdown vault (Obsidian / Logseq compatible) + if ($route === '/api/export-md' && $method === 'GET') { + $ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null; + if ($ids) { + $ph = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id"); + $stmt->execute($ids); + $cards = $stmt->fetchAll(); + } else { + $cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); + } + $allCards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); + $buckets = $db->query('SELECT * FROM buckets ORDER BY position, id')->fetchAll(); + $bucketMap = []; + foreach ($buckets as $b) $bucketMap[$b['id']] = $b['name']; + $cardMap = []; + foreach ($allCards as $c) $cardMap[$c['id']] = $c; + + // Sanitise filename + $safe = function ($name) { + $name = preg_replace('/[<>:"\/\\\\|?*]/', '_', $name); + return trim($name) ?: 'untitled'; + }; + + $tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_md_'); + $zip = new ZipArchive(); + $zip->open($tmp, ZipArchive::OVERWRITE); + + $usedNames = []; + $allBodies = ''; + foreach ($cards as $c) { + // Build folder path: bucket/group/ (skip folders for single-card export) + $folder = ''; + if (!$ids || count($ids) > 1) { + if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) { + $folder = $safe($bucketMap[$c['bucket_id']]) . '/'; + } + if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) { + $folder .= $safe($cardMap[$c['parent_id']]['title']) . '/'; + } + } + + $baseName = $safe($c['title']); + $fileName = $folder . $baseName . '.md'; + // Deduplicate + $n = 1; + while (isset($usedNames[$fileName])) { + $fileName = $folder . $baseName . ' ' . (++$n) . '.md'; + } + $usedNames[$fileName] = true; + + // YAML frontmatter + $fm = "---\n"; + $fm .= 'id: ' . $c['id'] . "\n"; + $fm .= 'color: "' . $c['color'] . "\"\n"; + if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) { + $fm .= 'bucket: "' . addcslashes($bucketMap[$c['bucket_id']], '"') . "\"\n"; + } + if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) { + $fm .= 'parent: "' . addcslashes($cardMap[$c['parent_id']]['title'], '"') . "\"\n"; + } + $fm .= 'created: ' . $c['created_at'] . "\n"; + $fm .= 'updated: ' . $c['updated_at'] . "\n"; + if (!empty($c['tags'])) { + $fm .= 'tags: [' . implode(', ', array_map(fn($t) => '"' . trim($t) . '"', explode(',', $c['tags']))) . "]\n"; + } + $fm .= "---\n\n"; + + // Body: rewrite upload paths to attachments/ + $body = $c['body'] ?? ''; + $allBodies .= "\n" . $body; + $body = preg_replace('#uploads/#', 'attachments/', $body); + + $zip->addFromString($fileName, $fm . '# ' . $c['title'] . "\n\n" . $body); + } + + // Copy referenced uploads → attachments/ + $uploadDir = __DIR__ . '/uploads'; + if (is_dir($uploadDir)) { + foreach (glob($uploadDir . '/*') as $f) { + $name = basename($f); + if (is_file($f) && (!$ids || str_contains($allBodies, $name))) { + $zip->addFile($f, 'attachments/' . $name); + } + } + } + + $zip->close(); + + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="hyprcrd-vault-' . date('Y-m-d') . '.zip"'); + header('Content-Length: ' . filesize($tmp)); + readfile($tmp); + unlink($tmp); + exit; + } + // Export — full or selective if ($route === '/api/export' && $method === 'GET') { $ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null; @@ -452,7 +556,6 @@ if (str_starts_with($route, '/api/')) {
-
@@ -476,7 +579,7 @@ if (str_starts_with($route, '/api/')) {
-
+
+ @@ -555,6 +661,7 @@ if (str_starts_with($route, '/api/')) {

+
@@ -633,6 +740,7 @@ if (str_starts_with($route, '/api/')) {

+ + +