forked from
margin.at/margin
Write on the margins of the internet. Powered by the AT Protocol.
1const API_BASE = "/api";
2const AUTH_BASE = "/auth";
3
4async function request(endpoint, options = {}) {
5 const response = await fetch(endpoint, {
6 credentials: "include",
7 headers: {
8 "Content-Type": "application/json",
9 ...options.headers,
10 },
11 ...options,
12 });
13
14 if (!response.ok) {
15 const error = await response.text();
16 throw new Error(error || `HTTP ${response.status}`);
17 }
18
19 return response.json();
20}
21
22export async function getURLMetadata(url) {
23 return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`);
24}
25
26export async function getAnnotationFeed(
27 limit = 50,
28 offset = 0,
29 tag = "",
30 creator = "",
31) {
32 let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`;
33 if (tag) url += `&tag=${encodeURIComponent(tag)}`;
34 if (creator) url += `&creator=${encodeURIComponent(creator)}`;
35 return request(url);
36}
37
38export async function getAnnotations({
39 source,
40 motivation,
41 limit = 50,
42 offset = 0,
43} = {}) {
44 let url = `${API_BASE}/annotations?limit=${limit}&offset=${offset}`;
45 if (source) url += `&source=${encodeURIComponent(source)}`;
46 if (motivation) url += `&motivation=${motivation}`;
47 return request(url);
48}
49
50export async function getByTarget(source, limit = 50, offset = 0) {
51 return request(
52 `${API_BASE}/targets?source=${encodeURIComponent(source)}&limit=${limit}&offset=${offset}`,
53 );
54}
55
56export async function getAnnotation(uri) {
57 return request(`${API_BASE}/annotation?uri=${encodeURIComponent(uri)}`);
58}
59
60export async function getProfile(did) {
61 return request(`${API_BASE}/profile/${encodeURIComponent(did)}`);
62}
63
64export async function getUserAnnotations(did, limit = 50, offset = 0) {
65 return request(
66 `${API_BASE}/users/${encodeURIComponent(did)}/annotations?limit=${limit}&offset=${offset}`,
67 );
68}
69
70export async function getUserHighlights(did, limit = 50, offset = 0) {
71 return request(
72 `${API_BASE}/users/${encodeURIComponent(did)}/highlights?limit=${limit}&offset=${offset}`,
73 );
74}
75
76export async function getUserBookmarks(did, limit = 50, offset = 0) {
77 return request(
78 `${API_BASE}/users/${encodeURIComponent(did)}/bookmarks?limit=${limit}&offset=${offset}`,
79 );
80}
81
82export async function getUserTargetItems(did, url, limit = 50, offset = 0) {
83 return request(
84 `${API_BASE}/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`,
85 );
86}
87
88export async function getHighlights(creatorDid, limit = 50, offset = 0) {
89 return request(
90 `${API_BASE}/highlights?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`,
91 );
92}
93
94export async function getBookmarks(creatorDid, limit = 50, offset = 0) {
95 return request(
96 `${API_BASE}/bookmarks?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`,
97 );
98}
99
100export async function getReplies(annotationUri) {
101 return request(
102 `${API_BASE}/replies?uri=${encodeURIComponent(annotationUri)}`,
103 );
104}
105
106export async function updateAnnotation(uri, text, tags) {
107 return request(`${API_BASE}/annotations?uri=${encodeURIComponent(uri)}`, {
108 method: "PUT",
109 body: JSON.stringify({ text, tags }),
110 });
111}
112
113export async function updateHighlight(uri, color, tags) {
114 return request(`${API_BASE}/highlights?uri=${encodeURIComponent(uri)}`, {
115 method: "PUT",
116 body: JSON.stringify({ color, tags }),
117 });
118}
119
120export async function createBookmark(url, title, description) {
121 return request(`${API_BASE}/bookmarks`, {
122 method: "POST",
123 body: JSON.stringify({ url, title, description }),
124 });
125}
126
127export async function updateBookmark(uri, title, description, tags) {
128 return request(`${API_BASE}/bookmarks?uri=${encodeURIComponent(uri)}`, {
129 method: "PUT",
130 body: JSON.stringify({ title, description, tags }),
131 });
132}
133
134export async function getCollections(did) {
135 let url = `${API_BASE}/collections`;
136 if (did) url += `?author=${encodeURIComponent(did)}`;
137 return request(url);
138}
139
140export async function getCollectionsContaining(annotationUri) {
141 return request(
142 `${API_BASE}/collections/containing?uri=${encodeURIComponent(annotationUri)}`,
143 );
144}
145
146export async function getEditHistory(uri) {
147 return request(
148 `${API_BASE}/annotations/history?uri=${encodeURIComponent(uri)}`,
149 );
150}
151
152export async function getNotifications(limit = 50, offset = 0) {
153 return request(`${API_BASE}/notifications?limit=${limit}&offset=${offset}`);
154}
155
156export async function getUnreadNotificationCount() {
157 return request(`${API_BASE}/notifications/count`);
158}
159
160export async function markNotificationsRead() {
161 return request(`${API_BASE}/notifications/read`, { method: "POST" });
162}
163
164export async function updateCollection(uri, name, description, icon) {
165 return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, {
166 method: "PUT",
167 body: JSON.stringify({ name, description, icon }),
168 });
169}
170
171export async function updateProfile({ bio, website, links }) {
172 return request(`${API_BASE}/profile`, {
173 method: "PUT",
174 body: JSON.stringify({ bio, website, links }),
175 });
176}
177
178export async function createCollection(name, description, icon) {
179 return request(`${API_BASE}/collections`, {
180 method: "POST",
181 body: JSON.stringify({ name, description, icon }),
182 });
183}
184
185export async function deleteCollection(uri) {
186 return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, {
187 method: "DELETE",
188 });
189}
190
191export async function getCollectionItems(collectionUri) {
192 return request(
193 `${API_BASE}/collections/${encodeURIComponent(collectionUri)}/items`,
194 );
195}
196
197export async function addItemToCollection(
198 collectionUri,
199 annotationUri,
200 position = 0,
201) {
202 return request(
203 `${API_BASE}/collections/${encodeURIComponent(collectionUri)}/items`,
204 {
205 method: "POST",
206 body: JSON.stringify({ annotationUri, position }),
207 },
208 );
209}
210
211export async function removeItemFromCollection(itemUri) {
212 return request(
213 `${API_BASE}/collections/items?uri=${encodeURIComponent(itemUri)}`,
214 {
215 method: "DELETE",
216 },
217 );
218}
219
220export async function getLikeCount(annotationUri) {
221 return request(`${API_BASE}/likes?uri=${encodeURIComponent(annotationUri)}`);
222}
223
224export async function deleteHighlight(rkey) {
225 return request(`${API_BASE}/highlights?rkey=${encodeURIComponent(rkey)}`, {
226 method: "DELETE",
227 });
228}
229
230export async function deleteBookmark(rkey) {
231 return request(`${API_BASE}/bookmarks?rkey=${encodeURIComponent(rkey)}`, {
232 method: "DELETE",
233 });
234}
235
236export async function createHighlight({ url, title, selector, color, tags }) {
237 return request(`${API_BASE}/highlights`, {
238 method: "POST",
239 body: JSON.stringify({ url, title, selector, color, tags }),
240 });
241}
242
243export async function createAnnotation({
244 url,
245 text,
246 quote,
247 title,
248 selector,
249 tags,
250}) {
251 return request(`${API_BASE}/annotations`, {
252 method: "POST",
253 body: JSON.stringify({ url, text, quote, title, selector, tags }),
254 });
255}
256
257export async function deleteAnnotation(rkey, type = "annotation") {
258 return request(
259 `${API_BASE}/annotations?rkey=${encodeURIComponent(rkey)}&type=${encodeURIComponent(type)}`,
260 {
261 method: "DELETE",
262 },
263 );
264}
265
266export async function likeAnnotation(subjectUri, subjectCid) {
267 return request(`${API_BASE}/annotations/like`, {
268 method: "POST",
269 headers: {
270 "Content-Type": "application/json",
271 },
272 body: JSON.stringify({
273 subjectUri,
274 subjectCid,
275 }),
276 });
277}
278
279export async function unlikeAnnotation(subjectUri) {
280 return request(
281 `${API_BASE}/annotations/like?uri=${encodeURIComponent(subjectUri)}`,
282 {
283 method: "DELETE",
284 },
285 );
286}
287
288export async function createReply({
289 parentUri,
290 parentCid,
291 rootUri,
292 rootCid,
293 text,
294}) {
295 return request(`${API_BASE}/annotations/reply`, {
296 method: "POST",
297 body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }),
298 });
299}
300
301export async function deleteReply(uri) {
302 return request(
303 `${API_BASE}/annotations/reply?uri=${encodeURIComponent(uri)}`,
304 {
305 method: "DELETE",
306 },
307 );
308}
309
310export async function getSession() {
311 return request(`${AUTH_BASE}/session`);
312}
313
314export async function logout() {
315 return request(`${AUTH_BASE}/logout`, { method: "POST" });
316}
317
318export function normalizeAnnotation(item) {
319 if (!item) return {};
320
321 if (item.type === "Annotation") {
322 return {
323 type: item.type,
324 uri: item.uri || item.id,
325 author: item.author || item.creator,
326 url: item.url || item.target?.source,
327 title: item.title || item.target?.title,
328 text: item.text || item.body?.value,
329 selector: item.selector || item.target?.selector,
330 motivation: item.motivation,
331 tags: item.tags || [],
332 createdAt: item.createdAt || item.created,
333 cid: item.cid || item.CID,
334 likeCount: item.likeCount || 0,
335 replyCount: item.replyCount || 0,
336 viewerHasLiked: item.viewerHasLiked || false,
337 };
338 }
339
340 if (item.type === "Bookmark") {
341 return {
342 type: item.type,
343 uri: item.uri || item.id,
344 author: item.author || item.creator,
345 url: item.url || item.source,
346 title: item.title,
347 description: item.description,
348 tags: item.tags || [],
349 createdAt: item.createdAt || item.created,
350 cid: item.cid || item.CID,
351 likeCount: item.likeCount || 0,
352 replyCount: item.replyCount || 0,
353 viewerHasLiked: item.viewerHasLiked || false,
354 };
355 }
356
357 if (item.type === "Highlight") {
358 return {
359 type: item.type,
360 uri: item.uri || item.id,
361 author: item.author || item.creator,
362 url: item.url || item.target?.source,
363 title: item.title || item.target?.title,
364 selector: item.selector || item.target?.selector,
365 color: item.color,
366 tags: item.tags || [],
367 createdAt: item.createdAt || item.created,
368 cid: item.cid || item.CID,
369 likeCount: item.likeCount || 0,
370 replyCount: item.replyCount || 0,
371 viewerHasLiked: item.viewerHasLiked || false,
372 };
373 }
374
375 return {
376 uri: item.uri || item.id,
377 author: item.author || item.creator,
378 url: item.url || item.source || item.target?.source,
379 title: item.title || item.target?.title,
380 text: item.text || item.body?.value,
381 description: item.description,
382 selector: item.selector || item.target?.selector,
383 color: item.color,
384 tags: item.tags || [],
385 createdAt: item.createdAt || item.created,
386 cid: item.cid || item.CID,
387 likeCount: item.likeCount || 0,
388 replyCount: item.replyCount || 0,
389 viewerHasLiked: item.viewerHasLiked || false,
390 };
391}
392
393export function normalizeHighlight(highlight) {
394 return {
395 uri: highlight.uri || highlight.id,
396 author: highlight.author || highlight.creator,
397 url: highlight.url || highlight.target?.source,
398 title: highlight.title || highlight.target?.title,
399 selector: highlight.selector || highlight.target?.selector,
400 color: highlight.color,
401 tags: highlight.tags || [],
402 createdAt: highlight.createdAt || highlight.created,
403 likeCount: highlight.likeCount || 0,
404 replyCount: highlight.replyCount || 0,
405 viewerHasLiked: highlight.viewerHasLiked || false,
406 };
407}
408
409export function normalizeBookmark(bookmark) {
410 return {
411 uri: bookmark.uri || bookmark.id,
412 author: bookmark.author || bookmark.creator,
413 url: bookmark.url || bookmark.source,
414 title: bookmark.title,
415 description: bookmark.description,
416 tags: bookmark.tags || [],
417 createdAt: bookmark.createdAt || bookmark.created,
418 likeCount: bookmark.likeCount || 0,
419 replyCount: bookmark.replyCount || 0,
420 viewerHasLiked: bookmark.viewerHasLiked || false,
421 };
422}
423
424export async function searchActors(query) {
425 const res = await fetch(
426 `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`,
427 );
428 if (!res.ok) throw new Error("Search failed");
429 return res.json();
430}
431
432export async function resolveHandle(handle) {
433 const res = await fetch(
434 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
435 );
436 if (!res.ok) throw new Error("Failed to resolve handle");
437 const data = await res.json();
438 return data.did;
439}
440
441export async function startLogin(handle, inviteCode) {
442 return request(`${AUTH_BASE}/start`, {
443 method: "POST",
444 body: JSON.stringify({ handle, invite_code: inviteCode }),
445 });
446}
447export async function getTrendingTags(limit = 10) {
448 return request(`${API_BASE}/tags/trending?limit=${limit}`);
449}
450
451export async function getAPIKeys() {
452 return request(`${API_BASE}/keys`);
453}
454
455export async function createAPIKey(name) {
456 return request(`${API_BASE}/keys`, {
457 method: "POST",
458 body: JSON.stringify({ name }),
459 });
460}
461
462export async function deleteAPIKey(id) {
463 return request(`${API_BASE}/keys/${id}`, { method: "DELETE" });
464}
465
466export async function describeServer(service) {
467 const res = await fetch(`${service}/xrpc/com.atproto.server.describeServer`);
468 if (!res.ok) throw new Error("Failed to describe server");
469 return res.json();
470}
471
472export async function createAccount(
473 service,
474 { handle, email, password, inviteCode },
475) {
476 const res = await fetch(`${service}/xrpc/com.atproto.server.createAccount`, {
477 method: "POST",
478 headers: {
479 "Content-Type": "application/json",
480 },
481 body: JSON.stringify({
482 handle,
483 email,
484 password,
485 inviteCode,
486 }),
487 });
488
489 const data = await res.json();
490 if (!res.ok) {
491 throw new Error(data.message || data.error || "Failed to create account");
492 }
493 return data;
494}