🌿 Collaborative wiki on ATProto
0
fork

Configure Feed

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

Add possibility to change wiki name and description in settings

juprodh f613e072 72929e48

+222 -5
+5
src/lib/i18n/en.ts
··· 128 128 }, 129 129 settings: { 130 130 heading: "Settings", 131 + details: "Wiki details", 132 + nameLabel: "Name", 133 + descriptionLabel: "Description", 134 + save: "Save changes", 135 + detailsSaved: "Wiki details saved.", 131 136 dangerZone: "Delete this wiki", 132 137 deleteWikiDescription: 133 138 "This will permanently delete the wiki and all its notes. This action cannot be undone.",
+5
src/lib/i18n/fr.ts
··· 130 130 }, 131 131 settings: { 132 132 heading: "Paramètres", 133 + details: "Détails du wiki", 134 + nameLabel: "Nom", 135 + descriptionLabel: "Description", 136 + save: "Enregistrer les modifications", 137 + detailsSaved: "Détails du wiki enregistrés.", 133 138 dangerZone: "Supprimer ce wiki", 134 139 deleteWikiDescription: 135 140 "Cela supprimera définitivement le wiki et toutes ses notes. Cette action est irréversible.",
+5
src/lib/i18n/index.ts
··· 128 128 }; 129 129 settings: { 130 130 heading: string; 131 + details: string; 132 + nameLabel: string; 133 + descriptionLabel: string; 134 + save: string; 135 + detailsSaved: string; 131 136 dangerZone: string; 132 137 deleteWikiDescription: string; 133 138 deleteWikiOwnerOnly: string;
+53
src/lib/orchestrators/wiki.ts
··· 200 200 } 201 201 202 202 /** 203 + * Update a wiki's name and description. Admin only (route-gated). 204 + * Slug, visibility, language, and createdAt are preserved. 205 + */ 206 + export async function editWikiAction( 207 + ctx: WikiRequestContext, 208 + fields: { name: string; description: string }, 209 + msg: Messages, 210 + ): Promise<void> { 211 + if (!fields.name.trim()) { 212 + throw new ValidationError(msg.error.wikiNameRequired); 213 + } 214 + if (fields.name.length > LIMITS.wiki.name) { 215 + throw new ValidationError( 216 + fmt(msg.error.wikiNameTooLong, { max: String(LIMITS.wiki.name) }), 217 + ); 218 + } 219 + 220 + const did = ctx.did; 221 + if (!did) throw new ForbiddenError(); 222 + 223 + const description = fields.description 224 + .trim() 225 + .slice(0, LIMITS.wiki.description); 226 + 227 + if (ctx.session) { 228 + const agent = await getAgent(ctx.session); 229 + await withPdsError("edit wiki", async () => { 230 + await writeWikiRecord( 231 + agent, 232 + did, 233 + ctx.wiki.slug, 234 + fields.name, 235 + ctx.wiki.visibility as "public" | "private", 236 + ctx.wiki.created_at, 237 + ctx.wiki.language, 238 + description || undefined, 239 + ); 240 + }); 241 + } 242 + 243 + upsertWiki( 244 + ctx.wiki.slug, 245 + ctx.wiki.did, 246 + fields.name, 247 + ctx.wiki.visibility, 248 + ctx.wiki.at_uri, 249 + ctx.wiki.created_at, 250 + ctx.wiki.language, 251 + description, 252 + ); 253 + } 254 + 255 + /** 203 256 * Delete a wiki. Owner only. 204 257 * PDS cleanup (wiki record + membership records) → DB cascade delete. 205 258 * Throws ForbiddenError if caller is not the wiki owner.
+44 -1
src/server/routes/wiki.ts
··· 19 19 import { 20 20 createWikiAction, 21 21 deleteWikiAction, 22 + editWikiAction, 22 23 } from "../../lib/orchestrators/wiki.ts"; 23 24 import { resolveProfiles } from "../../lib/profile.ts"; 24 25 import { htmlResponse } from "../../lib/response.ts"; ··· 104 105 throw err; 105 106 } 106 107 }) 107 - .get("/:wikiSlug/-/settings", async ({ params, request }) => { 108 + .get("/:wikiSlug/-/settings", async ({ params, request, query }) => { 108 109 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 109 110 const isOwner = ctx.did === ctx.wiki.did; 110 111 const { members, requests, profiles } = await loadSettingsData( ··· 124 125 locale: ctx.locale, 125 126 accessLevel: ctx.access, 126 127 wikiDid: ctx.wiki.did, 128 + wikiDescription: ctx.wiki.description, 129 + detailsSaved: query["saved"] === "1", 127 130 }, 128 131 ), 129 132 ); 130 133 }) 134 + .post("/:wikiSlug/-/edit", async ({ params, request }) => { 135 + const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 136 + const msg = t(ctx.locale); 137 + const formData = await request.formData(); 138 + const name = (formData.get("name") as string | null) ?? ""; 139 + const description = (formData.get("description") as string | null) ?? ""; 140 + 141 + try { 142 + await editWikiAction(ctx, { name, description }, msg); 143 + return redirect(`/wiki/${params.wikiSlug}/-/settings?saved=1`); 144 + } catch (err) { 145 + if (err instanceof ValidationError) { 146 + const isOwner = ctx.did === ctx.wiki.did; 147 + const { members, requests, profiles } = await loadSettingsData( 148 + params.wikiSlug, 149 + ); 150 + return htmlResponse( 151 + settingsPage( 152 + name || ctx.wiki.name, 153 + params.wikiSlug, 154 + isOwner, 155 + members, 156 + requests, 157 + profiles, 158 + { 159 + session: ctx.session, 160 + locale: ctx.locale, 161 + accessLevel: ctx.access, 162 + wikiDid: ctx.wiki.did, 163 + wikiDescription: description, 164 + error: err.message, 165 + }, 166 + ), 167 + 400, 168 + ); 169 + } 170 + throw err; 171 + } 172 + }) 131 173 .post("/:wikiSlug/-/delete", async ({ params, request }) => { 132 174 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 133 175 const formData = await request.formData(); ··· 150 192 locale: ctx.locale, 151 193 accessLevel: ctx.access, 152 194 wikiDid: ctx.wiki.did, 195 + wikiDescription: ctx.wiki.description, 153 196 error: "Wiki name does not match.", 154 197 }, 155 198 ),
+45 -3
src/views/settings.ts
··· 14 14 15 15 interface SettingsPageOptions extends LayoutOptions { 16 16 wikiDid: string; 17 + wikiDescription: string; 17 18 error?: string; 19 + detailsSaved?: boolean; 18 20 } 19 21 20 22 function renderIdentity(did: string, profile: ProfileInfo | undefined): string { ··· 192 194 </section>`; 193 195 } 194 196 197 + function renderDetailsSection( 198 + wikiSlug: string, 199 + wikiName: string, 200 + wikiDescription: string, 201 + detailsSaved: boolean, 202 + locale: string, 203 + ): string { 204 + const msg = t(locale as "en" | "fr"); 205 + const savedBanner = detailsSaved 206 + ? `<div class="mb-4 px-3 py-2 text-sm rounded ${THEME.accentLightHoverBg} ${THEME.accentText}">${msg.settings.detailsSaved}</div>` 207 + : ""; 208 + 209 + return `<section class="mb-10"> 210 + <h2 class="text-lg font-semibold mb-4">${msg.settings.details}</h2> 211 + ${savedBanner} 212 + <form method="POST" action="/wiki/${wikiSlug}/-/edit" class="flex flex-col gap-3 max-w-xl"> 213 + <div class="flex flex-col gap-1"> 214 + <label class="text-xs font-medium ${THEME.textMuted}">${msg.settings.nameLabel}</label> 215 + <input type="text" name="name" required autocomplete="off" value="${escapeHtml(wikiName)}" 216 + class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none ${THEME.accentInputFocusBorder}" /> 217 + </div> 218 + <div class="flex flex-col gap-1"> 219 + <label class="text-xs font-medium ${THEME.textMuted}">${msg.settings.descriptionLabel}</label> 220 + <textarea name="description" rows="3" 221 + class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none ${THEME.accentInputFocusBorder}">${escapeHtml(wikiDescription)}</textarea> 222 + </div> 223 + <div> 224 + <button type="submit" class="${primarySmallButtonClass}">${msg.settings.save}</button> 225 + </div> 226 + </form> 227 + </section>`; 228 + } 229 + 195 230 export function settingsPage( 196 231 wikiName: string, 197 232 wikiSlug: string, ··· 202 237 options: SettingsPageOptions, 203 238 ): string { 204 239 const errorHtml = errorBanner(options.error); 240 + const locale = options.locale ?? "en"; 241 + const detailsHtml = renderDetailsSection( 242 + wikiSlug, 243 + wikiName, 244 + options.wikiDescription, 245 + options.detailsSaved ?? false, 246 + locale, 247 + ); 205 248 const membersHtml = renderMembersSection( 206 249 wikiSlug, 207 250 members, 208 251 requests, 209 252 profiles, 210 253 options.wikiDid, 211 - options.locale ?? "en", 254 + locale, 212 255 ); 213 - const locale = options.locale ?? "en"; 214 256 const msg = t(locale); 215 257 const dangerHtml = renderDangerZone(wikiName, wikiSlug, isOwner, locale); 216 258 217 259 return layout( 218 260 `${msg.settings.heading} — ${wikiName}`, 219 - `<h1 class="text-2xl font-bold mb-8">${msg.settings.heading}</h1>${errorHtml}${membersHtml}${dangerHtml}`, 261 + `<h1 class="text-2xl font-bold mb-8">${msg.settings.heading}</h1>${errorHtml}${detailsHtml}${membersHtml}${dangerHtml}`, 220 262 { ...options, wikiName, wikiSlug }, 221 263 ); 222 264 }
+65 -1
tests/lib/orchestrators/wiki.test.ts
··· 42 42 getAgent: mockGetAgent, 43 43 })); 44 44 45 - const { createWikiAction, deleteWikiAction } = await import( 45 + const { createWikiAction, deleteWikiAction, editWikiAction } = await import( 46 46 "../../../src/lib/orchestrators/wiki.ts" 47 47 ); 48 48 ··· 56 56 wikiLanguageRequired: "Language required", 57 57 invalidSlug: "Invalid slug: {title}", 58 58 wikiSlugExists: "Slug exists: {slug}", 59 + wikiNameTooLong: "Too long (max {max})", 59 60 }, 60 61 } as never; 61 62 ··· 321 322 if (idx !== -1) createdSlugs.splice(idx, 1); 322 323 }); 323 324 }); 325 + 326 + describe("editWikiAction", () => { 327 + async function createTestWiki(name: string): Promise<WikiRow> { 328 + const result = await createWikiAction( 329 + makeCtx({ session: null }), 330 + { name, language: "en", visibility: "public", description: "orig" }, 331 + dummyMsg, 332 + ); 333 + createdSlugs.push(result.wikiSlug); 334 + const db = getDb(); 335 + return db 336 + .query("SELECT * FROM wikis WHERE slug = ?") 337 + .get(result.wikiSlug) as WikiRow; 338 + } 339 + 340 + test("updates name and description in DB, writes to PDS", async () => { 341 + const wiki = await createTestWiki("Orch Edit Test"); 342 + mockWriteWikiRecord.mockClear(); 343 + const ctx = makeWikiCtx(wiki); 344 + 345 + await editWikiAction( 346 + ctx, 347 + { name: "Renamed Wiki", description: "new description" }, 348 + dummyMsg, 349 + ); 350 + 351 + const db = getDb(); 352 + const row = db 353 + .query("SELECT name, description FROM wikis WHERE slug = ?") 354 + .get(wiki.slug) as { name: string; description: string }; 355 + expect(row.name).toBe("Renamed Wiki"); 356 + expect(row.description).toBe("new description"); 357 + expect(mockWriteWikiRecord).toHaveBeenCalledTimes(1); 358 + }); 359 + 360 + test("skips PDS when no session", async () => { 361 + const wiki = await createTestWiki("Orch Edit No Session"); 362 + mockWriteWikiRecord.mockClear(); 363 + const ctx = makeWikiCtx(wiki, { session: null }); 364 + 365 + await editWikiAction(ctx, { name: "Renamed", description: "" }, dummyMsg); 366 + 367 + expect(mockWriteWikiRecord).not.toHaveBeenCalled(); 368 + }); 369 + 370 + test("throws ValidationError on empty name", async () => { 371 + const wiki = await createTestWiki("Orch Edit Empty Name"); 372 + const ctx = makeWikiCtx(wiki); 373 + 374 + expect( 375 + editWikiAction(ctx, { name: "", description: "x" }, dummyMsg), 376 + ).rejects.toBeInstanceOf(ValidationError); 377 + }); 378 + 379 + test("throws ValidationError on oversize name", async () => { 380 + const wiki = await createTestWiki("Orch Edit Oversize Name"); 381 + const ctx = makeWikiCtx(wiki); 382 + 383 + expect( 384 + editWikiAction(ctx, { name: "x".repeat(257), description: "" }, dummyMsg), 385 + ).rejects.toBeInstanceOf(ValidationError); 386 + }); 387 + });