learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
at main 158 lines 6.7 kB view raw
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;