(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479import { atom } from "nanostores";import type { AnnotationItem, Collection, FeedResponse, HydratedLabel, NotificationItem, Selector, Target, UserProfile,} from "../types";
export type { Collection } from "../types";
export const sessionAtom = atom<UserProfile | null>(null);
export async function checkSession(): Promise<UserProfile | null> { try { const res = await fetch("/auth/session"); if (!res.ok) { sessionAtom.set(null); return null; } const data = await res.json();
if (data.authenticated || data.did) { const baseProfile: UserProfile = { did: data.did, handle: data.handle, displayName: data.displayName, avatar: data.avatar, description: data.description, website: data.website, links: data.links, followersCount: data.followersCount, followsCount: data.followsCount, postsCount: data.postsCount, };
const [bskyResult, marginResult] = await Promise.allSettled([ fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, ), fetch(`/api/profile/${data.did}`), ]);
if (bskyResult.status === "fulfilled" && bskyResult.value.ok) { try { const bskyData = await bskyResult.value.json(); if (bskyData.avatar) baseProfile.avatar = bskyData.avatar; if (bskyData.displayName) baseProfile.displayName = bskyData.displayName; } catch { /* ignore */ } }
if (marginResult.status === "fulfilled" && marginResult.value.ok) { try { const marginProfile = await marginResult.value.json(); if (marginProfile) { if (marginProfile.description) baseProfile.description = marginProfile.description; if (marginProfile.followersCount) baseProfile.followersCount = marginProfile.followersCount; if (marginProfile.followsCount) baseProfile.followsCount = marginProfile.followsCount; if (marginProfile.postsCount) baseProfile.postsCount = marginProfile.postsCount; if (marginProfile.website) baseProfile.website = marginProfile.website; if (marginProfile.links) baseProfile.links = marginProfile.links; } } catch { /* ignore */ } }
sessionAtom.set(baseProfile); return baseProfile; }
sessionAtom.set(null); return null; } catch (e) { console.error("Session check failed:", e); sessionAtom.set(null); return null; }}
async function apiRequest( path: string, options: RequestInit & { skipAuthRedirect?: boolean } = {},): Promise<Response> { const { skipAuthRedirect, ...fetchOptions } = options; const headers = { "Content-Type": "application/json", ...(fetchOptions.headers || {}), };
const apiPath = path.startsWith("/api") || path.startsWith("/auth") ? path : `/api${path}`;
const response = await fetch(apiPath, { ...fetchOptions, headers, });
if (response.status === 401 && !skipAuthRedirect) { sessionAtom.set(null); try { await fetch("/auth/logout", { method: "POST" }); } catch { // Ignore } if (window.location.pathname !== "/login") { window.location.href = "/login"; } }
return response;}
export interface GetFeedParams { source?: string; type?: string; limit?: number; offset?: number; motivation?: string; tag?: string; creator?: string;}
interface RawItem { type?: string; collectionUri?: string; annotation?: RawItem; highlight?: RawItem; bookmark?: RawItem; uri?: string; id?: string; cid?: string; author?: UserProfile; creator?: UserProfile; collection?: { uri: string; name: string; icon?: string; }; context?: { uri: string; name: string; icon?: string; }[]; created?: string; createdAt?: string; target?: string | { source?: string; title?: string; selector?: Selector }; url?: string; targetUrl?: string; title?: string; selector?: Selector; viewer?: { like?: string; [key: string]: unknown }; viewerHasLiked?: boolean; motivation?: string; [key: string]: unknown;}
function normalizeItem(raw: RawItem): AnnotationItem { if (raw.type === "CollectionItem" || raw.collectionUri) { const inner = raw.annotation || raw.highlight || raw.bookmark || {}; const normalizedInner = normalizeItem(inner);
return { ...normalizedInner, uri: normalizedInner.uri || raw.uri || "", cid: normalizedInner.cid || raw.cid || "", author: (normalizedInner.author || raw.author || raw.creator) as UserProfile, collection: raw.collection ? { uri: raw.collection.uri, name: raw.collection.name, icon: raw.collection.icon, } : undefined, context: raw.context ? raw.context.map((c) => ({ uri: c.uri, name: c.name, icon: c.icon, })) : undefined, addedBy: raw.creator || raw.author, createdAt: normalizedInner.createdAt || raw.created || raw.createdAt || new Date().toISOString(), collectionItemUri: raw.id || raw.uri, }; }
let target: Target | undefined;
if (raw.target) { if (typeof raw.target === "string") { target = { source: raw.target, title: raw.title, selector: raw.selector }; } else { target = { source: raw.target.source || "", title: raw.target.title || raw.title, selector: raw.target.selector || raw.selector, }; } }
if (!target || !target.source) { const url = raw.url || raw.targetUrl || (typeof raw.target === "string" ? raw.target : raw.target?.source); if (url) { target = { source: url, title: raw.title || (typeof raw.target !== "string" ? raw.target?.title : undefined), selector: raw.selector || (typeof raw.target !== "string" ? raw.target?.selector : undefined), }; } }
return { ...raw, uri: raw.id || raw.uri || "", cid: raw.cid || "", author: (raw.creator || raw.author) as UserProfile, createdAt: raw.created || raw.createdAt || new Date().toISOString(), target: target, viewer: raw.viewer || { like: raw.viewerHasLiked ? "true" : undefined }, motivation: raw.motivation || "highlighting", parentUri: (raw as Record<string, unknown>).inReplyTo as string | undefined, };}
export async function searchItems( query: string, options: { creator?: string; limit?: number; offset?: number } = {},): Promise<FeedResponse> { const params = new URLSearchParams(); params.append("q", query); if (options.creator) params.append("creator", options.creator); if (options.limit) params.append("limit", options.limit.toString()); if (options.offset) params.append("offset", options.offset.toString());
try { const res = await apiRequest(`/api/search?${params.toString()}`, { skipAuthRedirect: true, }); if (!res.ok) throw new Error("Search failed"); const data = await res.json(); const items: AnnotationItem[] = (data.items || []).map(normalizeItem); return { items, hasMore: items.length >= (options.limit || 50), fetchedCount: items.length, }; } catch (e) { console.error("Search error:", e); return { items: [], hasMore: false, fetchedCount: 0 }; }}
export async function getFeed({ source, type = "all", limit = 50, offset = 0, motivation, tag, creator,}: GetFeedParams): Promise<FeedResponse> { const params = new URLSearchParams(); if (source) params.append("source", source); if (type) params.append("type", type); if (limit) params.append("limit", limit.toString()); if (offset) params.append("offset", offset.toString()); if (motivation) params.append("motivation", motivation); if (tag) params.append("tag", tag); if (creator) params.append("creator", creator);
const endpoint = source ? "/api/targets" : "/api/notes/feed";
try { const res = await apiRequest(`${endpoint}?${params.toString()}`, { skipAuthRedirect: true, }); if (!res.ok) throw new Error("Failed to fetch feed"); const data = await res.json(); const normalizedItems: AnnotationItem[] = (data.items || []).map( normalizeItem, );
const groupedItems: AnnotationItem[] = []; if (normalizedItems.length > 0) { groupedItems.push(normalizedItems[0]);
for (let i = 1; i < normalizedItems.length; i++) { const prev = groupedItems[groupedItems.length - 1]; const curr = normalizedItems[i];
if (prev.collection && curr.collection) { if ( prev.uri === curr.uri && prev.addedBy?.did === curr.addedBy?.did ) { if (!prev.context) { prev.context = [prev.collection]; } prev.context.push(curr.collection); groupedItems[groupedItems.length - 1] = prev; continue; } } groupedItems.push(curr); } }
return { items: groupedItems, hasMore: normalizedItems.length >= limit, fetchedCount: normalizedItems.length, }; } catch (e) { console.error(e); return { items: [], hasMore: false, fetchedCount: 0 }; }}
interface CreateAnnotationParams { url: string; text?: string; title?: string; selector?: { exact: string; prefix?: string; suffix?: string }; tags?: string[]; labels?: string[];}
export async function createAnnotation({ url, text, title, selector, tags, labels,}: CreateAnnotationParams) { try { const res = await apiRequest("/api/notes", { method: "POST", body: JSON.stringify({ url, text, title, selector, tags, labels }), }); if (!res.ok) throw new Error(await res.text()); const raw = await res.json(); return normalizeItem(raw); } catch (e) { console.error(e); return { error: e instanceof Error ? e.message : "Unknown error" }; }}
interface CreateHighlightParams { url: string; selector: { exact: string; prefix?: string; suffix?: string }; color?: string; tags?: string[]; title?: string; labels?: string[];}
export async function createHighlight({ url, selector, color, tags, title, labels,}: CreateHighlightParams) { try { const res = await apiRequest("/api/highlights", { method: "POST", body: JSON.stringify({ url, selector, color, tags, title, labels }), }); if (!res.ok) throw new Error(await res.text()); const raw = await res.json(); return normalizeItem(raw); } catch (e) { console.error(e); return { error: e instanceof Error ? e.message : "Unknown error" }; }}
export async function createBookmark({ url, title, description, tags,}: { url: string; title?: string; description?: string; tags?: string[];}) { try { const res = await apiRequest("/api/bookmarks", { method: "POST", body: JSON.stringify({ url, title, description, tags }), }); if (!res.ok) throw new Error(await res.text()); const raw = await res.json(); return normalizeItem(raw); } catch (e) { console.error(e); return { error: e instanceof Error ? e.message : "Unknown error" }; }}
export async function uploadAvatar( file: File,): Promise<{ blob: Blob | string }> { const formData = new FormData(); formData.append("file", file); const res = await fetch("/api/upload/avatar", { method: "POST", headers: { Authorization: `Bearer ${(await checkSession())?.did}`, }, body: formData, }); if (!res.ok) throw new Error("Failed to upload avatar"); return res.json();}
export async function updateProfile(updates: { displayName?: string; description?: string; avatar?: Blob | string | null; website?: string; links?: string[];}): Promise<boolean> { try { const { description, ...rest } = updates; const body = { ...rest, bio: description }; const res = await apiRequest("/api/profile", { method: "PUT", body: JSON.stringify(body), }); return res.ok; } catch (e) { console.error(e); return false; }}
export async function likeItem(uri: string, cid: string): Promise<boolean> { try { const res = await apiRequest("/api/notes/like", { method: "POST", body: JSON.stringify({ subjectUri: uri, subjectCid: cid }), }); return res.ok; } catch (e) { console.error("Failed to like item:", e); return false; }}
export async function unlikeItem(uri: string): Promise<boolean> { try { const res = await apiRequest( `/api/notes/like?uri=${encodeURIComponent(uri)}`, { method: "DELETE", }, ); return res.ok; } catch (e) { console.error("Failed to unlike item:", e); return false; }}
export async function deleteItem( uri: string, type: string = "annotation",): Promise<boolean> { const rkey = (uri || "").split("/").pop();
let endpoint = "/api/notes"; if (type === "highlight" || uri.includes("highlight")) { endpoint = "/api/highlights"; } else if (type === "bookmark" || uri.includes("bookmark")) { endpoint = "/api/bookmarks"; }
try { const res = await apiRequest(`${endpoint}?rkey=${rkey}`, { method: "DELETE", }); return res.ok; } catch (e) { console.error("Failed to delete item:", e); return false; }}
export async function convertHighlightToAnnotation( highlightUri: string, url: string, text: string, selector?: { exact: string; prefix?: string; suffix?: string }, title?: string,): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> { try { const createRes = await apiRequest("/api/notes", { method: "POST", body: JSON.stringify({ url, text, title, selector }), }); if (!createRes.ok) { const err = await createRes.text(); return { success: false, error: err }; } const created = normalizeItem(await createRes.json());
const rkey = (highlightUri || "").split("/").pop(); if (rkey) { await apiRequest(`/api/highlights?rkey=${rkey}`, { method: "DELETE" }); }
return { success: true, item: created }; } catch (e) { console.error("Failed to convert highlight:", e); return { success: false, error: e instanceof Error ? e.message : "Unknown error", }; }}
export async function updateAnnotation( uri: string, text: string, tags?: string[], labels?: string[],): Promise<boolean> { try { const res = await apiRequest(`/api/notes?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ text, tags, labels }), }); return res.ok; } catch (e) { console.error("Failed to update annotation:", e); return false; }}
export async function updateHighlight( uri: string, color: string, tags?: string[], labels?: string[],): Promise<boolean> { try { const res = await apiRequest( `/api/highlights?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ color, tags, labels }), }, ); return res.ok; } catch (e) { console.error("Failed to update highlight:", e); return false; }}
export async function updateBookmark( uri: string, title?: string, description?: string, tags?: string[], labels?: string[],): Promise<boolean> { try { const res = await apiRequest( `/api/bookmarks?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ title, description, tags, labels }), }, ); return res.ok; } catch (e) { console.error("Failed to save bookmark:", e); return false; }}
export async function getCollectionsContaining( annotationUri: string,): Promise<string[]> { try { const res = await apiRequest( `/api/collections/containing?uri=${encodeURIComponent(annotationUri)}`, ); if (!res.ok) return []; return await res.json(); } catch (e) { console.error("Failed to fetch containing collections:", e); return []; }}
import type { EditHistoryItem } from "../types";
export async function getEditHistory(uri: string): Promise<EditHistoryItem[]> { try { const res = await apiRequest( `/api/notes/history?uri=${encodeURIComponent(uri)}`, ); if (!res.ok) return []; return await res.json(); } catch (e) { console.error("Failed to fetch edit history:", e); return []; }}
export async function getProfile(did: string): Promise<UserProfile | null> { try { const res = await apiRequest(`/api/profile/${did}`); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch profile:", e); return null; }}
export interface ActorSearchItem { did: string; handle: string; displayName?: string; avatar?: string;}
export function getAvatarUrl( did?: string, avatar?: string,): string | undefined { if (!avatar && !did) return undefined; if (avatar && !avatar.includes("cdn.bsky.app")) return avatar; if (!did) return avatar;
return `/api/avatar/${encodeURIComponent(did)}`;}
export async function searchActors( query: string,): Promise<{ actors: ActorSearchItem[] }> { try { const res = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`, ); if (!res.ok) throw new Error("Search failed"); return await res.json(); } catch (e) { console.error("Failed to search actors:", e); return { actors: [] }; }}
export async function resolveHandle(handle: string): Promise<string | null> { if (handle.startsWith("did:")) return handle; try { const res = await fetch( `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, ); if (!res.ok) throw new Error("Failed to resolve handle"); const data = await res.json(); return data.did; } catch (e) { console.error("Failed to resolve handle:", e); return null; }}
export async function startLogin( handle: string,): Promise<{ authorizationUrl?: string }> { const res = await apiRequest("/auth/start", { method: "POST", body: JSON.stringify({ handle }), }); if (!res.ok) throw new Error("Failed to start login"); return await res.json();}
export async function startSignup( pdsUrl: string,): Promise<{ authorizationUrl?: string }> { const res = await apiRequest("/auth/signup", { method: "POST", body: JSON.stringify({ pds_url: pdsUrl }), }); if (!res.ok) throw new Error("Failed to start signup"); return await res.json();}
export async function getNotifications( limit = 50, offset = 0,): Promise<NotificationItem[]> { try { const res = await apiRequest( `/api/notifications?limit=${limit}&offset=${offset}`, ); if (!res.ok) throw new Error("Failed to fetch notifications"); const data = await res.json(); return (data.items || []).map((n: NotificationItem) => ({ ...n, subject: n.subject ? normalizeItem(n.subject as RawItem) : undefined, })); } catch (e) { console.error("Failed to fetch notifications:", e); return []; }}
export async function getUnreadNotificationCount(): Promise<number> { try { const res = await apiRequest("/api/notifications/count", { skipAuthRedirect: true, }); if (!res.ok) return 0; const data = await res.json(); return data.count || 0; } catch (e) { console.error("Failed to fetch unread notification count:", e); return 0; }}
export async function markNotificationsRead(): Promise<boolean> { try { const res = await apiRequest("/api/notifications/read", { method: "POST" }); return res.ok; } catch (e) { console.error("Failed to mark notifications as read:", e); return false; }}
export interface APIKey { id: string; name: string; key?: string; createdAt: string;}
export async function getAPIKeys(): Promise<APIKey[]> { try { const res = await apiRequest("/api/keys"); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : data.keys || []; } catch (e) { console.error("Failed to fetch API keys:", e); return []; }}
export async function createAPIKey(name: string): Promise<APIKey | null> { try { const res = await apiRequest("/api/keys", { method: "POST", body: JSON.stringify({ name }), }); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to create API key:", e); return null; }}
export async function deleteAPIKey(id: string): Promise<boolean> { try { const res = await apiRequest(`/api/keys/${id}`, { method: "DELETE" }); return res.ok; } catch (e) { console.error("Failed to delete API key:", e); return false; }}
export interface Tag { tag: string; count: number;}
export async function getTrendingTags(limit = 50): Promise<Tag[]> { try { const res = await apiRequest(`/api/trending-tags?limit=${limit}`, { skipAuthRedirect: true, }); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : data.tags || []; } catch (e) { console.error("Failed to fetch trending tags:", e); return []; }}
export async function getUserTags(did: string, limit = 50): Promise<string[]> { try { const res = await apiRequest(`/api/users/${did}/tags?limit=${limit}`, { skipAuthRedirect: true, }); if (!res.ok) return []; const data = await res.json(); return (data || []).map((t: Tag) => t.tag); } catch (e) { console.error("Failed to fetch user tags:", e); return []; }}
export async function getCollections(creator?: string): Promise<Collection[]> { try { const query = creator ? `?author=${encodeURIComponent(creator)}` : ""; const res = await apiRequest(`/api/collections${query}`); if (!res.ok) throw new Error("Failed to fetch collections"); const data = await res.json(); let items = Array.isArray(data) ? data : data.items || data.collections || [];
items = items.map((item: Record<string, unknown>) => { if (!item.id && item.uri) { item.id = (item.uri as string).split("/").pop(); } return item; });
return items; } catch (e) { console.error(e); return []; }}
export async function getCollection(uri: string): Promise<Collection | null> { try { const res = await apiRequest( `/api/collection?uri=${encodeURIComponent(uri)}`, ); if (!res.ok) throw new Error("Failed to fetch collection"); return await res.json(); } catch (e) { console.error(e); return null; }}
export async function createCollection( name: string, description?: string, icon?: string,): Promise<Collection | null> { try { const res = await apiRequest("/api/collections", { method: "POST", body: JSON.stringify({ name, description, icon }), }); if (!res.ok) throw new Error("Failed to create collection"); return await res.json(); } catch (e) { console.error(e); return null; }}
export async function deleteCollection(id: string): Promise<boolean> { try { const res = await apiRequest( `/api/collections?uri=${encodeURIComponent(id)}`, { method: "DELETE" }, ); return res.ok; } catch (e) { console.error(e); return false; }}
export async function getCollectionItems( uri: string,): Promise<AnnotationItem[]> { try { const res = await apiRequest( `/api/collections/${encodeURIComponent(uri)}/items`, ); if (!res.ok) throw new Error("Failed to fetch collection items"); const data = await res.json(); return (data || []).map(normalizeItem); } catch (e) { console.error(e); return []; }}
export async function updateCollection( uri: string, name: string, description?: string, icon?: string,): Promise<Collection | null> { try { const res = await apiRequest( `/api/collections?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ name, description, icon }), }, ); if (!res.ok) throw new Error("Failed to update collection"); return await res.json(); } catch (e) { console.error(e); return null; }}
export async function addCollectionItem( collectionUri: string, annotationUri: string, position: number = 0,): Promise<boolean> { try { const res = await apiRequest( `/api/collections/${encodeURIComponent(collectionUri)}/items`, { method: "POST", body: JSON.stringify({ annotationUri, position }), }, ); return res.ok; } catch (e) { console.error(e); return false; }}
export async function removeCollectionItem(itemUri: string): Promise<boolean> { try { const res = await apiRequest( `/api/collections/items?uri=${encodeURIComponent(itemUri)}`, { method: "DELETE", }, ); return res.ok; } catch (e) { console.error(e); return false; }}
export async function createReply( parentUri: string, parentCid: string, rootUri: string, rootCid: string, text: string,): Promise<string | null> { try { const res = await apiRequest("/api/notes/reply", { method: "POST", body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }), }); if (!res.ok) throw new Error("Failed to create reply"); const data = await res.json(); return data.uri; } catch (e) { console.error(e); return null; }}
export async function deleteReply(uri: string): Promise<boolean> { try { const res = await apiRequest( `/api/notes/reply?uri=${encodeURIComponent(uri)}`, { method: "DELETE", }, ); return res.ok; } catch (e) { console.error(e); return false; }}
export async function getAnnotation( uri: string,): Promise<AnnotationItem | null> { try { const res = await apiRequest(`/api/note?uri=${encodeURIComponent(uri)}`); if (!res.ok) return null; return normalizeItem(await res.json()); } catch { return null; }}
export async function getReplies( uri: string,): Promise<{ items: AnnotationItem[] }> { try { const res = await apiRequest(`/api/replies?uri=${encodeURIComponent(uri)}`); if (!res.ok) return { items: [] }; const data = await res.json(); return { items: (data.items || []).map(normalizeItem) }; } catch { return { items: [] }; }}
export async function getByTarget( url: string, limit = 50, offset = 0,): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { try { const res = await apiRequest( `/api/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, ); if (!res.ok) return { annotations: [], highlights: [] }; const data = await res.json(); return { annotations: (data.annotations || []).map(normalizeItem), highlights: (data.highlights || []).map(normalizeItem), }; } catch { return { annotations: [], highlights: [] }; }}
export async function getUserTargetItems( did: string, url: string, limit = 50, offset = 0,): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { try { const res = await apiRequest( `/api/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, ); if (!res.ok) return { annotations: [], highlights: [] }; const data = await res.json(); return { annotations: (data.annotations || []).map(normalizeItem), highlights: (data.highlights || []).map(normalizeItem), }; } catch { return { annotations: [], highlights: [] }; }}
import type { LabelerInfo, LabelerSubscription, LabelPreference,} from "../types";
export interface PreferencesResponse { externalLinkSkippedHostnames?: string[]; subscribedLabelers?: LabelerSubscription[]; labelPreferences?: LabelPreference[]; disableExternalLinkWarning?: boolean; enableCommunityBookmarks?: boolean;}
export async function getPreferences(): Promise<PreferencesResponse> { try { const res = await apiRequest("/api/preferences", { skipAuthRedirect: true, }); if (!res.ok) return {}; return await res.json(); } catch (e) { console.error(e); return {}; }}
export async function updatePreferences(prefs: { externalLinkSkippedHostnames?: string[]; subscribedLabelers?: LabelerSubscription[]; labelPreferences?: LabelPreference[]; disableExternalLinkWarning?: boolean; enableCommunityBookmarks?: boolean;}): Promise<boolean> { try { const res = await apiRequest("/api/preferences", { method: "PUT", body: JSON.stringify(prefs), }); return res.ok; } catch (e) { console.error(e); return false; }}
export async function getLabelerInfo(): Promise<LabelerInfo | null> { try { const res = await apiRequest("/moderation/labeler", { skipAuthRedirect: true, }); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch labeler info:", e); return null; }}
import type { BlockedUser, ModerationRelationship, ModerationReport, MutedUser, ReportReasonType,} from "../types";
export async function blockUser(did: string): Promise<boolean> { try { const res = await apiRequest("/api/moderation/block", { method: "POST", body: JSON.stringify({ did }), }); return res.ok; } catch (e) { console.error("Failed to block user:", e); return false; }}
export async function unblockUser(did: string): Promise<boolean> { try { const res = await apiRequest( `/api/moderation/block?did=${encodeURIComponent(did)}`, { method: "DELETE" }, ); return res.ok; } catch (e) { console.error("Failed to unblock user:", e); return false; }}
export async function getBlocks(): Promise<BlockedUser[]> { try { const res = await apiRequest("/api/moderation/blocks"); if (!res.ok) return []; const data = await res.json(); return data.items || []; } catch (e) { console.error("Failed to fetch blocks:", e); return []; }}
export async function muteUser(did: string): Promise<boolean> { try { const res = await apiRequest("/api/moderation/mute", { method: "POST", body: JSON.stringify({ did }), }); return res.ok; } catch (e) { console.error("Failed to mute user:", e); return false; }}
export async function unmuteUser(did: string): Promise<boolean> { try { const res = await apiRequest( `/api/moderation/mute?did=${encodeURIComponent(did)}`, { method: "DELETE" }, ); return res.ok; } catch (e) { console.error("Failed to unmute user:", e); return false; }}
export async function getMutes(): Promise<MutedUser[]> { try { const res = await apiRequest("/api/moderation/mutes"); if (!res.ok) return []; const data = await res.json(); return data.items || []; } catch (e) { console.error("Failed to fetch mutes:", e); return []; }}
export async function getModerationRelationship( did: string,): Promise<ModerationRelationship> { try { const res = await apiRequest( `/api/moderation/relationship?did=${encodeURIComponent(did)}`, { skipAuthRedirect: true }, ); if (!res.ok) return { blocking: false, muting: false, blockedBy: false }; return await res.json(); } catch (e) { console.error("Failed to get moderation relationship:", e); return { blocking: false, muting: false, blockedBy: false }; }}
export async function reportUser(params: { subjectDid: string; subjectUri?: string; reasonType: ReportReasonType; reasonText?: string;}): Promise<boolean> { try { const res = await apiRequest("/api/moderation/report", { method: "POST", body: JSON.stringify(params), }); return res.ok; } catch (e) { console.error("Failed to submit report:", e); return false; }}
export async function checkAdminAccess(): Promise<boolean> { try { const res = await apiRequest("/api/moderation/admin/check", { skipAuthRedirect: true, }); if (!res.ok) return false; const data = await res.json(); return data.isAdmin || false; } catch { return false; }}
export async function getAdminReports( status?: string, limit = 50, offset = 0,): Promise<{ items: ModerationReport[]; totalItems: number; pendingCount: number;}> { try { const params = new URLSearchParams(); if (status) params.append("status", status); params.append("limit", limit.toString()); params.append("offset", offset.toString()); const res = await apiRequest( `/api/moderation/admin/reports?${params.toString()}`, ); if (!res.ok) return { items: [], totalItems: 0, pendingCount: 0 }; return await res.json(); } catch (e) { console.error("Failed to fetch admin reports:", e); return { items: [], totalItems: 0, pendingCount: 0 }; }}
export async function adminTakeAction(params: { reportId: number; action: string; comment?: string;}): Promise<boolean> { try { const res = await apiRequest("/api/moderation/admin/action", { method: "POST", body: JSON.stringify(params), }); return res.ok; } catch (e) { console.error("Failed to take moderation action:", e); return false; }}
export async function adminCreateLabel(params: { src: string; uri?: string; val: string;}): Promise<boolean> { try { const res = await apiRequest("/api/moderation/admin/label", { method: "POST", body: JSON.stringify(params), }); return res.ok; } catch (e) { console.error("Failed to create label:", e); return false; }}
export async function adminDeleteLabel(id: number): Promise<boolean> { try { const res = await apiRequest(`/api/moderation/admin/label?id=${id}`, { method: "DELETE", }); return res.ok; } catch (e) { console.error("Failed to delete label:", e); return false; }}
export async function adminGetLabels( limit = 50, offset = 0,): Promise<{ items: HydratedLabel[] }> { try { const res = await apiRequest( `/api/moderation/admin/labels?limit=${limit}&offset=${offset}`, ); if (!res.ok) return { items: [] }; return await res.json(); } catch (e) { console.error("Failed to fetch labels:", e); return { items: [] }; }}
export interface BannedAccount { did: string; reason?: string; bannedBy: string; bannedAt: string; profile?: { did: string; handle: string; displayName?: string; avatar?: string; };}
export async function adminBanAccount(params: { did: string; reason?: string;}): Promise<boolean> { try { const res = await apiRequest("/api/moderation/admin/ban", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }); return res.ok; } catch (e) { console.error("Failed to ban account:", e); return false; }}
export async function adminUnbanAccount(did: string): Promise<boolean> { try { const res = await apiRequest( `/api/moderation/admin/ban?did=${encodeURIComponent(did)}`, { method: "DELETE" }, ); return res.ok; } catch (e) { console.error("Failed to unban account:", e); return false; }}
export async function adminGetBannedAccounts(): Promise<{ items: BannedAccount[]; total: number;}> { try { const res = await apiRequest("/api/moderation/admin/bans"); if (!res.ok) return { items: [], total: 0 }; return await res.json(); } catch (e) { console.error("Failed to fetch banned accounts:", e); return { items: [], total: 0 }; }}
export interface DocumentItem { uri: string; authorDid: string; site: string; path?: string; title: string; description?: string; tags?: string[]; canonicalUrl: string; publishedAt: string;}
export interface DocumentsResponse { items: DocumentItem[]; totalItems: number;}
export async function getDocuments({ sort = "new", limit = 30, offset = 0,}: { sort?: string; limit?: number; offset?: number;}): Promise<DocumentsResponse> { try { const params = new URLSearchParams(); if (sort) params.append("sort", sort); params.append("limit", limit.toString()); params.append("offset", offset.toString());
const res = await apiRequest(`/api/documents?${params.toString()}`, { skipAuthRedirect: true, }); if (!res.ok) throw new Error("Failed to fetch documents"); return await res.json(); } catch (e) { console.error("Failed to fetch documents:", e); return { items: [], totalItems: 0 }; }}
export async function getRecommendations( limit = 20,): Promise<DocumentsResponse & { unavailable?: boolean }> { try { const res = await apiRequest(`/api/recommendations?limit=${limit}`); if (res.status === 503) return { items: [], totalItems: 0, unavailable: true }; if (!res.ok) throw new Error("Failed to fetch recommendations"); return await res.json(); } catch (e) { console.error("Failed to fetch recommendations:", e); return { items: [], totalItems: 0 }; }}