From 98a2fd9029de6cde28c08db0f8dce95f919aaa89 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 28 Aug 2026 13:44:54 +0000 Subject: [PATCH] feat(zettel): add topic page routing Amp-Thread-ID: https://ampcode.com/threads/T-01a047e7-d402-72a8-ad22-eb5d3d3df6b1 Co-authored-by: Aditya Balakrishnan --- apps/zettel/package.json | 1 + apps/zettel/src/frontend/api-client.ts | 4 ++ .../frontend/components/TopicPage.test.tsx | 53 +++++++++++++++ .../src/frontend/components/TopicPage.tsx | 68 +++++++++++++++++++ apps/zettel/src/frontend/main.tsx | 9 ++- pnpm-lock.yaml | 6 ++ pnpm-workspace.yaml | 1 + 7 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 apps/zettel/src/frontend/components/TopicPage.test.tsx create mode 100644 apps/zettel/src/frontend/components/TopicPage.tsx diff --git a/apps/zettel/package.json b/apps/zettel/package.json index 5b576e5..9d31f58 100644 --- a/apps/zettel/package.json +++ b/apps/zettel/package.json @@ -32,6 +32,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", + "react-router-dom": "catalog:", "remark-gfm": "^4.0.1", "zod": "catalog:" }, diff --git a/apps/zettel/src/frontend/api-client.ts b/apps/zettel/src/frontend/api-client.ts index 6923930..d52400d 100644 --- a/apps/zettel/src/frontend/api-client.ts +++ b/apps/zettel/src/frontend/api-client.ts @@ -36,6 +36,10 @@ export const api = { graph: { $get: () => authedFetch("/api/graph"), }, + wiki: { + $get: (opts: { param: { entity: string } }) => + authedFetch(`/api/wiki/${encodeURIComponent(opts.param.entity)}`), + }, transcribe: { $post: (opts: { form: Record }) => { const formData = new FormData(); diff --git a/apps/zettel/src/frontend/components/TopicPage.test.tsx b/apps/zettel/src/frontend/components/TopicPage.test.tsx new file mode 100644 index 0000000..d212c0a --- /dev/null +++ b/apps/zettel/src/frontend/components/TopicPage.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getTopic } = vi.hoisted(() => ({ getTopic: vi.fn() })); + +vi.mock("../api-client", () => ({ + api: { wiki: { $get: getTopic } }, +})); + +import TopicPage from "./TopicPage"; + +function renderTopic(path = "/wiki/Ada") { + return render( + + + } /> + + , + ); +} + +describe("TopicPage", () => { + beforeEach(() => { + getTopic.mockReset(); + }); + + it("loads the authenticated topic API and renders wikilinks as routes", async () => { + getTopic.mockResolvedValue({ + ok: true, + json: async () => ({ markdown: "# Ada\n\nRelated to [[Analytical Engine]]." }), + }); + + renderTopic(); + + expect(await screen.findByRole("heading", { name: "Ada" })).toBeInTheDocument(); + expect(getTopic).toHaveBeenCalledWith({ param: { entity: "Ada" } }); + expect(screen.getByRole("link", { name: "Analytical Engine" })).toHaveAttribute( + "href", + "/wiki/Analytical%20Engine", + ); + }); + + it("shows an API failure", async () => { + getTopic.mockResolvedValue({ ok: false, json: async () => ({ error: "not available" }) }); + + renderTopic(); + + expect(await screen.findByRole("alert")).toHaveTextContent("not available"); + }); +}); diff --git a/apps/zettel/src/frontend/components/TopicPage.tsx b/apps/zettel/src/frontend/components/TopicPage.tsx new file mode 100644 index 0000000..4f29400 --- /dev/null +++ b/apps/zettel/src/frontend/components/TopicPage.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { api } from "../api-client"; + +function wikilinks(markdown: string): string { + return markdown.replace(/\[\[([^\]]+)\]\]/g, (_match, label: string) => { + return `[${label}](/wiki/${encodeURIComponent(label)})`; + }); +} + +export default function TopicPage() { + const { entity = "" } = useParams<{ entity: string }>(); + const [markdown, setMarkdown] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + void api.wiki + .$get({ param: { entity } }) + .then(async (response) => { + const data = await response.json(); + if (!response.ok || "error" in data) { + throw new Error("error" in data ? data.error : "Failed to load topic"); + } + if (active) setMarkdown(data.markdown); + }) + .catch((err: unknown) => { + if (active) setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [entity]); + + if (loading) return
Loading topic…
; + if (error) return
Error: {error}
; + + return ( +
+ + + href?.startsWith("/wiki/") ? ( + {children} + ) : ( + + {children} + + ), + }} + > + {wikilinks(markdown)} + +
+ ); +} diff --git a/apps/zettel/src/frontend/main.tsx b/apps/zettel/src/frontend/main.tsx index 5d4a2be..76943b4 100644 --- a/apps/zettel/src/frontend/main.tsx +++ b/apps/zettel/src/frontend/main.tsx @@ -1,9 +1,16 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; import App from "./App.tsx"; +import TopicPage from "./components/TopicPage.tsx"; createRoot(document.getElementById("root")!).render( - + + + } /> + } /> + + , ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ada516..14e127f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ catalogs: react-dom: specifier: 19.2.7 version: 19.2.7 + react-router-dom: + specifier: 7.18.0 + version: 7.18.0 tsx: specifier: 4.22.4 version: 4.22.4 @@ -511,6 +514,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + react-router-dom: + specifier: 'catalog:' + version: 7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) reactflow: specifier: ^11.11.4 version: 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9de0c95..99d8d67 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,6 +52,7 @@ catalog: "@libsql/kysely-libsql": 0.4.1 react: 19.2.7 react-dom: 19.2.7 + react-router-dom: 7.18.0 tsx: 4.22.4 typescript: 7.0.1-rc vite: 8.0.16