+4
web/src/App.tsx
+4
web/src/App.tsx
···
16
16
import Library from "$pages/Library";
17
17
import Login from "$pages/Login";
18
18
import NoteNew from "$pages/NoteNew";
19
+
import Notes from "$pages/Notes";
20
+
import NoteView from "$pages/NoteView";
19
21
import NotFound from "$pages/NotFound";
20
22
import Review from "$pages/Review";
21
23
import Search from "$pages/Search";
···
63
65
<Route path="/decks" component={Home} />
64
66
<Route path="/decks/new" component={DeckNew} />
65
67
<Route path="/notes/new" component={NoteNew} />
68
+
<Route path="/notes/:id" component={NoteView} />
69
+
<Route path="/notes" component={Notes} />
66
70
<Route path="/decks/:id" component={DeckView} />
67
71
<Route path="/import" component={Import} />
68
72
<Route path="/import/lecture" component={LectureImport} />
+55
web/src/components/NoteCard.tsx
+55
web/src/components/NoteCard.tsx
···
1
+
import { useDensity } from "$lib/density-context";
2
+
import type { DensityMode } from "$lib/design-tokens";
3
+
import type { Note } from "$lib/model";
4
+
import { Card } from "$ui/Card";
5
+
import { Tag } from "$ui/Tag";
6
+
import { A } from "@solidjs/router";
7
+
import type { Component } from "solid-js";
8
+
import { For, Show } from "solid-js";
9
+
10
+
type NoteCardProps = { note: Note; density?: DensityMode };
11
+
12
+
export const NoteCard: Component<NoteCardProps> = (props) => {
13
+
const globalDensity = useDensity();
14
+
const density = () => props.density || globalDensity;
15
+
16
+
const truncateBody = (body: string, maxLength: number) => {
17
+
const plainText = body.replace(/[#*`[\]]/g, "").trim();
18
+
return plainText.length > maxLength ? plainText.slice(0, maxLength) + "..." : plainText;
19
+
};
20
+
21
+
const paddingClass = () => {
22
+
const d = density();
23
+
return d === "compact" ? "p-4" : d === "spacious" ? "p-8" : "p-6";
24
+
};
25
+
26
+
return (
27
+
<A href={`/notes/${props.note.id}`} class="block h-full no-underline group">
28
+
<Card class="h-full flex flex-col hover:border-blue-400 dark:hover:border-blue-500 transition-colors">
29
+
<div class={`${paddingClass()} flex-1 space-y-3`}>
30
+
<div class="space-y-1">
31
+
<h3 class="text-lg font-semibold text-slate-900 dark:text-white line-clamp-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
32
+
{props.note.title || "Untitled"}
33
+
</h3>
34
+
<p class="text-xs text-slate-500 dark:text-slate-400">
35
+
{new Date(props.note.updated_at).toLocaleDateString()}
36
+
</p>
37
+
</div>
38
+
39
+
<p class="text-sm text-slate-600 dark:text-slate-300 line-clamp-3">{truncateBody(props.note.body, 120)}</p>
40
+
41
+
<Show when={props.note.tags.length > 0}>
42
+
<div class="flex flex-wrap gap-1.5 pt-2">
43
+
<For each={props.note.tags.slice(0, 3)}>
44
+
{(tag) => <Tag label={tag} color="blue" density={density()} />}
45
+
</For>
46
+
<Show when={props.note.tags.length > 3}>
47
+
<span class="text-xs text-slate-400">+{props.note.tags.length - 3}</span>
48
+
</Show>
49
+
</div>
50
+
</Show>
51
+
</div>
52
+
</Card>
53
+
</A>
54
+
);
55
+
};
+70
web/src/components/tests/NoteCard.test.tsx
+70
web/src/components/tests/NoteCard.test.tsx
···
1
+
import type { Note } from "$lib/model";
2
+
import { MemoryRouter, Route } from "@solidjs/router";
3
+
import { cleanup, render, screen } from "@solidjs/testing-library";
4
+
import { afterEach, describe, expect, it, vi } from "vitest";
5
+
import { NoteCard } from "../NoteCard";
6
+
7
+
vi.mock("$lib/density-context", () => ({ useDensity: vi.fn(() => "comfortable") }));
8
+
9
+
const mockNote: Note = {
10
+
id: "note-1",
11
+
owner_did: "did:plc:test123",
12
+
title: "Test Note",
13
+
body: "This is the body of the test note with some **markdown** content.",
14
+
tags: ["rust", "learning"],
15
+
visibility: { type: "Private" },
16
+
created_at: "2026-01-01T10:00:00Z",
17
+
updated_at: "2026-01-01T12:00:00Z",
18
+
};
19
+
20
+
describe("NoteCard", () => {
21
+
afterEach(cleanup);
22
+
23
+
it("renders note title", () => {
24
+
render(() => (
25
+
<MemoryRouter>
26
+
<Route path="/" component={() => <NoteCard note={mockNote} />} />
27
+
</MemoryRouter>
28
+
));
29
+
expect(screen.getByText("Test Note")).toBeInTheDocument();
30
+
});
31
+
32
+
it("renders truncated body preview", () => {
33
+
render(() => (
34
+
<MemoryRouter>
35
+
<Route path="/" component={() => <NoteCard note={mockNote} />} />
36
+
</MemoryRouter>
37
+
));
38
+
expect(screen.getByText(/This is the body/)).toBeInTheDocument();
39
+
});
40
+
41
+
it("renders tags", () => {
42
+
render(() => (
43
+
<MemoryRouter>
44
+
<Route path="/" component={() => <NoteCard note={mockNote} />} />
45
+
</MemoryRouter>
46
+
));
47
+
expect(screen.getByText("rust")).toBeInTheDocument();
48
+
expect(screen.getByText("learning")).toBeInTheDocument();
49
+
});
50
+
51
+
it("links to note view page", () => {
52
+
render(() => (
53
+
<MemoryRouter>
54
+
<Route path="/" component={() => <NoteCard note={mockNote} />} />
55
+
</MemoryRouter>
56
+
));
57
+
const link = screen.getByRole("link");
58
+
expect(link).toHaveAttribute("href", "/notes/note-1");
59
+
});
60
+
61
+
it("shows +N for excess tags", () => {
62
+
const noteWithManyTags: Note = { ...mockNote, tags: ["tag1", "tag2", "tag3", "tag4", "tag5"] };
63
+
render(() => (
64
+
<MemoryRouter>
65
+
<Route path="/" component={() => <NoteCard note={noteWithManyTags} />} />
66
+
</MemoryRouter>
67
+
));
68
+
expect(screen.getByText("+2")).toBeInTheDocument();
69
+
});
70
+
});
+7
-4
web/src/lib/api.ts
+7
-4
web/src/lib/api.ts
···
45
45
getUserProfile: (did: string) => apiFetch(`/users/${did}/profile`, { method: "GET" }),
46
46
getRemoteDeck: (uri: string) => apiFetch(`/remote/deck?uri=${encodeURIComponent(uri)}`, { method: "GET" }),
47
47
exportData: (collection: "decks" | "notes") => apiFetch(`/export/${collection}`, { method: "GET" }),
48
+
getNotes: () => apiFetch("/notes", { method: "GET" }),
49
+
getNote: (id: string) => apiFetch(`/notes/${id}`, { method: "GET" }),
50
+
deleteNote: (id: string) => apiFetch(`/notes/${id}`, { method: "DELETE" }),
51
+
updateNote: (id: string, payload: object) => {
52
+
return apiFetch(`/notes/${id}`, { method: "PUT", body: JSON.stringify(payload) });
53
+
},
48
54
createDeck: async (payload: CreateDeckPayload) => {
49
55
const { cards, ...deckPayload } = payload;
50
56
const res = await apiFetch("/decks", { method: "POST", body: JSON.stringify(deckPayload) });
···
60
66
));
61
67
}
62
68
63
-
return {
64
-
ok: true,
65
-
json: async () => deck,
66
-
};
69
+
return { ok: true, json: async () => deck };
67
70
},
68
71
addComment: (deckId: string, content: string, parentId?: string) => {
69
72
return apiFetch(`/decks/${deckId}/comments`, {
+3
-4
web/src/lib/density-context.tsx
+3
-4
web/src/lib/density-context.tsx
···
5
5
/**
6
6
* Density Context Provider
7
7
*
8
-
* Provides density mode to all child components. Reads from user preferences
9
-
* and applies the appropriate density class to the container.
8
+
* Provides density mode to all child components.
9
+
* Reads from user preferences and applies the appropriate density class to the container.
10
10
*
11
-
* Components can override density locally via props, but will default to
12
-
* this global setting.
11
+
* Components can override density locally via props.
13
12
*/
14
13
const DensityContext = createContext<DensityMode>("comfortable");
15
14
+12
web/src/lib/model.ts
+12
web/src/lib/model.ts
···
27
27
fork_of?: string;
28
28
};
29
29
30
+
export type Note = {
31
+
id: string;
32
+
owner_did: string;
33
+
title: string;
34
+
body: string;
35
+
tags: string[];
36
+
visibility: Visibility;
37
+
published_at?: string;
38
+
created_at: string;
39
+
updated_at: string;
40
+
};
41
+
30
42
export type CreateDeckPayload = {
31
43
title: string;
32
44
description: string;
+108
web/src/pages/NoteView.tsx
+108
web/src/pages/NoteView.tsx
···
1
+
/* eslint-disable solid/no-innerhtml */
2
+
import { Button } from "$components/ui/Button";
3
+
import { api } from "$lib/api";
4
+
import type { Note } from "$lib/model";
5
+
import { Tag } from "$ui/Tag";
6
+
import { A, useParams } from "@solidjs/router";
7
+
import rehypeExternalLinks from "rehype-external-links";
8
+
import rehypeSanitize from "rehype-sanitize";
9
+
import rehypeStringify from "rehype-stringify";
10
+
import remarkParse from "remark-parse";
11
+
import remarkRehype from "remark-rehype";
12
+
import type { Component } from "solid-js";
13
+
import { createEffect, createResource, createSignal, For, Show } from "solid-js";
14
+
import { unified } from "unified";
15
+
16
+
const NoteView: Component = () => {
17
+
const params = useParams<{ id: string }>();
18
+
const [note] = createResource(() => params.id, async (id: string): Promise<Note | null> => {
19
+
const res = await api.getNote(id);
20
+
if (!res.ok) return null;
21
+
return res.json();
22
+
});
23
+
const [renderedContent, setRenderedContent] = createSignal("");
24
+
25
+
const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeSanitize).use(rehypeExternalLinks, {
26
+
target: "_blank",
27
+
rel: ["nofollow"],
28
+
}).use(rehypeStringify);
29
+
30
+
const updateRenderedContent = async (n: Note) => {
31
+
const file = await processor.process(n.body);
32
+
setRenderedContent(String(file));
33
+
};
34
+
35
+
createEffect(() => {
36
+
const n = note();
37
+
if (n?.body) {
38
+
updateRenderedContent(n).catch(console.error);
39
+
}
40
+
});
41
+
42
+
return (
43
+
<div class="max-w-5xl mx-auto p-6">
44
+
<Show
45
+
when={!note.loading}
46
+
fallback={
47
+
<div class="flex justify-center p-12">
48
+
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
49
+
</div>
50
+
}>
51
+
<Show
52
+
when={note()}
53
+
fallback={
54
+
<div class="text-center py-12">
55
+
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">Note not found</h2>
56
+
<p class="text-slate-600 dark:text-slate-400 mt-2">
57
+
This note may have been deleted or you don't have access to it.
58
+
</p>
59
+
<A href="/notes" class="text-blue-600 hover:text-blue-500 mt-4 inline-block">← Back to Notes</A>
60
+
</div>
61
+
}>
62
+
{(n) => (
63
+
<div class="space-y-6">
64
+
<nav class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
65
+
<A href="/notes" class="hover:text-blue-600 dark:hover:text-blue-400">Notes</A>
66
+
<span>›</span>
67
+
<span class="text-slate-900 dark:text-white">{n().title || "Untitled"}</span>
68
+
</nav>
69
+
70
+
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
71
+
<div class="space-y-2">
72
+
<h1 class="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
73
+
{n().title || "Untitled"}
74
+
</h1>
75
+
<div class="flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400">
76
+
<span>Updated {new Date(n().updated_at).toLocaleDateString()}</span>
77
+
<Show when={n().visibility.type !== "Private"}>
78
+
<span class="inline-flex items-center rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">
79
+
{n().visibility.type}
80
+
</span>
81
+
</Show>
82
+
</div>
83
+
</div>
84
+
<div class="flex gap-2">
85
+
<A href={`/notes/edit/${n().id}`}>
86
+
<Button variant="secondary">Edit</Button>
87
+
</A>
88
+
</div>
89
+
</header>
90
+
91
+
<Show when={n().tags.length > 0}>
92
+
<div class="flex flex-wrap gap-2">
93
+
<For each={n().tags}>{(tag) => <Tag label={tag} color="blue" />}</For>
94
+
</div>
95
+
</Show>
96
+
97
+
<article class="prose prose-slate dark:prose-invert max-w-none bg-white dark:bg-slate-800/50 rounded-xl p-8 border border-slate-200 dark:border-slate-700">
98
+
<div innerHTML={renderedContent()} />
99
+
</article>
100
+
</div>
101
+
)}
102
+
</Show>
103
+
</Show>
104
+
</div>
105
+
);
106
+
};
107
+
108
+
export default NoteView;
+149
web/src/pages/Notes.tsx
+149
web/src/pages/Notes.tsx
···
1
+
import { NoteCard } from "$components/NoteCard";
2
+
import { Button } from "$components/ui/Button";
3
+
import { EmptyState } from "$components/ui/EmptyState";
4
+
import { api } from "$lib/api";
5
+
import type { Note } from "$lib/model";
6
+
import { A } from "@solidjs/router";
7
+
import type { Component } from "solid-js";
8
+
import { createMemo, createResource, createSignal, For, Show } from "solid-js";
9
+
10
+
const fetchNotes = async (): Promise<Note[]> => {
11
+
const res = await api.getNotes();
12
+
if (!res.ok) return [];
13
+
return res.json();
14
+
};
15
+
16
+
type ViewMode = "grid" | "list";
17
+
18
+
const Notes: Component = () => {
19
+
const [notes] = createResource(fetchNotes);
20
+
const [viewMode, setViewMode] = createSignal<ViewMode>("grid");
21
+
const [searchQuery, setSearchQuery] = createSignal("");
22
+
23
+
const filteredNotes = createMemo(() => {
24
+
const allNotes = notes() || [];
25
+
const query = searchQuery().toLowerCase().trim();
26
+
if (!query) return allNotes;
27
+
return allNotes.filter((note) =>
28
+
note.title.toLowerCase().includes(query)
29
+
|| note.body.toLowerCase().includes(query)
30
+
|| note.tags.some((tag) => tag.toLowerCase().includes(query))
31
+
);
32
+
});
33
+
34
+
return (
35
+
<div class="max-w-7xl mx-auto p-6 space-y-6">
36
+
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
37
+
<div>
38
+
<h1 class="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">Notes</h1>
39
+
<p class="text-slate-600 dark:text-slate-400 mt-1">Your personal knowledge base</p>
40
+
</div>
41
+
<A href="/notes/new">
42
+
<Button variant="primary">New Note</Button>
43
+
</A>
44
+
</header>
45
+
46
+
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
47
+
<div class="relative flex-1 max-w-md">
48
+
<input
49
+
type="text"
50
+
placeholder="Search notes..."
51
+
value={searchQuery()}
52
+
onInput={(e) => setSearchQuery(e.currentTarget.value)}
53
+
class="w-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg px-4 py-2 pl-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
54
+
<svg
55
+
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"
56
+
fill="none"
57
+
stroke="currentColor"
58
+
viewBox="0 0 24 24">
59
+
<path
60
+
stroke-linecap="round"
61
+
stroke-linejoin="round"
62
+
stroke-width="2"
63
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
64
+
</svg>
65
+
</div>
66
+
67
+
<div class="flex items-center gap-2">
68
+
<button
69
+
onClick={() => setViewMode("grid")}
70
+
class={`p-2 rounded ${
71
+
viewMode() === "grid" ? "bg-slate-200 dark:bg-slate-700" : "hover:bg-slate-100 dark:hover:bg-slate-800"
72
+
}`}
73
+
aria-label="Grid view">
74
+
<svg
75
+
class="w-5 h-5 text-slate-600 dark:text-slate-300"
76
+
fill="none"
77
+
stroke="currentColor"
78
+
viewBox="0 0 24 24">
79
+
<path
80
+
stroke-linecap="round"
81
+
stroke-linejoin="round"
82
+
stroke-width="2"
83
+
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
84
+
</svg>
85
+
</button>
86
+
<button
87
+
onClick={() => setViewMode("list")}
88
+
class={`p-2 rounded ${
89
+
viewMode() === "list" ? "bg-slate-200 dark:bg-slate-700" : "hover:bg-slate-100 dark:hover:bg-slate-800"
90
+
}`}
91
+
aria-label="List view">
92
+
<svg
93
+
class="w-5 h-5 text-slate-600 dark:text-slate-300"
94
+
fill="none"
95
+
stroke="currentColor"
96
+
viewBox="0 0 24 24">
97
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
98
+
</svg>
99
+
</button>
100
+
</div>
101
+
</div>
102
+
103
+
<Show
104
+
when={!notes.loading}
105
+
fallback={
106
+
<div class="flex justify-center p-12">
107
+
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
108
+
</div>
109
+
}>
110
+
<Show
111
+
when={filteredNotes().length > 0}
112
+
fallback={
113
+
<Show
114
+
when={searchQuery()}
115
+
fallback={
116
+
<EmptyState
117
+
title="No notes yet"
118
+
description="Start capturing your thoughts and ideas"
119
+
action={
120
+
<A href="/notes/new">
121
+
<Button variant="primary">Create your first note</Button>
122
+
</A>
123
+
} />
124
+
}>
125
+
<EmptyState
126
+
title="No matching notes"
127
+
description={`No notes found for "${searchQuery()}"`}
128
+
action={
129
+
<button
130
+
onClick={() => setSearchQuery("")}
131
+
class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
132
+
Clear search
133
+
</button>
134
+
} />
135
+
</Show>
136
+
}>
137
+
<div
138
+
class={viewMode() === "grid"
139
+
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
140
+
: "flex flex-col gap-4"}>
141
+
<For each={filteredNotes()}>{(note) => <NoteCard note={note} />}</For>
142
+
</div>
143
+
</Show>
144
+
</Show>
145
+
</div>
146
+
);
147
+
};
148
+
149
+
export default Notes;
+81
web/src/pages/tests/NoteView.test.tsx
+81
web/src/pages/tests/NoteView.test.tsx
···
1
+
import { api } from "$lib/api";
2
+
import type { Note } from "$lib/model";
3
+
import { cleanup, render, screen, waitFor } from "@solidjs/testing-library";
4
+
import { JSX } from "solid-js";
5
+
import { afterEach, describe, expect, it, vi } from "vitest";
6
+
import NoteView from "../NoteView";
7
+
8
+
vi.mock("$lib/api", () => ({ api: { getNote: vi.fn() } }));
9
+
10
+
vi.mock(
11
+
"@solidjs/router",
12
+
() => ({
13
+
useParams: () => ({ id: "note-1" }),
14
+
A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>,
15
+
}),
16
+
);
17
+
18
+
const mockNote: Note = {
19
+
id: "note-1",
20
+
owner_did: "did:plc:test123",
21
+
title: "Test Note Title",
22
+
body: "# Heading\n\nSome **markdown** content.",
23
+
tags: ["rust", "learning"],
24
+
visibility: { type: "Public" },
25
+
created_at: "2026-01-01T10:00:00Z",
26
+
updated_at: "2026-01-01T12:00:00Z",
27
+
};
28
+
29
+
describe("NoteView", () => {
30
+
afterEach(() => {
31
+
cleanup();
32
+
vi.clearAllMocks();
33
+
});
34
+
35
+
it("renders note title in heading", async () => {
36
+
vi.mocked(api.getNote).mockResolvedValue(
37
+
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
38
+
);
39
+
40
+
render(() => <NoteView />);
41
+
42
+
await waitFor(() => {
43
+
expect(screen.getByRole("heading", { level: 1, name: "Test Note Title" })).toBeInTheDocument();
44
+
});
45
+
});
46
+
47
+
it("renders tags", async () => {
48
+
vi.mocked(api.getNote).mockResolvedValue(
49
+
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
50
+
);
51
+
52
+
render(() => <NoteView />);
53
+
54
+
await waitFor(() => {
55
+
expect(screen.getByText("rust")).toBeInTheDocument();
56
+
expect(screen.getByText("learning")).toBeInTheDocument();
57
+
});
58
+
});
59
+
60
+
it("has back to notes link", async () => {
61
+
vi.mocked(api.getNote).mockResolvedValue(
62
+
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
63
+
);
64
+
65
+
render(() => <NoteView />);
66
+
67
+
await waitFor(() => {
68
+
expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
69
+
});
70
+
});
71
+
72
+
it("renders not found state when note returns error", async () => {
73
+
vi.mocked(api.getNote).mockResolvedValue({ ok: false } as unknown as Response);
74
+
75
+
render(() => <NoteView />);
76
+
77
+
await waitFor(() => {
78
+
expect(screen.getByText("Note not found")).toBeInTheDocument();
79
+
});
80
+
});
81
+
});
+93
web/src/pages/tests/Notes.test.tsx
+93
web/src/pages/tests/Notes.test.tsx
···
1
+
import { api } from "$lib/api";
2
+
import type { Note } from "$lib/model";
3
+
import { MemoryRouter, Route } from "@solidjs/router";
4
+
import { cleanup, render, screen, waitFor } from "@solidjs/testing-library";
5
+
import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
6
+
import Notes from "../Notes";
7
+
8
+
vi.mock("$lib/api", () => ({ api: { getNotes: vi.fn() } }));
9
+
10
+
vi.mock("$lib/density-context", () => ({ useDensity: vi.fn(() => "comfortable") }));
11
+
12
+
const mockNotes: Note[] = [{
13
+
id: "note-1",
14
+
owner_did: "did:plc:test123",
15
+
title: "First Note",
16
+
body: "Content of first note",
17
+
tags: ["rust"],
18
+
visibility: { type: "Private" },
19
+
created_at: "2026-01-01T10:00:00Z",
20
+
updated_at: "2026-01-01T12:00:00Z",
21
+
}, {
22
+
id: "note-2",
23
+
owner_did: "did:plc:test123",
24
+
title: "Second Note",
25
+
body: "Content of second note",
26
+
tags: ["learning"],
27
+
visibility: { type: "Public" },
28
+
created_at: "2026-01-01T11:00:00Z",
29
+
updated_at: "2026-01-01T13:00:00Z",
30
+
}];
31
+
32
+
describe("Notes page", () => {
33
+
afterEach(() => {
34
+
cleanup();
35
+
vi.clearAllMocks();
36
+
});
37
+
38
+
it("renders page header", async () => {
39
+
(api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => mockNotes });
40
+
render(() => (
41
+
<MemoryRouter>
42
+
<Route path="/" component={Notes} />
43
+
</MemoryRouter>
44
+
));
45
+
expect(screen.getByRole("heading", { name: "Notes" })).toBeInTheDocument();
46
+
expect(screen.getByText("Your personal knowledge base")).toBeInTheDocument();
47
+
});
48
+
49
+
it("renders notes from API", async () => {
50
+
(api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => mockNotes });
51
+
render(() => (
52
+
<MemoryRouter>
53
+
<Route path="/" component={Notes} />
54
+
</MemoryRouter>
55
+
));
56
+
await waitFor(() => {
57
+
expect(screen.getByText("First Note")).toBeInTheDocument();
58
+
expect(screen.getByText("Second Note")).toBeInTheDocument();
59
+
});
60
+
});
61
+
62
+
it("shows empty state when no notes", async () => {
63
+
(api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => [] });
64
+
render(() => (
65
+
<MemoryRouter>
66
+
<Route path="/" component={Notes} />
67
+
</MemoryRouter>
68
+
));
69
+
await waitFor(() => {
70
+
expect(screen.getByText("No notes yet")).toBeInTheDocument();
71
+
});
72
+
});
73
+
74
+
it("has New Note button", () => {
75
+
(api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => [] });
76
+
render(() => (
77
+
<MemoryRouter>
78
+
<Route path="/" component={Notes} />
79
+
</MemoryRouter>
80
+
));
81
+
expect(screen.getByRole("link", { name: /new note/i })).toBeInTheDocument();
82
+
});
83
+
84
+
it("has search input", () => {
85
+
(api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => [] });
86
+
render(() => (
87
+
<MemoryRouter>
88
+
<Route path="/" component={Notes} />
89
+
</MemoryRouter>
90
+
));
91
+
expect(screen.getByPlaceholderText("Search notes...")).toBeInTheDocument();
92
+
});
93
+
});