This commit is contained in:
th committed 2026-04-07 17:47:09 +02:00
1 parent 03f57cbaee
commit a9603aa0e8
11 files changed
+1010 -93

No files matched your search

+115 -17
View File
@@ -30,9 +30,10 @@ body::after {
@keyframes blink { 50% { opacity: 0; } }
/* Line clamp */
.line-clamp-2, .line-clamp-3 { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
.line-clamp-2, .line-clamp-3, .line-clamp-5 { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
.line-clamp-2 { -webkit-line-clamp: 2; }
.line-clamp-3 { -webkit-line-clamp: 3; }
.line-clamp-5 { -webkit-line-clamp: 5; }
/* Cards */
.card-bar { image-rendering: pixelated; height: 3px; }
@@ -44,13 +45,19 @@ body::after {
background-repeat: no-repeat;
overflow: hidden;
}
.card-has-cover h3,
.card-has-cover p,
.card-has-cover .font-pixel,
.card-has-cover i {
text-shadow: 0 0 8px var(--t-bg), 0 0 16px var(--t-bg), 0 1px 3px var(--t-bg);
}
.card-group.card-has-cover {
overflow: visible;
}
.card-cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.22) 0%, rgba(15, 23, 42, 0.7) 62%, rgba(15, 23, 42, 0.86) 100%);
background: var(--t-cover-gradient, linear-gradient(180deg, rgba(15, 23, 42, 0.22) 0%, rgba(15, 23, 42, 0.7) 62%, rgba(15, 23, 42, 0.86) 100%));
pointer-events: none;
z-index: 0;
}
@@ -128,6 +135,18 @@ body::after {
background-repeat: no-repeat;
}
.card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
.card-select-check {
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
border-radius: 50%; background: var(--t-accent); z-index: 6;
display: flex; align-items: center; justify-content: center;
pointer-events: none;
}
.card-select-check::after {
content: ''; width: 5px; height: 9px;
border: solid var(--t-bg); border-width: 0 2px 2px 0;
transform: rotate(45deg); margin-top: -2px;
}
.card-dragging { opacity: 0.15; transform: scale(0.95); }
/* Drop indicators — thick glowing bar */
@@ -215,11 +234,26 @@ body::after {
transition: transform 0.2s ease, opacity 0.2s ease;
}
/* Split panes */
.split-pick {
padding: 6px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--t-text);
transition: background 0.1s;
}
.split-pick:hover, .split-pick.active { background: var(--t-raised); }
.split-pick .sp-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
/* Zen mode */
html.detail-zen-open,
body.detail-zen-open { overflow: hidden; }
#detail.zen > div { inset: 0 !important; border: none !important; border-radius: 0; overflow-y: auto; }
#detail.zen #detail-sidebar { display: none !important; }
#detail.zen #detail-sidebar,
#detail.zen #split-right { display: none !important; }
#detail.zen .card-bar { display: none; }
#detail.zen #detail-scroll {
max-width: 680px;
@@ -232,14 +266,18 @@ body.detail-zen-open { overflow: hidden; }
#detail.zen #detail-scroll #detail-meta { display: none; }
#detail.zen #detail-backlinks { display: none; }
#detail.zen #detail-controls {
position: fixed !important;
top: 1rem !important;
right: 1rem !important;
position: fixed;
top: 0; left: 0; right: 0;
z-index: 120;
border: none;
background: transparent;
justify-content: flex-end;
padding: 1rem;
opacity: 0.15;
transition: opacity 0.2s;
}
#detail.zen #detail-controls:hover { opacity: 1; }
#detail.zen #split-right-btn { display: none; }
/* Detail close morph */
.detail-close-frame {
@@ -359,6 +397,8 @@ body.detail-image-fs-open { overflow: hidden; }
/* Editor */
#editor-body { tab-size: 4; resize: none; }
#editor-body:focus { outline: none; }
#editor-gutter div { padding-right: 2px; box-sizing: border-box; }
.gutter-active { color: var(--t-accent) !important; font-weight: 700; background: var(--t-raised); }
/* Search */
#search { backdrop-filter: blur(4px); }
@@ -400,21 +440,79 @@ body.detail-image-fs-open { overflow: hidden; }
#link-ac .ac-item .ac-bucket { font-size: 9px; color: var(--t-dim); margin-left: 6px; }
/* Prose (markdown) */
.prose { line-height: 1.7; }
.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-pixel, 'Silkscreen', cursive); margin: 1.25em 0 0.5em; }
.prose h1 { font-size: 1.4em; }
.prose h2 { font-size: 1.15em; }
.prose h3 { font-size: 1em; }
.prose p { margin: 0.6em 0; }
.prose a { color: var(--t-accent); text-decoration: underline; }
.prose { line-height: 1.75; }
.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-pixel, 'Silkscreen', cursive); margin: 1.5em 0 0.6em; font-weight: 700; }
.prose h1 { font-size: 1.8em; padding-bottom: 0.3em; border-bottom: 1px solid var(--t-border); }
.prose h2 { font-size: 1.4em; }
.prose h3 { font-size: 1.15em; }
.prose h4 { font-size: 1em; color: var(--t-muted); }
.prose p { margin: 0.75em 0; min-height: 1.2em; }
.prose a { color: var(--t-accent); text-decoration: underline; text-underline-offset: 2px; }
.prose strong { color: var(--t-bright); font-weight: 700; }
.prose em { font-style: italic; }
.prose code { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.88em; }
.prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; }
.prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; position: relative; }
.prose pre code { background: none; border: none; padding: 0; font-size: 0.85em; }
.prose ul, .prose ol { padding-left: 1.5em; margin: 0.6em 0; }
.prose li { margin: 0.25em 0; }
.prose li::marker { color: var(--t-dim); }
.prose pre .code-copy {
position: absolute; top: 4px; right: 4px;
background: var(--t-surface); border: 1px solid var(--t-border);
color: var(--t-dim); cursor: pointer;
width: 26px; height: 26px; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.15s, color 0.15s;
font-size: 13px; padding: 0;
}
.prose pre:hover .code-copy { opacity: 1; }
.prose pre .code-copy:hover { color: var(--t-accent); border-color: var(--t-accent); }
.prose pre .code-copy.copied { color: var(--t-accent); opacity: 1; }
/* Lists — proper bullets */
.prose ul { list-style: disc; padding-left: 1.6em; margin: 0.75em 0; }
.prose ul ul { list-style: circle; }
.prose ul ul ul { list-style: square; }
.prose ol { list-style: decimal; padding-left: 1.6em; margin: 0.75em 0; }
.prose li { margin: 0.35em 0; padding-left: 0.3em; }
.prose li::marker { color: var(--t-accent); }
.prose li > ul, .prose li > ol { margin: 0.2em 0; }
/* Tree mode for lists — works on .prose.tree (global) or .tree-list (per-list) */
.prose.tree ul, .prose .tree-list, .prose .tree-list ul { list-style: none; padding-left: 0; }
.prose.tree ul ul, .prose .tree-list ul { padding-left: 1.4em; }
.prose.tree li, .prose .tree-list li {
position: relative;
padding-left: 1.5em;
margin: 0.15em 0;
}
.prose.tree li::before, .prose .tree-list li::before {
content: '';
position: absolute;
left: 0.25em;
top: 0;
bottom: 0;
width: 2px;
background: var(--t-dim);
border-radius: 1px;
}
.prose.tree li::after, .prose .tree-list li::after {
content: '';
position: absolute;
left: 0.25em;
top: 0.7em;
width: 1em;
height: 2px;
background: var(--t-dim);
border-radius: 1px;
}
.prose.tree li:last-child::before, .prose .tree-list li:last-child::before {
bottom: calc(100% - 0.7em);
}
.prose.tree li::marker, .prose .tree-list li::marker { color: transparent; font-size: 0; }
/* Explicit bullet-list keeps bullets even when global tree is on */
.prose.tree .bullet-list, .prose.tree .bullet-list ul { list-style: disc; padding-left: 1.6em; }
.prose.tree .bullet-list li { padding-left: 0.3em; }
.prose.tree .bullet-list li::before, .prose.tree .bullet-list li::after { display: none; }
.prose.tree .bullet-list li::marker { color: var(--t-accent); font-size: inherit; }
.prose blockquote { border-left: 3px solid var(--t-accent); padding-left: 1em; margin: 0.8em 0; color: var(--t-muted); }
.prose hr { border: none; border-top: 1px solid var(--t-border); margin: 1.2em 0; }
/* Todo checkboxes */
+93 -19
View File
@@ -275,20 +275,43 @@ if (str_starts_with($route, '/api/')) {
exit;
}
// Export — ZIP with JSON dump + uploads
// Export — full or selective
if ($route === '/api/export' && $method === 'GET') {
$cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll();
$buckets = $db->query('SELECT * FROM buckets ORDER BY id')->fetchAll();
$ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null;
if ($ids) {
$ph = implode(',', array_fill(0, count($ids), '?'));
$cards = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id")->execute($ids) ? $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id") : null;
// Re-query properly
$stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id");
$stmt->execute($ids);
$cards = $stmt->fetchAll();
$bucketIds = array_unique(array_filter(array_column($cards, 'bucket_id')));
$buckets = [];
if ($bucketIds) {
$bph = implode(',', array_fill(0, count($bucketIds), '?'));
$bstmt = $db->prepare("SELECT * FROM buckets WHERE id IN ($bph)");
$bstmt->execute(array_values($bucketIds));
$buckets = $bstmt->fetchAll();
}
} else {
$cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll();
$buckets = $db->query('SELECT * FROM buckets ORDER BY id')->fetchAll();
}
$tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_');
$zip = new ZipArchive();
$zip->open($tmp, ZipArchive::OVERWRITE);
$zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Only include referenced uploads
$uploadDir = __DIR__ . '/uploads';
$allBodies = implode("\n", array_column($cards, 'body'));
if (is_dir($uploadDir)) {
foreach (glob($uploadDir . '/*') as $f) {
if (is_file($f)) $zip->addFile($f, 'uploads/' . basename($f));
$name = basename($f);
if (is_file($f) && (!$ids || str_contains($allBodies, $name))) {
$zip->addFile($f, 'uploads/' . $name);
}
}
}
$zip->close();
@@ -328,14 +351,23 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['error' => 'invalid data.json']); exit;
}
// Clear existing data
$db->exec('DELETE FROM cards');
$db->exec('DELETE FROM buckets');
$merge = !empty($data['selective']) || !empty($_GET['merge']);
// Import buckets
if (!$merge) {
$db->exec('DELETE FROM cards');
$db->exec('DELETE FROM buckets');
}
// Import buckets (merge: match by name, create if missing)
$bucketMap = [];
foreach ($data['buckets'] as $b) {
$oldId = $b['id'];
if ($merge) {
$existing = $db->prepare('SELECT id FROM buckets WHERE name = :name');
$existing->execute(['name' => $b['name']]);
$found = $existing->fetchColumn();
if ($found) { $bucketMap[$oldId] = $found; continue; }
}
$stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)');
$stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]);
$bucketMap[$oldId] = $db->lastInsertId();
@@ -397,11 +429,12 @@ if (str_starts_with($route, '/api/')) {
</script>
<script>
window.themes = {
slate: { label:'Slate', vars:{ '--t-bg':'#1e293b','--t-surface':'#273349','--t-raised':'#2e3d56','--t-border':'#384a64','--t-dim':'#64748b','--t-muted':'#94a3b8','--t-text':'#cbd5e1','--t-bright':'#f1f5f9','--t-accent':'#38bdf8','--t-accent2':'#818cf8','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.04)','--t-overlay':'rgba(15,23,42,0.75)','--t-font-pixel':'"Silkscreen", cursive' }},
phosphor: { label:'Phosphor', vars:{ '--t-bg':'#0a0f0a','--t-surface':'#0f1a0f','--t-raised':'#142214','--t-border':'#1a3a1a','--t-dim':'#2d5a2d','--t-muted':'#4a8a4a','--t-text':'#7bc67b','--t-bright':'#b8f0b8','--t-accent':'#4ade80','--t-accent2':'#22d3ee','--t-danger':'#f87171','--t-scanline':'rgba(0,0,0,0.1)','--t-overlay':'rgba(5,10,5,0.85)','--t-font-pixel':'"Silkscreen", cursive' }},
midnight: { label:'Midnight', vars:{ '--t-bg':'#0f172a','--t-surface':'#162033','--t-raised':'#1c2940','--t-border':'#263650','--t-dim':'#475882','--t-muted':'#7088b0','--t-text':'#a8bdd8','--t-bright':'#e2e8f0','--t-accent':'#a78bfa','--t-accent2':'#f472b6','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.06)','--t-overlay':'rgba(10,15,30,0.8)','--t-font-pixel':'"Silkscreen", cursive' }},
amber: { label:'Amber', vars:{ '--t-bg':'#1a1408','--t-surface':'#231c0e','--t-raised':'#2c2414','--t-border':'#3d3520','--t-dim':'#6b5c38','--t-muted':'#a08c5a','--t-text':'#d4c088','--t-bright':'#fbe8b0','--t-accent':'#fbbf24','--t-accent2':'#fb923c','--t-danger':'#ef4444','--t-scanline':'rgba(0,0,0,0.08)','--t-overlay':'rgba(20,16,5,0.8)','--t-font-pixel':'"Silkscreen", cursive' }},
paper: { label:'Paper', vars:{ '--t-bg':'#f5f1eb','--t-surface':'#ffffff','--t-raised':'#eae5dd','--t-border':'#d6d0c4','--t-dim':'#a8a08e','--t-muted':'#7a7468','--t-text':'#3d3832','--t-bright':'#1a1714','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0)','--t-overlay':'rgba(245,241,235,0.8)','--t-font-pixel':'"JetBrains Mono", monospace' }},
slate: { label:'Slate', vars:{ '--t-bg':'#1e293b','--t-surface':'#273349','--t-raised':'#2e3d56','--t-border':'#384a64','--t-dim':'#64748b','--t-muted':'#94a3b8','--t-text':'#cbd5e1','--t-bright':'#f1f5f9','--t-accent':'#38bdf8','--t-accent2':'#818cf8','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.04)','--t-overlay':'rgba(15,23,42,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(15,23,42,0.22) 0%, rgba(15,23,42,0.7) 62%, rgba(15,23,42,0.86) 100%)' }},
phosphor: { label:'Phosphor', vars:{ '--t-bg':'#0a0f0a','--t-surface':'#0f1a0f','--t-raised':'#142214','--t-border':'#1a3a1a','--t-dim':'#2d5a2d','--t-muted':'#4a8a4a','--t-text':'#7bc67b','--t-bright':'#b8f0b8','--t-accent':'#4ade80','--t-accent2':'#22d3ee','--t-danger':'#f87171','--t-scanline':'rgba(0,0,0,0.1)','--t-overlay':'rgba(5,10,5,0.85)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(10,15,10,0.22) 0%, rgba(10,15,10,0.7) 62%, rgba(10,15,10,0.86) 100%)' }},
midnight: { label:'Midnight', vars:{ '--t-bg':'#0f172a','--t-surface':'#162033','--t-raised':'#1c2940','--t-border':'#263650','--t-dim':'#475882','--t-muted':'#7088b0','--t-text':'#a8bdd8','--t-bright':'#e2e8f0','--t-accent':'#a78bfa','--t-accent2':'#f472b6','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.06)','--t-overlay':'rgba(10,15,30,0.8)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(15,23,42,0.22) 0%, rgba(15,23,42,0.7) 62%, rgba(15,23,42,0.86) 100%)' }},
amber: { label:'Amber', vars:{ '--t-bg':'#1a1408','--t-surface':'#231c0e','--t-raised':'#2c2414','--t-border':'#3d3520','--t-dim':'#6b5c38','--t-muted':'#a08c5a','--t-text':'#d4c088','--t-bright':'#fbe8b0','--t-accent':'#fbbf24','--t-accent2':'#fb923c','--t-danger':'#ef4444','--t-scanline':'rgba(0,0,0,0.08)','--t-overlay':'rgba(20,16,5,0.8)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(26,20,8,0.22) 0%, rgba(26,20,8,0.7) 62%, rgba(26,20,8,0.86) 100%)' }},
void: { label:'Void', vars:{ '--t-bg':'#000000','--t-surface':'#0a0a0a','--t-raised':'#141414','--t-border':'#222222','--t-dim':'#444444','--t-muted':'#666666','--t-text':'#a0a0a0','--t-bright':'#e0e0e0','--t-accent':'#ffffff','--t-accent2':'#888888','--t-danger':'#ff4444','--t-scanline':'rgba(0,0,0,0)','--t-overlay':'rgba(0,0,0,0.85)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.75) 62%, rgba(0,0,0,0.9) 100%)' }},
paper: { label:'Paper', vars:{ '--t-bg':'#f4f4f5','--t-surface':'#ffffff','--t-raised':'#e8e8eb','--t-border':'#d4d4d8','--t-dim':'#a1a1aa','--t-muted':'#71717a','--t-text':'#3f3f46','--t-bright':'#18181b','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0)','--t-overlay':'rgba(244,244,245,0.8)','--t-font-pixel':'"JetBrains Mono", monospace','--t-cover-gradient':'linear-gradient(180deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.75) 40%, rgba(255,255,255,0.3) 100%)' }},
};
window.applyTheme = function(name) {
const t = themes[name] || themes.slate;
@@ -415,7 +448,7 @@ if (str_starts_with($route, '/api/')) {
applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
</script>
</head>
<!-- tailwind safelist: border-t-bright border-transparent scale-125 text-t-accent line-clamp-2 border-t-danger text-t-danger pr-16 -->
<!-- tailwind safelist: border-t-bright border-transparent scale-125 text-t-accent line-clamp-2 line-clamp-5 border-t-danger text-t-danger pr-16 text-amber-400 -->
<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;">
@@ -573,22 +606,32 @@ if (str_starts_with($route, '/api/')) {
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0">
<div class="card-bar w-full shrink-0" id="detail-color"></div>
<div id="detail-controls" class="absolute top-4 right-4 flex gap-2 z-10">
<div id="detail-controls" class="flex items-center gap-2 px-4 py-2 border-b border-t-border shrink-0">
<button id="detail-edit" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span>
</button>
<button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">del</span>
</button>
<button id="detail-extract" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="extract selection to new card">
<i class="ph ph-arrow-square-out text-sm"></i>
</button>
<button id="detail-tree" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="tree view">
<i class="ph ph-tree-structure text-sm"></i>
</button>
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="zen mode">
<i class="ph ph-corners-out text-sm"></i>
</button>
<div class="flex-1"></div>
<button id="split-right-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" title="split view">
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">split</span>
</button>
<button id="detail-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<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 pr-32 leading-relaxed"></h2>
<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-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>
@@ -597,6 +640,16 @@ if (str_starts_with($route, '/api/')) {
<div id="detail-meta" class="mt-8 pt-4 border-t border-t-border text-[11px] text-t-dim font-pixel"></div>
</div>
</div>
<!-- Split right pane -->
<div id="split-right" class="hidden w-2/5 shrink-0 border-l border-t-border flex flex-col min-w-0">
<div class="flex items-center gap-2 px-3 py-2 border-b border-t-border shrink-0">
<i class="ph ph-magnifying-glass text-t-dim text-xs"></i>
<input type="text" id="split-search" class="flex-1 bg-transparent text-xs text-t-bright placeholder:text-t-dim font-mono focus:outline-none" placeholder="search all cards..." autocomplete="off">
<button id="split-close" class="text-t-dim hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
</div>
<div id="split-picks" class="overflow-y-auto border-b border-t-border"></div>
<div id="split-content" class="flex-1 overflow-y-auto p-5"></div>
</div>
</div>
</div>
@@ -618,6 +671,17 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<!-- Selection action bar -->
<div id="select-bar" class="fixed bottom-16 left-1/2 -translate-x-1/2 z-[45] bg-t-surface border border-t-border shadow-2xl px-4 py-2 flex items-center gap-3 hidden" style="border-radius:6px;">
<span id="select-count" class="font-pixel text-[10px] text-t-accent"></span>
<button id="select-export" 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="select-clear" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">clear</span>
</button>
</div>
<!-- Context menu -->
<div id="ctx-menu" class="fixed z-[85] bg-t-surface border border-t-border shadow-2xl hidden" style="min-width:180px;">
<div id="ctx-items" class="py-1"></div>
@@ -635,7 +699,9 @@ if (str_starts_with($route, '/api/')) {
<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">
<button id="modal-cancel" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
<button id="modal-save" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="modal-save" 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-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="modal-save-view" 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-eye text-sm"></i><span class="font-pixel text-[10px]">save+view</span></button>
<button id="modal-save-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save+close</span></button>
</div>
</div>
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
@@ -654,6 +720,7 @@ if (str_starts_with($route, '/api/')) {
<button data-md="codeblock" title="Code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="ul" title="List" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button>
<button data-md="tree" title="Tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button>
<button data-md="ol" title="Numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button>
<button data-md="quote" title="Quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button>
<button data-md="todo" title="Todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button>
@@ -661,11 +728,18 @@ if (str_starts_with($route, '/api/')) {
<button data-md="link" title="Link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button>
<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>
<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>
</div>
<div class="flex flex-1 min-h-0">
<textarea id="editor-body" placeholder="> write markdown..." class="flex-1 bg-t-bg text-t-text text-sm p-4 font-mono leading-relaxed placeholder:text-t-dim border-none min-h-full overflow-y-auto"></textarea>
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none overflow-hidden shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; line-height:1.625;"></div>
<div class="flex-1 relative min-h-full bg-t-bg">
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
</div>
<div id="editor-preview" class="flex-1 border-l border-t-border bg-t-bg p-4 overflow-y-auto hidden"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
</div>
</div>
+29 -6
View File
@@ -7,9 +7,9 @@ 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 { renderBoard, updateSortUI } from './modules/board.js';
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
import { openModal, closeModal, saveCard } from './modules/editor.js';
import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
import { openSearch, closeSearch } from './modules/search.js';
import { showContextMenu } from './modules/context-menu.js';
import { undo, canUndo } from './modules/undo.js';
@@ -26,8 +26,24 @@ window._closeTasks = closeTasks;
window._renderBoard = renderBoard;
window._showContextMenu = showContextMenu;
window._ungroupCard = ungroupCard;
window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
window._reloadAll = async () => { state.cards = await api('api/cards'); renderBoard(); };
window._extractToCard = async (text) => {
if (!text?.trim()) return;
const lines = text.trim().split('\n');
const title = lines[0].replace(/^#+\s*/, '').replace(/^\*+/, '').replace(/^-\s*/, '').trim() || 'Extracted';
const body = text.trim();
// Inherit context from the source card (viewing or editing)
const source = state.viewingCard || (state.editingId ? state.cards.find(c => c.id == state.editingId) : null);
const data = { title, body, color: source?.color || '#38bdf8', position: -1 };
if (source?.bucket_id) data.bucket_id = source.bucket_id;
if (source?.parent_id) data.parent_id = source.parent_id;
else if (source && state.cards.some(c => c.parent_id == source.id)) data.parent_id = source.id;
await api('api/cards', { method: 'POST', body: data });
await window._reloadAll();
};
// ── Data loading ──
@@ -46,11 +62,18 @@ document.getElementById('import-btn')?.addEventListener('click', () => importFil
importFile?.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
const ok = await showDialog({ title: 'import_data', message: 'Alle bestehenden Daten werden ersetzt. Fortfahren?', danger: true, okLabel: 'import' });
if (!ok) { e.target.value = ''; return; }
// Check if it's a selective export (merge) or ask for full replace
const form = new FormData(); form.append('file', file);
const mode = await showDialog({ title: 'import_data', message: 'Wie importieren?', okLabel: 'merge (hinzufuegen)', cancelLabel: 'replace (alles ersetzen)' });
// mode: true = merge, null = replace
if (mode === null) {
const sure = await showDialog({ title: 'replace_all', message: 'Alle bestehenden Daten werden ersetzt!', danger: true, okLabel: 'replace' });
if (!sure) { e.target.value = ''; return; }
}
try {
const res = await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const url = mode ? 'api/import?merge=1' : 'api/import';
const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const data = JSON.parse(await res.text());
if (data.error) throw new Error(data.error);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
@@ -115,7 +138,7 @@ document.addEventListener('keydown', e => {
else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
return;
}
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; }
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; }
if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
+141 -23
View File
@@ -1,7 +1,7 @@
// ── Board rendering & card interactions ──
import { $, esc, api } from './util.js';
import { state, sortedCards, getChildren, getRecent, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js';
import { state, sortedCards, 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';
@@ -9,6 +9,58 @@ const board = $('board');
let activeDragEl = null;
let dragGhost = null;
// ── Multi-select ──
const selectedIds = new Set();
const selectBar = $('select-bar');
const selectCount = $('select-count');
function updateSelectBar() {
if (!selectBar) return;
if (selectedIds.size) {
selectBar.classList.remove('hidden');
selectCount.textContent = `${selectedIds.size} selected`;
} else {
selectBar.classList.add('hidden');
}
}
function toggleSelect(id) {
const sid = String(id);
const el = board.querySelector(`[data-id="${id}"]`);
if (selectedIds.has(sid)) {
selectedIds.delete(sid);
el?.classList.remove('card-selected');
el?.querySelector('.card-select-check')?.remove();
} else {
selectedIds.add(sid);
el?.classList.add('card-selected');
if (el && !el.querySelector('.card-select-check')) {
const check = document.createElement('div');
check.className = 'card-select-check';
el.appendChild(check);
}
}
updateSelectBar();
}
function clearSelection() {
selectedIds.clear();
board.querySelectorAll('.card-selected').forEach(el => {
el.classList.remove('card-selected');
el.querySelector('.card-select-check')?.remove();
});
updateSelectBar();
}
export function getSelectedIds() { return [...selectedIds]; }
export { clearSelection };
$('select-export')?.addEventListener('click', () => {
if (!selectedIds.size) return;
window.location.href = 'api/export?ids=' + [...selectedIds].join(',');
});
$('select-clear')?.addEventListener('click', clearSelection);
// Custom drag ghost that fades near header
document.addEventListener('dragover', e => {
if (!dragGhost) return;
@@ -29,8 +81,11 @@ document.addEventListener('dragover', e => {
// ── Sort toggle ──
export function updateSortUI() {
const btn = $('sort-toggle');
const label = $('sort-label');
const icon = document.querySelector('#sort-toggle i');
const fixedSort = state.activeBucket === 'current' || state.activeBucket === 'journal';
if (btn) btn.style.display = fixedSort ? 'none' : '';
if (label) label.textContent = state.sortMode;
if (icon) icon.className = `ph ${SORT_ICONS[state.sortMode]} text-xs`;
}
@@ -50,20 +105,37 @@ export function renderBoard() {
const parent = state.cards.find(c => c.id == state.activeGroup);
cards = parent ? [parent, ...getChildren(parent.id)] : [];
} else {
cards = state.cards.filter(c => !c.parent_id);
if (state.activeBucket === 'current') {
// Current shows ALL recently touched cards, including group children
const recent = getRecent();
const now = Date.now();
const freshMs = 48 * 60 * 60 * 1000; // 48h
cards = cards.filter(c => {
const freshMs = 48 * 60 * 60 * 1000;
cards = state.cards.filter(c => {
if (recent.includes(String(c.id))) return true;
const updated = new Date(c.updated_at || c.created_at || 0).getTime();
return (now - updated) < freshMs;
});
} else if (state.activeBucket === 'none') cards = cards.filter(c => !c.bucket_id);
else if (state.activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == state.activeBucket);
cards.sort((a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''));
} else {
cards = state.cards.filter(c => !c.parent_id);
if (state.activeBucket === 'favorites') {
const favs = getFavorites();
cards = cards.filter(c => favs.includes(String(c.id)));
} else if (state.activeBucket === 'journal') {
cards = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title));
cards.sort((a, b) => b.title.localeCompare(a.title));
} else if (state.activeBucket === 'none') {
cards = cards.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title));
} else if (state.activeBucket !== 'all') {
cards = cards.filter(c => c.bucket_id == state.activeBucket);
}
}
}
cards = sortedCards(cards);
// Current and journal have fixed sort order, skip global sort
if (state.activeBucket !== 'current' && state.activeBucket !== 'journal') {
cards = sortedCards(cards);
}
updateSortUI();
window._refreshTasks?.();
hideFan();
@@ -97,17 +169,18 @@ export function renderBoard() {
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
<div class="p-4 card-layer ${thumbImg ? 'pr-16' : ''}">
<div class="p-4 card-layer flex flex-col h-full ${thumbImg ? 'pr-16' : ''}">
<div class="flex items-start gap-2 mb-2">
${isFavorite(c.id) ? `<i class="ph-fill ph-star text-amber-400 text-xs mt-0.5 shrink-0"></i>` : ''}
${todos ? `<i class="ph ${todos.done === todos.total ? 'ph-check-square text-t-accent' : 'ph-squares-four text-t-muted'} text-sm mt-0.5 shrink-0"></i>` : ''}
<h3 class="font-pixel text-xs text-t-bright leading-relaxed">${esc(c.title)}</h3>
<h3 class="font-pixel text-xs text-t-bright leading-relaxed line-clamp-2">${esc(c.title)}</h3>
</div>
${todos ? `<div class="mb-2"><div class="flex items-center gap-2 mb-1">
<div class="flex-1 h-1.5 bg-t-border rounded-full overflow-hidden"><div class="h-full rounded-full transition-all" style="width:${todoPct}%;background:${c.color}"></div></div>
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
</div></div>` : ''}
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-3">${esc(stripMd(c.body))}</p>
<div class="mt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>
<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>` : ''}
</div>
@@ -117,6 +190,20 @@ export function renderBoard() {
bindCardEvents();
// Restore selection state
selectedIds.forEach(id => {
const el = board.querySelector(`[data-id="${id}"]`);
if (el) {
el.classList.add('card-selected');
if (!el.querySelector('.card-select-check')) {
const check = document.createElement('div');
check.className = 'card-select-check';
el.appendChild(check);
}
}
});
updateSelectBar();
// Footer count
const fc = $('footer-count');
if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`;
@@ -226,7 +313,8 @@ function bindCardEvents() {
});
}
el.addEventListener('click', () => {
el.addEventListener('click', (e) => {
if (e.ctrlKey || e.metaKey) { e.preventDefault(); toggleSelect(id); return; }
if (clickTimer) return;
clickTimer = setTimeout(() => { clickTimer = null; window._openDetail(id); }, 200);
});
@@ -246,9 +334,17 @@ function bindCardEvents() {
el.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed = 'move';
el.classList.add('card-dragging');
activeDragEl = el;
// Determine all dragged cards (selected or just this one)
const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
const dragIds = isMulti ? [...selectedIds] : [String(id)];
// Ghost all dragged cards
dragIds.forEach(did => {
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
});
// Hide native drag image
const empty = document.createElement('div');
empty.style.width = '1px'; empty.style.height = '1px'; empty.style.opacity = '0.01';
@@ -256,19 +352,41 @@ function bindCardEvents() {
e.dataTransfer.setDragImage(empty, 0, 0);
setTimeout(() => empty.remove(), 0);
// Create custom ghost
// Create stacked ghost
const rect = el.getBoundingClientRect();
const ghost = el.cloneNode(true);
ghost.style.cssText = `position:fixed;z-index:9998;width:${rect.width}px;pointer-events:none;opacity:0.5;transition:opacity 0.12s;`;
ghost._offsetX = e.clientX - rect.left;
ghost._offsetY = e.clientY - rect.top;
ghost.style.left = (e.clientX - ghost._offsetX) + 'px';
ghost.style.top = (e.clientY - ghost._offsetY) + 'px';
document.body.appendChild(ghost);
dragGhost = ghost;
const container = document.createElement('div');
container.style.cssText = `position:fixed;z-index:9998;pointer-events:none;transition:opacity 0.12s;opacity:0.5;`;
container._offsetX = e.clientX - rect.left;
container._offsetY = e.clientY - rect.top;
if (isMulti) {
// Stack effect: offset layers behind
const count = Math.min(dragIds.length, 4);
for (let i = count - 1; i >= 1; i--) {
const layer = document.createElement('div');
layer.style.cssText = `position:absolute;top:${i * 4}px;left:${i * 4}px;width:${rect.width}px;height:${rect.height}px;background:var(--t-surface);border:1px solid var(--t-border);border-radius:inherit;opacity:${0.3 + (count - i) * 0.15};`;
container.appendChild(layer);
}
// Badge
const badge = document.createElement('div');
badge.textContent = dragIds.length;
badge.style.cssText = `position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;border-radius:10px;background:var(--t-accent);color:var(--t-bg);font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 6px;z-index:5;font-family:var(--t-font-pixel,'Silkscreen',cursive);`;
container.appendChild(badge);
}
const clone = el.cloneNode(true);
clone.style.cssText = `position:relative;width:${rect.width}px;`;
clone.querySelector('.card-select-check')?.remove();
container.appendChild(clone);
container.style.left = (e.clientX - container._offsetX) + 'px';
container.style.top = (e.clientY - container._offsetY) + 'px';
document.body.appendChild(container);
dragGhost = container;
});
el.addEventListener('dragend', () => {
el.classList.remove('card-dragging');
// Remove ghost class from all dragged cards
board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
activeDragEl = null;
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x =>
+23 -7
View File
@@ -147,9 +147,11 @@ function renderBar() {
const tab = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`;
bar.innerHTML = `
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all"><i class="ph ph-squares-four text-sm"></i><span class="font-pixel text-[10px]">all</span></button>
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current"><i class="ph ph-lightning text-sm"></i><span class="font-pixel text-[10px]">current</span></button>
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none"><i class="ph ph-tray text-sm"></i><span class="font-pixel text-[10px]">unsorted</span></button>
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i></button>
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" title="unsorted"><i class="ph ph-tray text-sm"></i></button>
${state.buckets.map(b => {
const c = b.color || '#64748b';
const active = state.activeBucket == b.id;
@@ -223,12 +225,23 @@ function renderBar() {
return;
}
// Card drop on bucket
// Card drop on bucket — move dragged card + all selected
const cardId = e.dataTransfer.getData('text/plain');
if (!cardId) return;
const bucketId = bid === 'none' ? null : ((bid === 'all' || bid === 'current') ? undefined : bid);
const selected = window._getSelectedIds?.() || [];
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
if (bid === 'favorites') {
const { toggleFavorite, isFavorite } = await import('./state.js');
for (const id of ids) { if (!isFavorite(id)) toggleFavorite(id); }
window._clearSelection?.();
window._renderBoard();
return;
}
const bucketId = bid === 'none' ? null : (['all','current','journal'].includes(bid) ? undefined : bid);
if (bucketId === undefined) return;
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } });
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
window._clearSelection?.();
await window._reloadAll();
});
});
@@ -256,10 +269,13 @@ function renderBar() {
const cardId = e.dataTransfer.getData('text/plain');
if (!cardId) return;
e.preventDefault();
const selected = window._getSelectedIds?.() || [];
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
const card = state.cards.find(c => String(c.id) === String(cardId));
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
if (!created?.id) return;
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } });
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
window._clearSelection?.();
await loadBuckets();
await window._reloadAll();
});
+3 -1
View File
@@ -1,7 +1,7 @@
// ── Context menu ──
import { $, esc, api } from './util.js';
import { state, getChildren, CARD_COLORS } from './state.js';
import { state, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
import { showDialog } from './dialog.js';
import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js';
@@ -25,6 +25,7 @@ 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="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>
@@ -72,6 +73,7 @@ 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 === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
else if (a === 'bucket') toggle(buckets);
else if (a === 'group-members') toggle(members);
+195 -5
View File
@@ -1,8 +1,8 @@
// ── Detail view ──
import { $, esc, api } from './util.js';
import { $, esc, api, fuzzyMatch } from './util.js';
import { state, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
import { renderMd, firstImage } from './markdown.js';
import { renderMd, stripMd, firstImage, addCodeCopyButtons } from './markdown.js';
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
import { showDialog } from './dialog.js';
@@ -270,7 +270,15 @@ function startCloseMorph(cardId) {
const target = resolveMorphTarget(cardId);
if (!panel || !target) return;
const from = panel.getBoundingClientRect();
// Use known inset values instead of getBoundingClientRect to avoid offset issues
const isZen = detail.classList.contains('zen');
const inset = isZen ? 0 : 40;
const from = {
left: inset,
top: inset,
width: window.innerWidth - inset * 2,
height: window.innerHeight - inset * 2,
};
const to = target.getBoundingClientRect();
if (from.width < 40 || from.height < 40 || to.width < 20 || to.height < 20) return;
@@ -286,19 +294,21 @@ function startCloseMorph(cardId) {
? state.cards.find(c => c.id == target.dataset.id)
: card;
Object.assign(frame.style, {
position: 'fixed',
left: `${from.left}px`,
top: `${from.top}px`,
width: `${from.width}px`,
height: `${from.height}px`,
borderColor: targetCard?.color || card?.color || 'var(--t-accent2)',
position: 'fixed',
inset: 'auto',
margin: '0',
});
document.body.appendChild(frame);
morphGhost = frame;
// Force reflow so the browser commits the starting position before transitioning
void frame.offsetWidth;
requestAnimationFrame(() => {
frame.style.left = `${to.left}px`;
frame.style.top = `${to.top}px`;
@@ -331,6 +341,7 @@ export function closeDetail(options = {}) {
detail.classList.remove('open', 'zen');
document.documentElement.classList.remove('detail-zen-open');
document.body.classList.remove('detail-zen-open');
$('split-right')?.classList.add('hidden');
const icon = $('detail-zen')?.querySelector('i');
if (icon) icon.className = 'ph ph-corners-out text-sm';
state.viewingCard = null;
@@ -400,6 +411,7 @@ function showContent(card) {
$('detail-color').style.background = card.color;
titleEl.textContent = card.title;
bodyEl.innerHTML = renderMd(card.body);
addCodeCopyButtons(bodyEl);
applyReadingResizeHandles(bodyEl);
bindDetailImageFullscreen(bodyEl);
@@ -522,6 +534,23 @@ document.addEventListener('keydown', e => {
}, true);
// Zen mode
// Tree view toggle
// Extract selection to new card
$('detail-extract')?.addEventListener('click', async () => {
const sel = window.getSelection();
const text = sel?.toString();
if (!text?.trim()) return;
await window._extractToCard(text);
});
$('detail-tree')?.addEventListener('click', () => {
const body = $('detail-body');
if (!body) return;
body.classList.toggle('tree');
$('detail-tree')?.classList.toggle('text-t-accent', body.classList.contains('tree'));
$('detail-tree')?.classList.toggle('border-t-accent', body.classList.contains('tree'));
});
$('detail-zen')?.addEventListener('click', () => {
detail.classList.toggle('zen');
const isZen = detail.classList.contains('zen');
@@ -531,6 +560,167 @@ $('detail-zen')?.addEventListener('click', () => {
if (icon) icon.className = isZen ? 'ph ph-corners-in text-sm' : 'ph ph-corners-out text-sm';
});
// ── Split pane (right) ──
const splitPane = $('split-right');
const splitSearch = $('split-search');
const splitPicks = $('split-picks');
const splitContent = $('split-content');
function findRelatedCards(card) {
if (!card) return [];
const id = card.id;
const title = (card.title || '').toLowerCase();
const body = (card.body || '').toLowerCase();
// Extract significant words (3+ chars) from title and body
const stopwords = new Set(['the','and','for','that','this','with','from','are','was','were','been','have','has','had','not','but','what','all','can','her','his','how','its','may','new','now','old','see','way','who','did','get','let','say','she','too','use','eine','ein','der','die','das','und','ist','von','mit','den','dem','des','auf','fuer','auch','sich','noch','wie','man','aber','nach','bei','nur','dann','wenn','oder','als','bis','zum','zur','kann','doch','ich','wir','sie','aus','vor']);
const words = `${card.title} ${stripMd(card.body)}`.toLowerCase()
.split(/[^a-zA-Z0-9äöüß]+/)
.filter(w => w.length >= 3 && !stopwords.has(w));
const wordSet = [...new Set(words)];
return state.cards
.filter(c => c.id != id)
.map(c => {
let score = 0;
const ct = (c.title || '').toLowerCase();
const cb = stripMd(c.body || '').toLowerCase();
// Backlink: this card references the other
if (body.includes(`[[${c.title.toLowerCase()}]]`)) score += 80;
// Backlink: the other references this card
if ((c.body || '').toLowerCase().includes(`[[${title}]]`)) score += 80;
// Same bucket
if (card.bucket_id && c.bucket_id == card.bucket_id) score += 15;
// Same group
if (card.parent_id && c.parent_id == card.parent_id) score += 20;
// Word overlap
for (const w of wordSet) {
if (ct.includes(w)) score += 6;
if (cb.includes(w)) score += 2;
}
return score > 0 ? { card: c, score } : null;
})
.filter(Boolean)
.sort((a, b) => b.score - a.score)
.slice(0, 12)
.map(s => s.card);
}
function renderSplitPicks(cards, label) {
if (!cards.length) {
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px]">no matches</div>`;
return;
}
splitPicks.innerHTML =
`<div class="px-3 pt-2 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${label}</span></div>` +
cards.map(c => `<div class="split-pick" data-split-card="${c.id}">
<span class="sp-dot" style="background:${c.color}"></span>
<span class="font-pixel text-[10px] truncate flex-1">${esc(c.title)}</span>
${c.bucket_name ? `<span class="text-[8px] text-t-dim font-pixel">${esc(c.bucket_name)}</span>` : ''}
</div>`).join('');
splitPicks.querySelectorAll('[data-split-card]').forEach(el => {
el.addEventListener('click', () => loadSplitCard(el.dataset.splitCard));
});
}
function searchAllCards(query) {
const q = query.toLowerCase().trim();
return state.cards
.filter(c => c.id != state.viewingCard?.id)
.map(c => {
const t = (c.title || '').toLowerCase();
const b = stripMd(c.body || '').toLowerCase();
let score = 0;
if (t.includes(q)) score += 50;
if (t.startsWith(q)) score += 20;
if (b.includes(q)) score += 10;
// fuzzy on title
const fm = fuzzyMatch(q, t);
if (fm !== null) score += 5 + fm;
return score > 0 ? { card: c, score } : null;
})
.filter(Boolean)
.sort((a, b) => b.score - a.score)
.slice(0, 15)
.map(s => s.card);
}
let splitCardId = null;
function loadSplitCard(id) {
const card = state.cards.find(c => c.id == id);
if (!card) return;
splitCardId = id;
splitPicks.innerHTML = `<div class="flex items-center border-b border-t-border">
<button id="split-back" class="flex items-center gap-1.5 flex-1 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors">
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">related cards</span>
</button>
<button id="split-promote" class="flex items-center gap-1.5 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors" title="open as main card">
<i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">focus</span>
</button>
</div>`;
splitPicks.querySelector('#split-back').addEventListener('click', openSplitPane);
splitPicks.querySelector('#split-promote').addEventListener('click', () => promoteSplitCard(id));
splitSearch.value = '';
splitContent.innerHTML = `
<div class="card-bar w-16 mb-3 rounded-sm" style="background:${card.color}"></div>
<h2 class="font-pixel text-t-bright text-sm mb-3 leading-relaxed">${esc(card.title)}</h2>
<div class="prose text-xs text-t-text">${renderMd(card.body)}</div>
<div class="mt-6 pt-3 border-t border-t-border text-[10px] text-t-dim font-pixel">
> ${card.created_at}${card.bucket_name ? ' | ' + esc(card.bucket_name) : ''}
</div>`;
splitContent.querySelectorAll('.card-link[data-card-id]').forEach(link => {
link.addEventListener('click', () => loadSplitCard(link.dataset.cardId));
});
addCodeCopyButtons(splitContent);
}
function promoteSplitCard(id) {
// Open the split card as the main detail card, then refresh related in split pane
openDetail(id);
openSplitPane();
}
function openSplitPane() {
if (!splitPane) return;
splitPane.classList.remove('hidden');
splitSearch.value = '';
splitContent.innerHTML = '';
const related = findRelatedCards(state.viewingCard);
renderSplitPicks(related, related.length ? 'related' : 'no related cards');
splitSearch.focus();
}
$('split-right-btn')?.addEventListener('click', () => {
if (splitPane.classList.contains('hidden')) openSplitPane();
});
$('split-close')?.addEventListener('click', () => splitPane.classList.add('hidden'));
splitSearch?.addEventListener('input', () => {
const q = splitSearch.value.trim();
if (!q) {
const related = findRelatedCards(state.viewingCard);
renderSplitPicks(related, 'related');
return;
}
const results = searchAllCards(q);
renderSplitPicks(results, `${results.length} result${results.length !== 1 ? 's' : ''}`);
});
splitSearch?.addEventListener('keydown', e => {
if (e.key === 'Escape') { splitPane.classList.add('hidden'); e.stopPropagation(); }
if (e.key === 'Enter') {
const first = splitPicks.querySelector('[data-split-card]');
if (first) loadSplitCard(first.dataset.splitCard);
}
});
$('detail-edit')?.addEventListener('click', () => {
if (!state.viewingCard) return;
const card = state.viewingCard;
+332 -11
View File
@@ -1,8 +1,8 @@
// ── Editor (card create/edit) ──
import { $, api, escRe, getCsrf } from './util.js';
import { state, CARD_COLORS } from './state.js';
import { renderMd } from './markdown.js';
import { state, getChildren, CARD_COLORS } from './state.js';
import { renderMd, addCodeCopyButtons } from './markdown.js';
import { pushCardEdit, snapshotCard } from './undo.js';
import { fuzzyMatch } from './util.js';
import { esc } from './util.js';
@@ -18,6 +18,85 @@ const presets = $('color-presets');
const linkAc = $('link-ac');
let linkAcVisible = false, linkAcItems = [], linkAcActive = 0;
let morphGhost = null, morphTimer = null;
function clearMorphGhost() {
if (morphTimer) { clearTimeout(morphTimer); morphTimer = null; }
if (morphGhost) { morphGhost.remove(); morphGhost = null; }
}
function findMorphTarget(cardId) {
const board = $('board');
if (!board || !cardId) return null;
const el = board.querySelector(`[data-id="${cardId}"]`);
if (el) return el;
const card = state.cards.find(c => c.id == cardId);
if (card?.parent_id) {
const p = board.querySelector(`[data-id="${card.parent_id}"]`);
if (p) return p;
}
const children = getChildren(cardId);
for (const ch of children) {
const ce = board.querySelector(`[data-id="${ch.id}"]`);
if (ce) return ce;
}
return null;
}
function startCloseMorph() {
const cardId = state.editingId;
if (!cardId) return;
if ($('search')?.classList.contains('open')) return;
const target = findMorphTarget(cardId);
if (!target) return;
const from = { left: 40, top: 40, width: window.innerWidth - 80, height: window.innerHeight - 80 };
const to = target.getBoundingClientRect();
if (from.width < 40 || from.height < 40 || to.width < 20 || to.height < 20) return;
const visible = to.bottom > 0 && to.top < window.innerHeight && to.right > 0 && to.left < window.innerWidth;
if (!visible) return;
clearMorphGhost();
const card = state.cards.find(c => c.id == cardId);
const targetCard = target.dataset.id ? state.cards.find(c => c.id == target.dataset.id) : card;
const frame = document.createElement('div');
frame.className = 'detail-close-frame';
Object.assign(frame.style, {
position: 'fixed',
left: `${from.left}px`,
top: `${from.top}px`,
width: `${from.width}px`,
height: `${from.height}px`,
borderColor: targetCard?.color || card?.color || 'var(--t-accent2)',
margin: '0',
});
document.body.appendChild(frame);
morphGhost = frame;
void frame.offsetWidth;
requestAnimationFrame(() => {
frame.style.left = `${to.left}px`;
frame.style.top = `${to.top}px`;
frame.style.width = `${to.width}px`;
frame.style.height = `${to.height}px`;
frame.style.opacity = '0.32';
frame.style.borderRadius = '4px';
});
target.classList.add('card-morph-target');
setTimeout(() => target.classList.remove('card-morph-target'), 360);
const done = () => {
if (frame === morphGhost) morphGhost = null;
frame.remove();
};
frame.addEventListener('transitionend', done, { once: true });
morphTimer = setTimeout(done, 430);
}
// ── Color presets ──
@@ -46,30 +125,184 @@ function selectColor(hex) {
export function openModal(card = null) {
state.editingId = card ? card.id : null;
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
inpTitle.value = card ? card.title : '';
const isJournal = !card && state.activeBucket === 'journal';
const today = new Date().toISOString().substring(0, 10);
inpTitle.value = card ? card.title : (isJournal ? today : '');
body.value = card ? card.body : '';
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
inpBucket.value = card?.bucket_id ? card.bucket_id
: (state.activeBucket !== 'all' && state.activeBucket !== 'none' ? state.activeBucket : '');
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
selectColor(card ? card.color : '#38bdf8');
state.previewVisible = true;
preview.classList.remove('hidden');
$('toggle-preview').classList.add('text-t-accent');
updatePreview();
modal.classList.add('open');
if (linesVisible) updateGutter();
currentLine = -1;
updateActiveLine();
inpTitle.focus();
}
export function closeModal() {
inpTitle.addEventListener('input', updatePreview);
// ── Line numbers ──
const gutter = $('editor-gutter');
let linesVisible = localStorage.getItem('hyprcrd-lines') === '1';
let currentLine = -1;
let mirror = null;
function getMirror() {
if (!mirror) {
mirror = document.createElement('div');
mirror.setAttribute('aria-hidden', 'true');
mirror.style.cssText = 'position:absolute;top:-9999px;left:-9999px;visibility:hidden;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;box-sizing:border-box;';
document.body.appendChild(mirror);
}
// Sync all relevant styles from textarea
const cs = getComputedStyle(body);
mirror.style.font = cs.font;
mirror.style.lineHeight = cs.lineHeight;
mirror.style.letterSpacing = cs.letterSpacing;
mirror.style.tabSize = cs.tabSize;
mirror.style.paddingLeft = cs.paddingLeft;
mirror.style.paddingRight = cs.paddingRight;
mirror.style.borderLeft = cs.borderLeft;
mirror.style.borderRight = cs.borderRight;
mirror.style.width = body.offsetWidth + 'px';
return mirror;
}
function getCurrentLineNum() {
return body.value.substring(0, body.selectionStart).split('\n').length;
}
function updateGutter() {
if (!gutter || !linesVisible) return;
const m = getMirror();
const lines = body.value.split('\n');
const active = getCurrentLineNum();
// Build all lines as divs, then use offsetTop differences for heights
m.innerHTML = '';
const divs = [];
for (const line of lines) {
const d = document.createElement('div');
d.textContent = line || '\u200b';
m.appendChild(d);
divs.push(d);
}
// Sentinel div at end to get last line's height
const sentinel = document.createElement('div');
sentinel.textContent = '\u200b';
m.appendChild(sentinel);
let html = '';
for (let i = 0; i < divs.length; i++) {
const top = divs[i].offsetTop;
const nextTop = (i + 1 < divs.length) ? divs[i + 1].offsetTop : sentinel.offsetTop;
const h = nextTop - top;
html += `<div style="height:${h}px" class="${i + 1 === active ? 'gutter-active' : ''}">${i + 1}</div>`;
}
gutter.innerHTML = html;
currentLine = active;
}
const lineHl = $('editor-line-hl');
function updateActiveLine() {
const line = getCurrentLineNum();
if (line === currentLine) return;
currentLine = line;
// Gutter highlight
if (gutter && linesVisible) {
gutter.querySelector('.gutter-active')?.classList.remove('gutter-active');
const divs = gutter.children;
if (divs[line - 1]) divs[line - 1].classList.add('gutter-active');
}
// Line highlight overlay
updateLineHighlight();
}
function updateLineHighlight() {
if (!lineHl) return;
const line = getCurrentLineNum();
if (linesVisible && gutter?.children[line - 1]) {
const gutterDiv = gutter.children[line - 1];
const gutterRect = gutterDiv.getBoundingClientRect();
const wrapperRect = lineHl.parentElement.getBoundingClientRect();
lineHl.style.display = 'block';
lineHl.style.top = (gutterRect.top - wrapperRect.top) + 'px';
lineHl.style.height = gutterRect.height + 'px';
} else {
// Use mirror to measure correct position even with wrapping
const m = getMirror();
const lines = body.value.split('\n');
m.innerHTML = '';
const divs = [];
for (const l of lines) {
const d = document.createElement('div');
d.textContent = l || '\u200b';
m.appendChild(d);
divs.push(d);
}
const idx = line - 1;
if (!divs[idx]) { lineHl.style.display = 'none'; return; }
const top = divs[idx].offsetTop;
const h = (idx + 1 < divs.length) ? divs[idx + 1].offsetTop - top : divs[idx].offsetHeight;
const pad = parseFloat(getComputedStyle(body).paddingTop) || 16;
lineHl.style.display = 'block';
lineHl.style.top = (top + pad - body.scrollTop) + 'px';
lineHl.style.height = h + 'px';
}
}
function syncGutterScroll() {
if (gutter && linesVisible) gutter.scrollTop = body.scrollTop;
updateLineHighlight();
}
function toggleLineNumbers() {
linesVisible = !linesVisible;
localStorage.setItem('hyprcrd-lines', linesVisible ? '1' : '0');
gutter?.classList.toggle('hidden', !linesVisible);
$('toggle-lines')?.classList.toggle('text-t-accent', linesVisible);
if (linesVisible) updateGutter();
}
if (linesVisible && gutter) {
gutter.classList.remove('hidden');
$('toggle-lines')?.classList.add('text-t-accent');
}
$('toggle-lines')?.addEventListener('click', () => { toggleLineNumbers(); updateActiveLine(); });
body.addEventListener('scroll', syncGutterScroll);
body.addEventListener('input', () => { if (linesVisible) updateGutter(); currentLine = -1; updateActiveLine(); });
body.addEventListener('click', updateActiveLine);
body.addEventListener('keyup', updateActiveLine);
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
export function closeModal({ animate = true } = {}) {
if (animate) startCloseMorph();
else clearMorphGhost();
modal.classList.remove('open');
state.editingId = null;
state.returnToGalleryAfterEdit = false;
}
function updatePreview() {
if (state.previewVisible) previewContent.innerHTML = renderMd(body.value);
if (!state.previewVisible) return;
const title = inpTitle.value.trim();
previewContent.innerHTML = (title ? `<h1>${esc(title)}</h1>` : '') + renderMd(body.value);
addCodeCopyButtons(previewContent);
}
export async function saveCard() {
async function doSave(close, { animate = true } = {}) {
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null };
if (!data.title.trim()) return;
const returnToGallery = state.returnToGalleryAfterEdit;
@@ -87,11 +320,27 @@ export async function saveCard() {
} else {
data.position = -1;
}
await api('api/cards', { method: 'POST', body: data });
const created = await api('api/cards', { method: 'POST', body: data });
if (created?.id) {
if (!close) state.editingId = created.id;
state._lastSavedId = created.id;
}
}
closeModal();
await window._reloadAll();
if (returnToGallery) window._openGallery?.();
if (close) {
closeModal({ animate });
if (returnToGallery) window._openGallery?.();
}
return state.editingId || state._lastSavedId;
}
export async function saveCard() { return doSave(true); }
export async function saveOnly() { return doSave(false); }
export async function saveAndView() {
const id = state.editingId || null;
await doSave(true, { animate: false });
const viewId = id || state._lastSavedId;
if (viewId) window._openDetail(viewId);
}
// ── Markdown toolbar ──
@@ -114,9 +363,20 @@ $('md-toolbar')?.addEventListener('click', e => {
italic: () => insertMd('*', '*', 'italic'), strikethrough: () => insertMd('~~', '~~', 'text'),
code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'),
ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'),
tree: () => insertMd('```tree\n- ', '\n - child\n', 'parent'),
quote: () => insertMd('> ', '', 'quote'), todo: () => insertMd('- [ ] ', '', 'task'),
link: () => insertMd('[', '](url)', 'text'),
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
extract: async () => {
const sel = body.value.substring(body.selectionStart, body.selectionEnd);
if (!sel.trim()) return;
const title = sel.trim().split('\n')[0].replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim();
await window._extractToCard(sel);
// Replace selection with a card link
const link = `[[${title}]]`;
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
updatePreview();
},
hr: () => insertMd('\n---\n'),
};
cmds[btn.dataset.md]?.();
@@ -131,7 +391,9 @@ $('toggle-preview')?.addEventListener('click', () => {
$('add-btn')?.addEventListener('click', () => openModal());
$('modal-cancel')?.addEventListener('click', closeModal);
$('modal-save')?.addEventListener('click', saveCard);
$('modal-save')?.addEventListener('click', saveOnly);
$('modal-save-view')?.addEventListener('click', saveAndView);
$('modal-save-close')?.addEventListener('click', saveCard);
// ── Link autocomplete ([[) ──
@@ -193,6 +455,53 @@ body.addEventListener('input', () => {
updatePreview();
});
// ── Line helpers ──
function getLineInfo() {
const val = body.value;
const before = val.substring(0, body.selectionStart);
const lineStart = before.lastIndexOf('\n') + 1;
const lineEnd = val.indexOf('\n', body.selectionStart);
return { lineStart, lineEnd: lineEnd === -1 ? val.length : lineEnd, line: val.substring(lineStart, lineEnd === -1 ? val.length : lineEnd) };
}
function moveLine(dir) {
const val = body.value;
const lines = val.split('\n');
const before = val.substring(0, body.selectionStart);
const lineIdx = before.split('\n').length - 1;
const swapIdx = lineIdx + dir;
if (swapIdx < 0 || swapIdx >= lines.length) return;
const cursorInLine = body.selectionStart - before.lastIndexOf('\n') - 1;
[lines[lineIdx], lines[swapIdx]] = [lines[swapIdx], lines[lineIdx]];
body.value = lines.join('\n');
// Restore cursor on the moved line
let pos = 0;
for (let i = 0; i < swapIdx; i++) pos += lines[i].length + 1;
if (dir === -1) { for (let i = 0; i < lineIdx; i++) pos = 0; pos = lines.slice(0, lineIdx + dir).join('\n').length + 1; }
else { pos = lines.slice(0, lineIdx + dir).join('\n').length + 1; }
body.selectionStart = body.selectionEnd = pos + Math.min(cursorInLine, lines[swapIdx].length);
updatePreview();
if (linesVisible) updateGutter();
currentLine = -1; updateActiveLine();
}
function duplicateLine(dir) {
const { lineStart, lineEnd, line } = getLineInfo();
const val = body.value;
const cursorOffset = body.selectionStart - lineStart;
if (dir === -1) {
body.value = val.substring(0, lineStart) + line + '\n' + val.substring(lineStart);
body.selectionStart = body.selectionEnd = lineStart + cursorOffset;
} else {
body.value = val.substring(0, lineEnd) + '\n' + line + val.substring(lineEnd);
body.selectionStart = body.selectionEnd = lineEnd + 1 + cursorOffset;
}
updatePreview();
if (linesVisible) updateGutter();
currentLine = -1; updateActiveLine();
}
body.addEventListener('keydown', e => {
if (linkAcVisible) {
if (e.key === 'ArrowDown') { e.preventDefault(); linkAcActive = Math.min(linkAcActive + 1, linkAcItems.length - 1); }
@@ -202,6 +511,18 @@ body.addEventListener('keydown', e => {
linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive));
return;
}
// Alt+Arrow: move line
if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
moveLine(e.key === 'ArrowUp' ? -1 : 1);
return;
}
// Shift+Alt+Arrow: duplicate line
if (e.altKey && e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
duplicateLine(e.key === 'ArrowUp' ? -1 : 1);
return;
}
if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); }
});
+63 -3
View File
@@ -11,14 +11,74 @@ const purifyConfig = {
ALLOW_DATA_ATTR: true,
};
function normalizeListBlock(block) {
const lines = String(block || '').replace(/\r\n?/g, '\n').split('\n');
while (lines.length && !lines[0].trim()) lines.shift();
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
if (!lines.length) return '';
const minIndent = lines
.filter(l => l.trim())
.map(l => (l.match(/^[ \t]*/) || [''])[0].replace(/\t/g, ' ').length)
.reduce((min, n) => Math.min(min, n), Infinity);
return lines
.map(l => {
const expanded = l.replace(/\t/g, ' ');
if (!expanded.trim()) return '';
return expanded.slice(Math.min(minIndent, expanded.length));
})
.join('\n');
}
export function renderMd(src) {
const linked = (src || '').replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
let processed = String(src || '').replace(/\r\n?/g, '\n')
// Custom fenced list blocks: ```tree / ```bullets with or without closing ```
.replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => {
const normalized = normalizeListBlock(content);
return `\n<!-- list:${kind} -->\n${normalized}\n`;
})
// Help parser: ensure a list can start after text without requiring manual blank line
.replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n')
// Preserve extra blank lines as <br> spacers (3+ newlines = 1+ extra blank lines)
.replace(/\n{3,}/g, m => '\n\n' + '<br>\n'.repeat(m.length - 2))
// Ensure hr rules have one clean blank line before and after, keep extra <br> beyond that
.replace(/<br>\n(---+|___+|\*\*\*+)\n/gm, '\n\n$1\n')
.replace(/(---+|___+|\*\*\*+)\n<br>/gm, '$1\n\n');
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
if (card) return `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`;
return `<span class="card-link broken" title="card not found">${esc(title)}</span>`;
});
const raw = marked.parse(linked);
return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, purifyConfig) : raw;
let html = marked.parse(linked);
// Convert list hints before sanitize (DOMPurify strips comments)
html = html.replace(/<!--\s*list:tree\s*-->\s*<ul>/g, '<ul class="tree-list">');
html = html.replace(/<!--\s*list:bullets\s*-->\s*<ul>/g, '<ul class="bullet-list">');
html = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html, purifyConfig) : html;
return html;
}
export function addCodeCopyButtons(container) {
if (!container) return;
container.querySelectorAll('pre > code').forEach(code => {
const pre = code.parentElement;
if (pre.querySelector('.code-copy')) return;
const btn = document.createElement('button');
btn.className = 'code-copy';
btn.type = 'button';
btn.innerHTML = '<i class="ph ph-copy"></i>';
btn.addEventListener('click', async () => {
await navigator.clipboard.writeText(code.textContent);
btn.classList.add('copied');
btn.innerHTML = '<i class="ph ph-check"></i>';
setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = '<i class="ph ph-copy"></i>'; }, 1500);
});
pre.appendChild(btn);
});
}
export function stripMd(src) {
+15
View File
@@ -59,3 +59,18 @@ export function markRecent(id) {
r.unshift(String(id));
localStorage.setItem('hyprcrd-recent', JSON.stringify(r.slice(0, 10)));
}
// Favorites
export function getFavorites() {
try { return JSON.parse(localStorage.getItem('hyprcrd-favorites') || '[]'); } catch { return []; }
}
export function toggleFavorite(id) {
const favs = getFavorites();
const sid = String(id);
const idx = favs.indexOf(sid);
if (idx !== -1) favs.splice(idx, 1);
else favs.push(sid);
localStorage.setItem('hyprcrd-favorites', JSON.stringify(favs));
return favs.includes(sid);
}
export function isFavorite(id) { return getFavorites().includes(String(id)); }
+1 -1
View File
@@ -12,7 +12,7 @@ if (list) {
btn.dataset.themeDot = key;
btn.innerHTML = `<span class="w-3 h-3 rounded-full shrink-0 border border-t-border" style="background:${t.vars['--t-accent']}"></span>
<span class="font-pixel text-[10px]">${t.label}</span>`;
btn.onclick = () => window.applyTheme(key);
btn.onclick = () => { window.applyTheme(key); window._renderBoard?.(); };
list.appendChild(btn);
}
}