learn and share notes on atproto (wip) 馃 malfestio.stormlightlabs.org/
readability solid axum atproto srs
at main 314 lines 12 kB view raw
1/* eslint-disable solid/no-innerhtml */ 2import { EditorToolbar } from "$components/notes/EditorToolbar"; 3import { MarkdownEditor, type MarkdownEditorAPI } from "$components/notes/MarkdownEditor"; 4import { api } from "$lib/api"; 5import type { Note, Visibility } from "$lib/model"; 6import { authStore } from "$lib/store"; 7import { syncStore } from "$lib/sync-store"; 8import { toast } from "$lib/toast"; 9import { Button } from "$ui/Button"; 10import rehypeShiki from "@shikijs/rehype"; 11import { useNavigate } from "@solidjs/router"; 12import { Textcomplete } from "@textcomplete/core"; 13import { TextareaEditor } from "@textcomplete/textarea"; 14import rehypeExternalLinks from "rehype-external-links"; 15import rehypeStringify from "rehype-stringify"; 16import remarkParse from "remark-parse"; 17import remarkRehype from "remark-rehype"; 18import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"; 19import { unified } from "unified"; 20 21export type EditorFont = "neon" | "argon" | "krypton" | "radon" | "xenon" | "jetbrains" | "google"; 22 23type NoteEditorProps = { noteId?: string; initialTitle?: string; initialContent?: string }; 24 25type EditorTab = "write" | "preview"; 26 27const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { theme: "vitesse-dark" }).use( 28 rehypeExternalLinks, 29 { target: "_blank", rel: ["nofollow"] }, 30).use(rehypeStringify); 31 32export function NoteEditor(props: NoteEditorProps) { 33 const navigate = useNavigate(); 34 const [title, setTitle] = createSignal(props.initialTitle || ""); 35 const [content, setContent] = createSignal(props.initialContent || ""); 36 const [preview, setPreview] = createSignal(""); 37 const [tags, setTags] = createSignal(""); 38 const [visibilityType, setVisibilityType] = createSignal<string>("Private"); 39 const [sharedWith, setSharedWith] = createSignal(""); 40 const [showLineNumbers, setShowLineNumbers] = createSignal(true); 41 const [editorFont, setEditorFont] = createSignal<EditorFont>("jetbrains"); 42 const [activeTab, setActiveTab] = createSignal<EditorTab>("write"); 43 44 let editorApi: MarkdownEditorAPI | undefined; 45 let textcomplete: Textcomplete | undefined; 46 47 const [allNotes] = createResource(async (): Promise<Note[]> => { 48 const res = await api.getNotes(); 49 if (!res.ok) return []; 50 return res.json(); 51 }); 52 53 const updatePreviewContent = async () => { 54 const file = await processor.process(content()); 55 setPreview(String(file)); 56 }; 57 58 createEffect(() => { 59 updatePreviewContent().catch(e => console.error(`Preview error: ${e instanceof Error ? e.message : e}`)); 60 }); 61 62 onCleanup(() => { 63 textcomplete?.destroy(); 64 }); 65 66 const initTextcomplete = (api: MarkdownEditorAPI) => { 67 editorApi = api; 68 const textarea = api.getTextarea(); 69 if (!textarea) return; 70 71 const editor = new TextareaEditor(textarea); 72 textcomplete = new Textcomplete(editor, [{ 73 match: /\[\[([^\]]*)/, 74 search: (term: string, callback: (results: string[]) => void) => { 75 const notes = allNotes() ?? []; 76 const filtered = notes.filter((n) => n.title.toLowerCase().includes(term.toLowerCase())).slice(0, 10).map((n) => 77 n.title 78 ); 79 callback(filtered); 80 }, 81 replace: (title: string) => `[[${title}]]`, 82 template: (title: string) => title, 83 }]); 84 }; 85 86 const insertAtCursor = (before: string, after: string = "") => editorApi?.insertAtCursor(before, after); 87 88 const handleBold = () => insertAtCursor("**", "**"); 89 const handleItalic = () => insertAtCursor("*", "*"); 90 const handleLink = () => insertAtCursor("[", "](url)"); 91 const handleCode = () => insertAtCursor("`", "`"); 92 const handleCodeBlock = () => insertAtCursor("```\n", "\n```"); 93 const handleWikilink = () => insertAtCursor("[[", "]]"); 94 const handleList = () => insertAtCursor("- "); 95 const handleHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => insertAtCursor("#".repeat(level) + " "); 96 97 const handleKeyDown = (e: KeyboardEvent) => { 98 if (e.metaKey || e.ctrlKey) { 99 switch (e.key) { 100 case "b": 101 e.preventDefault(); 102 handleBold(); 103 break; 104 case "i": 105 e.preventDefault(); 106 handleItalic(); 107 break; 108 case "k": 109 e.preventDefault(); 110 handleLink(); 111 break; 112 } 113 } 114 }; 115 116 const handleEditorKeyDown = (e: KeyboardEvent) => handleKeyDown(e); 117 118 const handleSubmit = async (e: Event) => { 119 e.preventDefault(); 120 try { 121 const user = authStore.user(); 122 if (!user) { 123 toast.error("Not authenticated"); 124 return; 125 } 126 127 let visibility; 128 if (visibilityType() === "SharedWith") { 129 visibility = { type: "SharedWith", content: sharedWith().split(",").map((s) => s.trim()).filter((s) => s) }; 130 } else { 131 visibility = { type: visibilityType() }; 132 } 133 134 const parsedTags = tags().split(",").map((t) => t.trim()).filter((t) => t); 135 136 const localNote = await syncStore.saveNoteLocally({ 137 id: props.noteId, 138 ownerDid: user.did, 139 title: title(), 140 body: content(), 141 tags: parsedTags, 142 visibility: visibility as Visibility, 143 links: [], 144 }); 145 146 if (syncStore.isOnline()) { 147 const payload = { title: title(), body: content(), tags: parsedTags, visibility }; 148 const res = props.noteId ? await api.updateNote(props.noteId, payload) : await api.post("/notes", payload); 149 150 if (res.ok) { 151 toast.success("Note saved and synced!"); 152 try { 153 const serverNote = await res.json(); 154 navigate(`/notes/${serverNote.id}`); 155 } catch { 156 navigate(`/notes/${localNote.id}`); 157 } 158 return; 159 } 160 } 161 162 toast.success("Note saved locally"); 163 navigate(`/notes/${localNote.id}`); 164 } catch (e) { 165 console.error(e); 166 toast.error("Failed to save note"); 167 } 168 }; 169 170 return ( 171 <div class="max-w-5xl mx-auto p-6"> 172 <div class="flex items-center justify-between mb-6"> 173 <h1 class="text-2xl font-bold text-white">{props.noteId ? "Edit Note" : "New Note"}</h1> 174 175 <div class="flex items-center gap-4"> 176 <label class="flex items-center gap-2 text-sm text-slate-400"> 177 <input 178 type="checkbox" 179 checked={showLineNumbers()} 180 onChange={(e) => setShowLineNumbers(e.target.checked)} 181 class="rounded bg-slate-700 border-slate-600" /> 182 Line numbers 183 </label> 184 <select 185 value={editorFont()} 186 onChange={(e) => setEditorFont(e.target.value as EditorFont)} 187 class="bg-slate-800 border-slate-700 text-white text-sm rounded px-2 py-1"> 188 <option value="jetbrains">JetBrains Mono</option> 189 <option value="neon">Monaspace Neon</option> 190 <option value="argon">Monaspace Argon</option> 191 <option value="krypton">Monaspace Krypton</option> 192 <option value="radon">Monaspace Radon</option> 193 <option value="xenon">Monaspace Xenon</option> 194 <option value="google">Google Sans Code</option> 195 </select> 196 </div> 197 </div> 198 199 <form onSubmit={handleSubmit} class="space-y-4"> 200 <div> 201 <label class="block text-sm font-medium text-slate-400 mb-1">Title</label> 202 <input 203 type="text" 204 value={title()} 205 onInput={(e) => setTitle(e.target.value)} 206 class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent" 207 placeholder="Note Title" 208 required /> 209 </div> 210 211 <div> 212 <div class="flex border-b border-slate-700 mb-0"> 213 <button 214 type="button" 215 onClick={() => setActiveTab("write")} 216 class={`px-4 py-2 text-sm font-medium transition-colors ${ 217 activeTab() === "write" 218 ? "text-white border-b-2 border-blue-500 -mb-px" 219 : "text-slate-400 hover:text-white" 220 }`}> 221 <span class="i-ri-edit-line mr-1" /> 222 Write 223 </button> 224 <button 225 type="button" 226 onClick={() => setActiveTab("preview")} 227 class={`px-4 py-2 text-sm font-medium transition-colors ${ 228 activeTab() === "preview" 229 ? "text-white border-b-2 border-blue-500 -mb-px" 230 : "text-slate-400 hover:text-white" 231 }`}> 232 <span class="i-ri-eye-line mr-1" /> 233 Preview 234 </button> 235 </div> 236 237 <Show when={activeTab() === "write"}> 238 <div 239 class="border border-slate-700 border-t-0 rounded-b-lg overflow-hidden" 240 onKeyDown={handleEditorKeyDown}> 241 <EditorToolbar 242 onBold={handleBold} 243 onItalic={handleItalic} 244 onHeading={handleHeading} 245 onLink={handleLink} 246 onCode={handleCode} 247 onCodeBlock={handleCodeBlock} 248 onWikilink={handleWikilink} 249 onList={handleList} /> 250 <MarkdownEditor 251 value={content()} 252 onChange={setContent} 253 showLineNumbers={showLineNumbers()} 254 font={editorFont()} 255 ref={initTextcomplete} 256 placeholder="# Heading 257 258Write your thoughts... 259 260Link to other notes with [[Title]]" 261 class="bg-slate-800 min-h-[400px]" /> 262 </div> 263 </Show> 264 265 <Show when={activeTab() === "preview"}> 266 <div 267 class="prose prose-invert max-w-none bg-slate-800/50 p-6 rounded-b-lg border border-slate-700 border-t-0 min-h-[460px] overflow-auto" 268 innerHTML={preview()} /> 269 </Show> 270 </div> 271 272 <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 273 <div> 274 <label class="block text-sm font-medium text-slate-400 mb-1">Tags</label> 275 <input 276 type="text" 277 value={tags()} 278 onInput={(e) => setTags(e.target.value)} 279 class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2" 280 placeholder="rust, learning, ..." /> 281 </div> 282 <div> 283 <label class="block text-sm font-medium text-slate-400 mb-1">Visibility</label> 284 <select 285 value={visibilityType()} 286 onChange={(e) => setVisibilityType(e.target.value)} 287 class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2"> 288 <option value="Private">Private</option> 289 <option value="Unlisted">Unlisted</option> 290 <option value="Public">Public</option> 291 <option value="SharedWith">Shared With...</option> 292 </select> 293 </div> 294 </div> 295 296 <Show when={visibilityType() === "SharedWith"}> 297 <div> 298 <label class="block text-sm font-medium text-slate-400 mb-1">Share with DIDs (comma separated)</label> 299 <input 300 type="text" 301 value={sharedWith()} 302 onInput={(e) => setSharedWith(e.target.value)} 303 class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2" 304 placeholder="did:plc:..., did:plc:..." /> 305 </div> 306 </Show> 307 308 <div class="flex justify-end"> 309 <Button type="submit">Save Note</Button> 310 </div> 311 </form> 312 </div> 313 ); 314}