learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: notes management and updated UI

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + });