mirror of
https://github.com/anotherhadi/blog.git
synced 2026-08-22 20:45:51 +02:00
9c0bbc4b77
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
153 lines
5.3 KiB
Svelte
153 lines
5.3 KiB
Svelte
<script lang="ts">
|
||
import { slide } from "svelte/transition";
|
||
import { untrack } from "svelte";
|
||
|
||
interface Note {
|
||
id: string;
|
||
data: { title: string; tags: string[]; category?: string };
|
||
body?: string;
|
||
}
|
||
|
||
interface Props {
|
||
notes: Note[];
|
||
currentEntry?: Note;
|
||
currentCategory?: string;
|
||
categories: string[];
|
||
}
|
||
|
||
const { notes, currentEntry, currentCategory, categories }: Props = $props();
|
||
|
||
let search = $state("");
|
||
|
||
function getCategory(n: Note): string {
|
||
if (n.data.category) return n.data.category;
|
||
const parts = n.id.split("/");
|
||
return parts.length > 1 ? parts[0] : "General";
|
||
}
|
||
|
||
function extractInlineHashtags(body: string): string[] {
|
||
const re = /#(\w+)/g;
|
||
const tags: string[] = [];
|
||
let m;
|
||
while ((m = re.exec(body)) !== null) tags.push(m[1].toLowerCase());
|
||
return [...new Set(tags)];
|
||
}
|
||
|
||
function matchesSearch(note: Note): boolean {
|
||
const raw = search.toLowerCase().trim();
|
||
if (!raw) return true;
|
||
const isTag = raw.startsWith("#");
|
||
const term = isTag ? raw.slice(1) : raw;
|
||
const title = note.data.title.toLowerCase();
|
||
const tags = [
|
||
...note.data.tags,
|
||
...extractInlineHashtags(note.body ?? ""),
|
||
].map((t) => t.toLowerCase());
|
||
return isTag
|
||
? tags.some((t) => t.includes(term))
|
||
: title.includes(term) || tags.join(",").includes(term);
|
||
}
|
||
|
||
const activeCategory = $derived(
|
||
currentCategory ?? (currentEntry ? getCategory(currentEntry) : null),
|
||
);
|
||
|
||
let openCategories = $state<string[]>(
|
||
untrack(() => categories.filter((c) => c === activeCategory)),
|
||
);
|
||
|
||
function toggle(cat: string) {
|
||
if (openCategories.includes(cat)) {
|
||
openCategories = openCategories.filter((c) => c !== cat);
|
||
} else {
|
||
openCategories = [...openCategories, cat];
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="flex flex-col flex-1 min-h-0">
|
||
<div class="px-3 py-3 border-b border-base-300/40 shrink-0">
|
||
<div
|
||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-base-200/50 border border-base-300/40 focus-within:border-base-300/70 transition-colors"
|
||
>
|
||
<span class="text-base-content/30 font-mono text-xs shrink-0">›</span>
|
||
<input
|
||
type="text"
|
||
placeholder="search..."
|
||
bind:value={search}
|
||
class="flex-1 min-w-0 bg-transparent text-xs font-mono text-base-content/70 placeholder:text-base-content/25 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||
{#each categories as cat}
|
||
{@const catNotes = notes.filter(
|
||
(n) => getCategory(n) === cat && matchesSearch(n),
|
||
)}
|
||
{#if catNotes.length > 0 || !search}
|
||
{@const isFolder = notes.some((n) => n.id.includes("/") && getCategory(n) === cat)}
|
||
<div>
|
||
<div class="flex items-center w-full">
|
||
<button
|
||
onclick={() => toggle(cat)}
|
||
class="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-base-200/40 transition-colors duration-150 shrink-0"
|
||
>
|
||
<svg
|
||
class="w-3 h-3 text-base-content/35 shrink-0 transition-transform duration-200"
|
||
class:rotate-90={openCategories.includes(cat)}
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path d="m9 18 6-6-6-6" />
|
||
</svg>
|
||
<span class="text-primary/50 font-mono text-xs shrink-0">/</span>
|
||
</button>
|
||
{#if isFolder}
|
||
<a
|
||
href={`/notes/${cat}`}
|
||
class="flex-1 min-w-0 px-1 py-1 rounded-md hover:bg-base-200/40 transition-colors duration-150 font-bold tracking-tight text-sm truncate text-base-content/80 hover:text-base-content"
|
||
>
|
||
{cat}
|
||
</a>
|
||
{:else}
|
||
<span class="flex-1 min-w-0 px-1 py-1 font-bold tracking-tight text-sm truncate text-base-content/80">
|
||
{cat}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if openCategories.includes(cat)}
|
||
<ul
|
||
class="ml-4 mt-0.5 pb-1 space-y-px"
|
||
transition:slide={{ duration: 180 }}
|
||
>
|
||
{#each catNotes as note}
|
||
<li class="tooltip tooltip-right w-full" data-tip={note.data.title}>
|
||
<a
|
||
href={`/notes/${note.id}`}
|
||
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-mono truncate transition-colors duration-150
|
||
{currentEntry && note.id === currentEntry.id
|
||
? 'text-primary/90 bg-primary/10'
|
||
: 'text-base-content/45 hover:text-base-content/80 hover:bg-base-200/40'}"
|
||
>
|
||
<span class="shrink-0 font-mono text-base-content/20">
|
||
{currentEntry && note.id === currentEntry.id ? "▸" : "–"}
|
||
</span>
|
||
<span class="truncate">{note.data.title}</span>
|
||
</a>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
</nav>
|
||
</div>
|