🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add sidebar navigation and edit page improvements

juprodh 5fd3760a 15def8b8

+143 -59
+10 -2
public/editor/editor.ts
··· 25 25 const wrapper = document.createElement("div"); 26 26 wrapper.style.display = "flex"; 27 27 wrapper.style.gap = "1rem"; 28 - wrapper.style.minHeight = "400px"; 28 + wrapper.style.height = "100%"; 29 + wrapper.style.minHeight = "0"; 29 30 30 31 const editorPane = document.createElement("div"); 31 32 editorPane.style.flex = "1"; 32 33 editorPane.style.minWidth = "0"; 33 - editorPane.style.overflow = "auto"; 34 + editorPane.style.overflow = "hidden"; 34 35 editorPane.style.border = "1px solid #d1d5db"; 35 36 editorPane.style.borderRadius = "0.375rem"; 37 + editorPane.style.display = "flex"; 38 + editorPane.style.flexDirection = "column"; 36 39 37 40 const preview = document.createElement("div"); 38 41 preview.className = "prose max-w-none"; ··· 56 59 extensions: [ 57 60 basicSetup, 58 61 markdown(), 62 + EditorView.theme({ 63 + "&": { flex: "1", display: "flex", flexDirection: "column" }, 64 + ".cm-scroller": { flex: "1", overflow: "auto" }, 65 + }), 66 + EditorView.lineWrapping, 59 67 EditorView.updateListener.of((update) => { 60 68 if (update.docChanged) { 61 69 clearTimeout(debounceTimer);
+8
src/server/db/queries.ts
··· 192 192 newContent: string, 193 193 did: string, 194 194 message?: string, 195 + newTitle?: string, 195 196 ): { revisionAtUri: string } { 196 197 const db = getDb(); 197 198 ··· 216 217 const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; 217 218 218 219 db.transaction(() => { 220 + if (newTitle) { 221 + db.run("UPDATE notes SET title = ? WHERE wiki_slug = ? AND slug = ?", [ 222 + newTitle, 223 + wikiSlug, 224 + noteSlug, 225 + ]); 226 + } 219 227 db.run( 220 228 `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) 221 229 VALUES (?, ?, ?, ?, ?, ?)`,
+20 -10
src/server/db/seed.ts
··· 9 9 10 10 console.log("Seeding database with sample data..."); 11 11 12 + const homeTid = generateTid(); 12 13 const helloTid = generateTid(); 13 14 const gettingStartedTid = generateTid(); 14 - const secretsTid = generateTid(); 15 15 const mockDid = "did:plc:mock123"; 16 16 17 17 const noteAtUris = { 18 + home: `at://${mockDid}/pub.coral.note/${homeTid}`, 18 19 hello: `at://${mockDid}/pub.coral.note/${helloTid}`, 19 20 gettingStarted: `at://${mockDid}/pub.coral.note/${gettingStartedTid}`, 20 - secrets: `at://${mockDid}/pub.coral.note/${secretsTid}`, 21 21 }; 22 22 23 23 const noteContents: Record<string, string> = { 24 + [noteAtUris.home]: `# Welcome to the Test Wiki 25 + 26 + This is the **home page**. It appears when you visit the wiki root. 27 + 28 + ## Quick Links 29 + 30 + - [[hello|Hello World]] — a sample note 31 + - [[getting-started|Getting Started]] — how to use atwiki 32 + 33 + ## About 34 + 35 + This wiki is powered by ATProto. Every edit creates a diff stored permanently on the protocol. 36 + `, 24 37 [noteAtUris.hello]: `# Hello World 25 38 26 39 Welcome to the **Test Wiki**. This is a sample note rendered with markdown-it. ··· 43 56 2. Create or join a wiki 44 57 3. Start writing 45 58 `, 46 - [noteAtUris.secrets]: "# Secret Stuff\n\nThis is a private note.\n", 47 59 }; 48 60 49 61 db.run("BEGIN TRANSACTION"); 50 62 try { 51 63 db.run(` 52 64 INSERT INTO wikis (slug, did, name, visibility, at_uri) VALUES 53 - ('test', '${mockDid}', 'Test Wiki', 'public', 'at://${mockDid}/pub.coral.wiki/test'), 54 - ('private-notes', '${mockDid}', 'Private Notes', 'private', 'at://${mockDid}/pub.coral.wiki/private-notes') 65 + ('test', '${mockDid}', 'Test Wiki', 'public', 'at://${mockDid}/pub.coral.wiki/test') 55 66 `); 56 67 57 68 db.run( 58 69 "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 70 + ["home", "test", "Home", mockDid, noteAtUris.home], 71 + ); 72 + db.run( 73 + "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 59 74 ["hello", "test", "Hello World", mockDid, noteAtUris.hello], 60 75 ); 61 76 db.run( ··· 68 83 noteAtUris.gettingStarted, 69 84 ], 70 85 ); 71 - db.run( 72 - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 73 - ["secrets", "private-notes", "Secret Stuff", mockDid, noteAtUris.secrets], 74 - ); 75 - 76 86 for (const [atUri, content] of Object.entries(noteContents)) { 77 87 db.run( 78 88 `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at)
+26 -1
src/server/routes/note.ts
··· 11 11 getCurrentNote, 12 12 getNoteBySlug, 13 13 getWiki, 14 + listNotes, 14 15 saveNoteEdit, 15 16 } from "../db/queries.ts"; 16 17 ··· 27 28 return session?.did ?? DEV_DID; 28 29 } 29 30 31 + function getSidebarNotes(wikiSlug: string) { 32 + return listNotes(wikiSlug).map((n) => ({ slug: n.slug, title: n.title })); 33 + } 34 + 30 35 export const noteRoutes = new Elysia({ prefix: "/wiki" }) 31 36 .get("/:wikiSlug/new", async ({ params, request }) => { 32 37 const wiki = getWiki(params.wikiSlug); ··· 34 39 return new Response("Wiki not found", { status: 404 }); 35 40 } 36 41 const session = await getSessionFromRequest(request); 42 + const sidebarNotes = getSidebarNotes(params.wikiSlug); 37 43 return new Response( 38 44 newNotePage(wiki.name, params.wikiSlug, { 39 45 session, 40 46 scripts: EDITOR_SCRIPTS, 47 + sidebarNotes, 41 48 }), 42 49 { headers: { "Content-Type": "text/html" } }, 43 50 ); ··· 54 61 const content = (formData.get("content") as string | null) ?? ""; 55 62 const message = (formData.get("message") as string | null) ?? undefined; 56 63 64 + const sidebarNotes = getSidebarNotes(params.wikiSlug); 65 + 57 66 if (!title.trim()) { 58 67 return new Response( 59 68 newNotePage(wiki.name, params.wikiSlug, { 60 69 session, 61 70 scripts: EDITOR_SCRIPTS, 71 + sidebarNotes, 62 72 error: "Title is required.", 63 73 contentValue: content, 64 74 }), ··· 73 83 newNotePage(wiki.name, params.wikiSlug, { 74 84 session, 75 85 scripts: EDITOR_SCRIPTS, 86 + sidebarNotes, 76 87 error: `"${title}" produces an invalid slug. Please use a different title.`, 77 88 titleValue: title, 78 89 contentValue: content, ··· 87 98 newNotePage(wiki.name, params.wikiSlug, { 88 99 session, 89 100 scripts: EDITOR_SCRIPTS, 101 + sidebarNotes, 90 102 error: `A note with slug "${noteSlug}" already exists.`, 91 103 titleValue: title, 92 104 contentValue: content, ··· 139 151 params.noteSlug, 140 152 note.title, 141 153 current.content, 142 - { session, scripts: EDITOR_SCRIPTS }, 154 + { 155 + session, 156 + scripts: EDITOR_SCRIPTS, 157 + }, 143 158 ), 144 159 { headers: { "Content-Type": "text/html" } }, 145 160 ); ··· 157 172 158 173 const session = await getSessionFromRequest(request); 159 174 const formData = await request.formData(); 175 + const title = (formData.get("title") as string | null) ?? ""; 160 176 const content = (formData.get("content") as string | null) ?? ""; 161 177 const message = (formData.get("message") as string | null) ?? undefined; 162 178 179 + if (!title.trim()) { 180 + return new Response("Title is required.", { status: 400 }); 181 + } 182 + 163 183 try { 164 184 const did = getEffectiveDid(session); 185 + const newTitle = title !== note.title ? title : undefined; 165 186 saveNoteEdit( 166 187 params.wikiSlug, 167 188 params.noteSlug, 168 189 content, 169 190 did, 170 191 message || undefined, 192 + newTitle, 171 193 ); 172 194 } catch (err) { 173 195 const msg = err instanceof Error ? err.message : String(err); ··· 199 221 200 222 const { html, hasViz } = renderMarkdown(current.content, params.wikiSlug); 201 223 const session = await getSessionFromRequest(request); 224 + const sidebarNotes = getSidebarNotes(params.wikiSlug); 202 225 return new Response( 203 226 notePage(wiki.name, params.wikiSlug, params.noteSlug, note.title, html, { 204 227 scripts: hasViz ? VIZ_SCRIPTS : undefined, 205 228 session, 229 + sidebarNotes, 230 + currentNoteSlug: params.noteSlug, 206 231 }), 207 232 { headers: { "Content-Type": "text/html" } }, 208 233 );
+13 -7
src/server/routes/wiki.ts
··· 27 27 28 28 const session = await getSessionFromRequest(request); 29 29 30 + const sidebarNotes = listNotes(params.wikiSlug).map((n) => ({ 31 + slug: n.slug, 32 + title: n.title, 33 + })); 34 + 30 35 const homeNote = getNoteBySlug(params.wikiSlug, "home"); 31 36 if (homeNote) { 32 37 const current = getCurrentNote(params.wikiSlug, "home"); ··· 39 44 notePage(wiki.name, params.wikiSlug, "home", homeNote.title, html, { 40 45 scripts: hasViz ? VIZ_SCRIPTS : undefined, 41 46 session, 47 + sidebarNotes, 48 + currentNoteSlug: "home", 42 49 }), 43 50 { headers: { "Content-Type": "text/html" } }, 44 51 ); 45 52 } 46 53 } 47 54 48 - const notes = listNotes(params.wikiSlug).map((n) => ({ 49 - slug: n.slug, 50 - title: n.title, 51 - })); 52 - return new Response(wikiPage(wiki.name, wiki.slug, notes, { session }), { 53 - headers: { "Content-Type": "text/html" }, 54 - }); 55 + return new Response( 56 + wikiPage(wiki.name, wiki.slug, sidebarNotes, { session, sidebarNotes }), 57 + { 58 + headers: { "Content-Type": "text/html" }, 59 + }, 60 + ); 55 61 }, 56 62 ); 57 63
+17 -9
src/views/edit-note.ts
··· 12 12 return layout( 13 13 `Edit ${noteTitle} — ${wikiName}`, 14 14 ` 15 - <div class="flex items-center justify-between mb-6"> 16 - <h1 class="text-3xl font-bold text-gray-900">Editing ${escapeHtml(noteTitle)}</h1> 17 - <a href="/wiki/${wikiSlug}/${noteSlug}" class="text-sm text-gray-500 hover:underline">Cancel</a> 18 - </div> 19 - <form method="POST" action="/wiki/${wikiSlug}/${noteSlug}/edit" class="space-y-4"> 20 - <div> 15 + <form method="POST" action="/wiki/${wikiSlug}/${noteSlug}/edit" class="flex flex-col gap-4 md:h-[calc(100vh-8rem)] md:overflow-hidden"> 16 + <div class="shrink-0"> 17 + <label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label> 18 + <input 19 + id="title" 20 + name="title" 21 + type="text" 22 + value="${escapeHtml(noteTitle)}" 23 + required 24 + class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" 25 + > 26 + </div> 27 + <div class="flex-1 min-h-0 pb-4"> 21 28 <label for="content" class="block text-sm font-medium text-gray-700 mb-1">Content</label> 22 29 <textarea 23 30 id="content" 24 31 name="content" 25 32 rows="24" 26 - class="w-full font-mono text-sm border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" 33 + class="w-full h-full font-mono text-sm border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 md:resize-none" 27 34 >${escapeForTextarea(currentContent)}</textarea> 28 35 </div> 29 - <div> 36 + <div class="shrink-0"> 30 37 <label for="message" class="block text-sm font-medium text-gray-700 mb-1">Edit summary (optional)</label> 31 38 <input 32 39 id="message" ··· 36 43 placeholder="Describe your changes" 37 44 > 38 45 </div> 39 - <div> 46 + <div class="shrink-0 flex items-center gap-3 pb-1"> 40 47 <button 41 48 type="submit" 42 49 class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded hover:bg-indigo-700" 43 50 >Save</button> 51 + <a href="/wiki/${wikiSlug}/${noteSlug}" class="text-sm text-gray-500 hover:underline">Cancel</a> 44 52 </div> 45 53 </form> 46 54 `,
+33 -3
src/views/layout.ts
··· 5 5 session?: { did: string; handle: string } | null; 6 6 wikiName?: string; 7 7 wikiSlug?: string; 8 + sidebarNotes?: { slug: string; title: string }[]; 9 + currentNoteSlug?: string; 10 + pageTitle?: string; 11 + } 12 + 13 + function renderWithSidebar(body: string, options: LayoutOptions): string { 14 + const notes = [...(options.sidebarNotes ?? [])].sort((a, b) => 15 + a.title.localeCompare(b.title), 16 + ); 17 + const noteLinks = notes 18 + .map((n) => { 19 + const isCurrent = n.slug === options.currentNoteSlug; 20 + return `<li><a href="/wiki/${options.wikiSlug}/${n.slug}" class="${isCurrent ? "font-bold text-indigo-700" : "text-gray-700 hover:text-indigo-600"} block truncate text-sm">${escapeHtml(n.title)}</a></li>`; 21 + }) 22 + .join("\n"); 23 + 24 + const editLink = options.currentNoteSlug 25 + ? `<a href="/wiki/${options.wikiSlug}/${options.currentNoteSlug}/edit" class="block mb-2 px-3 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded hover:bg-indigo-700 text-center">Edit</a>` 26 + : ""; 27 + 28 + return `<div class="max-w-6xl mx-auto pl-6 pr-16 py-8 flex flex-col md:flex-row gap-8"> 29 + <aside class="order-2 md:order-first w-full md:w-44 md:shrink-0"> 30 + ${editLink} 31 + <a href="/wiki/${options.wikiSlug}/new" class="block mb-4 px-3 py-1.5 border border-indigo-600 text-indigo-600 text-sm font-medium rounded hover:bg-indigo-50 text-center">New Note</a> 32 + <nav> 33 + <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Pages</h3> 34 + <ul class="space-y-1">${noteLinks}</ul> 35 + </nav> 36 + </aside> 37 + <main class="order-1 md:order-last flex-1 min-w-0 flex justify-center"><div class="w-full max-w-3xl">${body}</div></main> 38 + </div>`; 8 39 } 9 40 10 41 export function layout( ··· 44 75 ${authHtml} 45 76 </div> 46 77 </nav> 47 - <main class="max-w-4xl mx-auto px-6 py-8"> 48 - ${body} 49 - </main> 78 + ${options?.pageTitle ? `<div class="text-center py-6 border-b border-gray-100"><h1 class="text-3xl font-bold text-gray-900">${escapeHtml(options.pageTitle)}</h1></div>` : ""} 79 + ${options?.sidebarNotes ? renderWithSidebar(body, options) : `<main class="max-w-6xl mx-auto px-6 py-8">${body}</main>`} 50 80 </body> 51 81 </html>`; 52 82 }
+2 -7
src/views/note.ts
··· 1 - import { escapeHtml } from "../lib/html.ts"; 2 1 import { type LayoutOptions, layout } from "./layout.ts"; 3 2 4 3 export function notePage( 5 4 wikiName: string, 6 5 wikiSlug: string, 7 - noteSlug: string, 6 + _noteSlug: string, 8 7 noteTitle: string, 9 8 renderedHtml: string, 10 9 options?: LayoutOptions, ··· 12 11 return layout( 13 12 `${noteTitle} — ${wikiName}`, 14 13 ` 15 - <div class="text-center mb-6"> 16 - <h1 class="text-3xl font-bold text-gray-900">${escapeHtml(noteTitle)}</h1> 17 - <a href="/wiki/${wikiSlug}/${noteSlug}/edit" class="text-sm text-indigo-600 hover:underline">Edit</a> 18 - </div> 19 14 <article class="prose max-w-none"> 20 15 ${renderedHtml} 21 16 </article> 22 17 `, 23 - { ...options, wikiName, wikiSlug }, 18 + { ...options, wikiName, wikiSlug, pageTitle: noteTitle }, 24 19 ); 25 20 }
+8 -14
tests/server/db/queries.test.ts
··· 46 46 describe("listWikis", () => { 47 47 test("returns seeded wikis", () => { 48 48 const wikis = listWikis(); 49 - expect(wikis.length).toBe(2); 49 + expect(wikis.length).toBe(1); 50 50 const slugs = wikis.map((w) => w.slug); 51 51 expect(slugs).toContain("test"); 52 - expect(slugs).toContain("private-notes"); 53 52 }); 54 53 55 54 test("returns all expected fields", () => { ··· 82 81 describe("listNotes", () => { 83 82 test("returns notes for a wiki", () => { 84 83 const notes = listNotes("test"); 85 - expect(notes.length).toBe(2); 84 + expect(notes.length).toBe(3); 86 85 const slugs = notes.map((n) => n.slug); 86 + expect(slugs).toContain("home"); 87 87 expect(slugs).toContain("hello"); 88 88 expect(slugs).toContain("getting-started"); 89 89 }); ··· 94 94 expect(hello?.title).toBe("Hello World"); 95 95 }); 96 96 97 - test("returns notes for private wiki", () => { 98 - const notes = listNotes("private-notes"); 99 - expect(notes.length).toBe(1); 100 - expect(notes[0]?.slug).toBe("secrets"); 101 - }); 102 - 103 97 test("returns empty array for nonexistent wiki", () => { 104 98 const notes = listNotes("nonexistent"); 105 99 expect(notes).toEqual([]); ··· 140 134 expect(hello?.content).not.toBe(gettingStarted?.content); 141 135 }); 142 136 143 - test("private wiki note is accessible", () => { 144 - const secrets = getCurrentNote("private-notes", "secrets"); 145 - expect(secrets).not.toBeNull(); 146 - expect(secrets?.content).toContain("# Secret Stuff"); 137 + test("home note is accessible", () => { 138 + const home = getCurrentNote("test", "home"); 139 + expect(home).not.toBeNull(); 140 + expect(home?.content).toContain("# Welcome to the Test Wiki"); 147 141 }); 148 142 }); 149 143 ··· 162 156 }); 163 157 164 158 test("returns null for wrong wiki", () => { 165 - const note = getNoteBySlug("private-notes", "hello"); 159 + const note = getNoteBySlug("nonexistent", "hello"); 166 160 expect(note).toBeNull(); 167 161 }); 168 162 });
+6 -6
tests/server/db/seed.test.ts
··· 18 18 name: string; 19 19 visibility: string; 20 20 }[]; 21 - expect(wikis.length).toBe(2); 22 - expect(wikis[0]?.slug).toBe("private-notes"); 23 - expect(wikis[0]?.visibility).toBe("private"); 24 - expect(wikis[1]?.slug).toBe("test"); 25 - expect(wikis[1]?.visibility).toBe("public"); 21 + expect(wikis.length).toBe(1); 22 + expect(wikis[0]?.slug).toBe("test"); 23 + expect(wikis[0]?.visibility).toBe("public"); 26 24 }); 27 25 28 26 test("seeds notes with titles", () => { ··· 33 31 title: string; 34 32 }[]; 35 33 expect(notes.length).toBe(3); 34 + const home = notes.find((n) => n.slug === "home"); 35 + expect(home?.title).toBe("Home"); 36 36 const hello = notes.find((n) => n.slug === "hello"); 37 37 expect(hello?.title).toBe("Hello World"); 38 38 const gs = notes.find((n) => n.slug === "getting-started"); ··· 69 69 const wikis = db.query("SELECT COUNT(*) as n FROM wikis").get() as { 70 70 n: number; 71 71 }; 72 - expect(wikis.n).toBe(2); 72 + expect(wikis.n).toBe(1); 73 73 }); 74 74 75 75 test("note contents contain wikilinks", () => {