learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1import type { CreateDeckPayload, CreateNotePayload } from "./model";
2import { authStore } from "./store";
3
4const API_BASE = "/api";
5
6export async function apiFetch(path: string, options: RequestInit = {}) {
7 const token = authStore.accessJwt();
8
9 const headers = new Headers(options.headers);
10 if (token) {
11 headers.set("Authorization", `Bearer ${token}`);
12 }
13
14 if (options.body && typeof options.body === "string" && !headers.has("Content-Type")) {
15 headers.set("Content-Type", "application/json");
16 }
17
18 const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
19
20 if (response.status === 401) {
21 authStore.logout();
22 window.location.href = "/login";
23 }
24
25 return response;
26}
27
28const syncMethods = {
29 pushDeck: (id: string) => apiFetch(`/sync/push/deck/${id}`, { method: "POST" }),
30 pushNote: (id: string) => apiFetch(`/sync/push/note/${id}`, { method: "POST" }),
31 getSyncStatus: () => apiFetch("/sync/status", { method: "GET" }),
32 resolveConflict: (entityType: string, id: string, strategy: "last_write_wins" | "keep_local" | "keep_remote") => {
33 return apiFetch(`/sync/resolve/${entityType}/${id}`, { method: "POST", body: JSON.stringify({ strategy }) });
34 },
35};
36
37export const api = {
38 get: (path: string) => apiFetch(path, { method: "GET" }),
39 post: (path: string, body: unknown) => apiFetch(path, { method: "POST", body: JSON.stringify(body) }),
40 getStats: () => apiFetch("/review/stats", { method: "GET" }),
41 follow: (did: string) => apiFetch(`/social/follow/${did}`, { method: "POST" }),
42 unfollow: (did: string) => apiFetch(`/social/unfollow/${did}`, { method: "POST" }),
43 getFollowers: (did: string) => apiFetch(`/social/followers/${did}`, { method: "GET" }),
44 getFollowing: (did: string) => apiFetch(`/social/following/${did}`, { method: "GET" }),
45 getComments: (deckId: string) => apiFetch(`/decks/${deckId}/comments`, { method: "GET" }),
46 getFeedFollows: () => apiFetch("/feeds/follows", { method: "GET" }),
47 getFeedTrending: () => apiFetch("/feeds/trending", { method: "GET" }),
48 forkDeck: (deckId: string) => apiFetch(`/decks/${deckId}/fork`, { method: "POST" }),
49 getDecks: () => apiFetch("/decks", { method: "GET" }),
50 getDeck: (id: string) => apiFetch(`/decks/${id}`, { method: "GET" }),
51 getDeckCards: (id: string) => apiFetch(`/decks/${id}/cards`, { method: "GET" }),
52 getPreferences: () => apiFetch("/preferences", { method: "GET" }),
53 getDiscovery: () => apiFetch("/discovery", { method: "GET" }),
54 getUserProfile: (did: string) => apiFetch(`/users/${did}/profile`, { method: "GET" }),
55 getRemoteDeck: (uri: string) => apiFetch(`/remote/deck?uri=${encodeURIComponent(uri)}`, { method: "GET" }),
56 exportData: (collection: "decks" | "notes") => apiFetch(`/export/${collection}`, { method: "GET" }),
57 getNotes: () => apiFetch("/notes", { method: "GET" }),
58 getNote: (id: string) => apiFetch(`/notes/${id}`, { method: "GET" }),
59 createNote: (payload: CreateNotePayload) => apiFetch("/notes", { method: "POST", body: JSON.stringify(payload) }),
60 deleteNote: (id: string) => apiFetch(`/notes/${id}`, { method: "DELETE" }),
61 updateNote: (id: string, payload: object) => {
62 return apiFetch(`/notes/${id}`, { method: "PUT", body: JSON.stringify(payload) });
63 },
64 createDeck: async (payload: CreateDeckPayload) => {
65 const { cards, ...deckPayload } = payload;
66 const res = await apiFetch("/decks", { method: "POST", body: JSON.stringify(deckPayload) });
67 if (!res.ok) return res;
68
69 const deck = await res.json();
70 if (cards && cards.length > 0) {
71 await Promise.all(
72 cards.map((c) =>
73 apiFetch("/cards", {
74 method: "POST",
75 body: JSON.stringify({ deck_id: deck.id, front: c.front, back: c.back, media_url: c.mediaUrl }),
76 })
77 ),
78 );
79 }
80
81 return { ok: true, json: async () => deck };
82 },
83 addComment: (deckId: string, content: string, parentId?: string) => {
84 return apiFetch(`/decks/${deckId}/comments`, {
85 method: "POST",
86 body: JSON.stringify({ content, parent_id: parentId }),
87 });
88 },
89 search: (query: string, limit = 20, offset = 0, source?: "local" | "remote") => {
90 const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) });
91 if (source) params.set("source", source);
92 return apiFetch(`/search?${params}`, { method: "GET" });
93 },
94 getDueCards: (deckId?: string, limit = 20) => {
95 const params = new URLSearchParams({ limit: String(limit) });
96 if (deckId) params.set("deck_id", deckId);
97 return apiFetch(`/review/due?${params}`, { method: "GET" });
98 },
99 submitReview: (cardId: string, grade: number) => {
100 return apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) });
101 },
102 updatePreferences: (updates: import("./model").UpdatePreferencesPayload) => {
103 return apiFetch("/preferences", { method: "PUT", body: JSON.stringify(updates) });
104 },
105 startOAuth: async (handle: string) => {
106 const res = await apiFetch("/oauth/authorize", { method: "POST", body: JSON.stringify({ handle }) });
107 if (res.ok) {
108 const data = await res.json();
109 window.location.href = data.authorization_url;
110 return { ok: true };
111 }
112 return res;
113 },
114 // TODO: type check visibility
115 saveImportedArticle: (payload: { url: string; tags?: string[]; visibility?: unknown }) => {
116 return apiFetch("/import/article/save", { method: "POST", body: JSON.stringify(payload) });
117 },
118 downloadNoteAsMarkdown: (note: { title: string; body: string }) => {
119 const blob = new Blob([note.body], { type: "text/markdown" });
120 const url = URL.createObjectURL(blob);
121 const a = document.createElement("a");
122 a.href = url;
123 a.download = `${note.title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}.md`;
124 document.body.appendChild(a);
125 a.click();
126 document.body.removeChild(a);
127 URL.revokeObjectURL(url);
128 },
129 ...syncMethods,
130};