learn and share notes on atproto (wip) 🦉
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1/* eslint-disable solid/no-innerhtml */
2import { BacklinksPanel } from "$components/notes/BacklinksPanel";
3import { OutlinePanel } from "$components/notes/OutlinePanel";
4import { WikilinksPanel } from "$components/notes/WikilinksPanel";
5import { Button } from "$components/ui/Button";
6import { api } from "$lib/api";
7import type { Note } from "$lib/model";
8import { extractHeadings, findBacklinks, parseWikilinks, resolveWikilink } from "$lib/wikilink";
9import type { Heading, WikiLink } from "$lib/wikilink";
10import { Tag } from "$ui/Tag";
11import rehypeShiki from "@shikijs/rehype";
12import { A, useParams } from "@solidjs/router";
13import rehypeExternalLinks from "rehype-external-links";
14import rehypeStringify from "rehype-stringify";
15import remarkParse from "remark-parse";
16import remarkRehype from "remark-rehype";
17import type { Component } from "solid-js";
18import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js";
19import { unified } from "unified";
20
21const NoteView: Component = () => {
22 const params = useParams<{ id: string }>();
23 const [note] = createResource(() => params.id, async (id: string): Promise<Note | null> => {
24 const res = await api.getNote(id);
25 if (!res.ok) return null;
26 return res.json();
27 });
28
29 const [allNotes] = createResource(async (): Promise<Note[]> => {
30 const res = await api.getNotes();
31 if (!res.ok) return [];
32 return res.json();
33 });
34
35 const [renderedContent, setRenderedContent] = createSignal("");
36 const [headings, setHeadings] = createSignal<Heading[]>([]);
37 const [wikilinks, setWikilinks] = createSignal<WikiLink[]>([]);
38
39 const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, {
40 theme: "vitesse-dark",
41 defaultLanguage: "text",
42 }).use(rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] }).use(rehypeStringify);
43
44 const updateRenderedContent = async (n: Note) => {
45 setHeadings(extractHeadings(n.body));
46 setWikilinks(parseWikilinks(n.body));
47 const file = await processor.process(n.body);
48 setRenderedContent(String(file));
49 };
50
51 createEffect(() => {
52 const n = note();
53 if (n?.body) {
54 updateRenderedContent(n).catch(console.error);
55 }
56 });
57
58 const backlinks = createMemo(() => {
59 const n = note();
60 const all = allNotes() ?? [];
61 if (!n) return [];
62 return findBacklinks(n.title, all);
63 });
64
65 const resolveNote = (title: string) => resolveWikilink(title, allNotes() ?? []);
66
67 return (
68 <div class="max-w-7xl mx-auto p-6">
69 <Show
70 when={!note.loading}
71 fallback={
72 <div class="flex justify-center p-12">
73 <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
74 </div>
75 }>
76 <Show
77 when={note()}
78 fallback={
79 <div class="text-center py-12">
80 <h2 class="text-xl font-semibold text-slate-900 dark:text-white">Note not found</h2>
81 <p class="text-slate-600 dark:text-slate-400 mt-2">
82 This note may have been deleted or you don't have access to it.
83 </p>
84 <A href="/notes" class="text-blue-600 hover:text-blue-500 mt-4 inline-block">← Back to Notes</A>
85 </div>
86 }>
87 {(n) => (
88 <div class="space-y-6">
89 <nav class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
90 <A href="/notes" class="hover:text-blue-600 dark:hover:text-blue-400">Notes</A>
91 <span>›</span>
92 <span class="text-slate-900 dark:text-white">{n().title || "Untitled"}</span>
93 </nav>
94
95 <header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
96 <div class="space-y-2">
97 <h1 class="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
98 {n().title || "Untitled"}
99 </h1>
100 <div class="flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400">
101 <Show when={n().updated_at ?? n().created_at}>
102 {val => <span>Updated {new Date(val()).toLocaleDateString()}</span>}
103 </Show>
104 <Show when={n().visibility.type !== "Private"}>
105 <span class="inline-flex items-center rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">
106 {n().visibility.type}
107 </span>
108 </Show>
109 </div>
110 </div>
111 <div class="flex gap-2">
112 <A href={`/notes/edit/${n().id}`}>
113 <Button variant="secondary">Edit</Button>
114 </A>
115 <Button variant="secondary" onClick={() => api.downloadNoteAsMarkdown(n())}>Download Markdown</Button>
116 </div>
117 </header>
118
119 <Show when={n().tags.length > 0}>
120 <div class="flex flex-wrap gap-2">
121 <For each={n().tags}>{(tag) => <Tag label={tag} color="blue" />}</For>
122 </div>
123 </Show>
124
125 <div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-6">
126 <article class="prose prose-slate dark:prose-invert max-w-none bg-white dark:bg-slate-800/50 rounded-xl p-8 border border-slate-200 dark:border-slate-700 overflow-hidden">
127 <div class="shiki-content" innerHTML={renderedContent()} />
128 </article>
129
130 <aside class="space-y-6">
131 <div class="surface-01 rounded-lg p-4">
132 <OutlinePanel headings={headings()} />
133 </div>
134 <div class="surface-01 rounded-lg p-4">
135 <WikilinksPanel links={wikilinks()} notes={allNotes() ?? []} resolveNote={resolveNote} />
136 </div>
137 <div class="surface-01 rounded-lg p-4">
138 <BacklinksPanel backlinks={backlinks()} />
139 </div>
140 <Show when={n().tags.length > 0}>
141 <div class="surface-01 rounded-lg p-4 hidden lg:block">
142 <h3 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-2">Tags</h3>
143 <div class="flex flex-wrap gap-2">
144 <For each={n().tags}>{(tag) => <Tag label={tag} density="compact" />}</For>
145 </div>
146 </div>
147 </Show>
148 </aside>
149 </div>
150 </div>
151 )}
152 </Show>
153 </Show>
154 </div>
155 );
156};
157
158export default NoteView;