mirror of
https://github.com/anotherhadi/blog.git
synced 2026-05-20 05:32:32 +02:00
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
+148
-253
@@ -2,9 +2,15 @@
|
||||
import { getCollection, render } from "astro:content";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { List, PanelRight } from "@lucide/astro";
|
||||
import Author from "../../components/Author.astro";
|
||||
import NoteVars from "../../components/NoteVars.astro";
|
||||
import { getCategory, extractInlineHashtags } from "../../utils/notes";
|
||||
import NoteTOC from "../../components/NoteTOC.astro";
|
||||
import NoteNavSidebar from "../../components/NoteNavSidebar.astro";
|
||||
import NoteGraphSidebar from "../../components/NoteGraphSidebar.astro";
|
||||
import NoteVars from "../../components/NoteVars.svelte";
|
||||
import {
|
||||
getCategory,
|
||||
extractLinks,
|
||||
extractHeadings,
|
||||
} from "../../utils/notes";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const notes = await getCollection("notes");
|
||||
@@ -23,22 +29,6 @@ const sortedNotes = allNotes.sort((a, b) =>
|
||||
);
|
||||
const categories = [...new Set(allNotes.map(getCategory))].sort();
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function extractLinks(body: string): string[] {
|
||||
const re = /\(\/notes\/([^)#\s]+)(?:#[^)\s]*)?\)/g;
|
||||
const ids: string[] = [];
|
||||
let m;
|
||||
while ((m = re.exec(body)) !== null) ids.push(m[1]);
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
const allLinks = Object.fromEntries(
|
||||
allNotes.map((n) => [n.id, extractLinks(n.body ?? "")]),
|
||||
);
|
||||
@@ -61,15 +51,6 @@ const graphEdges = [
|
||||
...backlinks.map((n) => ({ from: n.id, to: entry.id })),
|
||||
];
|
||||
|
||||
// Mirrors github-slugger: keeps _, keeps unicode letters/numbers, spaces → hyphens
|
||||
function slugify(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||
.trim()
|
||||
.replace(/ +/g, "-");
|
||||
}
|
||||
|
||||
const noteVars = [
|
||||
...new Set(
|
||||
Array.from(
|
||||
@@ -79,20 +60,7 @@ const noteVars = [
|
||||
),
|
||||
];
|
||||
|
||||
const headings: { depth: number; text: string; id: string }[] = [];
|
||||
const headingRe = /^(#{2,4}) (.+)$/gm;
|
||||
let hm;
|
||||
while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
const raw = hm[2]
|
||||
.trim()
|
||||
.replace(/`[^`]*`/g, "")
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||
.replace(/(?<!\p{L}\p{N})__(.*?)__(?!\p{L}\p{N})/gu, "$1")
|
||||
.replace(/\*(.*?)\*/g, "$1")
|
||||
.replace(/(?<!\p{L}\p{N})_(.*?)_(?!\p{L}\p{N})/gu, "$1")
|
||||
.replace(/[*]/g, "");
|
||||
headings.push({ depth: hm[1].length, text: raw, id: slugify(raw) });
|
||||
}
|
||||
const headings = extractHeadings(entry.body ?? "");
|
||||
---
|
||||
|
||||
<style>
|
||||
@@ -101,26 +69,6 @@ while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
top: 3rem;
|
||||
height: calc(100vh - 3rem);
|
||||
}
|
||||
|
||||
/* DaisyUI .menu forces width:fit-content and display:grid on items.
|
||||
Astro scoped styles add [data-astro-cid-*] which raises specificity above DaisyUI's class selectors. */
|
||||
.nav-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-sidebar a {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-sidebar a span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout
|
||||
@@ -139,9 +87,16 @@ while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
<main class="flex-1 px-4 sm:px-6 lg:px-10 py-6 lg:py-10 min-w-0">
|
||||
<div class="max-w-3xl mx-auto lg:mx-0">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="breadcrumbs text-xs font-mono text-base-content/35 p-0">
|
||||
<div
|
||||
class="breadcrumbs text-xs font-mono text-base-content/35 p-0"
|
||||
>
|
||||
<ul>
|
||||
<li><a href="/notes" class="hover:text-base-content/70">notes</a></li>
|
||||
<li>
|
||||
<a
|
||||
href="/notes"
|
||||
class="hover:text-base-content/70"
|
||||
>notes</a>
|
||||
</li>
|
||||
<li>{getCategory(entry)}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -153,7 +108,7 @@ while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
<List size={11} />
|
||||
nav
|
||||
</label>
|
||||
<NoteVars vars={noteVars} />
|
||||
<NoteVars client:load vars={noteVars} />
|
||||
<label
|
||||
for="graph-drawer"
|
||||
id="graph-toggle"
|
||||
@@ -167,7 +122,9 @@ while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
</div>
|
||||
|
||||
<header class="mb-8">
|
||||
<h1 class="text-4xl sm:text-5xl font-bold tracking-tight mb-3">
|
||||
<h1
|
||||
class="text-4xl sm:text-5xl font-bold tracking-tight mb-3"
|
||||
>
|
||||
{entry.data.title}
|
||||
</h1>
|
||||
<p class="text-base-content/50 mb-4">
|
||||
@@ -189,38 +146,7 @@ while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
}
|
||||
</header>
|
||||
|
||||
{
|
||||
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>
|
||||
)
|
||||
}
|
||||
<NoteTOC headings={headings} />
|
||||
|
||||
<div
|
||||
class="note-content text-sm leading-relaxed text-base-content/80
|
||||
@@ -246,11 +172,19 @@ while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-base-300/30 mt-12 pt-6 flex items-center justify-between font-mono text-[10px] text-base-content/25">
|
||||
<a href="/notes" class="hover:text-base-content/50 transition-colors">
|
||||
<div
|
||||
class="border-t border-base-300/30 mt-12 pt-6 flex items-center justify-between font-mono text-[10px] text-base-content/25"
|
||||
>
|
||||
<a
|
||||
href="/notes"
|
||||
class="hover:text-base-content/50 transition-colors"
|
||||
>
|
||||
← all notes
|
||||
</a>
|
||||
<a href="/" class="hover:text-base-content/50 transition-colors">
|
||||
<a
|
||||
href="/"
|
||||
class="hover:text-base-content/50 transition-colors"
|
||||
>
|
||||
~/hadi
|
||||
</a>
|
||||
</div>
|
||||
@@ -264,58 +198,11 @@ while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
aria-label="close sidebar"
|
||||
class="drawer-overlay"
|
||||
></label>
|
||||
<aside
|
||||
class="w-56 flex flex-col border-r border-base-300/60 h-full"
|
||||
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 overflow-y-auto px-2 py-2 bg-transparent">
|
||||
{
|
||||
categories.map((cat) => (
|
||||
<li class="w-full">
|
||||
<details open={cat === getCategory(entry)}>
|
||||
<summary class="font-bold tracking-tight text-sm">
|
||||
<span class="text-primary/50 font-mono">/</span>{cat}
|
||||
</summary>
|
||||
<ul class="w-full">
|
||||
{sortedNotes
|
||||
.filter((n) => getCategory(n) === cat)
|
||||
.map((n) => (
|
||||
<li class="w-full">
|
||||
<a
|
||||
href={`/notes/${n.id}`}
|
||||
class:list={[
|
||||
"nav-item font-mono text-xs tooltip tooltip-right",
|
||||
n.id === entry.id ? "active" : "",
|
||||
]}
|
||||
data-tip={n.data.title}
|
||||
data-title={n.data.title.toLowerCase()}
|
||||
data-tags={[
|
||||
...n.data.tags,
|
||||
...extractInlineHashtags(n.body ?? ""),
|
||||
].join(",")}
|
||||
>
|
||||
<span>{n.data.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</aside>
|
||||
<NoteNavSidebar
|
||||
notes={sortedNotes}
|
||||
currentEntry={entry}
|
||||
categories={categories}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,107 +213,115 @@ while ((hm = headingRe.exec(entry.body ?? "")) !== null) {
|
||||
aria-label="close sidebar"
|
||||
class="drawer-overlay xl:hidden"
|
||||
></label>
|
||||
<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>
|
||||
<canvas
|
||||
id="note-graph"
|
||||
height="190"
|
||||
role="img"
|
||||
aria-label="Graph of linked notes"
|
||||
style="width:100%; display:block; background: oklch(2% 0 0); cursor:default;"
|
||||
></canvas>
|
||||
{
|
||||
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">
|
||||
<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>
|
||||
<NoteGraphSidebar
|
||||
entry={entry}
|
||||
graphNodes={graphNodes}
|
||||
graphEdges={graphEdges}
|
||||
forwardLinks={forwardLinks}
|
||||
backlinks={backlinks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ graphNodes, graphEdges }}>
|
||||
window.__graphNodes = graphNodes;
|
||||
window.__graphEdges = graphEdges;
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import "../../utils/notes-graph.ts";
|
||||
</script>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
function injectHeadingAnchors() {
|
||||
if (!document.getElementById("heading-anchor-styles")) {
|
||||
const s = document.createElement("style");
|
||||
s.id = "heading-anchor-styles";
|
||||
s.textContent = `
|
||||
.note-content h2, .note-content h3, .note-content h4 {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
.heading-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.4em;
|
||||
color: oklch(38% 0 0);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, color 120ms;
|
||||
text-decoration: none;
|
||||
}
|
||||
.note-content h2:hover .heading-anchor,
|
||||
.note-content h3:hover .heading-anchor,
|
||||
.note-content h4:hover .heading-anchor { opacity: 1; }
|
||||
.heading-anchor:hover, .heading-anchor.copied { color: oklch(71% 0.0863 296.59); opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll(".note-content h2, .note-content h3, .note-content h4")
|
||||
.forEach((heading) => {
|
||||
if (!heading.id || heading.querySelector(".heading-anchor")) return;
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = `#${heading.id}`;
|
||||
anchor.className = "heading-anchor";
|
||||
anchor.setAttribute("aria-label", "Copy link to section");
|
||||
anchor.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
|
||||
anchor.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const url = `${location.origin}${location.pathname}#${heading.id}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
anchor.classList.add("copied");
|
||||
setTimeout(() => anchor.classList.remove("copied"), 1800);
|
||||
});
|
||||
history.pushState(null, "", `#${heading.id}`);
|
||||
});
|
||||
heading.appendChild(anchor);
|
||||
});
|
||||
}
|
||||
|
||||
function initXlGraphToggle() {
|
||||
const graphDrawer = document.getElementById(
|
||||
"graph-drawer",
|
||||
) as HTMLInputElement | null;
|
||||
if (!graphDrawer) return;
|
||||
|
||||
const outerDrawer = graphDrawer.closest<HTMLElement>(".drawer.drawer-end");
|
||||
const xlQuery = window.matchMedia("(min-width: 1280px)");
|
||||
const STORAGE_KEY = "notes-graph-sidebar";
|
||||
|
||||
function setXlSidebar(open: boolean) {
|
||||
if (!outerDrawer) return;
|
||||
if (open) {
|
||||
outerDrawer.classList.add("xl:drawer-open");
|
||||
} else {
|
||||
outerDrawer.classList.remove("xl:drawer-open");
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, open ? "1" : "0");
|
||||
}
|
||||
|
||||
const graphToggle = document.getElementById("graph-toggle");
|
||||
graphToggle?.addEventListener("click", (e) => {
|
||||
if (!xlQuery.matches) return;
|
||||
e.preventDefault();
|
||||
setXlSidebar(!outerDrawer?.classList.contains("xl:drawer-open"));
|
||||
});
|
||||
|
||||
if (xlQuery.matches) {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
// Open by default unless user explicitly closed it
|
||||
setXlSidebar(saved !== "0");
|
||||
}
|
||||
|
||||
xlQuery.addEventListener("change", (e) => {
|
||||
if (!e.matches) {
|
||||
outerDrawer?.classList.remove("xl:drawer-open");
|
||||
} else {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
setXlSidebar(saved !== "0");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("astro:page-load", () => {
|
||||
injectHeadingAnchors();
|
||||
initXlGraphToggle();
|
||||
});
|
||||
</script>
|
||||
|
||||
+12
-149
@@ -1,22 +1,24 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
import { ChevronRight, Shield } from "@lucide/astro";
|
||||
import { getCategory } from "../../utils/notes";
|
||||
import NotesSearch from "../../components/NotesSearch.svelte";
|
||||
|
||||
const notes = await getCollection("notes");
|
||||
const sortedNotes = notes.sort(
|
||||
(a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime(),
|
||||
);
|
||||
|
||||
const categories = [...new Set(notes.map(getCategory))].sort();
|
||||
|
||||
const searchIndex = Object.fromEntries(
|
||||
sortedNotes.map((n) => [
|
||||
n.id,
|
||||
[n.data.title, n.data.description, n.body ?? ""].join(" ").toLowerCase(),
|
||||
]),
|
||||
);
|
||||
const searchNotes = sortedNotes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.data.title,
|
||||
description: n.data.description,
|
||||
tags: n.data.tags,
|
||||
category: getCategory(n),
|
||||
searchText: [n.data.title, n.data.description, n.body ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase(),
|
||||
}));
|
||||
---
|
||||
|
||||
<Layout
|
||||
@@ -30,145 +32,6 @@ const searchIndex = Object.fromEntries(
|
||||
Reference sheets on cybersecurity tools and techniques.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-12 max-w-sm mx-auto">
|
||||
<label class="input w-full">
|
||||
<span class="text-base-content/25">›</span>
|
||||
<input data-search type="text" placeholder="search or #tag..." />
|
||||
</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 id="notes-container" class="space-y-12">
|
||||
{
|
||||
categories.map((cat) => {
|
||||
const catNotes = sortedNotes.filter((n) => getCategory(n) === cat);
|
||||
return (
|
||||
<section data-category={cat.toLowerCase()}>
|
||||
<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" />
|
||||
|
||||
<ul class="divide-y divide-base-300/20">
|
||||
{catNotes.map((n) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/notes/${n.id}`}
|
||||
class="note-card group flex items-center gap-4 py-3 hover:bg-base-200/30 px-2 -mx-2 transition-colors"
|
||||
data-id={n.id}
|
||||
data-tags={n.data.tags.join(",")}
|
||||
>
|
||||
<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.data.title}
|
||||
</span>
|
||||
{n.data.description && (
|
||||
<span class="text-xs text-base-content/35 truncate">
|
||||
{n.data.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{n.data.tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{n.data.tags.map((tag) => (
|
||||
<span class="badge badge-ghost badge-xs font-mono text-base-content/30">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
class="text-base-content/20 group-hover:text-primary/50 shrink-0 transition-colors"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="empty-state"
|
||||
class="hidden text-center py-20 font-mono text-sm text-base-content/25"
|
||||
>
|
||||
no results.
|
||||
</div>
|
||||
|
||||
<p class="text-center font-mono text-xs text-base-content/20 mt-16">
|
||||
<span id="note-count">{notes.length}</span> note{
|
||||
notes.length !== 1 ? "s" : ""
|
||||
} total
|
||||
</p>
|
||||
<NotesSearch client:load notes={searchNotes} />
|
||||
</main>
|
||||
|
||||
<script is:inline define:vars={{ searchIndex }}>
|
||||
function init() {
|
||||
const noteCards = document.querySelectorAll(".note-card");
|
||||
const sections = document.querySelectorAll("[data-category]");
|
||||
const emptyState = document.getElementById("empty-state");
|
||||
const noteCount = document.getElementById("note-count");
|
||||
const container = document.getElementById("notes-container");
|
||||
|
||||
function filter(raw) {
|
||||
const isTag = raw.startsWith("#");
|
||||
const query = isTag ? raw.slice(1) : raw;
|
||||
|
||||
let visible = 0;
|
||||
noteCards.forEach((card) => {
|
||||
const id = card.dataset.id ?? "";
|
||||
const tags = card.dataset.tags ? card.dataset.tags.split(",") : [];
|
||||
const show =
|
||||
!query ||
|
||||
(isTag
|
||||
? tags.some((t) => t.includes(query)) ||
|
||||
(searchIndex[id] ?? "").includes(`#${query}`)
|
||||
: (searchIndex[id] ?? "").includes(query));
|
||||
card.style.display = show ? "" : "none";
|
||||
if (show) visible++;
|
||||
});
|
||||
|
||||
sections.forEach((section) => {
|
||||
const anyVisible = [...section.querySelectorAll(".note-card")].some(
|
||||
(c) => c.style.display !== "none",
|
||||
);
|
||||
section.style.display = anyVisible ? "" : "none";
|
||||
});
|
||||
|
||||
noteCount.textContent = String(visible);
|
||||
container.style.display = visible > 0 ? "" : "none";
|
||||
emptyState.classList.toggle("hidden", visible > 0);
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-search]").forEach((input) => {
|
||||
input.addEventListener("input", (e) => {
|
||||
filter(e.target.value.toLowerCase().trim());
|
||||
});
|
||||
});
|
||||
|
||||
const urlTag = new URLSearchParams(window.location.search).get("tag");
|
||||
if (urlTag) {
|
||||
document.querySelectorAll("[data-search]").forEach((i) => {
|
||||
i.value = `#${urlTag}`;
|
||||
});
|
||||
filter(`#${urlTag}`);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("astro:page-load", init);
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
const PRIMARY = "oklch(71% 0.0863 296.59)";
|
||||
|
||||
type GNode = {
|
||||
id: string;
|
||||
title: string;
|
||||
current: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
};
|
||||
type GEdge = { from: string; to: string };
|
||||
|
||||
let stopGraph: (() => void) | null = null;
|
||||
|
||||
function startGraph(): (() => void) | null {
|
||||
const w = window as typeof window & {
|
||||
__graphNodes?: { id: string; title: string; current: boolean }[];
|
||||
__graphEdges?: GEdge[];
|
||||
};
|
||||
const graphNodes = w.__graphNodes ?? [];
|
||||
const graphEdges: GEdge[] = w.__graphEdges ?? [];
|
||||
const canvas = document.getElementById(
|
||||
"note-graph",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (!canvas || graphNodes.length === 0) return null;
|
||||
|
||||
const W = (canvas.width = canvas.offsetWidth);
|
||||
const H = (canvas.height = 190);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const nodes: GNode[] = graphNodes.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: GNode | null = null;
|
||||
let hovered: GNode | null = null;
|
||||
|
||||
function nodeAt(x: number, y: number): GNode | null {
|
||||
return (
|
||||
nodes.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 < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i],
|
||||
b = nodes[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 graphEdges) {
|
||||
const a = nodes.find((n: GNode) => n.id === e.from);
|
||||
const b = nodes.find((n: GNode) => 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 nodes) {
|
||||
n.vx += (W / 2 - n.x) * 0.025;
|
||||
n.vy += (H / 2 - n.y) * 0.025;
|
||||
}
|
||||
for (const n of nodes) {
|
||||
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 graphEdges) {
|
||||
if (e.from === hovered.id) connected.add(e.to);
|
||||
if (e.to === hovered.id) connected.add(e.from);
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of graphEdges) {
|
||||
const a = nodes.find((n: GNode) => n.id === e.from);
|
||||
const b = nodes.find((n: GNode) => 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 nodes) {
|
||||
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);
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const sx = W / canvas.offsetWidth;
|
||||
dragging = nodeAt(
|
||||
(e.clientX - r.left) * sx,
|
||||
(e.clientY - r.top) * (H / canvas.offsetHeight),
|
||||
);
|
||||
});
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
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";
|
||||
});
|
||||
canvas.addEventListener("mouseup", () => {
|
||||
dragging = null;
|
||||
});
|
||||
canvas.addEventListener("mouseleave", () => {
|
||||
dragging = null;
|
||||
hovered = null;
|
||||
});
|
||||
canvas.addEventListener("click", (e) => {
|
||||
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}`;
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(animId);
|
||||
}
|
||||
|
||||
function injectHeadingAnchors() {
|
||||
if (!document.getElementById("heading-anchor-styles")) {
|
||||
const s = document.createElement("style");
|
||||
s.id = "heading-anchor-styles";
|
||||
s.textContent = `
|
||||
.note-content h2, .note-content h3, .note-content h4 {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
.heading-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.4em;
|
||||
color: oklch(38% 0 0);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, color 120ms;
|
||||
text-decoration: none;
|
||||
}
|
||||
.note-content h2:hover .heading-anchor,
|
||||
.note-content h3:hover .heading-anchor,
|
||||
.note-content h4:hover .heading-anchor { opacity: 1; }
|
||||
.heading-anchor:hover, .heading-anchor.copied { color: oklch(71% 0.0863 296.59); opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll(".note-content h2, .note-content h3, .note-content h4")
|
||||
.forEach((heading) => {
|
||||
if (!heading.id || heading.querySelector(".heading-anchor")) return;
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = `#${heading.id}`;
|
||||
anchor.className = "heading-anchor";
|
||||
anchor.setAttribute("aria-label", "Copy link to section");
|
||||
anchor.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
|
||||
anchor.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const url = `${location.origin}${location.pathname}#${heading.id}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
anchor.classList.add("copied");
|
||||
setTimeout(() => anchor.classList.remove("copied"), 1800);
|
||||
});
|
||||
history.pushState(null, "", `#${heading.id}`);
|
||||
});
|
||||
heading.appendChild(anchor);
|
||||
});
|
||||
}
|
||||
|
||||
function initSearch() {
|
||||
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";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (stopGraph) {
|
||||
stopGraph();
|
||||
stopGraph = null;
|
||||
}
|
||||
|
||||
const graphDrawer = document.getElementById(
|
||||
"graph-drawer",
|
||||
) as HTMLInputElement | null;
|
||||
if (!graphDrawer) return;
|
||||
|
||||
function onGraphDrawerChange() {
|
||||
if (graphDrawer!.checked) {
|
||||
requestAnimationFrame(() => {
|
||||
stopGraph = startGraph() ?? null;
|
||||
});
|
||||
} else {
|
||||
if (stopGraph) {
|
||||
stopGraph();
|
||||
stopGraph = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
graphDrawer.addEventListener("change", onGraphDrawerChange);
|
||||
|
||||
const outerDrawer = graphDrawer.closest<HTMLElement>(".drawer.drawer-end");
|
||||
const xlQuery = window.matchMedia("(min-width: 1280px)");
|
||||
|
||||
function setXlSidebar(open: boolean) {
|
||||
if (!outerDrawer) return;
|
||||
if (open) {
|
||||
outerDrawer.classList.add("xl:drawer-open");
|
||||
requestAnimationFrame(() => {
|
||||
stopGraph = startGraph() ?? null;
|
||||
});
|
||||
} else {
|
||||
outerDrawer.classList.remove("xl:drawer-open");
|
||||
if (stopGraph) {
|
||||
stopGraph();
|
||||
stopGraph = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphToggle = document.getElementById("graph-toggle");
|
||||
graphToggle?.addEventListener("click", (e) => {
|
||||
if (!xlQuery.matches) return;
|
||||
e.preventDefault();
|
||||
setXlSidebar(!outerDrawer?.classList.contains("xl:drawer-open"));
|
||||
});
|
||||
|
||||
if (xlQuery.matches) {
|
||||
outerDrawer?.classList.add("xl:drawer-open");
|
||||
requestAnimationFrame(() => {
|
||||
stopGraph = startGraph() ?? null;
|
||||
});
|
||||
}
|
||||
xlQuery.addEventListener("change", (e) => {
|
||||
if (!e.matches) setXlSidebar(false);
|
||||
});
|
||||
|
||||
injectHeadingAnchors();
|
||||
initSearch();
|
||||
}
|
||||
|
||||
document.addEventListener("astro:page-load", init);
|
||||
document.addEventListener("astro:before-preparation", () => {
|
||||
if (stopGraph) {
|
||||
stopGraph();
|
||||
stopGraph = null;
|
||||
}
|
||||
});
|
||||
@@ -14,3 +14,48 @@ export function extractInlineHashtags(body: string): string[] {
|
||||
while ((m = re.exec(body)) !== null) tags.push(m[1].toLowerCase());
|
||||
return [...new Set(tags)];
|
||||
}
|
||||
|
||||
// Mirrors github-slugger: keeps _, keeps unicode letters/numbers, spaces → hyphens
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||
.trim()
|
||||
.replace(/ +/g, "-");
|
||||
}
|
||||
|
||||
export function extractLinks(body: string): string[] {
|
||||
const re = /\(\/notes\/([^)#\s]+)(?:#[^)\s]*)?\)/g;
|
||||
const ids: string[] = [];
|
||||
let m;
|
||||
while ((m = re.exec(body)) !== null) ids.push(m[1]);
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function extractHeadings(
|
||||
body: string,
|
||||
): { depth: number; text: string; id: string }[] {
|
||||
const headings: { depth: number; text: string; id: string }[] = [];
|
||||
const re = /^(#{2,4}) (.+)$/gm;
|
||||
let m;
|
||||
while ((m = re.exec(body)) !== null) {
|
||||
const raw = m[2]
|
||||
.trim()
|
||||
.replace(/`[^`]*`/g, "")
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||
.replace(/(?<!\p{L}\p{N})__(.*?)__(?!\p{L}\p{N})/gu, "$1")
|
||||
.replace(/\*(.*?)\*/g, "$1")
|
||||
.replace(/(?<!\p{L}\p{N})_(.*?)_(?!\p{L}\p{N})/gu, "$1")
|
||||
.replace(/[*]/g, "");
|
||||
headings.push({ depth: m[1].length, text: raw, id: slugify(raw) });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user