Init svelte components

Signed-off-by: Hadi <hadi@example.com>
This commit is contained in:
Hadi
2026-04-28 16:57:00 +02:00
parent f00515e4c3
commit 1025d5bfa1
16 changed files with 1050 additions and 931 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ function isActive(href: string) {
~/hadi
</a>
<div id="oneko-track" class="flex-1 relative h-12 pointer-events-none">
<div id="oneko-track" transition:persist class="flex-1 relative h-12 pointer-events-none">
</div>
<nav class="hidden md:flex items-center">
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts">
import { onMount } from "svelte";
interface GNode {
id: string;
title: string;
current: boolean;
}
interface GEdge {
from: string;
to: string;
}
interface Props {
nodes?: GNode[];
edges?: GEdge[];
}
const { nodes = [], edges = [] }: Props = $props();
const PRIMARY = "oklch(71% 0.0863 296.59)";
let canvas: HTMLCanvasElement;
function startAnimation(): () => void {
if (!canvas || nodes.length === 0) return () => {};
const W = (canvas.width = canvas.offsetWidth);
const H = (canvas.height = 190);
const ctx = canvas.getContext("2d")!;
type SimNode = GNode & { x: number; y: number; vx: number; vy: number };
const simNodes: SimNode[] = nodes.map((n) => ({
...n,
x: n.current ? W / 2 : W / 2 + (Math.random() - 0.5) * 80,
y: n.current ? H / 2 : H / 2 + (Math.random() - 0.5) * 80,
vx: 0,
vy: 0,
}));
let dragging: SimNode | null = null;
let hovered: SimNode | null = null;
function nodeAt(x: number, y: number): SimNode | null {
return (
simNodes.find((n) => {
const dx = n.x - x,
dy = n.y - y;
return Math.sqrt(dx * dx + dy * dy) < (n.current ? 10 : 8);
}) ?? null
);
}
function tick() {
for (let i = 0; i < simNodes.length; i++) {
for (let j = i + 1; j < simNodes.length; j++) {
const a = simNodes[i],
b = simNodes[j];
const dx = b.x - a.x,
dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const f = 900 / (d * d);
a.vx -= (dx / d) * f;
a.vy -= (dy / d) * f;
b.vx += (dx / d) * f;
b.vy += (dy / d) * f;
}
}
for (const e of edges) {
const a = simNodes.find((n) => n.id === e.from);
const b = simNodes.find((n) => n.id === e.to);
if (!a || !b) continue;
const dx = b.x - a.x,
dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const f = (d - 75) * 0.04;
a.vx += (dx / d) * f;
a.vy += (dy / d) * f;
b.vx -= (dx / d) * f;
b.vy -= (dy / d) * f;
}
for (const n of simNodes) {
n.vx += (W / 2 - n.x) * 0.025;
n.vy += (H / 2 - n.y) * 0.025;
}
for (const n of simNodes) {
if (n === dragging) continue;
n.vx *= 0.78;
n.vy *= 0.78;
n.x = Math.max(16, Math.min(W - 16, n.x + n.vx));
n.y = Math.max(16, Math.min(H - 16, n.y + n.vy));
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "oklch(2% 0 0)";
ctx.fillRect(0, 0, W, H);
const connected = new Set<string>();
if (hovered) {
for (const e of edges) {
if (e.from === hovered.id) connected.add(e.to);
if (e.to === hovered.id) connected.add(e.from);
}
}
for (const e of edges) {
const a = simNodes.find((n) => n.id === e.from);
const b = simNodes.find((n) => n.id === e.to);
if (!a || !b) continue;
const lit =
hovered && (e.from === hovered.id || e.to === hovered.id);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = lit ? "oklch(55% 0 0)" : "oklch(27% 0 0)";
ctx.lineWidth = lit ? 1.5 : 1;
ctx.stroke();
}
for (const n of simNodes) {
const isHov = hovered?.id === n.id;
const isCon = connected.has(n.id);
const r = n.current ? 7 : isHov ? 6 : 4.5;
if (isHov && !n.current) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 5, 0, Math.PI * 2);
ctx.fillStyle = "oklch(71% 0.0863 296.59 / 0.15)";
ctx.fill();
}
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = n.current
? PRIMARY
: isHov
? "oklch(78% 0.05 296.59)"
: isCon
? "oklch(58% 0.03 296.59)"
: "oklch(40% 0 0)";
ctx.fill();
if (n.current || isHov || isCon) {
ctx.font = `${n.current ? "10px" : "9px"} monospace`;
ctx.textAlign = "center";
ctx.fillStyle = n.current ? "oklch(87% 0 0)" : "oklch(62% 0 0)";
const label =
n.title.length > 14 ? n.title.slice(0, 13) + "…" : n.title;
ctx.fillText(label, n.x, n.y + r + 9);
}
}
}
let animId: number;
function loop() {
tick();
draw();
animId = requestAnimationFrame(loop);
}
animId = requestAnimationFrame(loop);
const onMousedown = (e: MouseEvent) => {
const r = canvas.getBoundingClientRect();
const sx = W / canvas.offsetWidth;
dragging = nodeAt(
(e.clientX - r.left) * sx,
(e.clientY - r.top) * (H / canvas.offsetHeight),
);
};
const onMousemove = (e: MouseEvent) => {
const r = canvas.getBoundingClientRect();
const sx = W / canvas.offsetWidth;
const x = (e.clientX - r.left) * sx;
const y = (e.clientY - r.top) * (H / canvas.offsetHeight);
if (dragging) {
dragging.x = x;
dragging.y = y;
dragging.vx = 0;
dragging.vy = 0;
}
hovered = nodeAt(x, y);
canvas.style.cursor =
hovered && !hovered.current ? "pointer" : "default";
};
const onMouseup = () => {
dragging = null;
};
const onMouseleave = () => {
dragging = null;
hovered = null;
};
const onClick = (e: MouseEvent) => {
const r = canvas.getBoundingClientRect();
const sx = W / canvas.offsetWidth;
const n = nodeAt(
(e.clientX - r.left) * sx,
(e.clientY - r.top) * (H / canvas.offsetHeight),
);
if (n && !n.current) window.location.href = `/notes/${n.id}`;
};
canvas.addEventListener("mousedown", onMousedown);
canvas.addEventListener("mousemove", onMousemove);
canvas.addEventListener("mouseup", onMouseup);
canvas.addEventListener("mouseleave", onMouseleave);
canvas.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(animId);
canvas.removeEventListener("mousedown", onMousedown);
canvas.removeEventListener("mousemove", onMousemove);
canvas.removeEventListener("mouseup", onMouseup);
canvas.removeEventListener("mouseleave", onMouseleave);
canvas.removeEventListener("click", onClick);
};
}
onMount(() => {
const drawer = document.getElementById(
"graph-drawer",
) as HTMLInputElement | null;
const outerDrawer =
drawer?.closest<HTMLElement>(".drawer.drawer-end") ?? null;
let stopFn: (() => void) | null = null;
function isVisible() {
return (
(drawer?.checked ?? false) ||
(outerDrawer?.classList.contains("xl:drawer-open") ?? false)
);
}
function start() {
if (stopFn) return;
stopFn = startAnimation();
}
function stop() {
stopFn?.();
stopFn = null;
}
if (isVisible()) start();
const onDrawerChange = () => {
isVisible() ? start() : stop();
};
drawer?.addEventListener("change", onDrawerChange);
// Watch for xl:drawer-open class toggled by the graph button script
const observer = new MutationObserver(() => {
isVisible() ? start() : stop();
});
if (outerDrawer) {
observer.observe(outerDrawer, {
attributes: true,
attributeFilter: ["class"],
});
}
return () => {
stop();
drawer?.removeEventListener("change", onDrawerChange);
observer.disconnect();
};
});
</script>
<canvas
bind:this={canvas}
height="190"
role="img"
aria-label="Graph of linked notes"
style="width:100%; display:block; background: oklch(2% 0 0); cursor:default;"
></canvas>
+104
View File
@@ -0,0 +1,104 @@
---
import NoteGraph from "./NoteGraph.svelte";
import Author from "./Author.astro";
import { formatDate } from "../utils/notes";
import type { CollectionEntry } from "astro:content";
interface Props {
entry: CollectionEntry<"notes">;
graphNodes: { id: string; title: string; current: boolean }[];
graphEdges: { from: string; to: string }[];
forwardLinks: CollectionEntry<"notes">[];
backlinks: CollectionEntry<"notes">[];
}
const { entry, graphNodes, graphEdges, forwardLinks, backlinks } = Astro.props;
---
<aside
id="right-sidebar"
class="w-52 flex flex-col border-l border-base-300/60 h-full overflow-y-auto"
style="background: oklch(4% 0 0);"
>
<div class="border-b border-base-300/40">
<p
class="font-mono text-[10px] text-base-content/25 uppercase tracking-widest px-3 pt-3 pb-2"
>
graph
</p>
<NoteGraph client:visible nodes={graphNodes} edges={graphEdges} />
{
graphNodes.length < 2 && (
<p class="font-mono text-[9px] text-base-content/20 text-center py-2">
no connections yet
</p>
)
}
</div>
{
forwardLinks.length > 0 && (
<div class="p-3 border-b border-base-300/40">
<p class="font-mono text-[10px] text-base-content/25 uppercase tracking-widest mb-2">
links
</p>
<ul class="space-y-1">
{forwardLinks.map((n) => (
<li>
<a
href={`/notes/${n.id}`}
class="font-mono text-xs text-base-content/45 hover:text-primary/80 transition-colors block truncate"
>
→ {n.data.title}
</a>
</li>
))}
</ul>
</div>
)
}
{
backlinks.length > 0 && (
<div class="p-3">
<p class="font-mono text-[10px] text-base-content/25 uppercase tracking-widest mb-2">
backlinks
</p>
<ul class="space-y-1">
{backlinks.map((n) => (
<li>
<a
href={`/notes/${n.id}`}
class="font-mono text-xs text-base-content/45 hover:text-primary/80 transition-colors block truncate"
>
← {n.data.title}
</a>
</li>
))}
</ul>
</div>
)
}
{
forwardLinks.length === 0 && backlinks.length === 0 && (
<div class="p-3">
<p class="font-mono text-[9px] text-base-content/20">
no linked notes
</p>
</div>
)
}
<div class="px-4 pt-4 pb-1 border-t border-base-300/40 mt-auto">
<time
datetime={entry.data.publishDate.toISOString()}
class="font-mono text-[10px] text-base-content/30 uppercase tracking-widest"
>
{formatDate(entry.data.publishDate)}
</time>
</div>
<div class="px-4 py-4">
<Author />
</div>
</aside>
+115
View File
@@ -0,0 +1,115 @@
---
import { getCategory, extractInlineHashtags } from "../utils/notes";
import type { CollectionEntry } from "astro:content";
interface Props {
notes: CollectionEntry<"notes">[];
currentEntry: CollectionEntry<"notes">;
categories: string[];
}
const { notes, currentEntry, categories } = Astro.props;
---
<style>
/*
* DaisyUI's menu items use display:grid with grid-auto-columns:minmax(auto,max-content)
* which expands to fit content. Override to block so text-overflow:ellipsis works directly.
* Inner <ul> has no w-full so DaisyUI's margin-inline-start/padding-inline-start don't overflow.
*/
.nav-item {
display: block !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
</style>
<aside
class="w-56 flex flex-col border-r border-base-300/60 h-[calc(100vh-3rem)]"
style="background: oklch(4% 0 0);"
>
<div class="px-3 py-3 border-b border-base-300/40">
<label
class="input input-sm w-full font-mono text-xs border-base-300/40 bg-base-200/50"
>
<span class="text-base-content/30"></span>
<input
data-search
type="text"
placeholder="search..."
class="text-base-content/70 placeholder:text-base-content/25"
/>
</label>
</div>
<ul
class="nav-sidebar menu menu-xs flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-2 py-2 bg-transparent"
>
{
categories.map((cat) => (
<li class="w-full">
<details open={cat === getCategory(currentEntry)}>
<summary class="font-bold tracking-tight text-sm">
<span class="text-primary/50 font-mono">/</span>
{cat}
</summary>
<ul>
{notes
.filter((n) => getCategory(n) === cat)
.map((n) => (
<li>
<a
href={`/notes/${n.id}`}
class:list={[
"nav-item font-mono text-xs tooltip tooltip-right",
n.id === currentEntry.id ? "active" : "",
]}
data-tip={n.data.title}
data-title={n.data.title.toLowerCase()}
data-tags={[
...n.data.tags,
...extractInlineHashtags(n.body ?? ""),
].join(",")}
>
{n.data.title}
</a>
</li>
))}
</ul>
</details>
</li>
))
}
</ul>
</aside>
<script>
document.addEventListener("astro:page-load", () => {
const navItems = document.querySelectorAll<HTMLElement>(".nav-item");
document
.querySelectorAll<HTMLInputElement>("[data-search]")
.forEach((input) => {
input.addEventListener("input", (e) => {
const target = e.target as HTMLInputElement;
const raw = target.value.toLowerCase().trim();
document
.querySelectorAll<HTMLInputElement>("[data-search]")
.forEach((o) => {
if (o !== target) o.value = target.value;
});
const isTag = raw.startsWith("#");
const search = isTag ? raw.slice(1) : raw;
navItems.forEach((item) => {
const title = item.dataset.title ?? "";
const tags = item.dataset.tags ? item.dataset.tags.split(",") : [];
const match =
!search ||
(isTag
? tags.some((t) => t.includes(search))
: title.includes(search) || tags.join(",").includes(search));
item.style.display = match ? "" : "none";
});
});
});
});
</script>
+40
View File
@@ -0,0 +1,40 @@
---
interface Props {
headings: { depth: number; text: string; id: string }[];
}
const { headings } = Astro.props;
---
{
headings.length > 0 && (
<div
class="collapse collapse-arrow mb-8 border border-base-300/40"
style="background: oklch(4% 0 0);"
>
<input type="checkbox" />
<div class="collapse-title font-mono text-xs text-base-content/35 flex items-center gap-2 py-2 px-3 min-h-0">
<span class="text-primary/40">§</span>
table of contents
</div>
<div class="collapse-content px-0 pb-0">
<nav class="px-3 pb-3 pt-1 border-t border-base-300/30 space-y-0.5">
{headings.map((h) => (
<a
href={`#${h.id}`}
class:list={[
"block text-xs text-base-content/45 hover:text-base-content/80 transition-colors py-0.5",
h.depth === 3 ? "pl-4" : h.depth === 4 ? "pl-8" : "",
]}
>
<span class="font-mono text-primary/25 mr-1.5">
{"#".repeat(h.depth)}
</span>
{h.text}
</a>
))}
</nav>
</div>
</div>
)
}
-110
View File
@@ -1,110 +0,0 @@
---
interface Props {
vars: string[];
}
const { vars } = Astro.props;
---
{
vars.length > 0 && (
<>
<button
id="vars-toggle"
data-note-vars={JSON.stringify(vars)}
class="btn btn-ghost btn-xs font-mono text-base-content/40 hover:text-base-content/70 border border-base-300/50"
onclick="document.getElementById('vars-modal').showModal()"
>
<span class="font-mono text-[10px] leading-none">$</span>
vars
</button>
<dialog id="vars-modal" class="modal">
<div
class="modal-box max-w-sm"
style="background: oklch(10% 0 0); border: 1px solid oklch(71% 0.0863 296.59 / 0.5);"
>
<h3 class="font-mono text-[10px] text-base-content/40 uppercase tracking-widest mb-4">
variables
</h3>
<div class="space-y-2.5">
{vars.map((v) => (
<div class="flex items-center gap-3">
<label
class="font-mono text-xs text-primary/70 w-36 shrink-0 truncate"
title={`$${v}`}
>
${v}
</label>
<input
type="text"
data-var={v}
placeholder={`$${v}`}
class="vars-input input input-sm flex-1 min-w-0 font-mono text-xs bg-base-300/20 border-base-300/60 text-base-content/80 placeholder:text-base-content/25 focus:border-primary/60"
/>
</div>
))}
</div>
<div class="mt-5 flex justify-end">
<button
class="btn btn-ghost btn-xs font-mono text-base-content/40 hover:text-base-content/70 border border-base-300/50"
onclick="document.getElementById('vars-modal').close()"
>
close
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</>
)
}
<script>
document.addEventListener("astro:page-load", () => {
const btn = document.getElementById("vars-toggle");
if (!btn) return;
const vars: string[] = JSON.parse(btn.dataset.noteVars ?? "[]");
const content = document.querySelector(".note-content");
if (!vars.length || !content) return;
const originals = new Map<Text, string>();
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (/\$[a-zA-Z_][a-zA-Z0-9_]*/.test(node.nodeValue ?? "")) {
originals.set(node, node.nodeValue!);
}
}
function applyVars() {
const values: Record<string, string> = {};
document.querySelectorAll<HTMLInputElement>(".vars-input").forEach((input) => {
values[input.dataset.var!] = input.value.trim();
});
const sorted = [...vars].sort((a, b) => b.length - a.length);
for (const [node, original] of originals) {
let text = original;
for (const name of sorted) {
const val = values[name];
if (val) {
text = text.replace(
new RegExp(`\\$${name}(?![a-zA-Z0-9_])`, "g"),
val,
);
}
}
node.nodeValue = text;
}
}
document.addEventListener("input", (e) => {
if ((e.target as HTMLElement)?.classList.contains("vars-input")) applyVars();
});
});
</script>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from "svelte";
interface Props {
vars: string[];
}
const { vars }: Props = $props();
let values = $state<Record<string, string>>(
Object.fromEntries(vars.map((v) => [v, ""])),
);
let open = $state(false);
let applied = $state(false);
const originals = new Map<Text, string>();
onMount(() => {
const content = document.querySelector(".note-content");
if (!content) return;
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (/\$[a-zA-Z_][a-zA-Z0-9_]*/.test(node.nodeValue ?? "")) {
originals.set(node, node.nodeValue!);
}
}
});
function applyVars() {
const sorted = [...vars].sort((a, b) => b.length - a.length);
for (const [node, original] of originals) {
let text = original;
for (const name of sorted) {
const val = values[name];
if (val) {
text = text.replace(
new RegExp(`\\$${name}(?![a-zA-Z0-9_])`, "g"),
val,
);
}
}
node.nodeValue = text;
}
applied = true;
setTimeout(() => (applied = false), 1800);
}
</script>
{#if vars.length > 0}
<button
onclick={() => (open = true)}
class="btn btn-ghost btn-xs font-mono text-base-content/40 hover:text-base-content/70 border border-base-300/50"
>
<span class="font-mono text-[10px] leading-none">$</span>
vars
</button>
{#if open}
<dialog class="modal modal-open">
<div
class="modal-box max-w-sm"
style="background: oklch(10% 0 0); border: 1px solid oklch(71% 0.0863 296.59 / 0.5);"
>
<h3
class="font-mono text-[10px] text-base-content/40 uppercase tracking-widest mb-4"
>
variables
</h3>
<div class="space-y-2.5">
{#each vars as v}
<div class="flex items-center gap-3">
<label
class="font-mono text-xs text-primary/70 w-36 shrink-0 truncate"
title={`$${v}`}
>
${v}
</label>
<input
type="text"
bind:value={values[v]}
placeholder={`$${v}`}
class="input input-sm flex-1 min-w-0 font-mono text-xs bg-base-300/20 border-base-300/60 text-base-content/80 placeholder:text-base-content/25 focus:border-primary/60"
/>
</div>
{/each}
</div>
<div class="mt-5 flex items-center justify-between">
<span
class="font-mono text-[10px] text-primary/60 transition-opacity duration-300"
class:opacity-0={!applied}
>
✓ applied
</span>
<div class="flex gap-2">
<button
onclick={applyVars}
class="btn btn-ghost btn-xs font-mono text-primary/60 hover:text-primary border border-primary/30"
>
apply
</button>
<button
onclick={() => (open = false)}
class="btn btn-ghost btn-xs font-mono text-base-content/40 hover:text-base-content/70 border border-base-300/50"
>
close
</button>
</div>
</div>
</div>
<button
onclick={() => (open = false)}
class="modal-backdrop"
aria-label="close"
></button>
</dialog>
{/if}
{/if}
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { onMount } from "svelte";
interface NoteItem {
id: string;
title: string;
description: string;
tags: string[];
category: string;
searchText: string;
}
interface Props {
notes: NoteItem[];
}
const { notes }: Props = $props();
let inputValue = $state("");
const query = $derived(inputValue.toLowerCase().trim());
const categories = $derived([
...new Set(notes.map((n) => n.category)),
].sort());
const filtered = $derived.by(() => {
if (!query) return notes;
const isTag = query.startsWith("#");
const q = isTag ? query.slice(1) : query;
return notes.filter((n) =>
isTag
? n.tags.some((t) => t.includes(q)) || n.searchText.includes(`#${q}`)
: n.searchText.includes(q),
);
});
const visibleCount = $derived(filtered.length);
onMount(() => {
const urlTag = new URLSearchParams(window.location.search).get("tag");
if (urlTag) inputValue = `#${urlTag}`;
});
$effect(() => {
const url = new URL(window.location.href);
if (query.startsWith("#") && query.length > 1) {
url.searchParams.set("tag", query.slice(1));
} else {
url.searchParams.delete("tag");
}
history.replaceState(null, "", url.toString());
});
function filteredByCategory(cat: string) {
return filtered.filter((n) => n.category === cat);
}
</script>
<div class="mb-12 max-w-sm mx-auto">
<label class="input w-full">
<span class="text-base-content/25"></span>
<input
type="text"
placeholder="search or #tag..."
bind:value={inputValue}
/>
</label>
<p class="font-mono text-[10px] text-base-content/20 mt-1.5 text-center">
use #tag to filter by tag
</p>
</div>
<div class="space-y-12">
{#each categories as cat}
{@const catNotes = filteredByCategory(cat)}
{#if catNotes.length > 0}
<section>
<div class="flex items-baseline gap-3 mb-4">
<h2 class="text-xl font-bold tracking-tight">
<span class="text-primary/50 font-mono mr-1">/</span>{cat}
</h2>
<span class="font-mono text-xs text-base-content/25">
{catNotes.length} note{catNotes.length !== 1 ? "s" : ""}
</span>
</div>
<div class="border-t border-base-300/40 mb-1"></div>
<ul class="divide-y divide-base-300/20">
{#each catNotes as n}
<li>
<a
href={`/notes/${n.id}`}
class="group flex items-center gap-4 py-3 hover:bg-base-200/30 px-2 -mx-2 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="flex flex-col mb-0.5">
<span
class="font-semibold text-sm group-hover:text-primary transition-colors"
>
{n.title}
</span>
{#if n.description}
<span class="text-xs text-base-content/35 truncate">
{n.description}
</span>
{/if}
</div>
{#if n.tags.length > 0}
<div class="flex flex-wrap gap-1 mt-1">
{#each n.tags as tag}
<span
class="badge badge-ghost badge-xs font-mono text-base-content/30"
>
{tag}
</span>
{/each}
</div>
{/if}
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-base-content/20 group-hover:text-primary/50 shrink-0 transition-colors"
>
<path d="m9 18 6-6-6-6" />
</svg>
</a>
</li>
{/each}
</ul>
</section>
{/if}
{/each}
</div>
{#if visibleCount === 0}
<div class="text-center py-20 font-mono text-sm text-base-content/25">
no results.
</div>
{/if}
<p class="text-center font-mono text-xs text-base-content/20 mt-16">
{visibleCount} note{visibleCount !== 1 ? "s" : ""} total
</p>