This commit is contained in:
th committed 2026-04-07 21:58:45 +02:00
1 parent a9603aa0e8
commit 719f9d51ff
13 files changed
+625 -70

No files matched your search

+32 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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());
+26 -1
View File
@@ -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);
+47 -6
View File
@@ -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();
});
}
+68 -2
View File
@@ -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(); } }
+80 -3
View File
@@ -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();
+76 -3
View File
@@ -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) ──
+53 -30
View File
@@ -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: `![${name}](${url})`, color: '#38bdf8', position: -1 } });
await window._reloadAll();
openGallery(); // refresh
});
});
// Orphan: delete
grid.querySelectorAll('[data-del]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const name = btn.dataset.del;
const ok = await showDialog({ title: 'delete_image', message: `"${name}" loeschen?`, danger: true, okLabel: 'delete' });
if (!ok) return;
await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
openGallery(); // refresh
// 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: `![${name}](${file.url})`, 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');
}
+19 -2
View File
@@ -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
View File
@@ -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() {
+23 -2
View File
@@ -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));
}
}
+14
View File
@@ -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');