learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
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}