diff --git a/web/src/App.tsx b/web/src/App.tsx index 1c1b4d5..1999100 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,8 @@ import LectureImport from "$pages/LectureImport"; import Library from "$pages/Library"; import Login from "$pages/Login"; import NoteNew from "$pages/NoteNew"; +import Notes from "$pages/Notes"; +import NoteView from "$pages/NoteView"; import NotFound from "$pages/NotFound"; import Review from "$pages/Review"; import Search from "$pages/Search"; @@ -63,6 +65,8 @@ const App: Component = () => { + + diff --git a/web/src/components/NoteCard.tsx b/web/src/components/NoteCard.tsx new file mode 100644 index 0000000..8631327 --- /dev/null +++ b/web/src/components/NoteCard.tsx @@ -0,0 +1,55 @@ +import { useDensity } from "$lib/density-context"; +import type { DensityMode } from "$lib/design-tokens"; +import type { Note } from "$lib/model"; +import { Card } from "$ui/Card"; +import { Tag } from "$ui/Tag"; +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { For, Show } from "solid-js"; + +type NoteCardProps = { note: Note; density?: DensityMode }; + +export const NoteCard: Component = (props) => { + const globalDensity = useDensity(); + const density = () => props.density || globalDensity; + + const truncateBody = (body: string, maxLength: number) => { + const plainText = body.replace(/[#*`[\]]/g, "").trim(); + return plainText.length > maxLength ? plainText.slice(0, maxLength) + "..." : plainText; + }; + + const paddingClass = () => { + const d = density(); + return d === "compact" ? "p-4" : d === "spacious" ? "p-8" : "p-6"; + }; + + return ( + + +
+
+

+ {props.note.title || "Untitled"} +

+

+ {new Date(props.note.updated_at).toLocaleDateString()} +

+
+ +

{truncateBody(props.note.body, 120)}

+ + 0}> +
+ + {(tag) => } + + 3}> + +{props.note.tags.length - 3} + +
+
+
+
+
+ ); +}; diff --git a/web/src/components/tests/NoteCard.test.tsx b/web/src/components/tests/NoteCard.test.tsx new file mode 100644 index 0000000..b882148 --- /dev/null +++ b/web/src/components/tests/NoteCard.test.tsx @@ -0,0 +1,70 @@ +import type { Note } from "$lib/model"; +import { MemoryRouter, Route } from "@solidjs/router"; +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NoteCard } from "../NoteCard"; + +vi.mock("$lib/density-context", () => ({ useDensity: vi.fn(() => "comfortable") })); + +const mockNote: Note = { + id: "note-1", + owner_did: "did:plc:test123", + title: "Test Note", + body: "This is the body of the test note with some **markdown** content.", + tags: ["rust", "learning"], + visibility: { type: "Private" }, + created_at: "2026-01-01T10:00:00Z", + updated_at: "2026-01-01T12:00:00Z", +}; + +describe("NoteCard", () => { + afterEach(cleanup); + + it("renders note title", () => { + render(() => ( + + } /> + + )); + expect(screen.getByText("Test Note")).toBeInTheDocument(); + }); + + it("renders truncated body preview", () => { + render(() => ( + + } /> + + )); + expect(screen.getByText(/This is the body/)).toBeInTheDocument(); + }); + + it("renders tags", () => { + render(() => ( + + } /> + + )); + expect(screen.getByText("rust")).toBeInTheDocument(); + expect(screen.getByText("learning")).toBeInTheDocument(); + }); + + it("links to note view page", () => { + render(() => ( + + } /> + + )); + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("href", "/notes/note-1"); + }); + + it("shows +N for excess tags", () => { + const noteWithManyTags: Note = { ...mockNote, tags: ["tag1", "tag2", "tag3", "tag4", "tag5"] }; + render(() => ( + + } /> + + )); + expect(screen.getByText("+2")).toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a177985..175dd6a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -45,6 +45,12 @@ export const api = { getUserProfile: (did: string) => apiFetch(`/users/${did}/profile`, { method: "GET" }), getRemoteDeck: (uri: string) => apiFetch(`/remote/deck?uri=${encodeURIComponent(uri)}`, { method: "GET" }), exportData: (collection: "decks" | "notes") => apiFetch(`/export/${collection}`, { method: "GET" }), + getNotes: () => apiFetch("/notes", { method: "GET" }), + getNote: (id: string) => apiFetch(`/notes/${id}`, { method: "GET" }), + deleteNote: (id: string) => apiFetch(`/notes/${id}`, { method: "DELETE" }), + updateNote: (id: string, payload: object) => { + return apiFetch(`/notes/${id}`, { method: "PUT", body: JSON.stringify(payload) }); + }, createDeck: async (payload: CreateDeckPayload) => { const { cards, ...deckPayload } = payload; const res = await apiFetch("/decks", { method: "POST", body: JSON.stringify(deckPayload) }); @@ -60,10 +66,7 @@ export const api = { )); } - return { - ok: true, - json: async () => deck, - }; + return { ok: true, json: async () => deck }; }, addComment: (deckId: string, content: string, parentId?: string) => { return apiFetch(`/decks/${deckId}/comments`, { diff --git a/web/src/lib/density-context.tsx b/web/src/lib/density-context.tsx index fd7f22f..dc581b7 100644 --- a/web/src/lib/density-context.tsx +++ b/web/src/lib/density-context.tsx @@ -5,11 +5,10 @@ import { prefStore } from "./store"; /** * Density Context Provider * - * Provides density mode to all child components. Reads from user preferences - * and applies the appropriate density class to the container. + * Provides density mode to all child components. + * Reads from user preferences and applies the appropriate density class to the container. * - * Components can override density locally via props, but will default to - * this global setting. + * Components can override density locally via props. */ const DensityContext = createContext("comfortable"); diff --git a/web/src/lib/model.ts b/web/src/lib/model.ts index b91fe75..96d0b85 100644 --- a/web/src/lib/model.ts +++ b/web/src/lib/model.ts @@ -27,6 +27,18 @@ export type Deck = { fork_of?: string; }; +export type Note = { + id: string; + owner_did: string; + title: string; + body: string; + tags: string[]; + visibility: Visibility; + published_at?: string; + created_at: string; + updated_at: string; +}; + export type CreateDeckPayload = { title: string; description: string; diff --git a/web/src/pages/NoteView.tsx b/web/src/pages/NoteView.tsx new file mode 100644 index 0000000..3f8edd0 --- /dev/null +++ b/web/src/pages/NoteView.tsx @@ -0,0 +1,108 @@ +/* eslint-disable solid/no-innerhtml */ +import { Button } from "$components/ui/Button"; +import { api } from "$lib/api"; +import type { Note } from "$lib/model"; +import { Tag } from "$ui/Tag"; +import { A, useParams } from "@solidjs/router"; +import rehypeExternalLinks from "rehype-external-links"; +import rehypeSanitize from "rehype-sanitize"; +import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import type { Component } from "solid-js"; +import { createEffect, createResource, createSignal, For, Show } from "solid-js"; +import { unified } from "unified"; + +const NoteView: Component = () => { + const params = useParams<{ id: string }>(); + const [note] = createResource(() => params.id, async (id: string): Promise => { + const res = await api.getNote(id); + if (!res.ok) return null; + return res.json(); + }); + const [renderedContent, setRenderedContent] = createSignal(""); + + const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeSanitize).use(rehypeExternalLinks, { + target: "_blank", + rel: ["nofollow"], + }).use(rehypeStringify); + + const updateRenderedContent = async (n: Note) => { + const file = await processor.process(n.body); + setRenderedContent(String(file)); + }; + + createEffect(() => { + const n = note(); + if (n?.body) { + updateRenderedContent(n).catch(console.error); + } + }); + + return ( +
+ +
+
+ }> + +

Note not found

+

+ This note may have been deleted or you don't have access to it. +

+ ← Back to Notes +
+ }> + {(n) => ( +
+ + +
+
+

+ {n().title || "Untitled"} +

+
+ Updated {new Date(n().updated_at).toLocaleDateString()} + + + {n().visibility.type} + + +
+
+ +
+ + 0}> +
+ {(tag) => } +
+
+ +
+
+
+
+ )} + + + + ); +}; + +export default NoteView; diff --git a/web/src/pages/Notes.tsx b/web/src/pages/Notes.tsx new file mode 100644 index 0000000..f4a7dae --- /dev/null +++ b/web/src/pages/Notes.tsx @@ -0,0 +1,149 @@ +import { NoteCard } from "$components/NoteCard"; +import { Button } from "$components/ui/Button"; +import { EmptyState } from "$components/ui/EmptyState"; +import { api } from "$lib/api"; +import type { Note } from "$lib/model"; +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { createMemo, createResource, createSignal, For, Show } from "solid-js"; + +const fetchNotes = async (): Promise => { + const res = await api.getNotes(); + if (!res.ok) return []; + return res.json(); +}; + +type ViewMode = "grid" | "list"; + +const Notes: Component = () => { + const [notes] = createResource(fetchNotes); + const [viewMode, setViewMode] = createSignal("grid"); + const [searchQuery, setSearchQuery] = createSignal(""); + + const filteredNotes = createMemo(() => { + const allNotes = notes() || []; + const query = searchQuery().toLowerCase().trim(); + if (!query) return allNotes; + return allNotes.filter((note) => + note.title.toLowerCase().includes(query) + || note.body.toLowerCase().includes(query) + || note.tags.some((tag) => tag.toLowerCase().includes(query)) + ); + }); + + return ( +
+
+
+

Notes

+

Your personal knowledge base

+
+ + + +
+ +
+
+ setSearchQuery(e.currentTarget.value)} + 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" /> + + + +
+ +
+ + +
+
+ + +
+
+ }> + 0} + fallback={ + + + + } /> + }> + setSearchQuery("")} + class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"> + Clear search + + } /> + + }> +
+ {(note) => } +
+
+
+
+ ); +}; + +export default Notes; diff --git a/web/src/pages/tests/NoteView.test.tsx b/web/src/pages/tests/NoteView.test.tsx new file mode 100644 index 0000000..6a3e568 --- /dev/null +++ b/web/src/pages/tests/NoteView.test.tsx @@ -0,0 +1,81 @@ +import { api } from "$lib/api"; +import type { Note } from "$lib/model"; +import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; +import { JSX } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import NoteView from "../NoteView"; + +vi.mock("$lib/api", () => ({ api: { getNote: vi.fn() } })); + +vi.mock( + "@solidjs/router", + () => ({ + useParams: () => ({ id: "note-1" }), + A: (props: { href: string; children: JSX.Element }) => {props.children}, + }), +); + +const mockNote: Note = { + id: "note-1", + owner_did: "did:plc:test123", + title: "Test Note Title", + body: "# Heading\n\nSome **markdown** content.", + tags: ["rust", "learning"], + visibility: { type: "Public" }, + created_at: "2026-01-01T10:00:00Z", + updated_at: "2026-01-01T12:00:00Z", +}; + +describe("NoteView", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders note title in heading", async () => { + vi.mocked(api.getNote).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByRole("heading", { level: 1, name: "Test Note Title" })).toBeInTheDocument(); + }); + }); + + it("renders tags", async () => { + vi.mocked(api.getNote).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText("rust")).toBeInTheDocument(); + expect(screen.getByText("learning")).toBeInTheDocument(); + }); + }); + + it("has back to notes link", async () => { + vi.mocked(api.getNote).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument(); + }); + }); + + it("renders not found state when note returns error", async () => { + vi.mocked(api.getNote).mockResolvedValue({ ok: false } as unknown as Response); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText("Note not found")).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/pages/tests/Notes.test.tsx b/web/src/pages/tests/Notes.test.tsx new file mode 100644 index 0000000..cb689ec --- /dev/null +++ b/web/src/pages/tests/Notes.test.tsx @@ -0,0 +1,93 @@ +import { api } from "$lib/api"; +import type { Note } from "$lib/model"; +import { MemoryRouter, Route } from "@solidjs/router"; +import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; +import Notes from "../Notes"; + +vi.mock("$lib/api", () => ({ api: { getNotes: vi.fn() } })); + +vi.mock("$lib/density-context", () => ({ useDensity: vi.fn(() => "comfortable") })); + +const mockNotes: Note[] = [{ + id: "note-1", + owner_did: "did:plc:test123", + title: "First Note", + body: "Content of first note", + tags: ["rust"], + visibility: { type: "Private" }, + created_at: "2026-01-01T10:00:00Z", + updated_at: "2026-01-01T12:00:00Z", +}, { + id: "note-2", + owner_did: "did:plc:test123", + title: "Second Note", + body: "Content of second note", + tags: ["learning"], + visibility: { type: "Public" }, + created_at: "2026-01-01T11:00:00Z", + updated_at: "2026-01-01T13:00:00Z", +}]; + +describe("Notes page", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders page header", async () => { + (api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => mockNotes }); + render(() => ( + + + + )); + expect(screen.getByRole("heading", { name: "Notes" })).toBeInTheDocument(); + expect(screen.getByText("Your personal knowledge base")).toBeInTheDocument(); + }); + + it("renders notes from API", async () => { + (api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => mockNotes }); + render(() => ( + + + + )); + await waitFor(() => { + expect(screen.getByText("First Note")).toBeInTheDocument(); + expect(screen.getByText("Second Note")).toBeInTheDocument(); + }); + }); + + it("shows empty state when no notes", async () => { + (api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => [] }); + render(() => ( + + + + )); + await waitFor(() => { + expect(screen.getByText("No notes yet")).toBeInTheDocument(); + }); + }); + + it("has New Note button", () => { + (api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => [] }); + render(() => ( + + + + )); + expect(screen.getByRole("link", { name: /new note/i })).toBeInTheDocument(); + }); + + it("has search input", () => { + (api.getNotes as Mock).mockResolvedValue({ ok: true, json: async () => [] }); + render(() => ( + + + + )); + expect(screen.getByPlaceholderText("Search notes...")).toBeInTheDocument(); + }); +});