diff --git a/public/editor/editor.ts b/public/editor/editor.ts index c24707b..7d2093d 100644 --- a/public/editor/editor.ts +++ b/public/editor/editor.ts @@ -77,7 +77,8 @@ function initEditor(root: Document | Element = document): void { editorPane.style.flexDirection = "column"; const preview = document.createElement("div"); - preview.className = "prose max-w-none"; + const previewClass = textarea.dataset["previewClass"] ?? "prose max-w-none"; + preview.className = previewClass; preview.style.flex = "1 1 0%"; preview.style.minWidth = "0"; preview.style.overflow = "auto"; diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index aca8914..afac3c4 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -66,7 +66,23 @@ export const en: Messages = { historyEmpty: "No revisions yet.", historyDefaultMessage: "edit", backToNotePrefix: "Back to", + backToWikiPrefix: "Back to wiki", historyLoadMore: "View older revisions", + editSidebar: "Edit sidebar", + editSidebarHeading: "Edit sidebar", + sidebarPlaceholder: "Write the sidebar in markdown. [[wikilinks]] work.", + sidebarTooLong: "Sidebar is too long.", + sidebarResetToDefault: "Reset to default", + sidebarResetConfirm: + "Reset the sidebar to the default auto-generated note list?", + sidebarHelpTitle: "Sidebar tips", + sidebarHelpMarkdown: + "Markdown is supported. Use [[wikilinks]] to link to notes in this wiki.", + sidebarHelpHeadings: + "All headings (#, ##, ###…) render the same small uppercase label — use them as section dividers, not hierarchy.", + sidebarHelpImages: + "Images render but are constrained to the column width — keep them small or skip them.", + sidebarHelpEmpty: "Leave empty (or use Reset) to show the auto note list.", }, editor: { title: "Title", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index af45ca3..2cb5097 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -67,7 +67,25 @@ export const fr: PartialMessages = { historyEmpty: "Aucune révision pour le moment.", historyDefaultMessage: "modification", backToNotePrefix: "Retour à", + backToWikiPrefix: "Retour au wiki", historyLoadMore: "Voir les anciennes révisions", + editSidebar: "Modifier la barre latérale", + editSidebarHeading: "Modifier la barre latérale", + sidebarPlaceholder: + "Écrivez la barre latérale en markdown. Les [[liens wiki]] fonctionnent.", + sidebarTooLong: "Barre latérale trop longue.", + sidebarResetToDefault: "Réinitialiser", + sidebarResetConfirm: + "Réinitialiser la barre latérale à la liste de notes par défaut ?", + sidebarHelpTitle: "Conseils pour la barre latérale", + sidebarHelpMarkdown: + "Markdown supporté. Utilisez [[liens wiki]] pour lier aux notes de ce wiki.", + sidebarHelpHeadings: + "Tous les titres (#, ##, ###…) ont le même rendu (petit, majuscules) — utilisez-les comme séparateurs de section, pas comme hiérarchie.", + sidebarHelpImages: + "Les images s'affichent mais sont limitées à la largeur de la colonne — gardez-les petites ou évitez-les.", + sidebarHelpEmpty: + "Laissez vide (ou utilisez Réinitialiser) pour afficher la liste automatique.", }, editor: { title: "Titre", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index fc46755..eceb0de 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -68,7 +68,19 @@ export interface Messages { historyEmpty: string; historyDefaultMessage: string; backToNotePrefix: string; + backToWikiPrefix: string; historyLoadMore: string; + editSidebar: string; + editSidebarHeading: string; + sidebarPlaceholder: string; + sidebarTooLong: string; + sidebarResetToDefault: string; + sidebarResetConfirm: string; + sidebarHelpTitle: string; + sidebarHelpMarkdown: string; + sidebarHelpHeadings: string; + sidebarHelpImages: string; + sidebarHelpEmpty: string; }; editor: { title: string; diff --git a/src/lib/limits.ts b/src/lib/limits.ts index 37c83ae..e8d516b 100644 --- a/src/lib/limits.ts +++ b/src/lib/limits.ts @@ -16,6 +16,9 @@ export const LIMITS = { visibility: 32, language: 16, description: 300, + // Appview-only — no lexicon constraint. Generous cap; matches a long + // markdown page. Tighten later if abuse surfaces. + sidebar: 100_000, }, note: { slug: 256, diff --git a/src/lib/markdown/wikilink-plugin.ts b/src/lib/markdown/wikilink-plugin.ts index 5606468..7fdf647 100644 --- a/src/lib/markdown/wikilink-plugin.ts +++ b/src/lib/markdown/wikilink-plugin.ts @@ -115,21 +115,21 @@ export function wikilinkPlugin(mdi: MarkdownIt): void { if (!target) return false; const anchor = target.section ? `#${slugify(target.section)}` : ""; + const noteSlug = target.noteSlug ? slugify(target.noteSlug) : ""; + const wikiSlug = target.wikiSlug ? slugify(target.wikiSlug) : ""; let href: string; - if (!target.wikiSlug && !target.noteSlug && target.section) { + if (!wikiSlug && !noteSlug && target.section) { // [[#section]] — anchor only href = anchor; - } else if (!target.noteSlug && target.wikiSlug && target.ownerHandle) { + } else if (!noteSlug && wikiSlug && target.ownerHandle) { // [[@handle/wiki/]] — wiki home page - href = wikiUrl(target.ownerHandle, target.wikiSlug) + anchor; - } else if (target.wikiSlug && target.ownerHandle) { + href = wikiUrl(target.ownerHandle, wikiSlug) + anchor; + } else if (wikiSlug && target.ownerHandle) { // [[@handle/wiki/note]] - href = - noteUrl(target.ownerHandle, target.wikiSlug, target.noteSlug) + - anchor; + href = noteUrl(target.ownerHandle, wikiSlug, noteSlug) + anchor; } else { // [[note]] or [[note#section]] — bare slug in same wiki - href = target.noteSlug + anchor; + href = noteSlug + anchor; } const token = state.push("html_inline", "", 0); diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 2813477..54de13d 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -26,6 +26,10 @@ export function settingsUrl(handle: string, wikiSlug: string): string { return `/@${handle}/${wikiSlug}/-/settings`; } +export function sidebarEditUrl(handle: string, wikiSlug: string): string { + return `/@${handle}/${wikiSlug}/-/sidebar/edit`; +} + export function profileUrl(didOrHandle: string): string { return `/@${didOrHandle}`; } diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 2ac0ae1..0465876 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -60,6 +60,7 @@ export { listPublicNotesForSitemap, listPublicWikisForSitemap, listPublicWikisPaginated, + setWikiSidebar, setWikiTheme, upsertWiki, } from "./wiki.ts"; diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index 5a18245..c88872c 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -185,6 +185,20 @@ export function upsertWiki( ); } +export function setWikiSidebar( + did: string, + slug: string, + sidebar: string | null, +): void { + const db = getDb(); + db.run( + `UPDATE wikis + SET sidebar = ?, updated_at = datetime('now') + WHERE did = ? AND slug = ?`, + [sidebar, did, slug], + ); +} + export function setWikiTheme( did: string, slug: string, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index da118c4..b995741 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -17,6 +17,7 @@ export function initSchema(db: Database): void { description TEXT NOT NULL DEFAULT '', theme_mode TEXT NOT NULL DEFAULT 'reader' CHECK (theme_mode IN ('reader', 'enforce')), theme TEXT NOT NULL DEFAULT 'light', + sidebar TEXT, at_uri TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -24,6 +25,8 @@ export function initSchema(db: Database): void { ) `); + addColumnIfMissing(db, "wikis", "sidebar", "TEXT"); + db.run(` CREATE TABLE IF NOT EXISTS notes ( slug TEXT NOT NULL, @@ -180,6 +183,19 @@ export function initSchema(db: Database): void { db.run("CREATE INDEX IF NOT EXISTS idx_wikis_did ON wikis(did)"); } +function addColumnIfMissing( + db: Database, + table: string, + column: string, + decl: string, +): void { + const cols = db.query(`PRAGMA table_info(${table})`).all() as { + name: string; + }[]; + if (cols.some((c) => c.name === column)) return; + db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`); +} + /** * Detect the old schema (wikis.slug as sole primary key) and migrate to the * new composite-primary-key schema. Safe to call on already-migrated DBs. diff --git a/src/server/db/types.ts b/src/server/db/types.ts index e7ae1ce..811e95c 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -12,6 +12,7 @@ export interface WikiRow { description: string; theme_mode: WikiThemeMode; theme: WikiThemeName; + sidebar: string | null; at_uri: string; created_at: string; updated_at: string; diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 903fcc2..c3ba2f1 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -318,6 +318,7 @@ export const noteRoutes = new Elysia() ...(hasMath && { stylesheets: KATEX_STYLESHEETS }), session: ctx.session, sidebarNotes, + sidebarMarkdown: ctx.wiki.sidebar, currentNoteSlug: noteSlug, locale: ctx.locale, userTheme: ctx.userTheme, diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index 34048b0..574f405 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -6,7 +6,11 @@ import { resolveWikiContext, resolveWikiContextSoft, } from "../../lib/access.ts"; -import { KATEX_STYLESHEETS, VIZ_SCRIPTS } from "../../lib/constants.ts"; +import { + EDITOR_SCRIPTS, + KATEX_STYLESHEETS, + VIZ_SCRIPTS, +} from "../../lib/constants.ts"; import { ForbiddenError, ImportError, @@ -16,6 +20,7 @@ import { extractHeadings } from "../../lib/headings.ts"; import { t } from "../../lib/i18n/index.ts"; import { exportWikiZip } from "../../lib/import-export/export.ts"; import { importWikiAction } from "../../lib/import-export/import.ts"; +import { LIMITS } from "../../lib/limits.ts"; import { renderMarkdown } from "../../lib/markdown.ts"; import { createWikiAction, @@ -28,6 +33,7 @@ import { htmlResponse } from "../../lib/response.ts"; import { noteUrl, redirect, settingsUrl, wikiUrl } from "../../lib/urls.ts"; import { accessDeniedPage } from "../../views/access-denied.ts"; import { resolveBookmarkHtml } from "../../views/bookmark.ts"; +import { editSidebarPage } from "../../views/edit-sidebar.ts"; import { newWikiPage } from "../../views/new-wiki.ts"; import { notePage } from "../../views/note.ts"; import { settingsPage } from "../../views/settings.ts"; @@ -39,6 +45,7 @@ import { getSidebarNotes, listMembers, listRequests, + setWikiSidebar, } from "../db/queries/index.ts"; interface HandleWikiParams { @@ -257,6 +264,76 @@ export const wikiRoutes = new Elysia() throw err; } }) + .get("/@:handle/:wikiSlug/-/sidebar/edit", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); + const handle = ctx.ownerHandle ?? urlHandle; + const sidebarNotes = getSidebarNotes(ctx.wiki.at_uri); + return htmlResponse( + editSidebarPage( + ctx.wiki.name, + handle, + ctx.wiki.slug, + ctx.wiki.sidebar ?? "", + sidebarNotes, + { + session: ctx.session, + scripts: EDITOR_SCRIPTS, + stylesheets: KATEX_STYLESHEETS, + locale: ctx.locale, + userTheme: ctx.userTheme, + accessLevel: ctx.access, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, + }, + ), + ); + }) + .post("/@:handle/:wikiSlug/-/sidebar/edit", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); + const handle = ctx.ownerHandle ?? urlHandle; + const msg = t(ctx.locale); + const formData = await request.formData(); + const content = ((formData.get("content") as string | null) ?? "").trim(); + + if (content.length > LIMITS.wiki.sidebar) { + const sidebarNotes = getSidebarNotes(ctx.wiki.at_uri); + return htmlResponse( + editSidebarPage( + ctx.wiki.name, + handle, + ctx.wiki.slug, + content, + sidebarNotes, + { + session: ctx.session, + scripts: EDITOR_SCRIPTS, + stylesheets: KATEX_STYLESHEETS, + locale: ctx.locale, + userTheme: ctx.userTheme, + accessLevel: ctx.access, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, + error: msg.wiki.sidebarTooLong, + }, + ), + 400, + ); + } + + // Appview-only feature — no PDS write, hence no orchestrator. Plain UPDATE. + setWikiSidebar( + ctx.wiki.did, + ctx.wiki.slug, + content === "" ? null : content, + ); + return redirect(wikiUrl(handle, ctx.wiki.slug)); + }) .post("/@:handle/:wikiSlug/-/delete", async ({ params, request }) => { const { handle: urlHandle, wikiSlug } = hp(params); const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); @@ -396,6 +473,7 @@ export const wikiRoutes = new Elysia() ...(hasMath && { stylesheets: KATEX_STYLESHEETS }), session: ctx.session, sidebarNotes, + sidebarMarkdown: ctx.wiki.sidebar, currentNoteSlug: "home", locale: ctx.locale, userTheme: ctx.userTheme, @@ -425,6 +503,7 @@ export const wikiRoutes = new Elysia() { session: ctx.session, sidebarNotes, + sidebarMarkdown: ctx.wiki.sidebar, locale: ctx.locale, userTheme: ctx.userTheme, accessLevel: ctx.access, diff --git a/src/views/edit-sidebar.ts b/src/views/edit-sidebar.ts new file mode 100644 index 0000000..e1016b7 --- /dev/null +++ b/src/views/edit-sidebar.ts @@ -0,0 +1,129 @@ +import { escapeHtml } from "../lib/html.ts"; +import { t } from "../lib/i18n/index.ts"; +import { noteUrl, sidebarEditUrl, wikiUrl } from "../lib/urls.ts"; +import { type LayoutOptions, layout } from "./layout.ts"; +import { + dangerSmallButtonClass, + inputClass, + primaryButtonClass, + THEME, +} from "./theme/index.ts"; + +export function editSidebarPage( + wikiName: string, + ownerHandle: string, + wikiSlug: string, + currentSidebar: string, + notes: { slug: string; title: string }[], + options: LayoutOptions & { error?: string }, +): string { + const locale = options.locale ?? "en"; + const msg = t(locale); + + const noteLinks = [...notes] + .sort((a, b) => a.title.localeCompare(b.title)) + .map( + (n) => + `