🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add robustness to editor

juprodh 1fb507f5 32cc2b32

+54 -6
+14 -1
public/editor/editor.ts
··· 4 4 import { THEME_HEX } from "../../src/views/theme.ts"; 5 5 import { renderPreview } from "./preview.ts"; 6 6 import { createToolbar } from "./toolbar.ts"; 7 - import { syncBlobMetadata, uploadImage } from "./upload.ts"; 7 + import { showToast, syncBlobMetadata, uploadImage } from "./upload.ts"; 8 8 9 9 let activeView: EditorView | null = null; 10 10 ··· 150 150 if (!file) return; 151 151 152 152 const reader = new FileReader(); 153 + reader.onerror = () => { 154 + showToast("Failed to read file"); 155 + }; 153 156 reader.onload = () => { 154 157 const content = reader.result as string; 155 158 const title = file.name.replace(/\.(md|markdown|txt)$/i, ""); ··· 186 189 document.addEventListener("DOMContentLoaded", () => { 187 190 initEditor(); 188 191 initFileImport(); 192 + }); 193 + 194 + // Destroy editor before HTMX replaces its DOM 195 + document.addEventListener("htmx:beforeSwap", (event: Event) => { 196 + if (!activeView) return; 197 + const target = (event as CustomEvent).detail?.target; 198 + if (target instanceof Element && target.contains(activeView.dom)) { 199 + activeView.destroy(); 200 + activeView = null; 201 + } 189 202 }); 190 203 191 204 // Re-init after HTMX partial swaps
+15 -1
public/editor/upload.ts
··· 2 2 3 3 const uploadedBlobs = new Map<string, { mimeType: string; size: number }>(); // cid -> blob meta 4 4 5 + export function showToast(message: string): void { 6 + const toast = document.getElementById("htmx-toast"); 7 + if (!toast) return; 8 + toast.textContent = message; 9 + toast.classList.remove("hidden"); 10 + clearTimeout( 11 + (window as { _htmxToastTimeout?: ReturnType<typeof setTimeout> }) 12 + ._htmxToastTimeout, 13 + ); 14 + ( 15 + window as { _htmxToastTimeout?: ReturnType<typeof setTimeout> } 16 + )._htmxToastTimeout = setTimeout(() => toast.classList.add("hidden"), 3000); 17 + } 18 + 5 19 export async function uploadImage( 6 20 file: File, 7 21 view: EditorView, ··· 64 78 }); 65 79 } 66 80 const msg = err instanceof Error ? err.message : String(err); 67 - alert(`Image upload failed: ${msg}`); 81 + showToast(`Image upload failed: ${msg}`); 68 82 } 69 83 } 70 84
+1
src/lib/i18n/en.ts
··· 161 161 zipTooLarge: "Zip content exceeds the 50MB limit.", 162 162 noMarkdownFiles: "No markdown files found in zip.", 163 163 importFailed: "Import failed: {error}", 164 + requestFailed: "Something went wrong. Please try again.", 164 165 }, 165 166 };
+1
src/lib/i18n/fr.ts
··· 163 163 zipTooLarge: "Le contenu du zip dépasse la limite de 50 Mo.", 164 164 noMarkdownFiles: "Aucun fichier markdown trouvé dans le zip.", 165 165 importFailed: "Échec de l'importation : {error}", 166 + requestFailed: "Une erreur est survenue. Veuillez réessayer.", 166 167 }, 167 168 };
+1
src/lib/i18n/index.ts
··· 159 159 zipTooLarge: string; 160 160 noMarkdownFiles: string; 161 161 importFailed: string; 162 + requestFailed: string; 162 163 }; 163 164 } 164 165
+1
src/views/bookmark.ts
··· 24 24 const fillClass = isBookmarked ? "fill-current" : ""; 25 25 26 26 return `<form id="bookmark-form" hx-post="/api/bookmark" hx-swap="outerHTML" 27 + hx-disabled-elt="find button" 27 28 class="inline"> 28 29 <input type="hidden" name="wikiAtUri" value="${escapeHtml(wikiAtUri)}"> 29 30 <input type="hidden" name="action" value="${action}">
+12 -1
src/views/layout.ts
··· 201 201 </div> 202 202 </div> 203 203 </nav> 204 + <div id="htmx-toast" class="fixed top-16 right-4 z-50 hidden ${THEME.errorBg} border ${THEME.errorBorder} ${THEME.errorText} text-sm px-4 py-2 rounded-lg shadow-lg">${msg.error.requestFailed}</div> 204 205 <dialog id="search-modal" style="margin-left:auto;margin-right:auto;margin-top:15vh;" class="p-0 rounded-xl shadow-2xl backdrop:bg-black/50 w-full max-w-lg" onclick="if(event.target===this)this.close()" aria-label="${msg.search.searchNotes}"> 205 206 <div class="p-3"> 206 207 <div class="relative"> 207 208 <input id="search-input" type="text" placeholder="${msg.search.searchNotes}" autocomplete="off" 208 209 aria-label="${msg.search.searchNotes}" 209 - class="w-full px-3 py-2 text-sm border ${THEME.borderInput} rounded-lg outline-none ${THEME.accentFocusBorder} focus:ring-2 ${THEME.accentSubtleRing}" 210 + class="w-full px-3 py-2 pr-8 text-sm border ${THEME.borderInput} rounded-lg outline-none ${THEME.accentFocusBorder} focus:ring-2 ${THEME.accentSubtleRing}" 210 211 hx-get="/search${options?.wikiSlug ? `?wiki=${options.wikiSlug}` : ""}" 211 212 hx-trigger="input changed delay:150ms" 212 213 hx-target="#search-results" 214 + hx-indicator="#search-spinner" 213 215 name="q"> 216 + <span id="search-spinner" class="htmx-indicator absolute right-2.5 top-1/2 -translate-y-1/2 ${THEME.textMuted}"> 217 + <svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg> 218 + </span> 214 219 </div> 215 220 </div> 216 221 <div id="search-results" class="max-h-80 overflow-y-auto border-t ${THEME.borderSubtle} empty:hidden" aria-live="polite"></div> ··· 241 246 document.getElementById('search-results')?.addEventListener('click', (e) => { 242 247 const link = e.target.closest('a'); 243 248 if (link) document.getElementById('search-modal').close(); 249 + }); 250 + document.body.addEventListener('htmx:responseError', () => { 251 + const toast = document.getElementById('htmx-toast'); 252 + toast.classList.remove('hidden'); 253 + clearTimeout(window._htmxToastTimeout); 254 + window._htmxToastTimeout = setTimeout(() => toast.classList.add('hidden'), 3000); 244 255 }); 245 256 </script> 246 257 <div class="flex-1 flex flex-col">
+9 -3
src/views/wiki-list.ts
··· 28 28 <option value="created"${sort === "created" ? " selected" : ""}>${msg.home.recentlyCreated}</option> 29 29 </select>`; 30 30 31 - const searchInput = `<input id="wiki-search" type="text" placeholder="${msg.search.searchWikis}" autocomplete="off" 31 + const searchInput = `<div class="relative flex-1 min-w-0"> 32 + <input id="wiki-search" type="text" placeholder="${msg.search.searchWikis}" autocomplete="off" 32 33 aria-label="${msg.search.searchWikis}" 33 - class="flex-1 min-w-0 px-3 py-1.5 text-sm border ${THEME.borderDefault} rounded-lg outline-none ${THEME.accentInputFocusBorder} focus:ring-1 ${THEME.accentSubtleRing} ${THEME.bgSurface}" 34 + class="w-full px-3 py-1.5 pr-8 text-sm border ${THEME.borderDefault} rounded-lg outline-none ${THEME.accentInputFocusBorder} focus:ring-1 ${THEME.accentSubtleRing} ${THEME.bgSurface}" 34 35 hx-get="/search" hx-trigger="input changed delay:150ms, filterChange" hx-target="#${gridTargetId}" name="q" 35 - hx-include="#lang-select, #sort-select, #limit-input">`; 36 + hx-include="#lang-select, #sort-select, #limit-input" 37 + hx-indicator="#wiki-search-spinner"> 38 + <span id="wiki-search-spinner" class="htmx-indicator absolute right-2.5 top-1/2 -translate-y-1/2 ${THEME.textMuted}"> 39 + <svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg> 40 + </span> 41 + </div>`; 36 42 37 43 const langDropdown = languageDropdown(languages, locale); 38 44