🌿 Collaborative wiki on ATProto
0
fork

Configure Feed

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

Connect import/export routes and views

juprodh 2da4b2f2 696140e5

+117 -15
+56 -1
public/editor/editor.ts
··· 6 6 import { createToolbar } from "./toolbar.ts"; 7 7 import { syncBlobMetadata, uploadImage } from "./upload.ts"; 8 8 9 + let activeView: EditorView | null = null; 10 + 9 11 function initEditor(root: Document | Element = document): void { 10 12 const textarea = root.querySelector<HTMLTextAreaElement>("textarea#content"); 11 13 if (!textarea) return; ··· 106 108 parent: editorPane, 107 109 }); 108 110 111 + activeView = view; 112 + 109 113 // Insert toolbar at top of editor pane (before CodeMirror) 110 114 const toolbar = createToolbar(view, fileInput); 111 115 editorPane.insertBefore(toolbar, editorPane.firstChild); ··· 130 134 }); 131 135 } 132 136 137 + /** 138 + * Set up the single-file import handler on the New Note page. 139 + * Reads a .md file and populates the title and content fields. 140 + * If CodeMirror is already mounted, updates its state directly. 141 + */ 142 + function initFileImport(): void { 143 + const fileInput = document.getElementById( 144 + "import-file", 145 + ) as HTMLInputElement | null; 146 + if (!fileInput) return; 147 + 148 + fileInput.addEventListener("change", () => { 149 + const file = fileInput.files?.[0]; 150 + if (!file) return; 151 + 152 + const reader = new FileReader(); 153 + reader.onload = () => { 154 + const content = reader.result as string; 155 + const title = file.name.replace(/\.(md|markdown|txt)$/i, ""); 156 + 157 + const titleInput = document.getElementById( 158 + "title", 159 + ) as HTMLInputElement | null; 160 + if (titleInput) { 161 + titleInput.value = title; 162 + } 163 + 164 + const textarea = 165 + document.querySelector<HTMLTextAreaElement>("textarea#content"); 166 + if (textarea) { 167 + textarea.value = content; 168 + } 169 + 170 + // If CodeMirror is mounted, update its state 171 + if (activeView) { 172 + activeView.dispatch({ 173 + changes: { 174 + from: 0, 175 + to: activeView.state.doc.length, 176 + insert: content, 177 + }, 178 + }); 179 + } 180 + }; 181 + reader.readAsText(file); 182 + }); 183 + } 184 + 133 185 // Initial hydration — script loads in <head>, so wait for DOM 134 - document.addEventListener("DOMContentLoaded", () => initEditor()); 186 + document.addEventListener("DOMContentLoaded", () => { 187 + initEditor(); 188 + initFileImport(); 189 + }); 135 190 136 191 // Re-init after HTMX partial swaps 137 192 document.addEventListener("htmx:afterSwap", (event: Event) => {
+39 -13
src/server/routes/wiki.ts
··· 6 6 resolveWikiContextSoft, 7 7 } from "../../lib/access.ts"; 8 8 import { VIZ_SCRIPTS } from "../../lib/constants.ts"; 9 - import { ValidationError } from "../../lib/errors.ts"; 9 + import { ImportError, ValidationError } from "../../lib/errors.ts"; 10 10 import { t } from "../../lib/i18n/index.ts"; 11 + import { exportWikiZip } from "../../lib/import-export/export.ts"; 12 + import { importWikiAction } from "../../lib/import-export/import.ts"; 11 13 import { renderMarkdown } from "../../lib/markdown.ts"; 12 14 import { 13 15 createWikiAction, ··· 46 48 const visibility = 47 49 (formData.get("visibility") as string | null) ?? "public"; 48 50 const description = (formData.get("description") as string | null) ?? ""; 51 + const zipFile = formData.get("zipFile"); 52 + 53 + const renderError = (error: string) => 54 + htmlResponse( 55 + newWikiPage({ 56 + session: ctx.session, 57 + locale: ctx.locale, 58 + error, 59 + nameValue: name, 60 + languageValue: language, 61 + visibilityValue: visibility, 62 + descriptionValue: description, 63 + }), 64 + ); 49 65 50 66 try { 67 + if (zipFile instanceof File && zipFile.size > 0) { 68 + const zipBuffer = await zipFile.arrayBuffer(); 69 + const { wikiSlug } = await importWikiAction( 70 + ctx, 71 + { name, language, visibility, description, zipBuffer }, 72 + msg, 73 + ); 74 + return redirect(wikiUrl(wikiSlug)); 75 + } 76 + 51 77 const { wikiSlug } = await createWikiAction( 52 78 ctx, 53 79 { name, language, visibility, description }, ··· 55 81 ); 56 82 return redirect(wikiUrl(wikiSlug)); 57 83 } catch (err) { 58 - if (err instanceof ValidationError) { 59 - return htmlResponse( 60 - newWikiPage({ 61 - session: ctx.session, 62 - locale: ctx.locale, 63 - error: err.message, 64 - nameValue: name, 65 - languageValue: language, 66 - visibilityValue: visibility, 67 - descriptionValue: description, 68 - }), 69 - ); 84 + if (err instanceof ValidationError || err instanceof ImportError) { 85 + return renderError(err.message); 70 86 } 71 87 throw err; 72 88 } ··· 134 150 } 135 151 await deleteWikiAction(ctx); 136 152 return redirect("/"); 153 + }) 154 + .get("/:wikiSlug/-/export", async ({ params, request }) => { 155 + await resolveWikiContext(request, params.wikiSlug, "read"); 156 + const zipBytes = await exportWikiZip(params.wikiSlug); 157 + return new Response(zipBytes, { 158 + headers: { 159 + "Content-Type": "application/zip", 160 + "Content-Disposition": `attachment; filename="${params.wikiSlug}.zip"`, 161 + }, 162 + }); 137 163 }) 138 164 .get("/:wikiSlug", async ({ params, request }) => { 139 165 const ctx = await resolveWikiContextSoft(request, params.wikiSlug);
+10
src/views/new-note.ts
··· 27 27 ` 28 28 <h1 class="text-3xl font-bold ${THEME.textPrimary} mb-6">${msg.wiki.newNote}</h1> 29 29 ${errorHtml} 30 + <div class="mb-6 pb-4 border-b ${THEME.borderDefault}"> 31 + <label for="import-file" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.editor.importFile}</label> 32 + <input 33 + type="file" 34 + id="import-file" 35 + accept=".md,.markdown,.txt" 36 + class="block w-full text-sm ${THEME.textSecondary} file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:${THEME.accentBg} file:text-white hover:file:opacity-90 file:cursor-pointer" 37 + > 38 + <p class="text-xs ${THEME.textMuted} mt-1">${msg.editor.importFileHelp}</p> 39 + </div> 30 40 <form method="POST" action="/wiki/${wikiSlug}/new" class="space-y-4"> 31 41 <div> 32 42 <label for="title" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.editor.title}</label>
+12 -1
src/views/new-wiki.ts
··· 34 34 <div class="max-w-lg mx-auto"> 35 35 <h1 class="text-3xl font-bold ${THEME.textPrimary} mb-6">${msg.createWiki.heading}</h1> 36 36 ${errorHtml} 37 - <form method="POST" action="/wiki/new" class="space-y-4"> 37 + <form method="POST" action="/wiki/new" enctype="multipart/form-data" class="space-y-4"> 38 38 <div> 39 39 <label for="name" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.createWiki.name}</label> 40 40 <input ··· 85 85 <div id="private-warning" class="${selectedVis === "private" ? "" : "hidden "}mt-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"> 86 86 ${msg.createWiki.privateWarning} 87 87 </div> 88 + </div> 89 + <div> 90 + <label for="zipFile" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.createWiki.importZip}</label> 91 + <input 92 + id="zipFile" 93 + name="zipFile" 94 + type="file" 95 + accept=".zip" 96 + class="block w-full text-sm ${THEME.textSecondary} file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:${THEME.accentBg} file:text-white hover:file:opacity-90 file:cursor-pointer" 97 + > 98 + <p class="text-xs ${THEME.textMuted} mt-1">${msg.createWiki.importZipHelp}</p> 88 99 </div> 89 100 <div class="flex items-center gap-3"> 90 101 <button