tags
This commit is contained in:
1 parent
a9603aa0e8
commit
719f9d51ff
13 files changed
+625
-70
No files matched your search
+32
-4
@@ -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;
|
||||
|
||||
+119
-5
@@ -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/')) {
|
||||
<body class="bg-t-bg text-t-text min-h-screen transition-colors duration-200 flex flex-col">
|
||||
|
||||
<header id="main-header" class="sticky top-0 z-40 bg-t-bg border-b border-t-border shadow-[0_4px_20px_-4px_rgba(0,0,0,0.3)]" style="overflow:hidden;">
|
||||
<div id="header-tint" class="absolute inset-0 pointer-events-none" style="opacity:0;transition:opacity 0.3s,background 0.3s;z-index:0;"></div>
|
||||
<div class="flex items-center justify-between px-6 py-3 relative z-[1]">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
|
||||
@@ -476,7 +579,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center px-6 pb-2 relative z-[1]">
|
||||
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
|
||||
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
|
||||
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2">
|
||||
<i class="ph ph-sort-ascending text-xs"></i>
|
||||
@@ -496,6 +599,9 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button id="export-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
|
||||
</button>
|
||||
<button id="export-md-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-vault text-sm"></i><span class="font-pixel text-[10px]">vault</span>
|
||||
</button>
|
||||
<button id="logout-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-danger hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
|
||||
</button>
|
||||
@@ -555,6 +661,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<div class="card-bar w-16 mb-4 rounded-sm" id="search-preview-color"></div>
|
||||
<h2 id="search-preview-title" class="font-pixel text-t-bright text-sm mb-4 leading-relaxed"></h2>
|
||||
<div id="search-preview-body" class="prose text-xs text-t-text"></div>
|
||||
<div id="search-preview-tags" class="hidden flex flex-wrap gap-1.5 mt-4"></div>
|
||||
<div id="search-preview-meta" class="mt-6 pt-3 border-t border-t-border text-[10px] text-t-dim font-pixel"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,6 +740,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<div id="detail-scroll" class="p-6 pt-5 flex-1 overflow-y-auto">
|
||||
<h2 id="detail-title" class="font-pixel text-t-bright text-lg mb-4 leading-relaxed"></h2>
|
||||
<div id="detail-body" class="prose text-sm text-t-text"></div>
|
||||
<div id="detail-tags" class="hidden flex flex-wrap gap-1.5 mt-4"></div>
|
||||
<div id="detail-backlinks" class="hidden mt-6 pt-4 border-t border-t-border">
|
||||
<div class="font-pixel text-[10px] text-t-dim mb-2 flex items-center gap-1.5"><i class="ph ph-arrow-bend-left-up text-xs"></i> referenced_by</div>
|
||||
<div id="detail-backlinks-list" class="flex flex-wrap gap-2"></div>
|
||||
@@ -654,6 +762,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
</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>
|
||||
<div id="tag-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:180px;max-width:300px;"></div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
||||
@@ -690,11 +799,16 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
<div id="ctx-buckets" class="hidden border-t border-t-border py-1"></div>
|
||||
<div id="ctx-members" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
|
||||
<div id="ctx-tags" class="hidden border-t border-t-border py-1">
|
||||
<div class="px-3 py-1.5"><input id="ctx-tag-input" type="text" placeholder="#tag..." class="w-full bg-t-bg border border-t-border text-t-text text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none"></div>
|
||||
<div id="ctx-tag-list" class="max-h-36 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="overlay fixed inset-0 z-[60]" style="background:var(--t-overlay)" id="modal">
|
||||
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
||||
<div class="card-bar w-full shrink-0" id="editor-color"></div>
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-t-border shrink-0">
|
||||
<div class="flex items-center gap-3"><i class="ph ph-file-plus text-t-accent text-sm"></i><h2 id="modal-title" class="font-pixel text-xs text-t-accent">new_card</h2></div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -729,7 +843,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button data-md="cardlink" title="Card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
||||
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
||||
<div class="flex-1"></div>
|
||||
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
||||
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">preview</span></button>
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@ import { api, setCsrf, getCsrf } from './modules/util.js';
|
||||
import { state, getGroupCards } from './modules/state.js';
|
||||
import { showDialog, isDialogOpen } from './modules/dialog.js';
|
||||
import './modules/theme.js';
|
||||
import { loadBuckets } from './modules/buckets.js';
|
||||
import { loadBuckets, renderTagBar } from './modules/buckets.js';
|
||||
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
||||
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
|
||||
import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
|
||||
@@ -24,6 +24,7 @@ window._openGallery = openGallery;
|
||||
window._closeSearch = closeSearch;
|
||||
window._closeTasks = closeTasks;
|
||||
window._renderBoard = renderBoard;
|
||||
window._renderTagBar = renderTagBar;
|
||||
window._showContextMenu = showContextMenu;
|
||||
window._ungroupCard = ungroupCard;
|
||||
window._getSelectedIds = getSelectedIds;
|
||||
@@ -56,6 +57,7 @@ async function loadCards() {
|
||||
// ── Export / Import ──
|
||||
|
||||
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
|
||||
document.getElementById('export-md-btn')?.addEventListener('click', () => { window.location.href = 'api/export-md'; });
|
||||
|
||||
const importFile = document.getElementById('import-file');
|
||||
document.getElementById('import-btn')?.addEventListener('click', () => importFile?.click());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ── Board rendering & card interactions ──
|
||||
|
||||
import { $, esc, api } from './util.js';
|
||||
import { state, sortedCards, getChildren, getRecent, getFavorites, isFavorite, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js';
|
||||
import { state, sortedCards, parseTags, tagChipHtml, getChildren, getRecent, getFavorites, isFavorite, 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';
|
||||
|
||||
@@ -131,6 +131,13 @@ export function renderBoard() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel
|
||||
if (state.activeTags.length) {
|
||||
cards = cards.filter(c => {
|
||||
const ct = parseTags(c);
|
||||
return state.activeTags.every(at => ct.some(t => t === at || t.startsWith(at + '/')));
|
||||
});
|
||||
}
|
||||
// Current and journal have fixed sort order, skip global sort
|
||||
if (state.activeBucket !== 'current' && state.activeBucket !== 'journal') {
|
||||
cards = sortedCards(cards);
|
||||
@@ -161,6 +168,7 @@ export function renderBoard() {
|
||||
const todos = getTodos(c.body);
|
||||
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
|
||||
const hasCover = !!coverImg;
|
||||
const tags = parseTags(c);
|
||||
|
||||
return `
|
||||
<div class="board-card ${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"
|
||||
@@ -180,6 +188,7 @@ export function renderBoard() {
|
||||
<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-5 flex-1">${esc(stripMd(c.body))}</p>
|
||||
${tags.length ? `<div class="flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||
<div class="mt-auto pt-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>` : ''}
|
||||
@@ -291,6 +300,22 @@ function bindCardEvents() {
|
||||
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
|
||||
});
|
||||
|
||||
board.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
|
||||
seg.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const tag = seg.dataset.tag;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const idx = state.activeTags.indexOf(tag);
|
||||
if (idx >= 0) state.activeTags.splice(idx, 1);
|
||||
else state.activeTags.push(tag);
|
||||
} else {
|
||||
state.activeTags = state.activeTags.length === 1 && state.activeTags[0] === tag ? [] : [tag];
|
||||
}
|
||||
window._renderTagBar?.();
|
||||
renderBoard();
|
||||
});
|
||||
});
|
||||
|
||||
board.querySelectorAll('[data-id]').forEach(el => {
|
||||
const id = el.dataset.id;
|
||||
const children = getChildren(id);
|
||||
|
||||
@@ -131,14 +131,15 @@ function autoBucketNameFromCard(card) {
|
||||
}
|
||||
|
||||
function updateHeaderTint() {
|
||||
const tint = $('header-tint');
|
||||
if (!tint) return;
|
||||
const header = $('main-header');
|
||||
if (!header) 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';
|
||||
header.style.borderBottomWidth = '4px';
|
||||
header.style.borderBottomColor = bucket.color;
|
||||
} else {
|
||||
tint.style.opacity = '0';
|
||||
header.style.borderBottomWidth = '';
|
||||
header.style.borderBottomColor = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +156,7 @@ function renderBar() {
|
||||
${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>`;
|
||||
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-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="bucket-add 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>`;
|
||||
|
||||
@@ -279,4 +280,44 @@ function renderBar() {
|
||||
await loadBuckets();
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
renderTagBar();
|
||||
}
|
||||
|
||||
export function renderTagBar() {
|
||||
let tagBar = $('tag-bar');
|
||||
if (!tagBar) {
|
||||
tagBar = document.createElement('div');
|
||||
tagBar.id = 'tag-bar';
|
||||
tagBar.className = 'flex items-center gap-1.5 px-6 py-1';
|
||||
const bucketRow = $('bucket-row');
|
||||
bucketRow?.parentNode.insertBefore(tagBar, bucketRow.nextSibling);
|
||||
}
|
||||
if (!state.activeTags.length) {
|
||||
tagBar.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
tagBar.classList.remove('hidden');
|
||||
tagBar.innerHTML = `<i class="ph ph-funnel text-xs text-t-accent"></i>` +
|
||||
state.activeTags.map(t => `
|
||||
<span class="tag-chip" style="font-size:10px;">
|
||||
<span class="font-pixel">#${esc(t)}</span>
|
||||
<i class="ph ph-x text-[9px] ml-0.5 cursor-pointer hover:text-t-danger" data-remove-tag="${t}"></i>
|
||||
</span>`).join('') +
|
||||
`<button id="clear-tag-filter" class="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-pixel text-t-dim hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-x text-xs"></i>clear all
|
||||
</button>`;
|
||||
tagBar.querySelectorAll('[data-remove-tag]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
state.activeTags = state.activeTags.filter(t => t !== btn.dataset.removeTag);
|
||||
renderTagBar();
|
||||
window._renderBoard();
|
||||
});
|
||||
});
|
||||
$('clear-tag-filter')?.addEventListener('click', () => {
|
||||
state.activeTags = [];
|
||||
renderTagBar();
|
||||
window._renderBoard();
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// ── Context menu ──
|
||||
|
||||
import { $, esc, api } from './util.js';
|
||||
import { state, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
|
||||
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
|
||||
import { showDialog } from './dialog.js';
|
||||
import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js';
|
||||
|
||||
@@ -10,6 +10,7 @@ const items = $('ctx-items');
|
||||
const colors = $('ctx-colors');
|
||||
const buckets = $('ctx-buckets');
|
||||
const members = $('ctx-members');
|
||||
const tags = $('ctx-tags');
|
||||
|
||||
export function hideContextMenu() { menu.classList.add('hidden'); }
|
||||
|
||||
@@ -25,10 +26,12 @@ export function showContextMenu(x, y, card) {
|
||||
<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>
|
||||
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
|
||||
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</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>
|
||||
<button class="ctx-item" data-action="tags"><i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">tags</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>
|
||||
@@ -60,11 +63,72 @@ export function showContextMenu(x, y, card) {
|
||||
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');
|
||||
|
||||
// Tag sub-panel
|
||||
const cardTags = parseTags(card);
|
||||
const allTags = [...new Set(state.cards.flatMap(c => parseTags(c)))].sort();
|
||||
const tagInput = $('ctx-tag-input');
|
||||
const tagList = $('ctx-tag-list');
|
||||
|
||||
function renderCtxTags(filter = '') {
|
||||
const f = filter.toLowerCase().replace(/^#/, '');
|
||||
const shown = f ? allTags.filter(t => t.includes(f)) : allTags;
|
||||
const hasCard = tag => cardTags.includes(tag);
|
||||
tagList.innerHTML = shown.map(t => `
|
||||
<button class="ctx-item ${hasCard(t) ? 'text-t-accent' : ''}" data-ctx-tag="${t}">
|
||||
<i class="ph ${hasCard(t) ? 'ph-check-square' : 'ph-square'} text-sm"></i>
|
||||
<span class="font-pixel text-[10px]">#${esc(t)}</span>
|
||||
</button>`).join('') +
|
||||
(f && !allTags.includes(f) ? `<button class="ctx-item text-t-dim" data-ctx-tag-new="${f}">
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">add #${esc(f)}</span>
|
||||
</button>` : '');
|
||||
|
||||
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const tag = btn.dataset.ctxTag;
|
||||
const has = cardTags.includes(tag);
|
||||
let newBody = card.body || '';
|
||||
if (has) {
|
||||
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
|
||||
cardTags.splice(cardTags.indexOf(tag), 1);
|
||||
} else {
|
||||
newBody = newBody.trimEnd() + ' #' + tag;
|
||||
cardTags.push(tag);
|
||||
}
|
||||
card.body = newBody;
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
|
||||
const c = state.cards.find(x => x.id == card.id);
|
||||
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
|
||||
renderCtxTags(tagInput.value);
|
||||
window._renderBoard();
|
||||
}));
|
||||
|
||||
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const tag = btn.dataset.ctxTagNew;
|
||||
const newBody = (card.body || '').trimEnd() + ' #' + tag;
|
||||
card.body = newBody;
|
||||
cardTags.push(tag);
|
||||
allTags.push(tag);
|
||||
allTags.sort();
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
|
||||
const c = state.cards.find(x => x.id == card.id);
|
||||
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
|
||||
tagInput.value = '';
|
||||
renderCtxTags('');
|
||||
window._renderBoard();
|
||||
}));
|
||||
}
|
||||
|
||||
renderCtxTags();
|
||||
tagInput.value = '';
|
||||
tagInput.addEventListener('input', () => renderCtxTags(tagInput.value));
|
||||
tags.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'); };
|
||||
const toggle = (panel) => { [colors, buckets, members, tags].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
|
||||
|
||||
// Actions
|
||||
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
||||
@@ -73,9 +137,11 @@ export function showContextMenu(x, y, card) {
|
||||
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 === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
||||
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
|
||||
else if (a === 'color') toggle(colors);
|
||||
else if (a === 'bucket') toggle(buckets);
|
||||
else if (a === 'tags') toggle(tags);
|
||||
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(); } }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ── Detail view ──
|
||||
|
||||
import { $, esc, api, fuzzyMatch } from './util.js';
|
||||
import { state, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
|
||||
import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
|
||||
import { renderMd, stripMd, firstImage, addCodeCopyButtons } from './markdown.js';
|
||||
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
||||
import { showDialog } from './dialog.js';
|
||||
@@ -87,6 +87,9 @@ function clearMorphGhost() {
|
||||
}
|
||||
}
|
||||
|
||||
let galleryImages = [];
|
||||
let galleryIndex = 0;
|
||||
|
||||
function ensureImageFullscreenOverlay() {
|
||||
if (imageFsOverlay) return imageFsOverlay;
|
||||
|
||||
@@ -96,6 +99,9 @@ function ensureImageFullscreenOverlay() {
|
||||
<button type="button" class="detail-image-fs-close" aria-label="close image">
|
||||
<i class="ph ph-x text-xl"></i>
|
||||
</button>
|
||||
<button type="button" class="detail-image-fs-nav detail-image-fs-prev" aria-label="previous"><i class="ph ph-caret-left"></i></button>
|
||||
<button type="button" class="detail-image-fs-nav detail-image-fs-next" aria-label="next"><i class="ph ph-caret-right"></i></button>
|
||||
<div class="detail-image-fs-counter"></div>
|
||||
<div class="detail-image-fs-stage">
|
||||
<img class="detail-image-fs-img" alt="">
|
||||
</div>
|
||||
@@ -104,6 +110,8 @@ function ensureImageFullscreenOverlay() {
|
||||
if (e.target === overlay) closeImageFullscreen();
|
||||
});
|
||||
overlay.querySelector('.detail-image-fs-close')?.addEventListener('click', closeImageFullscreen);
|
||||
overlay.querySelector('.detail-image-fs-prev')?.addEventListener('click', e => { e.stopPropagation(); navigateGallery(-1); });
|
||||
overlay.querySelector('.detail-image-fs-next')?.addEventListener('click', e => { e.stopPropagation(); navigateGallery(1); });
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
imageFsOverlay = overlay;
|
||||
@@ -118,6 +126,26 @@ function ensureImageFullscreenOverlay() {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function updateGalleryNav() {
|
||||
if (!imageFsOverlay) return;
|
||||
const hasGallery = galleryImages.length > 1;
|
||||
imageFsOverlay.querySelector('.detail-image-fs-prev').style.display = hasGallery ? '' : 'none';
|
||||
imageFsOverlay.querySelector('.detail-image-fs-next').style.display = hasGallery ? '' : 'none';
|
||||
const counter = imageFsOverlay.querySelector('.detail-image-fs-counter');
|
||||
counter.style.display = hasGallery ? '' : 'none';
|
||||
if (hasGallery) counter.textContent = `${galleryIndex + 1} / ${galleryImages.length}`;
|
||||
}
|
||||
|
||||
function navigateGallery(dir) {
|
||||
if (galleryImages.length < 2) return;
|
||||
galleryIndex = (galleryIndex + dir + galleryImages.length) % galleryImages.length;
|
||||
resetImageFsTransform();
|
||||
if (imageFsImg) {
|
||||
imageFsImg.src = galleryImages[galleryIndex];
|
||||
}
|
||||
updateGalleryNav();
|
||||
}
|
||||
|
||||
function applyImageFsTransform() {
|
||||
if (!imageFsImg) return;
|
||||
imageFsImg.style.transform = `translate(${fsTx}px, ${fsTy}px) scale(${fsScale})`;
|
||||
@@ -178,14 +206,18 @@ function onImageFsMouseUp() {
|
||||
imageFsOverlay?.classList.remove('dragging');
|
||||
}
|
||||
|
||||
function openImageFullscreen(src, alt = '') {
|
||||
function openImageFullscreen(src, alt = '', gallery = []) {
|
||||
if (!src) return;
|
||||
const overlay = ensureImageFullscreenOverlay();
|
||||
resetImageFsTransform();
|
||||
galleryImages = gallery.length > 1 ? gallery : [];
|
||||
galleryIndex = galleryImages.indexOf(src);
|
||||
if (galleryIndex < 0) galleryIndex = 0;
|
||||
if (imageFsImg) {
|
||||
imageFsImg.src = src;
|
||||
imageFsImg.alt = alt || 'image';
|
||||
}
|
||||
updateGalleryNav();
|
||||
overlay.classList.add('open');
|
||||
document.body.classList.add('detail-image-fs-open');
|
||||
}
|
||||
@@ -433,6 +465,32 @@ function showContent(card) {
|
||||
(card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
|
||||
applyZoom();
|
||||
|
||||
// Tags
|
||||
const tags = parseTags(card);
|
||||
const tagEl = $('detail-tags');
|
||||
if (tagEl) {
|
||||
if (tags.length) {
|
||||
tagEl.classList.remove('hidden');
|
||||
tagEl.innerHTML = tags.map(t => tagChipHtml(t)).join('');
|
||||
tagEl.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
|
||||
seg.addEventListener('click', e => {
|
||||
const tag = seg.dataset.tag;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const idx = state.activeTags.indexOf(tag);
|
||||
if (idx >= 0) state.activeTags.splice(idx, 1);
|
||||
else state.activeTags.push(tag);
|
||||
} else {
|
||||
state.activeTags = [tag];
|
||||
}
|
||||
window._renderTagBar?.();
|
||||
window._renderBoard();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
tagEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Backlinks
|
||||
const pattern = `[[${card.title}]]`.toLowerCase();
|
||||
const backlinks = state.cards.filter(c => c.id != card.id && (c.body || '').toLowerCase().includes(pattern));
|
||||
@@ -505,7 +563,24 @@ function applyReadingResizeHandles(root) {
|
||||
|
||||
function bindDetailImageFullscreen(root) {
|
||||
if (!root) return;
|
||||
|
||||
// Gallery grids: single click, with prev/next
|
||||
root.querySelectorAll('.md-gallery').forEach(gallery => {
|
||||
const srcs = Array.from(gallery.querySelectorAll('img')).map(i => i.getAttribute('src'));
|
||||
gallery.querySelectorAll('.md-gallery-item').forEach(item => {
|
||||
const img = item.querySelector('img');
|
||||
if (!img) return;
|
||||
item.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openImageFullscreen(img.getAttribute('src'), img.getAttribute('alt') || 'image', srcs);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Regular images: double-click, no gallery nav
|
||||
root.querySelectorAll('img').forEach(img => {
|
||||
if (img.closest('.md-gallery')) return;
|
||||
img.title = 'double-click to open fullscreen';
|
||||
img.addEventListener('dblclick', e => {
|
||||
e.preventDefault();
|
||||
@@ -526,8 +601,10 @@ detail.addEventListener('click', e => {
|
||||
$('detail-close')?.addEventListener('click', closeDetail);
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!imageFsOverlay?.classList.contains('open')) return;
|
||||
if (e.key === 'ArrowLeft') { navigateGallery(-1); e.preventDefault(); return; }
|
||||
if (e.key === 'ArrowRight') { navigateGallery(1); e.preventDefault(); return; }
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
closeImageFullscreen();
|
||||
|
||||
@@ -16,8 +16,10 @@ const inpColor = $('inp-color');
|
||||
const inpBucket = $('inp-bucket');
|
||||
const presets = $('color-presets');
|
||||
const linkAc = $('link-ac');
|
||||
const tagAc = $('tag-ac');
|
||||
|
||||
let linkAcVisible = false, linkAcItems = [], linkAcActive = 0;
|
||||
let tagAcVisible = false, tagAcItems = [], tagAcActive = 0;
|
||||
let morphGhost = null, morphTimer = null;
|
||||
|
||||
function clearMorphGhost() {
|
||||
@@ -112,6 +114,8 @@ CARD_COLORS.forEach(c => {
|
||||
|
||||
function selectColor(hex) {
|
||||
inpColor.value = hex;
|
||||
const bar = $('editor-color');
|
||||
if (bar) bar.style.background = hex;
|
||||
presets.querySelectorAll('.color-swatch').forEach(el => {
|
||||
const a = el.dataset.color === hex;
|
||||
el.classList.toggle('border-t-bright', a);
|
||||
@@ -450,11 +454,69 @@ function completeLinkAc(idx) {
|
||||
}
|
||||
|
||||
body.addEventListener('input', () => {
|
||||
const info = getLinkQuery();
|
||||
info ? showLinkAc(info.query) : hideLinkAc();
|
||||
const linkInfo = getLinkQuery();
|
||||
linkInfo ? showLinkAc(linkInfo.query) : hideLinkAc();
|
||||
const tagInfo = getTagQuery();
|
||||
tagInfo ? showTagAc(tagInfo.query) : hideTagAc();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// ── Tag autocomplete (#) ──
|
||||
|
||||
function getTagQuery() {
|
||||
const before = body.value.substring(0, body.selectionStart);
|
||||
// Find # preceded by whitespace or start of line, not part of heading (# + space)
|
||||
const m = before.match(/(?:^|[\s])#([a-zA-Z0-9_\/-]*)$/m);
|
||||
if (!m) return null;
|
||||
const start = before.length - m[0].length + (m[0].startsWith('#') ? 0 : 1); // skip leading whitespace
|
||||
return { start, query: m[1] };
|
||||
}
|
||||
|
||||
function getAllTags() {
|
||||
const set = new Set();
|
||||
for (const c of state.cards) {
|
||||
for (const t of (c.tags || '').split(',')) { if (t) set.add(t); }
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
function showTagAc(query) {
|
||||
const q = query.toLowerCase();
|
||||
const all = getAllTags();
|
||||
tagAcItems = all.filter(t => !q || t.includes(q));
|
||||
if (!tagAcItems.length) { hideTagAc(); return; }
|
||||
tagAcActive = 0;
|
||||
tagAc.innerHTML = tagAcItems.slice(0, 8).map((t, i) => `
|
||||
<div class="ac-item ${i === 0 ? 'active' : ''}" data-idx="${i}">
|
||||
<span class="ac-title font-pixel">#${esc(t)}</span>
|
||||
</div>`).join('');
|
||||
tagAcItems = tagAcItems.slice(0, 8);
|
||||
tagAc.querySelectorAll('.ac-item').forEach(el => {
|
||||
el.addEventListener('mousedown', e => { e.preventDefault(); completeTagAc(parseInt(el.dataset.idx)); });
|
||||
});
|
||||
const rect = body.getBoundingClientRect();
|
||||
const lines = body.value.substring(0, body.selectionStart).split('\n');
|
||||
tagAc.style.top = Math.min(rect.top + (lines.length - 1) * 22 - body.scrollTop + 26, innerHeight - 200) + 'px';
|
||||
tagAc.style.left = Math.min(rect.left + 40, innerWidth - 320) + 'px';
|
||||
tagAc.classList.remove('hidden');
|
||||
tagAcVisible = true;
|
||||
}
|
||||
|
||||
function hideTagAc() { tagAc.classList.add('hidden'); tagAcVisible = false; }
|
||||
|
||||
function completeTagAc(idx) {
|
||||
const tag = tagAcItems[idx];
|
||||
if (!tag) return;
|
||||
const info = getTagQuery();
|
||||
if (!info) { hideTagAc(); return; }
|
||||
const before = body.value.substring(0, info.start);
|
||||
const after = body.value.substring(body.selectionStart);
|
||||
const ins = `#${tag} `;
|
||||
body.value = before + ins + after;
|
||||
body.selectionStart = body.selectionEnd = info.start + ins.length;
|
||||
hideTagAc(); updatePreview(); body.focus();
|
||||
}
|
||||
|
||||
// ── Line helpers ──
|
||||
|
||||
function getLineInfo() {
|
||||
@@ -511,6 +573,17 @@ body.addEventListener('keydown', e => {
|
||||
linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive));
|
||||
return;
|
||||
}
|
||||
if (tagAcVisible) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); tagAcActive = Math.min(tagAcActive + 1, tagAcItems.length - 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); tagAcActive = Math.max(tagAcActive - 1, 0); }
|
||||
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeTagAc(tagAcActive); return; }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); hideTagAc(); return; }
|
||||
tagAc.querySelectorAll('.ac-item').forEach((el, i) => {
|
||||
el.classList.toggle('active', i === tagAcActive);
|
||||
if (i === tagAcActive) el.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Alt+Arrow: move line
|
||||
if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
e.preventDefault();
|
||||
@@ -526,7 +599,7 @@ body.addEventListener('keydown', e => {
|
||||
if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); }
|
||||
});
|
||||
|
||||
body.addEventListener('blur', () => setTimeout(hideLinkAc, 150));
|
||||
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); });
|
||||
|
||||
// ── File upload (paste + drag-drop) ──
|
||||
|
||||
|
||||
@@ -73,16 +73,8 @@ export async function openGallery() {
|
||||
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="gallery-item group relative cursor-pointer" data-orphan="${f.name}">
|
||||
${mediaTile(f.url, 'border-t-danger/30')}
|
||||
<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('');
|
||||
}
|
||||
@@ -113,27 +105,13 @@ export async function openGallery() {
|
||||
});
|
||||
});
|
||||
|
||||
// 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
|
||||
// Orphan: click to enlarge
|
||||
grid.querySelectorAll('[data-orphan]').forEach(item => {
|
||||
item.addEventListener('click', e => {
|
||||
if (e.target.closest('button')) return;
|
||||
const name = item.dataset.orphan;
|
||||
const f = orphaned.find(o => o.name === name);
|
||||
if (f && !isAudio(f.url)) openOrphanLightbox(f);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -141,6 +119,51 @@ export async function openGallery() {
|
||||
el.classList.add('open');
|
||||
}
|
||||
|
||||
function openOrphanLightbox(file) {
|
||||
let overlay = document.querySelector('.orphan-lightbox');
|
||||
if (overlay) overlay.remove();
|
||||
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'orphan-lightbox';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;background:rgba(2,6,23,0.92);';
|
||||
overlay.innerHTML = `
|
||||
<img src="${file.url}" style="max-width:min(92vw,1400px);max-height:85vh;object-fit:contain;border:1px solid var(--t-border);box-shadow:0 16px 60px rgba(0,0,0,0.55);">
|
||||
<div style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;gap:8px;">
|
||||
<button class="orphan-lb-create flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">new card</span>
|
||||
</button>
|
||||
<button class="orphan-lb-delete flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<button style="position:fixed;top:12px;right:12px;color:rgba(255,255,255,0.6);font-size:20px;background:none;border:none;cursor:pointer;" class="orphan-lb-close">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const close = () => overlay.remove();
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('.orphan-lb-close').addEventListener('click', close);
|
||||
|
||||
overlay.querySelector('.orphan-lb-create').addEventListener('click', async () => {
|
||||
const name = file.url.split('/').pop().replace(/\.[^.]+$/, '');
|
||||
await api('api/cards', { method: 'POST', body: { title: name, body: ``, color: '#38bdf8', position: -1 } });
|
||||
await window._reloadAll();
|
||||
close();
|
||||
openGallery();
|
||||
});
|
||||
|
||||
overlay.querySelector('.orphan-lb-delete').addEventListener('click', async () => {
|
||||
const ok = await showDialog({ title: 'delete_image', message: `"${file.name}" loeschen?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
await fetch(`api/uploads/${file.name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
close();
|
||||
openGallery();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
export function closeGallery() {
|
||||
$('gallery')?.classList.remove('open');
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
import { esc } from './util.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
marked.use({ breaks: true, gfm: true });
|
||||
marked.use({ breaks: true, gfm: true, renderer: {
|
||||
link({ href, text }) {
|
||||
const isExternal = /^https?:\/\//.test(href);
|
||||
if (isExternal) return `<a href="${href}" target="_blank" rel="noopener">${text}</a>`;
|
||||
return `<a href="${href}">${text}</a>`;
|
||||
}
|
||||
}});
|
||||
|
||||
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','audio','source'],
|
||||
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload'],
|
||||
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel'],
|
||||
ALLOW_DATA_ATTR: true,
|
||||
};
|
||||
|
||||
@@ -33,6 +39,17 @@ function normalizeListBlock(block) {
|
||||
|
||||
export function renderMd(src) {
|
||||
let processed = String(src || '').replace(/\r\n?/g, '\n')
|
||||
// Gallery fenced block: ```gallery
|
||||
.replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
|
||||
const imgs = [];
|
||||
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
let m;
|
||||
while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
|
||||
if (!imgs.length) return '';
|
||||
return '<div class="md-gallery">' + imgs.map(i =>
|
||||
`<div class="md-gallery-item"><img src="${i.src}" alt="${esc(i.alt)}" loading="lazy"></div>`
|
||||
).join('') + '</div>';
|
||||
})
|
||||
// Custom fenced list blocks: ```tree / ```bullets with or without closing ```
|
||||
.replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => {
|
||||
const normalized = normalizeListBlock(content);
|
||||
|
||||
+65
-11
@@ -1,7 +1,7 @@
|
||||
// ── Search ──
|
||||
|
||||
import { $, esc, escRe, fuzzyMatch, countSubstring, highlight } from './util.js';
|
||||
import { state } from './state.js';
|
||||
import { state, parseTags, tagChipHtml } from './state.js';
|
||||
import { renderMd, stripMd } from './markdown.js';
|
||||
|
||||
const overlay = $('search');
|
||||
@@ -50,6 +50,31 @@ function updatePreview() {
|
||||
for (const w of words) html = html.replace(new RegExp('(>)([^<]*?)(' + escRe(w) + ')', 'gi'), '$1$2<mark>$3</mark>');
|
||||
}
|
||||
$('search-preview-body').innerHTML = html;
|
||||
|
||||
const ptags = parseTags(card);
|
||||
const ptagEl = $('search-preview-tags');
|
||||
if (ptags.length) {
|
||||
ptagEl.classList.remove('hidden');
|
||||
ptagEl.innerHTML = ptags.map(t => tagChipHtml(t)).join('');
|
||||
ptagEl.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
|
||||
seg.addEventListener('click', e => {
|
||||
closeSearch();
|
||||
const tag = seg.dataset.tag;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const idx = state.activeTags.indexOf(tag);
|
||||
if (idx >= 0) state.activeTags.splice(idx, 1);
|
||||
else state.activeTags.push(tag);
|
||||
} else {
|
||||
state.activeTags = [tag];
|
||||
}
|
||||
window._renderTagBar?.();
|
||||
window._renderBoard();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ptagEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
$('search-preview-meta').textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
|
||||
}
|
||||
|
||||
@@ -66,24 +91,51 @@ function renderHome() {
|
||||
function search(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) { renderHome(); return; }
|
||||
const words = q.split(/\s+/).filter(Boolean);
|
||||
const scored = getPool().map(c => {
|
||||
|
||||
// Split into #tag filters and regular words
|
||||
const parts = q.split(/\s+/).filter(Boolean);
|
||||
const tagFilters = parts.filter(p => p.startsWith('#') && p.length > 1).map(p => p.substring(1));
|
||||
const words = parts.filter(p => !p.startsWith('#'));
|
||||
const textQ = words.join(' ');
|
||||
|
||||
// Pre-filter by tags (must match ALL)
|
||||
let pool = getPool();
|
||||
if (tagFilters.length) {
|
||||
pool = pool.filter(c => {
|
||||
const ct = (c.tags || '').toLowerCase().split(',');
|
||||
return tagFilters.every(tf => ct.some(t => t === tf || t.startsWith(tf + '/') || t.includes(tf)));
|
||||
});
|
||||
}
|
||||
|
||||
// Tag-only search (no words)
|
||||
if (!words.length) {
|
||||
hits = pool; active = pool.length ? 0 : -1;
|
||||
if (!pool.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-hash text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel">no cards with ${tagFilters.map(t => '#' + t).join(' + ')}</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">${pool.length} tagged</span></div>` +
|
||||
pool.map((c, i) => hitHtml(c, i, null)).join('');
|
||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); return;
|
||||
}
|
||||
|
||||
const scored = pool.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; }
|
||||
const tags = (c.tags || '').toLowerCase();
|
||||
let score = tagFilters.length ? 50 : 0, matched = tagFilters.length > 0;
|
||||
if (title.includes(textQ)) { score += 150; matched = true; }
|
||||
if (body.includes(textQ)) { score += 40 + countSubstring(body, textQ) * 10; matched = true; }
|
||||
if (tags.includes(textQ)) { score += 60; 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 (tags.includes(w)) score += 25;
|
||||
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) {
|
||||
const tf = fuzzyMatch(textQ, title);
|
||||
if (tf !== null) { score += 15 + tf * 2; matched = true; }
|
||||
}
|
||||
if (!matched) return null;
|
||||
return { card: c, score: score - title.length * 0.1 };
|
||||
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
||||
@@ -117,10 +169,12 @@ function hitHtml(card, idx, words) {
|
||||
snippet = highlight(esc(snippet), words);
|
||||
} else { snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); }
|
||||
|
||||
const htags = parseTags(card);
|
||||
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>`;
|
||||
<div class="text-[11px] text-t-muted leading-relaxed mt-0.5 line-clamp-2">${snippet}</div>
|
||||
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
|
||||
}
|
||||
|
||||
function bindHits() {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// ── Shared app state ──
|
||||
|
||||
import { esc } from './util.js';
|
||||
|
||||
export const state = {
|
||||
cards: [],
|
||||
buckets: [],
|
||||
activeBucket: 'all',
|
||||
activeTags: [],
|
||||
activeGroup: null,
|
||||
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
||||
editingId: null,
|
||||
@@ -15,8 +18,8 @@ export const state = {
|
||||
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 SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
||||
export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' };
|
||||
|
||||
export const CARD_COLORS = [
|
||||
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
||||
@@ -26,12 +29,30 @@ export const CARD_COLORS = [
|
||||
{ name: 'lime', hex: '#a3e635' }, { name: 'slate', hex: '#94a3b8' },
|
||||
];
|
||||
|
||||
export function parseTags(card) {
|
||||
return (card.tags || '').split(',').filter(Boolean);
|
||||
}
|
||||
|
||||
export function tagChipHtml(tag) {
|
||||
const parts = tag.split('/');
|
||||
const segs = parts.map((p, i) => {
|
||||
const path = parts.slice(0, i + 1).join('/');
|
||||
return `<span class="tag-seg" data-tag="${path}">${i === 0 ? '#' : ''}${esc(p)}</span>`;
|
||||
});
|
||||
return `<span class="tag-chip">${segs.join('<span class="tag-sep">/</span>')}</span>`;
|
||||
}
|
||||
|
||||
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 || ''));
|
||||
case 'tag': return arr.sort((a, b) => {
|
||||
const at = (a.tags || '').split(',')[0] || '\uffff';
|
||||
const bt = (b.tags || '').split(',')[0] || '\uffff';
|
||||
return at.localeCompare(bt);
|
||||
});
|
||||
default: return arr.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,20 @@ class Database
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES cards(id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
if (!in_array('tags', $colNames)) {
|
||||
self::$pdo->exec("ALTER TABLE cards ADD COLUMN tags TEXT NOT NULL DEFAULT ''");
|
||||
// Backfill tags from existing card bodies
|
||||
$all = self::$pdo->query('SELECT id, body FROM cards')->fetchAll();
|
||||
foreach ($all as $row) {
|
||||
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $row['body'], $matches);
|
||||
if (!empty($matches[1])) {
|
||||
$tags = implode(',', array_unique(array_map('strtolower', $matches[1])));
|
||||
self::$pdo->prepare('UPDATE cards SET tags = :tags WHERE id = :id')
|
||||
->execute(['tags' => $tags, 'id' => $row['id']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket color migration
|
||||
$bcols = self::$pdo->query('PRAGMA table_info(buckets)')->fetchAll();
|
||||
$bcolNames = array_column($bcols, 'name');
|
||||
|
||||
Reference in new issue
Block a user