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

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement DeckView page

* add 'Unlisted' and 'SharedWith' visibility for notes/decks

* improve form error handling

* updated linting conf

+384 -56
+9 -7
web/eslint.config.js
··· 1 + // @ts-check 1 2 import js from "@eslint/js"; 2 - import * as tsParser from "@typescript-eslint/parser"; 3 3 import solid from "eslint-plugin-solid/configs/typescript"; 4 - import globals from "globals"; 4 + import tseslint from "typescript-eslint"; 5 5 6 - export default [js.configs.recommended, { 7 - files: ["**/*.{ts,tsx}"], 8 - ...solid, 9 - languageOptions: { parser: tsParser, globals: globals.browser }, 10 - }]; 6 + export default tseslint.config( 7 + { ignores: ["dist/", "node_modules/", "coverage/"] }, 8 + js.configs.recommended, 9 + tseslint.configs.recommended, 10 + solid, 11 + { rules: { "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }] } }, 12 + );
+1
web/package.json
··· 38 38 "globals": "^16.5.0", 39 39 "jsdom": "^27.4.0", 40 40 "typescript": "~5.9.3", 41 + "typescript-eslint": "^8.50.1", 41 42 "vite": "npm:rolldown-vite@7.2.5", 42 43 "vite-plugin-solid": "^2.11.10", 43 44 "vitest": "^4.0.16"
+70
web/pnpm-lock.yaml
··· 84 84 typescript: 85 85 specifier: ~5.9.3 86 86 version: 5.9.3 87 + typescript-eslint: 88 + specifier: ^8.50.1 89 + version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 87 90 vite: 88 91 specifier: npm:rolldown-vite@7.2.5 89 92 version: rolldown-vite@7.2.5(@types/node@24.10.4)(jiti@2.6.1) ··· 587 590 '@types/unist@3.0.3': 588 591 resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} 589 592 593 + '@typescript-eslint/eslint-plugin@8.50.1': 594 + resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} 595 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 596 + peerDependencies: 597 + '@typescript-eslint/parser': ^8.50.1 598 + eslint: ^8.57.0 || ^9.0.0 599 + typescript: '>=4.8.4 <6.0.0' 600 + 590 601 '@typescript-eslint/parser@8.50.1': 591 602 resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} 592 603 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} ··· 608 619 resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} 609 620 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 610 621 peerDependencies: 622 + typescript: '>=4.8.4 <6.0.0' 623 + 624 + '@typescript-eslint/type-utils@8.50.1': 625 + resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} 626 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 627 + peerDependencies: 628 + eslint: ^8.57.0 || ^9.0.0 611 629 typescript: '>=4.8.4 <6.0.0' 612 630 613 631 '@typescript-eslint/types@8.50.1': ··· 1028 1046 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} 1029 1047 engines: {node: '>= 4'} 1030 1048 1049 + ignore@7.0.5: 1050 + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} 1051 + engines: {node: '>= 4'} 1052 + 1031 1053 import-fresh@3.3.1: 1032 1054 resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} 1033 1055 engines: {node: '>=6'} ··· 1591 1613 type-check@0.4.0: 1592 1614 resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} 1593 1615 engines: {node: '>= 0.8.0'} 1616 + 1617 + typescript-eslint@8.50.1: 1618 + resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} 1619 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 1620 + peerDependencies: 1621 + eslint: ^8.57.0 || ^9.0.0 1622 + typescript: '>=4.8.4 <6.0.0' 1594 1623 1595 1624 typescript@5.9.3: 1596 1625 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} ··· 2222 2251 2223 2252 '@types/unist@3.0.3': {} 2224 2253 2254 + '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': 2255 + dependencies: 2256 + '@eslint-community/regexpp': 4.12.2 2257 + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 2258 + '@typescript-eslint/scope-manager': 8.50.1 2259 + '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 2260 + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 2261 + '@typescript-eslint/visitor-keys': 8.50.1 2262 + eslint: 9.39.2(jiti@2.6.1) 2263 + ignore: 7.0.5 2264 + natural-compare: 1.4.0 2265 + ts-api-utils: 2.2.0(typescript@5.9.3) 2266 + typescript: 5.9.3 2267 + transitivePeerDependencies: 2268 + - supports-color 2269 + 2225 2270 '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': 2226 2271 dependencies: 2227 2272 '@typescript-eslint/scope-manager': 8.50.1 ··· 2251 2296 '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': 2252 2297 dependencies: 2253 2298 typescript: 5.9.3 2299 + 2300 + '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': 2301 + dependencies: 2302 + '@typescript-eslint/types': 8.50.1 2303 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) 2304 + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 2305 + debug: 4.4.3 2306 + eslint: 9.39.2(jiti@2.6.1) 2307 + ts-api-utils: 2.2.0(typescript@5.9.3) 2308 + typescript: 5.9.3 2309 + transitivePeerDependencies: 2310 + - supports-color 2254 2311 2255 2312 '@typescript-eslint/types@8.50.1': {} 2256 2313 ··· 2687 2744 - supports-color 2688 2745 2689 2746 ignore@5.3.2: {} 2747 + 2748 + ignore@7.0.5: {} 2690 2749 2691 2750 import-fresh@3.3.1: 2692 2751 dependencies: ··· 3287 3346 type-check@0.4.0: 3288 3347 dependencies: 3289 3348 prelude-ls: 1.2.1 3349 + 3350 + typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): 3351 + dependencies: 3352 + '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 3353 + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 3354 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) 3355 + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 3356 + eslint: 9.39.2(jiti@2.6.1) 3357 + typescript: 5.9.3 3358 + transitivePeerDependencies: 3359 + - supports-color 3290 3360 3291 3361 typescript@5.9.3: {} 3292 3362
+4 -6
web/src/App.tsx
··· 4 4 import { AppLayout } from "./components/layout/AppLayout"; 5 5 import { authStore } from "./lib/store"; 6 6 import DeckNew from "./pages/DeckNew"; 7 + import DeckView from "./pages/DeckView"; 7 8 import Home from "./pages/Home"; 8 9 import Import from "./pages/Import"; 9 10 import Landing from "./pages/Landing"; 10 11 import Login from "./pages/Login"; 11 12 import NoteNew from "./pages/NoteNew"; 13 + import NotFound from "./pages/NotFound"; 12 14 13 15 const ProtectedRoute: Component<{ component: Component }> = (props) => { 14 16 return ( ··· 24 26 return ( 25 27 <Router> 26 28 <Route path="/login" component={Login} /> 27 - 28 - {/* Protected Routes */} 29 29 <Route path="/" component={() => <ProtectedRoute component={Home} />} /> 30 30 <Route path="/decks/new" component={() => <ProtectedRoute component={DeckNew} />} /> 31 31 <Route path="/notes/new" component={() => <ProtectedRoute component={NoteNew} />} /> 32 + <Route path="/decks/:id" component={() => <ProtectedRoute component={DeckView} />} /> 32 33 <Route path="/import" component={() => <ProtectedRoute component={Import} />} /> 33 - 34 - { 35 - /* TODO: Catch-all or 404 */ 36 - } 34 + <Route path="*" component={() => <ProtectedRoute component={NotFound} />} /> 37 35 </Router> 38 36 ); 39 37 };
+10 -4
web/src/components/CardEditor.tsx
··· 1 - import { createSignal, Show } from "solid-js"; 1 + import { createEffect, createSignal, Show } from "solid-js"; 2 2 import { Button } from "./ui/Button"; 3 3 4 4 type CardEditorProps = { ··· 10 10 }; 11 11 12 12 export function CardEditor(props: CardEditorProps) { 13 - const [front, setFront] = createSignal(props.front || ""); 14 - const [back, setBack] = createSignal(props.back || ""); 15 - const [mediaUrl, setMediaUrl] = createSignal(props.mediaUrl || ""); 13 + const [front, setFront] = createSignal(""); 14 + const [back, setBack] = createSignal(""); 15 + const [mediaUrl, setMediaUrl] = createSignal(""); 16 + 17 + createEffect(() => { 18 + if (props.front) setFront(props.front); 19 + if (props.back) setBack(props.back); 20 + if (props.mediaUrl) setMediaUrl(props.mediaUrl); 21 + }); 16 22 17 23 const handleSubmit = (e: Event) => { 18 24 e.preventDefault();
+4 -1
web/src/components/DeckEditor.test.tsx
··· 3 3 import { api } from "../lib/api"; 4 4 import { DeckEditor } from "./DeckEditor"; 5 5 6 - vi.mock("../lib/api", () => ({ api: { post: vi.fn() } })); 6 + vi.mock( 7 + "../lib/api", 8 + () => ({ api: { post: vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) } }), 9 + ); 7 10 8 11 describe("DeckEditor", () => { 9 12 afterEach(cleanup);
+11 -7
web/src/components/DeckEditor.tsx
··· 1 1 import { createSignal, For, Show } from "solid-js"; 2 2 import { api } from "../lib/api"; 3 - import type { Visibility } from "../lib/store"; 3 + import type { Card, CreateDeckPayload, Visibility } from "../lib/store"; 4 4 import { toast } from "../lib/toast"; 5 5 import { CardEditor } from "./CardEditor"; 6 6 import { Button } from "./ui/Button"; 7 7 8 - export function DeckEditor(props: { onSave?: (data: any) => void }) { 8 + export function DeckEditor(props: { onSave?: (deck: CreateDeckPayload) => void }) { 9 9 const [title, setTitle] = createSignal(""); 10 10 const [description, setDescription] = createSignal(""); 11 11 const [visibilityType, setVisibilityType] = createSignal<string>("Private"); 12 12 const [sharedWith, setSharedWith] = createSignal(""); 13 13 14 - const [cards, setCards] = createSignal<any[]>([]); 14 + const [cards, setCards] = createSignal<Card[]>([]); 15 15 const [showCardEditor, setShowCardEditor] = createSignal(false); 16 16 17 17 const handleSubmit = async (e: Event) => { ··· 32 32 } 33 33 34 34 try { 35 - const _res = await api.post("/decks", payload); 36 - toast.success("Deck created!"); 35 + const res = await api.post("/decks", payload); 36 + if (res.ok) { 37 + toast.success("Deck created!"); 38 + } else { 39 + toast.error("Failed to create deck"); 40 + } 37 41 } catch { 38 - toast.error("Failed to create deck"); 42 + toast.error("Network error creating deck"); 39 43 } 40 44 }; 41 45 42 - const addCard = (cardData: any) => { 46 + const addCard = (cardData: Card) => { 43 47 setCards([...cards(), cardData]); 44 48 setShowCardEditor(false); 45 49 };
+49 -27
web/src/components/NoteEditor.tsx
··· 1 + /* eslint-disable solid/no-innerhtml */ 1 2 import rehypeExternalLinks from "rehype-external-links"; 2 3 import rehypeSanitize from "rehype-sanitize"; 3 4 import rehypeStringify from "rehype-stringify"; 4 5 import remarkParse from "remark-parse"; 5 6 import remarkRehype from "remark-rehype"; 6 - import { createEffect, createSignal } from "solid-js"; 7 + import { createEffect, createSignal, Show } from "solid-js"; 7 8 import { unified } from "unified"; 8 9 import { api } from "../lib/api"; 9 10 import { toast } from "../lib/toast"; 10 11 import { Button } from "./ui/Button"; 11 12 12 - interface NoteEditorProps { 13 - noteId?: string; // If editing existing 14 - initialTitle?: string; 15 - initialContent?: string; 16 - } 13 + type NoteEditorProps = { noteId?: string; initialTitle?: string; initialContent?: string }; 17 14 18 15 export function NoteEditor(props: NoteEditorProps) { 19 16 const [title, setTitle] = createSignal(props.initialTitle || ""); 20 17 const [content, setContent] = createSignal(props.initialContent || ""); 21 18 const [preview, setPreview] = createSignal(""); 22 - const [tags, setTags] = createSignal(""); // Comma sep 23 - const [visibility, setVisibility] = createSignal("Private"); 19 + const [tags, setTags] = createSignal(""); 20 + const [visibilityType, setVisibilityType] = createSignal<string>("Private"); 21 + const [sharedWith, setSharedWith] = createSignal(""); 24 22 25 - const processor = unified().use(remarkParse) // .use(remarkWikiLink) // Would need a plugin for wikilinks -> links 26 - .use(remarkRehype).use(rehypeSanitize) // Safety first 27 - .use(rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] }).use(rehypeStringify); 23 + const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeSanitize).use(rehypeExternalLinks, { 24 + target: "_blank", 25 + rel: ["nofollow"], 26 + }).use(rehypeStringify); 28 27 29 - createEffect(async () => { 30 - try { 31 - const file = await processor.process(content()); 32 - setPreview(String(file)); 33 - } catch (e) { 34 - console.error(e); 35 - } 28 + createEffect(() => { 29 + processor.process(content()).then((file) => setPreview(String(file))).catch((e) => console.error(e)); 36 30 }); 37 31 38 32 const handleSubmit = async (e: Event) => { 39 33 e.preventDefault(); 40 34 try { 35 + let visibility; 36 + if (visibilityType() === "SharedWith") { 37 + visibility = { type: "SharedWith", content: sharedWith().split(",").map(s => s.trim()).filter(s => s) }; 38 + } else { 39 + visibility = { type: visibilityType() }; 40 + } 41 + 41 42 const payload = { 42 43 title: title(), 43 44 body: content(), 44 45 tags: tags().split(",").map(t => t.trim()).filter(t => t), 45 - visibility: { type: visibility() as "Private" | "Public" }, 46 + visibility, 46 47 }; 47 48 48 - await api.post("/notes", payload); 49 - toast.success("Note saved!"); 50 - if (!props.noteId) { 51 - setTitle(""); 52 - setContent(""); 53 - setTags(""); 49 + const res = await api.post("/notes", payload); 50 + if (res.ok) { 51 + toast.success("Note saved!"); 52 + if (!props.noteId) { 53 + setTitle(""); 54 + setContent(""); 55 + setTags(""); 56 + setVisibilityType("Private"); 57 + setSharedWith(""); 58 + } 59 + } else { 60 + toast.error("Failed to save note"); 54 61 } 55 62 } catch (e) { 56 63 console.error(e); ··· 97 104 <div> 98 105 <label class="block text-sm font-medium text-gray-400 mb-1">Visibility</label> 99 106 <select 100 - value={visibility()} 101 - onChange={e => setVisibility(e.target.value)} 107 + value={visibilityType()} 108 + onChange={e => setVisibilityType(e.target.value)} 102 109 class="w-full bg-gray-800 border-gray-700 text-white rounded p-2"> 103 110 <option value="Private">Private</option> 111 + <option value="Unlisted">Unlisted</option> 104 112 <option value="Public">Public</option> 113 + <option value="SharedWith">Shared With...</option> 105 114 </select> 106 115 </div> 107 116 </div> 108 117 118 + <Show when={visibilityType() === "SharedWith"}> 119 + <div> 120 + <label class="block text-sm font-medium text-gray-400 mb-1">Share with DIDs (comma separated)</label> 121 + <input 122 + type="text" 123 + value={sharedWith()} 124 + onInput={(e) => setSharedWith(e.target.value)} 125 + class="w-full bg-gray-800 border-gray-700 text-white rounded p-2" 126 + placeholder="did:plc:..., did:plc:..." /> 127 + </div> 128 + </Show> 129 + 109 130 <Button type="submit">Save Note</Button> 110 131 </form> 111 132 </div> 112 133 113 134 <div class="space-y-4"> 114 135 <h2 class="text-xl font-semibold text-gray-300">Preview</h2> 136 + {} 115 137 <div 116 138 class="prose prose-invert max-w-none bg-gray-900/50 p-6 rounded border border-gray-800 min-h-[500px]" 117 139 innerHTML={preview()} />
+1 -1
web/src/lib/api.ts
··· 26 26 27 27 export const api = { 28 28 get: (path: string) => apiFetch(path, { method: "GET" }), 29 - post: (path: string, body: any) => apiFetch(path, { method: "POST", body: JSON.stringify(body) }), 29 + post: (path: string, body: unknown) => apiFetch(path, { method: "POST", body: JSON.stringify(body) }), 30 30 };
+10
web/src/lib/store.ts
··· 46 46 content: string[]; 47 47 }; 48 48 49 + export type Card = { id?: string; front: string; back: string; mediaUrl?: string }; 50 + 49 51 export type Deck = { 50 52 id: string; 51 53 owner_did: string; ··· 56 58 published_at?: string; 57 59 fork_of?: string; 58 60 }; 61 + 62 + export type CreateDeckPayload = { 63 + title: string; 64 + description: string; 65 + tags: string[]; 66 + visibility: Visibility; 67 + cards: Card[]; 68 + };
+3 -2
web/src/pages/DeckNew.tsx
··· 2 2 import type { Component } from "solid-js"; 3 3 import { DeckEditor } from "../components/DeckEditor"; 4 4 import { api } from "../lib/api"; 5 + import type { Card, CreateDeckPayload } from "../lib/store"; 5 6 import { toast } from "../lib/toast"; 6 7 7 8 const DeckNew: Component = () => { 8 9 const navigate = useNavigate(); 9 10 10 - const handleSave = async (data: any) => { 11 + const handleSave = async (data: CreateDeckPayload) => { 11 12 try { 12 13 const { cards, ...deckPayload } = data; 13 14 const res = await api.post("/decks", deckPayload); ··· 17 18 18 19 if (cards && cards.length > 0) { 19 20 await Promise.all( 20 - cards.map((c: any) => 21 + cards.map((c: Card) => 21 22 api.post("/cards", { deck_id: deck.id, front: c.front, back: c.back, media_url: c.mediaUrl }) 22 23 ), 23 24 );
+67
web/src/pages/DeckView.test.tsx
··· 1 + import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { JSX } from "solid-js"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import { api } from "../lib/api"; 5 + import DeckView from "./DeckView"; 6 + 7 + vi.mock("../lib/api", () => ({ api: { get: vi.fn() } })); 8 + 9 + vi.mock( 10 + "@solidjs/router", 11 + () => ({ 12 + useParams: () => ({ id: "123" }), 13 + A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 14 + }), 15 + ); 16 + 17 + describe("DeckView", () => { 18 + afterEach(cleanup); 19 + 20 + it("renders deck details and cards", async () => { 21 + const deck = { 22 + id: "123", 23 + title: "Test Deck", 24 + description: "A test deck", 25 + tags: ["test"], 26 + visibility: { type: "Public" }, 27 + owner_did: "did:test", 28 + }; 29 + 30 + const cards = [{ id: "c1", front: "Front 1", back: "Back 1" }, { id: "c2", front: "Front 2", back: "Back 2" }]; 31 + 32 + vi.mocked(api.get).mockImplementation( 33 + ((path: string) => { 34 + if (path === "/decks/123") { 35 + return Promise.resolve({ ok: true, json: () => Promise.resolve(deck) }); 36 + } 37 + if (path === "/decks/123/cards") { 38 + return Promise.resolve({ ok: true, json: () => Promise.resolve(cards) }); 39 + } 40 + return Promise.reject(new Error(`Unexpected path: ${path}`)); 41 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 + }) as any, 43 + ); 44 + 45 + render(() => <DeckView />); 46 + 47 + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 48 + expect(screen.getByText("A test deck")).toBeInTheDocument(); 49 + expect(screen.getByText("#test")).toBeInTheDocument(); 50 + expect(screen.getByText("Front 1")).toBeInTheDocument(); 51 + expect(screen.getByText("Front 2")).toBeInTheDocument(); 52 + expect(screen.getByText("Back 1")).toBeInTheDocument(); 53 + }); 54 + 55 + it("renders not found state when deck returns error", async () => { 56 + vi.mocked(api.get).mockImplementation( 57 + (() => { 58 + return Promise.resolve({ ok: false }); 59 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 + }) as any, 61 + ); 62 + 63 + render(() => <DeckView />); 64 + 65 + await waitFor(() => expect(screen.getByText(/Deck not found/i)).toBeInTheDocument()); 66 + }); 67 + });
+124
web/src/pages/DeckView.tsx
··· 1 + import { A, useParams } from "@solidjs/router"; 2 + import type { Component } from "solid-js"; 3 + import { createResource, For, Show } from "solid-js"; 4 + import { api } from "../lib/api"; 5 + import type { Visibility } from "../lib/store"; 6 + 7 + type Deck = { 8 + id: string; 9 + title: string; 10 + description: string; 11 + tags: string[]; 12 + visibility: Visibility; 13 + owner_did: string; 14 + }; 15 + 16 + type Card = { id: string; front: string; back?: string }; 17 + 18 + const fetchDeck = async (id: string): Promise<Deck | null> => { 19 + const res = await api.get(`/decks/${id}`); 20 + if (!res.ok) return null; 21 + return res.json(); 22 + }; 23 + 24 + const fetchCards = async (id: string): Promise<Card[]> => { 25 + const res = await api.get(`/decks/${id}/cards`); 26 + if (!res.ok) return []; 27 + return res.json(); 28 + }; 29 + 30 + const DeckView: Component = () => { 31 + const params = useParams(); 32 + 33 + const [deck] = createResource(() => params.id, fetchDeck); 34 + const [cards] = createResource(() => params.id, fetchCards); 35 + 36 + return ( 37 + <div class="max-w-4xl mx-auto px-6 py-12"> 38 + <Show when={deck.loading}> 39 + <div class="text-[#8D8D8D] font-light">Loading deck...</div> 40 + </Show> 41 + 42 + <Show when={!deck.loading && deck() === null}> 43 + <div class="p-8 border border-red-900/50 bg-red-900/10 text-red-400"> 44 + Deck not found or you don't have access. 45 + </div> 46 + </Show> 47 + 48 + <Show when={deck()}> 49 + <div class="mb-12"> 50 + <div class="flex justify-between items-start mb-4"> 51 + <h1 class="text-4xl font-light text-[#F4F4F4] tracking-tight">{deck()?.title}</h1> 52 + <Show when={deck()?.visibility.type !== "Public"}> 53 + <span class="text-xs uppercase font-bold tracking-widest px-2 py-1 bg-[#393939] text-[#C6C6C6]"> 54 + {deck()?.visibility.type} 55 + </span> 56 + </Show> 57 + </div> 58 + 59 + <p class="text-[#C6C6C6] mb-6 font-light">{deck()?.description}</p> 60 + 61 + <div class="flex gap-2 mb-8"> 62 + <For each={deck()?.tags}> 63 + {(tag) => ( 64 + <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-1 border border-[#393939]">#{tag}</span> 65 + )} 66 + </For> 67 + </div> 68 + 69 + <div class="flex gap-4 border-t border-[#393939] pt-6"> 70 + {/* Placeholder for Study Action */} 71 + <button class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors"> 72 + Study Deck (Coming Soon) 73 + </button> 74 + <A 75 + href="/" 76 + class="px-6 py-3 border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] font-medium text-sm transition-colors"> 77 + Back to Library 78 + </A> 79 + </div> 80 + </div> 81 + 82 + <div> 83 + <h2 class="text-xl font-medium text-[#F4F4F4] mb-6 border-b border-[#393939] pb-4"> 84 + Cards ({cards()?.length || 0}) 85 + </h2> 86 + 87 + <Show when={cards.loading}> 88 + <div class="text-[#8D8D8D] font-light text-sm">Loading cards...</div> 89 + </Show> 90 + 91 + <div class="grid gap-4"> 92 + <For each={cards()}> 93 + {(card, i) => ( 94 + <div class="p-6 bg-[#262626] border border-[#393939] hover:border-[#525252] transition-colors group"> 95 + <div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono"> 96 + <span class="opacity-50">CARD {i() + 1}</span> 97 + </div> 98 + <div class="grid md:grid-cols-2 gap-8"> 99 + <div class="prose prose-invert prose-sm max-w-none"> 100 + <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div> 101 + <div class="text-[#E0E0E0]">{card.front}</div> 102 + </div> 103 + <div class="prose prose-invert prose-sm max-w-none md:border-l md:border-[#393939] md:pl-8"> 104 + <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div> 105 + <div class="text-[#C6C6C6]">{card.back || <span class="italic opacity-50">Empty</span>}</div> 106 + </div> 107 + </div> 108 + </div> 109 + )} 110 + </For> 111 + 112 + <Show when={!cards.loading && cards()?.length === 0}> 113 + <div class="text-center py-12 border border-dashed border-[#393939] text-[#8D8D8D] font-light italic"> 114 + No cards in this deck. 115 + </div> 116 + </Show> 117 + </div> 118 + </div> 119 + </Show> 120 + </div> 121 + ); 122 + }; 123 + 124 + export default DeckView;
+21
web/src/pages/NotFound.tsx
··· 1 + import { A } from "@solidjs/router"; 2 + import type { Component } from "solid-js"; 3 + 4 + const NotFound: Component = () => { 5 + return ( 6 + <div class="max-w-7xl mx-auto px-6 py-24 text-center"> 7 + <h1 class="text-8xl font-thin text-[#393939] mb-4">404</h1> 8 + <h2 class="text-2xl font-light text-[#F4F4F4] mb-6">Page not found</h2> 9 + <p class="text-[#C6C6C6] mb-12 max-w-md mx-auto font-light"> 10 + The page you are looking for might have been removed, had its name changed, or is temporarily unavailable. 11 + </p> 12 + <A 13 + href="/" 14 + class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-8 py-3 font-medium text-sm transition-colors inline-block"> 15 + Go to Library 16 + </A> 17 + </div> 18 + ); 19 + }; 20 + 21 + export default NotFound;
-1
web/src/pages/NoteNew.tsx
··· 1 - import { useNavigate } from "@solidjs/router"; 2 1 import { NoteEditor } from "../components/NoteEditor"; 3 2 4 3 const NoteNew = () => {