learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1import { NoteCard } from "$components/NoteCard";
2import { NotesGraph } from "$components/notes/NotesGraph";
3import { Button } from "$components/ui/Button";
4import { EmptyState } from "$components/ui/EmptyState";
5import { api } from "$lib/api";
6import type { Note } from "$lib/model";
7import { authStore } from "$lib/store";
8import { syncStore } from "$lib/sync-store";
9import { A, useLocation } from "@solidjs/router";
10import type { Component } from "solid-js";
11import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js";
12
13const fetchNotes = async (): Promise<Note[]> => {
14 const user = authStore.user();
15 const remoteNotes: Note[] = [];
16 const localNotes: Note[] = [];
17
18 try {
19 const res = await api.getNotes();
20 if (res.ok) {
21 remoteNotes.push(...(await res.json()));
22 }
23 } catch {
24 console.log("Offline - continuing with local only");
25 }
26
27 if (user) {
28 const locals = await syncStore.getLocalNotes(user.did);
29 for (const local of locals) {
30 if (local.syncStatus === "local_only" || local.syncStatus === "pending_push" || local.syncStatus === "conflict") {
31 localNotes.push(
32 {
33 id: local.id,
34 owner_did: local.ownerDid,
35 title: local.title,
36 body: local.body,
37 tags: local.tags,
38 visibility: local.visibility,
39 updated_at: local.updatedAt,
40 links: local.links ?? [],
41 } as Note,
42 );
43 }
44 }
45 }
46
47 return [...localNotes, ...remoteNotes];
48};
49
50type ViewMode = "grid" | "list" | "graph";
51
52const Notes: Component = () => {
53 const location = useLocation();
54 const [notes, { refetch }] = createResource(fetchNotes);
55 const [viewMode, setViewMode] = createSignal<ViewMode>("grid");
56 const [searchQuery, setSearchQuery] = createSignal("");
57
58 createEffect(() => {
59 if (location.pathname === "/notes") {
60 refetch();
61 }
62 });
63
64 const filteredNotes = createMemo(() => {
65 const allNotes = notes() || [];
66 const query = searchQuery().toLowerCase().trim();
67 if (!query) return allNotes;
68 return allNotes.filter((note) =>
69 note.title.toLowerCase().includes(query)
70 || note.body.toLowerCase().includes(query)
71 || note.tags.some((tag) => tag.toLowerCase().includes(query))
72 );
73 });
74
75 return (
76 <div class="max-w-7xl mx-auto p-6 space-y-6">
77 <header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
78 <div>
79 <h1 class="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">Notes</h1>
80 <p class="text-slate-600 dark:text-slate-400 mt-1">Your personal knowledge base</p>
81 </div>
82 <A href="/notes/new">
83 <Button variant="primary">New Note</Button>
84 </A>
85 </header>
86
87 <div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
88 <div class="relative flex-1 max-w-md">
89 <input
90 type="text"
91 placeholder="Search notes..."
92 value={searchQuery()}
93 onInput={(e) => setSearchQuery(e.currentTarget.value)}
94 class="w-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg px-4 py-2 pl-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
95 <svg
96 class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"
97 fill="none"
98 stroke="currentColor"
99 viewBox="0 0 24 24">
100 <path
101 stroke-linecap="round"
102 stroke-linejoin="round"
103 stroke-width="2"
104 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
105 </svg>
106 </div>
107
108 <div class="flex items-center gap-2">
109 <button
110 onClick={() => setViewMode("grid")}
111 class={`p-2 rounded ${
112 viewMode() === "grid" ? "bg-slate-200 dark:bg-slate-700" : "hover:bg-slate-100 dark:hover:bg-slate-800"
113 }`}
114 aria-label="Grid view">
115 <svg
116 class="w-5 h-5 text-slate-600 dark:text-slate-300"
117 fill="none"
118 stroke="currentColor"
119 viewBox="0 0 24 24">
120 <path
121 stroke-linecap="round"
122 stroke-linejoin="round"
123 stroke-width="2"
124 d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
125 </svg>
126 </button>
127 <button
128 onClick={() => setViewMode("list")}
129 class={`p-2 rounded ${
130 viewMode() === "list" ? "bg-slate-200 dark:bg-slate-700" : "hover:bg-slate-100 dark:hover:bg-slate-800"
131 }`}
132 aria-label="List view">
133 <svg
134 class="w-5 h-5 text-slate-600 dark:text-slate-300"
135 fill="none"
136 stroke="currentColor"
137 viewBox="0 0 24 24">
138 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
139 </svg>
140 </button>
141 <button
142 onClick={() => setViewMode("graph")}
143 class={`p-2 rounded ${
144 viewMode() === "graph" ? "bg-slate-200 dark:bg-slate-700" : "hover:bg-slate-100 dark:hover:bg-slate-800"
145 }`}
146 aria-label="Graph view">
147 <svg
148 class="w-5 h-5 text-slate-600 dark:text-slate-300"
149 fill="none"
150 stroke="currentColor"
151 viewBox="0 0 24 24">
152 <circle cx="5" cy="6" r="2" stroke-width="2" />
153 <circle cx="12" cy="18" r="2" stroke-width="2" />
154 <circle cx="19" cy="10" r="2" stroke-width="2" />
155 <path stroke-linecap="round" stroke-width="2" d="M6.5 7.5L10.5 16M13.5 16.5L17 11.5M7 6h10" />
156 </svg>
157 </button>
158 </div>
159 </div>
160
161 <Show
162 when={!notes.loading}
163 fallback={
164 <div class="flex justify-center p-12">
165 <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
166 </div>
167 }>
168 <Show
169 when={filteredNotes().length > 0}
170 fallback={
171 <Show
172 when={searchQuery()}
173 fallback={
174 <EmptyState
175 title="No notes yet"
176 description="Start capturing your thoughts and ideas"
177 action={
178 <A href="/notes/new">
179 <Button variant="primary">Create your first note</Button>
180 </A>
181 } />
182 }>
183 <EmptyState
184 title="No matching notes"
185 description={`No notes found for "${searchQuery()}"`}
186 action={
187 <button
188 onClick={() => setSearchQuery("")}
189 class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
190 Clear search
191 </button>
192 } />
193 </Show>
194 }>
195 <Show
196 when={viewMode() === "graph"}
197 fallback={
198 <div
199 class={viewMode() === "grid"
200 ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
201 : "flex flex-col gap-4"}>
202 <For each={filteredNotes()}>{(note) => <NoteCard note={note} />}</For>
203 </div>
204 }>
205 <NotesGraph notes={filteredNotes()} />
206 </Show>
207 </Show>
208 </Show>
209 </div>
210 );
211};
212
213export default Notes;