powerpointproto
slides.waow.tech
slides
1import { currentDid, getRpc, getPdsUrl, publicFetch, resolvePdsUrl } from "./client";
2import { createSlide, deleteSlide, getPublicSlide, getSlide, updateSlide } from "./slide";
3import { getBlobUrl } from "./blob";
4import { DECK, type Deck, type DeckRecord, type Slide, type SlideRef } from "./types";
5
6const rkeyFromUri = (uri: string): string => uri.split("/").pop()!;
7
8export const createDeck = async (name: string, slides: Slide[], thumbnail?: Deck["thumbnail"]): Promise<string | null> => {
9 if (!currentDid) return null;
10
11 const rpc = getRpc();
12 const now = new Date().toISOString();
13
14 // create each slide as a separate record
15 const slideRefs: SlideRef[] = [];
16 for (const slide of slides) {
17 const ref = await createSlide(slide);
18 if (!ref) throw new Error("failed to create slide");
19 slideRefs.push({ subject: ref });
20 }
21
22 // create deck with references
23 const record: DeckRecord = {
24 name,
25 slides: slideRefs,
26 createdAt: now,
27 updatedAt: now,
28 ...(thumbnail && { thumbnail }),
29 };
30
31 const res = await rpc.post("com.atproto.repo.createRecord", {
32 input: {
33 repo: currentDid,
34 collection: DECK,
35 record: { $type: DECK, ...record },
36 },
37 });
38
39 if (!res.ok) throw new Error(res.data.error || "failed to create deck");
40 return res.data.uri;
41};
42
43export const updateDeck = async (rkey: string, deck: Partial<Deck>): Promise<void> => {
44 if (!currentDid) throw new Error("not logged in");
45
46 const rpc = getRpc();
47
48 // get existing deck record
49 const existing = await rpc.get("com.atproto.repo.getRecord", {
50 params: { repo: currentDid, collection: DECK, rkey },
51 });
52
53 if (!existing.ok) throw new Error("deck not found");
54
55 const existingRecord = existing.data.value as DeckRecord;
56 const existingRefs = existingRecord.slides;
57
58 // if slides changed, update/create/delete slide records
59 let slideRefs = existingRefs;
60 if (deck.slides) {
61 slideRefs = [];
62
63 for (let i = 0; i < deck.slides.length; i++) {
64 const slide = deck.slides[i];
65
66 if (slide.uri && slide.rkey) {
67 // existing slide - update it
68 const ref = await updateSlide(slide.rkey, slide);
69 slideRefs.push({ subject: ref });
70 } else {
71 // new slide - create it
72 const ref = await createSlide(slide);
73 if (!ref) throw new Error("failed to create slide");
74 slideRefs.push({ subject: ref });
75 }
76 }
77
78 // delete removed slides
79 const newUris = new Set(deck.slides.filter(s => s.uri).map(s => s.uri!));
80 for (const ref of existingRefs) {
81 if (!newUris.has(ref.subject.uri)) {
82 const slideRkey = rkeyFromUri(ref.subject.uri);
83 await deleteSlide(slideRkey).catch(() => {
84 // ignore deletion errors (slide may already be gone)
85 });
86 }
87 }
88 }
89
90 const record: DeckRecord = {
91 name: deck.name || existingRecord.name,
92 slides: slideRefs,
93 createdAt: existingRecord.createdAt,
94 updatedAt: new Date().toISOString(),
95 // preserve existing thumbnail or use new one if provided
96 ...(deck.thumbnail !== undefined
97 ? (deck.thumbnail ? { thumbnail: deck.thumbnail } : {})
98 : (existingRecord.thumbnail ? { thumbnail: existingRecord.thumbnail } : {})),
99 };
100
101 const res = await rpc.post("com.atproto.repo.putRecord", {
102 input: {
103 repo: currentDid,
104 collection: DECK,
105 rkey,
106 record: { $type: DECK, ...record },
107 },
108 });
109
110 if (!res.ok) throw new Error(res.data.error || "failed to update deck");
111};
112
113export const deleteDeck = async (rkey: string): Promise<void> => {
114 if (!currentDid) throw new Error("not logged in");
115
116 const rpc = getRpc();
117
118 // get deck to find slide refs
119 const existing = await rpc.get("com.atproto.repo.getRecord", {
120 params: { repo: currentDid, collection: DECK, rkey },
121 });
122
123 if (existing.ok) {
124 const record = existing.data.value as DeckRecord;
125 // delete all slides
126 for (const ref of record.slides) {
127 const slideRkey = rkeyFromUri(ref.subject.uri);
128 await deleteSlide(slideRkey).catch(() => {
129 // ignore - slide may already be gone
130 });
131 }
132 }
133
134 // delete deck
135 const res = await rpc.post("com.atproto.repo.deleteRecord", {
136 input: { repo: currentDid, collection: DECK, rkey },
137 });
138
139 if (!res.ok) throw new Error(res.data.error || "failed to delete deck");
140};
141
142export const getDeck = async (repo: string, rkey: string): Promise<Deck | null> => {
143 const rpc = getRpc();
144 const res = await rpc.get("com.atproto.repo.getRecord", {
145 params: { repo, collection: DECK, rkey },
146 });
147
148 if (!res.ok) return null;
149
150 const record = res.data.value as DeckRecord;
151
152 // resolve all slide refs
153 const slides: Slide[] = [];
154 for (const ref of record.slides) {
155 const slide = await getSlide(ref.subject.uri);
156 if (slide) slides.push(slide);
157 }
158
159 // resolve thumbnail URL if present
160 const thumbnailUrl = record.thumbnail
161 ? getBlobUrl(repo, record.thumbnail.ref.$link, getPdsUrl() || undefined)
162 : undefined;
163
164 return {
165 uri: res.data.uri,
166 repo,
167 rkey,
168 name: record.name,
169 slides,
170 createdAt: record.createdAt,
171 updatedAt: record.updatedAt,
172 thumbnail: record.thumbnail,
173 thumbnailUrl,
174 };
175};
176
177export const listMyDecks = async (): Promise<Deck[]> => {
178 if (!currentDid) return [];
179
180 const rpc = getRpc();
181 const res = await rpc.get("com.atproto.repo.listRecords", {
182 params: { repo: currentDid, collection: DECK, limit: 100 },
183 });
184
185 if (!res.ok) return [];
186
187 const pdsUrl = getPdsUrl() || undefined;
188
189 // eslint-disable-next-line @typescript-eslint/no-explicit-any
190 return res.data.records.map((r: any) => {
191 const val = r.value as DeckRecord;
192 const rkey = r.uri.split("/").pop()!;
193 const thumbnailUrl = val.thumbnail
194 ? getBlobUrl(currentDid!, val.thumbnail.ref.$link, pdsUrl)
195 : undefined;
196 // return shallow deck with slide count (slides not resolved yet)
197 return {
198 uri: r.uri,
199 repo: currentDid!,
200 rkey,
201 name: val.name,
202 slides: [],
203 slideCount: val.slides.length, // for display before full resolution
204 createdAt: val.createdAt,
205 updatedAt: val.updatedAt,
206 thumbnail: val.thumbnail,
207 thumbnailUrl,
208 };
209 });
210};
211
212// list public decks for any user (no auth required)
213export const listPublicDecks = async (did: string): Promise<Deck[]> => {
214 const pdsUrl = await resolvePdsUrl(did);
215
216 const data = await publicFetch(pdsUrl, "com.atproto.repo.listRecords", {
217 repo: did,
218 collection: DECK,
219 limit: "100",
220 });
221
222 if (!data?.records) return [];
223
224 // eslint-disable-next-line @typescript-eslint/no-explicit-any
225 return data.records.map((r: any) => {
226 const val = r.value as DeckRecord;
227 const rkey = r.uri.split("/").pop()!;
228 const thumbnailUrl = val.thumbnail
229 ? getBlobUrl(did, val.thumbnail.ref.$link, pdsUrl)
230 : undefined;
231 return {
232 uri: r.uri,
233 repo: did,
234 rkey,
235 name: val.name,
236 slides: [],
237 slideCount: val.slides.length,
238 createdAt: val.createdAt,
239 updatedAt: val.updatedAt,
240 thumbnail: val.thumbnail,
241 thumbnailUrl,
242 };
243 });
244};
245
246// public deck fetch (no auth required)
247export const getPublicDeck = async (did: string, rkey: string): Promise<Deck | null> => {
248 const pdsUrl = await resolvePdsUrl(did);
249
250 const data = await publicFetch(pdsUrl, "com.atproto.repo.getRecord", {
251 repo: did,
252 collection: DECK,
253 rkey,
254 });
255
256 if (!data) return null;
257
258 const record = data.value as DeckRecord;
259
260 // resolve all slide refs
261 const slides: Slide[] = [];
262 for (const ref of record.slides) {
263 const slide = await getPublicSlide(ref.subject.uri, pdsUrl);
264 if (slide) slides.push(slide);
265 }
266
267 // resolve thumbnail URL if present
268 const thumbnailUrl = record.thumbnail
269 ? getBlobUrl(did, record.thumbnail.ref.$link, pdsUrl)
270 : undefined;
271
272 return {
273 uri: data.uri,
274 repo: did,
275 rkey,
276 name: record.name,
277 slides,
278 createdAt: record.createdAt,
279 updatedAt: record.updatedAt,
280 thumbnail: record.thumbnail,
281 thumbnailUrl,
282 };
283};