Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { atom } from "nanostores";
2import type {
3 AnnotationItem,
4 Collection,
5 FeedResponse,
6 HydratedLabel,
7 NotificationItem,
8 Selector,
9 Target,
10 UserProfile,
11} from "../types";
12
13export type { Collection } from "../types";
14
15export const sessionAtom = atom<UserProfile | null>(null);
16
17export async function checkSession(): Promise<UserProfile | null> {
18 try {
19 const res = await fetch("/auth/session");
20 if (!res.ok) {
21 sessionAtom.set(null);
22 return null;
23 }
24 const data = await res.json();
25
26 if (data.authenticated || data.did) {
27 const baseProfile: UserProfile = {
28 did: data.did,
29 handle: data.handle,
30 displayName: data.displayName,
31 avatar: data.avatar,
32 description: data.description,
33 website: data.website,
34 links: data.links,
35 followersCount: data.followersCount,
36 followsCount: data.followsCount,
37 postsCount: data.postsCount,
38 };
39
40 try {
41 const bskyRes = await fetch(
42 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`,
43 );
44 if (bskyRes.ok) {
45 const bskyData = await bskyRes.json();
46 if (bskyData.avatar) baseProfile.avatar = bskyData.avatar;
47 if (bskyData.displayName)
48 baseProfile.displayName = bskyData.displayName;
49 }
50 } catch (e) {
51 console.warn("Failed to fetch Bsky profile for session", e);
52 }
53
54 try {
55 const res = await fetch(`/api/profile/${data.did}`);
56 if (res.ok) {
57 const marginProfile = await res.json();
58 if (marginProfile) {
59 if (marginProfile.description)
60 baseProfile.description = marginProfile.description;
61 if (marginProfile.followersCount)
62 baseProfile.followersCount = marginProfile.followersCount;
63 if (marginProfile.followsCount)
64 baseProfile.followsCount = marginProfile.followsCount;
65 if (marginProfile.postsCount)
66 baseProfile.postsCount = marginProfile.postsCount;
67 if (marginProfile.website)
68 baseProfile.website = marginProfile.website;
69 if (marginProfile.links) baseProfile.links = marginProfile.links;
70 }
71 }
72 } catch (e) {
73 console.debug("Failed to fetch Margin profile:", e);
74 }
75
76 sessionAtom.set(baseProfile);
77 return baseProfile;
78 }
79
80 sessionAtom.set(null);
81 return null;
82 } catch (e) {
83 console.error("Session check failed:", e);
84 sessionAtom.set(null);
85 return null;
86 }
87}
88
89async function apiRequest(
90 path: string,
91 options: RequestInit & { skipAuthRedirect?: boolean } = {},
92): Promise<Response> {
93 const { skipAuthRedirect, ...fetchOptions } = options;
94 const headers = {
95 "Content-Type": "application/json",
96 ...(fetchOptions.headers || {}),
97 };
98
99 const apiPath =
100 path.startsWith("/api") || path.startsWith("/auth") ? path : `/api${path}`;
101
102 const response = await fetch(apiPath, {
103 ...fetchOptions,
104 headers,
105 });
106
107 if (response.status === 401 && !skipAuthRedirect) {
108 sessionAtom.set(null);
109 try {
110 await fetch("/auth/logout", { method: "POST" });
111 } catch {
112 // Ignore
113 }
114 if (window.location.pathname !== "/login") {
115 window.location.href = "/login";
116 }
117 }
118
119 return response;
120}
121
122export interface GetFeedParams {
123 source?: string;
124 type?: string;
125 limit?: number;
126 offset?: number;
127 motivation?: string;
128 tag?: string;
129 creator?: string;
130}
131
132interface RawItem {
133 type?: string;
134 collectionUri?: string;
135 annotation?: RawItem;
136 highlight?: RawItem;
137 bookmark?: RawItem;
138 uri?: string;
139 id?: string;
140 cid?: string;
141 author?: UserProfile;
142 creator?: UserProfile;
143 collection?: {
144 uri: string;
145 name: string;
146 icon?: string;
147 };
148 context?: {
149 uri: string;
150 name: string;
151 icon?: string;
152 }[];
153 created?: string;
154 createdAt?: string;
155 target?: string | { source?: string; title?: string; selector?: Selector };
156 url?: string;
157 targetUrl?: string;
158 title?: string;
159 selector?: Selector;
160 viewer?: { like?: string; [key: string]: unknown };
161 viewerHasLiked?: boolean;
162 motivation?: string;
163 [key: string]: unknown;
164}
165
166function normalizeItem(raw: RawItem): AnnotationItem {
167 if (raw.type === "CollectionItem" || raw.collectionUri) {
168 const inner = raw.annotation || raw.highlight || raw.bookmark || {};
169 const normalizedInner = normalizeItem(inner);
170
171 return {
172 ...normalizedInner,
173 uri: normalizedInner.uri || raw.uri || "",
174 cid: normalizedInner.cid || raw.cid || "",
175 author: (normalizedInner.author ||
176 raw.author ||
177 raw.creator) as UserProfile,
178 collection: raw.collection
179 ? {
180 uri: raw.collection.uri,
181 name: raw.collection.name,
182 icon: raw.collection.icon,
183 }
184 : undefined,
185 context: raw.context
186 ? raw.context.map((c) => ({
187 uri: c.uri,
188 name: c.name,
189 icon: c.icon,
190 }))
191 : undefined,
192 addedBy: raw.creator || raw.author,
193 createdAt:
194 normalizedInner.createdAt ||
195 raw.created ||
196 raw.createdAt ||
197 new Date().toISOString(),
198 collectionItemUri: raw.id || raw.uri,
199 };
200 }
201
202 let target: Target | undefined;
203
204 if (raw.target) {
205 if (typeof raw.target === "string") {
206 target = { source: raw.target, title: raw.title, selector: raw.selector };
207 } else {
208 target = {
209 source: raw.target.source || "",
210 title: raw.target.title || raw.title,
211 selector: raw.target.selector || raw.selector,
212 };
213 }
214 }
215
216 if (!target || !target.source) {
217 const url =
218 raw.url ||
219 raw.targetUrl ||
220 (typeof raw.target === "string" ? raw.target : raw.target?.source);
221 if (url) {
222 target = {
223 source: url,
224 title:
225 raw.title ||
226 (typeof raw.target !== "string" ? raw.target?.title : undefined),
227 selector:
228 raw.selector ||
229 (typeof raw.target !== "string" ? raw.target?.selector : undefined),
230 };
231 }
232 }
233
234 return {
235 ...raw,
236 uri: raw.id || raw.uri || "",
237 cid: raw.cid || "",
238 author: (raw.creator || raw.author) as UserProfile,
239 createdAt: raw.created || raw.createdAt || new Date().toISOString(),
240 target: target,
241 viewer: raw.viewer || { like: raw.viewerHasLiked ? "true" : undefined },
242 motivation: raw.motivation || "highlighting",
243 parentUri: (raw as Record<string, unknown>).inReplyTo as string | undefined,
244 };
245}
246
247export async function searchItems(
248 query: string,
249 options: { creator?: string; limit?: number; offset?: number } = {},
250): Promise<FeedResponse> {
251 const params = new URLSearchParams();
252 params.append("q", query);
253 if (options.creator) params.append("creator", options.creator);
254 if (options.limit) params.append("limit", options.limit.toString());
255 if (options.offset) params.append("offset", options.offset.toString());
256
257 try {
258 const res = await apiRequest(`/api/search?${params.toString()}`, {
259 skipAuthRedirect: true,
260 });
261 if (!res.ok) throw new Error("Search failed");
262 const data = await res.json();
263 const items: AnnotationItem[] = (data.items || []).map(normalizeItem);
264 return {
265 items,
266 hasMore: items.length >= (options.limit || 50),
267 fetchedCount: items.length,
268 };
269 } catch (e) {
270 console.error("Search error:", e);
271 return { items: [], hasMore: false, fetchedCount: 0 };
272 }
273}
274
275export async function getFeed({
276 source,
277 type = "all",
278 limit = 50,
279 offset = 0,
280 motivation,
281 tag,
282 creator,
283}: GetFeedParams): Promise<FeedResponse> {
284 const params = new URLSearchParams();
285 if (source) params.append("source", source);
286 if (type) params.append("type", type);
287 if (limit) params.append("limit", limit.toString());
288 if (offset) params.append("offset", offset.toString());
289 if (motivation) params.append("motivation", motivation);
290 if (tag) params.append("tag", tag);
291 if (creator) params.append("creator", creator);
292
293 const endpoint = source ? "/api/targets" : "/api/annotations/feed";
294
295 try {
296 const res = await apiRequest(`${endpoint}?${params.toString()}`, {
297 skipAuthRedirect: true,
298 });
299 if (!res.ok) throw new Error("Failed to fetch feed");
300 const data = await res.json();
301 const normalizedItems: AnnotationItem[] = (data.items || []).map(
302 normalizeItem,
303 );
304
305 const groupedItems: AnnotationItem[] = [];
306 if (normalizedItems.length > 0) {
307 groupedItems.push(normalizedItems[0]);
308
309 for (let i = 1; i < normalizedItems.length; i++) {
310 const prev = groupedItems[groupedItems.length - 1];
311 const curr = normalizedItems[i];
312
313 if (prev.collection && curr.collection) {
314 if (
315 prev.uri === curr.uri &&
316 prev.addedBy?.did === curr.addedBy?.did
317 ) {
318 if (!prev.context) {
319 prev.context = [prev.collection];
320 }
321 prev.context.push(curr.collection);
322 groupedItems[groupedItems.length - 1] = prev;
323 continue;
324 }
325 }
326 groupedItems.push(curr);
327 }
328 }
329
330 return {
331 items: groupedItems,
332 hasMore: normalizedItems.length >= limit,
333 fetchedCount: normalizedItems.length,
334 };
335 } catch (e) {
336 console.error(e);
337 return { items: [], hasMore: false, fetchedCount: 0 };
338 }
339}
340
341interface CreateAnnotationParams {
342 url: string;
343 text?: string;
344 title?: string;
345 selector?: { exact: string; prefix?: string; suffix?: string };
346 tags?: string[];
347 labels?: string[];
348}
349
350export async function createAnnotation({
351 url,
352 text,
353 title,
354 selector,
355 tags,
356 labels,
357}: CreateAnnotationParams) {
358 try {
359 const res = await apiRequest("/api/annotations", {
360 method: "POST",
361 body: JSON.stringify({ url, text, title, selector, tags, labels }),
362 });
363 if (!res.ok) throw new Error(await res.text());
364 const raw = await res.json();
365 return normalizeItem(raw);
366 } catch (e) {
367 console.error(e);
368 return { error: e instanceof Error ? e.message : "Unknown error" };
369 }
370}
371
372interface CreateHighlightParams {
373 url: string;
374 selector: { exact: string; prefix?: string; suffix?: string };
375 color?: string;
376 tags?: string[];
377 title?: string;
378 labels?: string[];
379}
380
381export async function createHighlight({
382 url,
383 selector,
384 color,
385 tags,
386 title,
387 labels,
388}: CreateHighlightParams) {
389 try {
390 const res = await apiRequest("/api/highlights", {
391 method: "POST",
392 body: JSON.stringify({ url, selector, color, tags, title, labels }),
393 });
394 if (!res.ok) throw new Error(await res.text());
395 const raw = await res.json();
396 return normalizeItem(raw);
397 } catch (e) {
398 console.error(e);
399 return { error: e instanceof Error ? e.message : "Unknown error" };
400 }
401}
402
403export async function createBookmark({
404 url,
405 title,
406 description,
407 tags,
408}: {
409 url: string;
410 title?: string;
411 description?: string;
412 tags?: string[];
413}) {
414 try {
415 const res = await apiRequest("/api/bookmarks", {
416 method: "POST",
417 body: JSON.stringify({ url, title, description, tags }),
418 });
419 if (!res.ok) throw new Error(await res.text());
420 const raw = await res.json();
421 return normalizeItem(raw);
422 } catch (e) {
423 console.error(e);
424 return { error: e instanceof Error ? e.message : "Unknown error" };
425 }
426}
427
428export async function uploadAvatar(
429 file: File,
430): Promise<{ blob: Blob | string }> {
431 const formData = new FormData();
432 formData.append("file", file);
433 const res = await fetch("/api/upload/avatar", {
434 method: "POST",
435 headers: {
436 Authorization: `Bearer ${(await checkSession())?.did}`,
437 },
438 body: formData,
439 });
440 if (!res.ok) throw new Error("Failed to upload avatar");
441 return res.json();
442}
443
444export async function updateProfile(updates: {
445 displayName?: string;
446 description?: string;
447 avatar?: Blob | string | null;
448 website?: string;
449 links?: string[];
450}): Promise<boolean> {
451 try {
452 const { description, ...rest } = updates;
453 const body = { ...rest, bio: description };
454 const res = await apiRequest("/api/profile", {
455 method: "PUT",
456 body: JSON.stringify(body),
457 });
458 return res.ok;
459 } catch (e) {
460 console.error(e);
461 return false;
462 }
463}
464
465export async function likeItem(uri: string, cid: string): Promise<boolean> {
466 try {
467 const res = await apiRequest("/api/annotations/like", {
468 method: "POST",
469 body: JSON.stringify({ subjectUri: uri, subjectCid: cid }),
470 });
471 return res.ok;
472 } catch (e) {
473 console.error("Failed to like item:", e);
474 return false;
475 }
476}
477
478export async function unlikeItem(uri: string): Promise<boolean> {
479 try {
480 const res = await apiRequest(
481 `/api/annotations/like?uri=${encodeURIComponent(uri)}`,
482 {
483 method: "DELETE",
484 },
485 );
486 return res.ok;
487 } catch (e) {
488 console.error("Failed to unlike item:", e);
489 return false;
490 }
491}
492
493export async function deleteItem(
494 uri: string,
495 type: string = "annotation",
496): Promise<boolean> {
497 const rkey = (uri || "").split("/").pop();
498
499 let endpoint = "/api/annotations";
500 if (type === "highlight" || uri.includes("highlight")) {
501 endpoint = "/api/highlights";
502 } else if (type === "bookmark" || uri.includes("bookmark")) {
503 endpoint = "/api/bookmarks";
504 }
505
506 try {
507 const res = await apiRequest(`${endpoint}?rkey=${rkey}`, {
508 method: "DELETE",
509 });
510 return res.ok;
511 } catch (e) {
512 console.error("Failed to delete item:", e);
513 return false;
514 }
515}
516
517export async function convertHighlightToAnnotation(
518 highlightUri: string,
519 url: string,
520 text: string,
521 selector?: { exact: string; prefix?: string; suffix?: string },
522 title?: string,
523): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> {
524 try {
525 const createRes = await apiRequest("/api/annotations", {
526 method: "POST",
527 body: JSON.stringify({ url, text, title, selector }),
528 });
529 if (!createRes.ok) {
530 const err = await createRes.text();
531 return { success: false, error: err };
532 }
533 const created = normalizeItem(await createRes.json());
534
535 const rkey = (highlightUri || "").split("/").pop();
536 if (rkey) {
537 await apiRequest(`/api/highlights?rkey=${rkey}`, { method: "DELETE" });
538 }
539
540 return { success: true, item: created };
541 } catch (e) {
542 console.error("Failed to convert highlight:", e);
543 return {
544 success: false,
545 error: e instanceof Error ? e.message : "Unknown error",
546 };
547 }
548}
549
550export async function updateAnnotation(
551 uri: string,
552 text: string,
553 tags?: string[],
554 labels?: string[],
555): Promise<boolean> {
556 try {
557 const res = await apiRequest(
558 `/api/annotations?uri=${encodeURIComponent(uri)}`,
559 {
560 method: "PUT",
561 body: JSON.stringify({ text, tags, labels }),
562 },
563 );
564 return res.ok;
565 } catch (e) {
566 console.error("Failed to update annotation:", e);
567 return false;
568 }
569}
570
571export async function updateHighlight(
572 uri: string,
573 color: string,
574 tags?: string[],
575 labels?: string[],
576): Promise<boolean> {
577 try {
578 const res = await apiRequest(
579 `/api/highlights?uri=${encodeURIComponent(uri)}`,
580 {
581 method: "PUT",
582 body: JSON.stringify({ color, tags, labels }),
583 },
584 );
585 return res.ok;
586 } catch (e) {
587 console.error("Failed to update highlight:", e);
588 return false;
589 }
590}
591
592export async function updateBookmark(
593 uri: string,
594 title?: string,
595 description?: string,
596 tags?: string[],
597 labels?: string[],
598): Promise<boolean> {
599 try {
600 const res = await apiRequest(
601 `/api/bookmarks?uri=${encodeURIComponent(uri)}`,
602 {
603 method: "PUT",
604 body: JSON.stringify({ title, description, tags, labels }),
605 },
606 );
607 return res.ok;
608 } catch (e) {
609 console.error("Failed to save bookmark:", e);
610 return false;
611 }
612}
613
614export async function getCollectionsContaining(
615 annotationUri: string,
616): Promise<string[]> {
617 try {
618 const res = await apiRequest(
619 `/api/collections/containing?uri=${encodeURIComponent(annotationUri)}`,
620 );
621 if (!res.ok) return [];
622 return await res.json();
623 } catch (e) {
624 console.error("Failed to fetch containing collections:", e);
625 return [];
626 }
627}
628
629import type { EditHistoryItem } from "../types";
630
631export async function getEditHistory(uri: string): Promise<EditHistoryItem[]> {
632 try {
633 const res = await apiRequest(
634 `/api/annotations/history?uri=${encodeURIComponent(uri)}`,
635 );
636 if (!res.ok) return [];
637 return await res.json();
638 } catch (e) {
639 console.error("Failed to fetch edit history:", e);
640 return [];
641 }
642}
643
644export async function getProfile(did: string): Promise<UserProfile | null> {
645 try {
646 const res = await apiRequest(`/api/profile/${did}`);
647 if (!res.ok) return null;
648 return await res.json();
649 } catch (e) {
650 console.error("Failed to fetch profile:", e);
651 return null;
652 }
653}
654
655export interface ActorSearchItem {
656 did: string;
657 handle: string;
658 displayName?: string;
659 avatar?: string;
660}
661
662export function getAvatarUrl(
663 did?: string,
664 avatar?: string,
665): string | undefined {
666 if (!avatar && !did) return undefined;
667 if (avatar && !avatar.includes("cdn.bsky.app")) return avatar;
668 if (!did) return avatar;
669
670 return `/api/avatar/${encodeURIComponent(did)}`;
671}
672
673export async function searchActors(
674 query: string,
675): Promise<{ actors: ActorSearchItem[] }> {
676 try {
677 const res = await fetch(
678 `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`,
679 );
680 if (!res.ok) throw new Error("Search failed");
681 return await res.json();
682 } catch (e) {
683 console.error("Failed to search actors:", e);
684 return { actors: [] };
685 }
686}
687
688export async function resolveHandle(handle: string): Promise<string | null> {
689 if (handle.startsWith("did:")) return handle;
690 try {
691 const res = await fetch(
692 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
693 );
694 if (!res.ok) throw new Error("Failed to resolve handle");
695 const data = await res.json();
696 return data.did;
697 } catch (e) {
698 console.error("Failed to resolve handle:", e);
699 return null;
700 }
701}
702
703export async function startLogin(
704 handle: string,
705): Promise<{ authorizationUrl?: string }> {
706 const res = await apiRequest("/auth/start", {
707 method: "POST",
708 body: JSON.stringify({ handle }),
709 });
710 if (!res.ok) throw new Error("Failed to start login");
711 return await res.json();
712}
713
714export async function startSignup(
715 pdsUrl: string,
716): Promise<{ authorizationUrl?: string }> {
717 const res = await apiRequest("/auth/signup", {
718 method: "POST",
719 body: JSON.stringify({ pds_url: pdsUrl }),
720 });
721 if (!res.ok) throw new Error("Failed to start signup");
722 return await res.json();
723}
724
725export async function getNotifications(
726 limit = 50,
727 offset = 0,
728): Promise<NotificationItem[]> {
729 try {
730 const res = await apiRequest(
731 `/api/notifications?limit=${limit}&offset=${offset}`,
732 );
733 if (!res.ok) throw new Error("Failed to fetch notifications");
734 const data = await res.json();
735 return (data.items || []).map((n: NotificationItem) => ({
736 ...n,
737 subject: n.subject ? normalizeItem(n.subject as RawItem) : undefined,
738 }));
739 } catch (e) {
740 console.error("Failed to fetch notifications:", e);
741 return [];
742 }
743}
744
745export async function getUnreadNotificationCount(): Promise<number> {
746 try {
747 const res = await apiRequest("/api/notifications/count", {
748 skipAuthRedirect: true,
749 });
750 if (!res.ok) return 0;
751 const data = await res.json();
752 return data.count || 0;
753 } catch (e) {
754 console.error("Failed to fetch unread notification count:", e);
755 return 0;
756 }
757}
758
759export async function markNotificationsRead(): Promise<boolean> {
760 try {
761 const res = await apiRequest("/api/notifications/read", { method: "POST" });
762 return res.ok;
763 } catch (e) {
764 console.error("Failed to mark notifications as read:", e);
765 return false;
766 }
767}
768
769export interface APIKey {
770 id: string;
771 name: string;
772 key?: string;
773 createdAt: string;
774}
775
776export async function getAPIKeys(): Promise<APIKey[]> {
777 try {
778 const res = await apiRequest("/api/keys");
779 if (!res.ok) return [];
780 const data = await res.json();
781 return Array.isArray(data) ? data : data.keys || [];
782 } catch (e) {
783 console.error("Failed to fetch API keys:", e);
784 return [];
785 }
786}
787
788export async function createAPIKey(name: string): Promise<APIKey | null> {
789 try {
790 const res = await apiRequest("/api/keys", {
791 method: "POST",
792 body: JSON.stringify({ name }),
793 });
794 if (!res.ok) return null;
795 return await res.json();
796 } catch (e) {
797 console.error("Failed to create API key:", e);
798 return null;
799 }
800}
801
802export async function deleteAPIKey(id: string): Promise<boolean> {
803 try {
804 const res = await apiRequest(`/api/keys/${id}`, { method: "DELETE" });
805 return res.ok;
806 } catch (e) {
807 console.error("Failed to delete API key:", e);
808 return false;
809 }
810}
811
812export interface Tag {
813 tag: string;
814 count: number;
815}
816
817export async function getTrendingTags(limit = 50): Promise<Tag[]> {
818 try {
819 const res = await apiRequest(`/api/trending-tags?limit=${limit}`, {
820 skipAuthRedirect: true,
821 });
822 if (!res.ok) return [];
823 const data = await res.json();
824 return Array.isArray(data) ? data : data.tags || [];
825 } catch (e) {
826 console.error("Failed to fetch trending tags:", e);
827 return [];
828 }
829}
830
831export async function getUserTags(did: string, limit = 50): Promise<string[]> {
832 try {
833 const res = await apiRequest(`/api/users/${did}/tags?limit=${limit}`, {
834 skipAuthRedirect: true,
835 });
836 if (!res.ok) return [];
837 const data = await res.json();
838 return (data || []).map((t: Tag) => t.tag);
839 } catch (e) {
840 console.error("Failed to fetch user tags:", e);
841 return [];
842 }
843}
844
845export async function getCollections(creator?: string): Promise<Collection[]> {
846 try {
847 const query = creator ? `?author=${encodeURIComponent(creator)}` : "";
848 const res = await apiRequest(`/api/collections${query}`);
849 if (!res.ok) throw new Error("Failed to fetch collections");
850 const data = await res.json();
851 let items = Array.isArray(data)
852 ? data
853 : data.items || data.collections || [];
854
855 items = items.map((item: Record<string, unknown>) => {
856 if (!item.id && item.uri) {
857 item.id = (item.uri as string).split("/").pop();
858 }
859 return item;
860 });
861
862 return items;
863 } catch (e) {
864 console.error(e);
865 return [];
866 }
867}
868
869export async function getCollection(uri: string): Promise<Collection | null> {
870 try {
871 const res = await apiRequest(
872 `/api/collection?uri=${encodeURIComponent(uri)}`,
873 );
874 if (!res.ok) throw new Error("Failed to fetch collection");
875 return await res.json();
876 } catch (e) {
877 console.error(e);
878 return null;
879 }
880}
881
882export async function createCollection(
883 name: string,
884 description?: string,
885 icon?: string,
886): Promise<Collection | null> {
887 try {
888 const res = await apiRequest("/api/collections", {
889 method: "POST",
890 body: JSON.stringify({ name, description, icon }),
891 });
892 if (!res.ok) throw new Error("Failed to create collection");
893 return await res.json();
894 } catch (e) {
895 console.error(e);
896 return null;
897 }
898}
899
900export async function deleteCollection(id: string): Promise<boolean> {
901 try {
902 const res = await apiRequest(
903 `/api/collections?uri=${encodeURIComponent(id)}`,
904 { method: "DELETE" },
905 );
906 return res.ok;
907 } catch (e) {
908 console.error(e);
909 return false;
910 }
911}
912
913export async function getCollectionItems(
914 uri: string,
915): Promise<AnnotationItem[]> {
916 try {
917 const res = await apiRequest(
918 `/api/collections/${encodeURIComponent(uri)}/items`,
919 );
920 if (!res.ok) throw new Error("Failed to fetch collection items");
921 const data = await res.json();
922 return (data || []).map(normalizeItem);
923 } catch (e) {
924 console.error(e);
925 return [];
926 }
927}
928
929export async function updateCollection(
930 uri: string,
931 name: string,
932 description?: string,
933 icon?: string,
934): Promise<Collection | null> {
935 try {
936 const res = await apiRequest(
937 `/api/collections?uri=${encodeURIComponent(uri)}`,
938 {
939 method: "PUT",
940 body: JSON.stringify({ name, description, icon }),
941 },
942 );
943 if (!res.ok) throw new Error("Failed to update collection");
944 return await res.json();
945 } catch (e) {
946 console.error(e);
947 return null;
948 }
949}
950
951export async function addCollectionItem(
952 collectionUri: string,
953 annotationUri: string,
954 position: number = 0,
955): Promise<boolean> {
956 try {
957 const res = await apiRequest(
958 `/api/collections/${encodeURIComponent(collectionUri)}/items`,
959 {
960 method: "POST",
961 body: JSON.stringify({ annotationUri, position }),
962 },
963 );
964 return res.ok;
965 } catch (e) {
966 console.error(e);
967 return false;
968 }
969}
970
971export async function removeCollectionItem(itemUri: string): Promise<boolean> {
972 try {
973 const res = await apiRequest(
974 `/api/collections/items?uri=${encodeURIComponent(itemUri)}`,
975 {
976 method: "DELETE",
977 },
978 );
979 return res.ok;
980 } catch (e) {
981 console.error(e);
982 return false;
983 }
984}
985
986export async function createReply(
987 parentUri: string,
988 parentCid: string,
989 rootUri: string,
990 rootCid: string,
991 text: string,
992): Promise<string | null> {
993 try {
994 const res = await apiRequest("/api/annotations/reply", {
995 method: "POST",
996 body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }),
997 });
998 if (!res.ok) throw new Error("Failed to create reply");
999 const data = await res.json();
1000 return data.uri;
1001 } catch (e) {
1002 console.error(e);
1003 return null;
1004 }
1005}
1006
1007export async function deleteReply(uri: string): Promise<boolean> {
1008 try {
1009 const res = await apiRequest(
1010 `/api/annotations/reply?uri=${encodeURIComponent(uri)}`,
1011 {
1012 method: "DELETE",
1013 },
1014 );
1015 return res.ok;
1016 } catch (e) {
1017 console.error(e);
1018 return false;
1019 }
1020}
1021
1022export async function getAnnotation(
1023 uri: string,
1024): Promise<AnnotationItem | null> {
1025 try {
1026 const res = await apiRequest(
1027 `/api/annotation?uri=${encodeURIComponent(uri)}`,
1028 );
1029 if (!res.ok) return null;
1030 return normalizeItem(await res.json());
1031 } catch {
1032 return null;
1033 }
1034}
1035
1036export async function getReplies(
1037 uri: string,
1038): Promise<{ items: AnnotationItem[] }> {
1039 try {
1040 const res = await apiRequest(`/api/replies?uri=${encodeURIComponent(uri)}`);
1041 if (!res.ok) return { items: [] };
1042 const data = await res.json();
1043 return { items: (data.items || []).map(normalizeItem) };
1044 } catch {
1045 return { items: [] };
1046 }
1047}
1048
1049export async function getByTarget(
1050 url: string,
1051 limit = 50,
1052 offset = 0,
1053): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> {
1054 try {
1055 const res = await apiRequest(
1056 `/api/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`,
1057 );
1058 if (!res.ok) return { annotations: [], highlights: [] };
1059 const data = await res.json();
1060 return {
1061 annotations: (data.annotations || []).map(normalizeItem),
1062 highlights: (data.highlights || []).map(normalizeItem),
1063 };
1064 } catch {
1065 return { annotations: [], highlights: [] };
1066 }
1067}
1068
1069export async function getUserTargetItems(
1070 did: string,
1071 url: string,
1072 limit = 50,
1073 offset = 0,
1074): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> {
1075 try {
1076 const res = await apiRequest(
1077 `/api/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`,
1078 );
1079 if (!res.ok) return { annotations: [], highlights: [] };
1080 const data = await res.json();
1081 return {
1082 annotations: (data.annotations || []).map(normalizeItem),
1083 highlights: (data.highlights || []).map(normalizeItem),
1084 };
1085 } catch {
1086 return { annotations: [], highlights: [] };
1087 }
1088}
1089
1090import type {
1091 LabelerInfo,
1092 LabelerSubscription,
1093 LabelPreference,
1094} from "../types";
1095
1096export interface PreferencesResponse {
1097 externalLinkSkippedHostnames?: string[];
1098 subscribedLabelers?: LabelerSubscription[];
1099 labelPreferences?: LabelPreference[];
1100 disableExternalLinkWarning?: boolean;
1101}
1102
1103export async function getPreferences(): Promise<PreferencesResponse> {
1104 try {
1105 const res = await apiRequest("/api/preferences", {
1106 skipAuthRedirect: true,
1107 });
1108 if (!res.ok) return {};
1109 return await res.json();
1110 } catch (e) {
1111 console.error(e);
1112 return {};
1113 }
1114}
1115
1116export async function updatePreferences(prefs: {
1117 externalLinkSkippedHostnames?: string[];
1118 subscribedLabelers?: LabelerSubscription[];
1119 labelPreferences?: LabelPreference[];
1120 disableExternalLinkWarning?: boolean;
1121}): Promise<boolean> {
1122 try {
1123 const res = await apiRequest("/api/preferences", {
1124 method: "PUT",
1125 body: JSON.stringify(prefs),
1126 });
1127 return res.ok;
1128 } catch (e) {
1129 console.error(e);
1130 return false;
1131 }
1132}
1133
1134export async function getLabelerInfo(): Promise<LabelerInfo | null> {
1135 try {
1136 const res = await apiRequest("/moderation/labeler", {
1137 skipAuthRedirect: true,
1138 });
1139 if (!res.ok) return null;
1140 return await res.json();
1141 } catch (e) {
1142 console.error("Failed to fetch labeler info:", e);
1143 return null;
1144 }
1145}
1146
1147import type {
1148 BlockedUser,
1149 ModerationRelationship,
1150 ModerationReport,
1151 MutedUser,
1152 ReportReasonType,
1153} from "../types";
1154
1155export async function blockUser(did: string): Promise<boolean> {
1156 try {
1157 const res = await apiRequest("/api/moderation/block", {
1158 method: "POST",
1159 body: JSON.stringify({ did }),
1160 });
1161 return res.ok;
1162 } catch (e) {
1163 console.error("Failed to block user:", e);
1164 return false;
1165 }
1166}
1167
1168export async function unblockUser(did: string): Promise<boolean> {
1169 try {
1170 const res = await apiRequest(
1171 `/api/moderation/block?did=${encodeURIComponent(did)}`,
1172 { method: "DELETE" },
1173 );
1174 return res.ok;
1175 } catch (e) {
1176 console.error("Failed to unblock user:", e);
1177 return false;
1178 }
1179}
1180
1181export async function getBlocks(): Promise<BlockedUser[]> {
1182 try {
1183 const res = await apiRequest("/api/moderation/blocks");
1184 if (!res.ok) return [];
1185 const data = await res.json();
1186 return data.items || [];
1187 } catch (e) {
1188 console.error("Failed to fetch blocks:", e);
1189 return [];
1190 }
1191}
1192
1193export async function muteUser(did: string): Promise<boolean> {
1194 try {
1195 const res = await apiRequest("/api/moderation/mute", {
1196 method: "POST",
1197 body: JSON.stringify({ did }),
1198 });
1199 return res.ok;
1200 } catch (e) {
1201 console.error("Failed to mute user:", e);
1202 return false;
1203 }
1204}
1205
1206export async function unmuteUser(did: string): Promise<boolean> {
1207 try {
1208 const res = await apiRequest(
1209 `/api/moderation/mute?did=${encodeURIComponent(did)}`,
1210 { method: "DELETE" },
1211 );
1212 return res.ok;
1213 } catch (e) {
1214 console.error("Failed to unmute user:", e);
1215 return false;
1216 }
1217}
1218
1219export async function getMutes(): Promise<MutedUser[]> {
1220 try {
1221 const res = await apiRequest("/api/moderation/mutes");
1222 if (!res.ok) return [];
1223 const data = await res.json();
1224 return data.items || [];
1225 } catch (e) {
1226 console.error("Failed to fetch mutes:", e);
1227 return [];
1228 }
1229}
1230
1231export async function getModerationRelationship(
1232 did: string,
1233): Promise<ModerationRelationship> {
1234 try {
1235 const res = await apiRequest(
1236 `/api/moderation/relationship?did=${encodeURIComponent(did)}`,
1237 { skipAuthRedirect: true },
1238 );
1239 if (!res.ok) return { blocking: false, muting: false, blockedBy: false };
1240 return await res.json();
1241 } catch (e) {
1242 console.error("Failed to get moderation relationship:", e);
1243 return { blocking: false, muting: false, blockedBy: false };
1244 }
1245}
1246
1247export async function reportUser(params: {
1248 subjectDid: string;
1249 subjectUri?: string;
1250 reasonType: ReportReasonType;
1251 reasonText?: string;
1252}): Promise<boolean> {
1253 try {
1254 const res = await apiRequest("/api/moderation/report", {
1255 method: "POST",
1256 body: JSON.stringify(params),
1257 });
1258 return res.ok;
1259 } catch (e) {
1260 console.error("Failed to submit report:", e);
1261 return false;
1262 }
1263}
1264
1265export async function checkAdminAccess(): Promise<boolean> {
1266 try {
1267 const res = await apiRequest("/api/moderation/admin/check", {
1268 skipAuthRedirect: true,
1269 });
1270 if (!res.ok) return false;
1271 const data = await res.json();
1272 return data.isAdmin || false;
1273 } catch {
1274 return false;
1275 }
1276}
1277
1278export async function getAdminReports(
1279 status?: string,
1280 limit = 50,
1281 offset = 0,
1282): Promise<{
1283 items: ModerationReport[];
1284 totalItems: number;
1285 pendingCount: number;
1286}> {
1287 try {
1288 const params = new URLSearchParams();
1289 if (status) params.append("status", status);
1290 params.append("limit", limit.toString());
1291 params.append("offset", offset.toString());
1292 const res = await apiRequest(
1293 `/api/moderation/admin/reports?${params.toString()}`,
1294 );
1295 if (!res.ok) return { items: [], totalItems: 0, pendingCount: 0 };
1296 return await res.json();
1297 } catch (e) {
1298 console.error("Failed to fetch admin reports:", e);
1299 return { items: [], totalItems: 0, pendingCount: 0 };
1300 }
1301}
1302
1303export async function adminTakeAction(params: {
1304 reportId: number;
1305 action: string;
1306 comment?: string;
1307}): Promise<boolean> {
1308 try {
1309 const res = await apiRequest("/api/moderation/admin/action", {
1310 method: "POST",
1311 body: JSON.stringify(params),
1312 });
1313 return res.ok;
1314 } catch (e) {
1315 console.error("Failed to take moderation action:", e);
1316 return false;
1317 }
1318}
1319
1320export async function adminCreateLabel(params: {
1321 src: string;
1322 uri?: string;
1323 val: string;
1324}): Promise<boolean> {
1325 try {
1326 const res = await apiRequest("/api/moderation/admin/label", {
1327 method: "POST",
1328 body: JSON.stringify(params),
1329 });
1330 return res.ok;
1331 } catch (e) {
1332 console.error("Failed to create label:", e);
1333 return false;
1334 }
1335}
1336
1337export async function adminDeleteLabel(id: number): Promise<boolean> {
1338 try {
1339 const res = await apiRequest(`/api/moderation/admin/label?id=${id}`, {
1340 method: "DELETE",
1341 });
1342 return res.ok;
1343 } catch (e) {
1344 console.error("Failed to delete label:", e);
1345 return false;
1346 }
1347}
1348
1349export async function adminGetLabels(
1350 limit = 50,
1351 offset = 0,
1352): Promise<{ items: HydratedLabel[] }> {
1353 try {
1354 const res = await apiRequest(
1355 `/api/moderation/admin/labels?limit=${limit}&offset=${offset}`,
1356 );
1357 if (!res.ok) return { items: [] };
1358 return await res.json();
1359 } catch (e) {
1360 console.error("Failed to fetch labels:", e);
1361 return { items: [] };
1362 }
1363}
1364
1365export interface DocumentItem {
1366 uri: string;
1367 authorDid: string;
1368 site: string;
1369 path?: string;
1370 title: string;
1371 description?: string;
1372 tags?: string[];
1373 canonicalUrl: string;
1374 publishedAt: string;
1375}
1376
1377export interface DocumentsResponse {
1378 items: DocumentItem[];
1379 totalItems: number;
1380}
1381
1382export async function getDocuments({
1383 sort = "new",
1384 limit = 30,
1385 offset = 0,
1386}: {
1387 sort?: string;
1388 limit?: number;
1389 offset?: number;
1390}): Promise<DocumentsResponse> {
1391 try {
1392 const params = new URLSearchParams();
1393 if (sort) params.append("sort", sort);
1394 params.append("limit", limit.toString());
1395 params.append("offset", offset.toString());
1396
1397 const res = await apiRequest(`/api/documents?${params.toString()}`, {
1398 skipAuthRedirect: true,
1399 });
1400 if (!res.ok) throw new Error("Failed to fetch documents");
1401 return await res.json();
1402 } catch (e) {
1403 console.error("Failed to fetch documents:", e);
1404 return { items: [], totalItems: 0 };
1405 }
1406}
1407
1408export async function getRecommendations(
1409 limit = 20,
1410): Promise<DocumentsResponse & { unavailable?: boolean }> {
1411 try {
1412 const res = await apiRequest(`/api/recommendations?limit=${limit}`);
1413 if (res.status === 503)
1414 return { items: [], totalItems: 0, unavailable: true };
1415 if (!res.ok) throw new Error("Failed to fetch recommendations");
1416 return await res.json();
1417 } catch (e) {
1418 console.error("Failed to fetch recommendations:", e);
1419 return { items: [], totalItems: 0 };
1420 }
1421}