import { currentDid, getRpc, getPdsUrl, publicFetch, resolvePdsUrl } from "./client"; import { createSlide, deleteSlide, getPublicSlide, getSlide, updateSlide } from "./slide"; import { getBlobUrl } from "./blob"; import { DECK, type Deck, type DeckRecord, type Slide, type SlideRef } from "./types"; const rkeyFromUri = (uri: string): string => uri.split("/").pop()!; export const createDeck = async (name: string, slides: Slide[], thumbnail?: Deck["thumbnail"]): Promise => { if (!currentDid) return null; const rpc = getRpc(); const now = new Date().toISOString(); // create each slide as a separate record const slideRefs: SlideRef[] = []; for (const slide of slides) { const ref = await createSlide(slide); if (!ref) throw new Error("failed to create slide"); slideRefs.push({ subject: ref }); } // create deck with references const record: DeckRecord = { name, slides: slideRefs, createdAt: now, updatedAt: now, ...(thumbnail && { thumbnail }), }; const res = await rpc.post("com.atproto.repo.createRecord", { input: { repo: currentDid, collection: DECK, record: { $type: DECK, ...record }, }, }); if (!res.ok) throw new Error(res.data.error || "failed to create deck"); return res.data.uri; }; export const updateDeck = async (rkey: string, deck: Partial): Promise => { if (!currentDid) throw new Error("not logged in"); const rpc = getRpc(); // get existing deck record const existing = await rpc.get("com.atproto.repo.getRecord", { params: { repo: currentDid, collection: DECK, rkey }, }); if (!existing.ok) throw new Error("deck not found"); const existingRecord = existing.data.value as DeckRecord; const existingRefs = existingRecord.slides; // if slides changed, update/create/delete slide records let slideRefs = existingRefs; if (deck.slides) { slideRefs = []; for (let i = 0; i < deck.slides.length; i++) { const slide = deck.slides[i]; if (slide.uri && slide.rkey) { // existing slide - update it const ref = await updateSlide(slide.rkey, slide); slideRefs.push({ subject: ref }); } else { // new slide - create it const ref = await createSlide(slide); if (!ref) throw new Error("failed to create slide"); slideRefs.push({ subject: ref }); } } // delete removed slides const newUris = new Set(deck.slides.filter(s => s.uri).map(s => s.uri!)); for (const ref of existingRefs) { if (!newUris.has(ref.subject.uri)) { const slideRkey = rkeyFromUri(ref.subject.uri); await deleteSlide(slideRkey).catch(() => { // ignore deletion errors (slide may already be gone) }); } } } const record: DeckRecord = { name: deck.name || existingRecord.name, slides: slideRefs, createdAt: existingRecord.createdAt, updatedAt: new Date().toISOString(), // preserve existing thumbnail or use new one if provided ...(deck.thumbnail !== undefined ? (deck.thumbnail ? { thumbnail: deck.thumbnail } : {}) : (existingRecord.thumbnail ? { thumbnail: existingRecord.thumbnail } : {})), }; const res = await rpc.post("com.atproto.repo.putRecord", { input: { repo: currentDid, collection: DECK, rkey, record: { $type: DECK, ...record }, }, }); if (!res.ok) throw new Error(res.data.error || "failed to update deck"); }; export const deleteDeck = async (rkey: string): Promise => { if (!currentDid) throw new Error("not logged in"); const rpc = getRpc(); // get deck to find slide refs const existing = await rpc.get("com.atproto.repo.getRecord", { params: { repo: currentDid, collection: DECK, rkey }, }); if (existing.ok) { const record = existing.data.value as DeckRecord; // delete all slides for (const ref of record.slides) { const slideRkey = rkeyFromUri(ref.subject.uri); await deleteSlide(slideRkey).catch(() => { // ignore - slide may already be gone }); } } // delete deck const res = await rpc.post("com.atproto.repo.deleteRecord", { input: { repo: currentDid, collection: DECK, rkey }, }); if (!res.ok) throw new Error(res.data.error || "failed to delete deck"); }; export const getDeck = async (repo: string, rkey: string): Promise => { const rpc = getRpc(); const res = await rpc.get("com.atproto.repo.getRecord", { params: { repo, collection: DECK, rkey }, }); if (!res.ok) return null; const record = res.data.value as DeckRecord; // resolve all slide refs const slides: Slide[] = []; for (const ref of record.slides) { const slide = await getSlide(ref.subject.uri); if (slide) slides.push(slide); } // resolve thumbnail URL if present const thumbnailUrl = record.thumbnail ? getBlobUrl(repo, record.thumbnail.ref.$link, getPdsUrl() || undefined) : undefined; return { uri: res.data.uri, repo, rkey, name: record.name, slides, createdAt: record.createdAt, updatedAt: record.updatedAt, thumbnail: record.thumbnail, thumbnailUrl, }; }; export const listMyDecks = async (): Promise => { if (!currentDid) return []; const rpc = getRpc(); const res = await rpc.get("com.atproto.repo.listRecords", { params: { repo: currentDid, collection: DECK, limit: 100 }, }); if (!res.ok) return []; const pdsUrl = getPdsUrl() || undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any return res.data.records.map((r: any) => { const val = r.value as DeckRecord; const rkey = r.uri.split("/").pop()!; const thumbnailUrl = val.thumbnail ? getBlobUrl(currentDid!, val.thumbnail.ref.$link, pdsUrl) : undefined; // return shallow deck with slide count (slides not resolved yet) return { uri: r.uri, repo: currentDid!, rkey, name: val.name, slides: [], slideCount: val.slides.length, // for display before full resolution createdAt: val.createdAt, updatedAt: val.updatedAt, thumbnail: val.thumbnail, thumbnailUrl, }; }); }; // list public decks for any user (no auth required) export const listPublicDecks = async (did: string): Promise => { const pdsUrl = await resolvePdsUrl(did); const data = await publicFetch(pdsUrl, "com.atproto.repo.listRecords", { repo: did, collection: DECK, limit: "100", }); if (!data?.records) return []; // eslint-disable-next-line @typescript-eslint/no-explicit-any return data.records.map((r: any) => { const val = r.value as DeckRecord; const rkey = r.uri.split("/").pop()!; const thumbnailUrl = val.thumbnail ? getBlobUrl(did, val.thumbnail.ref.$link, pdsUrl) : undefined; return { uri: r.uri, repo: did, rkey, name: val.name, slides: [], slideCount: val.slides.length, createdAt: val.createdAt, updatedAt: val.updatedAt, thumbnail: val.thumbnail, thumbnailUrl, }; }); }; // public deck fetch (no auth required) export const getPublicDeck = async (did: string, rkey: string): Promise => { const pdsUrl = await resolvePdsUrl(did); const data = await publicFetch(pdsUrl, "com.atproto.repo.getRecord", { repo: did, collection: DECK, rkey, }); if (!data) return null; const record = data.value as DeckRecord; // resolve all slide refs const slides: Slide[] = []; for (const ref of record.slides) { const slide = await getPublicSlide(ref.subject.uri, pdsUrl); if (slide) slides.push(slide); } // resolve thumbnail URL if present const thumbnailUrl = record.thumbnail ? getBlobUrl(did, record.thumbnail.ref.$link, pdsUrl) : undefined; return { uri: data.uri, repo: did, rkey, name: record.name, slides, createdAt: record.createdAt, updatedAt: record.updatedAt, thumbnail: record.thumbnail, thumbnailUrl, }; };