···33- site.standard
44 - move description to markdownDescription and set description as text only
5566+- allow editing on mobile
77+88+- get automatic layout for mobile if only edited on desktop (and vice versa)
99+1010+- add cards in middle of current position (both mobile and desktop version)
1111+1212+- show nsfw warnings
1313+614- big button card
715816- card with big call to action button
9171010-- link card allow changing favicon, og image (+ hide favicon)
1111-1218- video card?
13191420- allow setting base and accent color
15211622- ask to fill with some default cards on page creation
17231818-- share button (copy share link to blento, maybe post to bluesky?)
1919-2020-- add icons to "change card to..." popover
2121-2224- when adding images try to add them in a size that best fits aspect ratio
23252426- onboarding
2525-2626-- show alert when user tries to close window with unsaved changes
+12-7
docs/CardIdeas.md
···33## media
4455- general video card
66-- inline youtube video
66+- [x] inline youtube video
77- cartoons: aka https://www.opendoodles.com/
88- excalidraw (/svg card)
99- latest blog post (e.g. leaflet)
1010- fake 3d image (with depth map)
1111-- fluid text effect (https://flo-bit.dev/projects/fluid-text-effect/)
1212-- gifs
1313-- little drawing app
1111+- [x] fluid text effect (https://flo-bit.dev/projects/fluid-text-effect/)
1212+- [x] gifs
1313+- [x] little drawing app
1414- css voxel art
1515- 3d model
1616+- spotify or apple music playlist
16171718## social accounts
18191920- instagram card (showing follow button, follower count, latest posts)
2020-- github card (showing activity grid)
2121+- [x] github card (showing activity grid)
2122- bluesky account card (showing follow button, follower count, avatar, name, cover image)
2223- youtube channel card (showing channel name, latest videos, follow button?)
2324- bluesky posts workcloud
···4041- teal.fm
4142 - [x] last played songs
4243- tangled.sh
4444+ - pinned repos
4545+ - activity heatmap?
4346- popfeed.social
4447 - reading goal
4548 - [x] latest ratings
4649 - lists
4747-- smokesignal.events (https://pdsls.dev/at://did:plc:xbtmt2zjwlrfegqvch7fboei/events.smokesignal.calendar.event/3ltn2qrxf3626)
4848-- statusphere.xyz (https://googlefonts.github.io/noto-emoji-animation/, https://gist.github.com/sanjacob/a0ccdf6d88f15bf158d8895090722d14)
5050+- smokesignal.events
5151+ - [x] specific event
5252+ - all future events i'm hosting/attending
5353+- [x] statusphere.xyz (TODO: assing to specific record)
4954- goals.garden
5055- flashes.blue (https://pdsls.dev/at://did:plc:aytgljyikzbtgrnac2u4ccft/blue.flashes.actor.portfolio, https://app.flashes.blue/profile/j4ck.xyz)
5156- room: flo-bit.dev/room
···11-import type { Did, Handle } from '@atcute/lexicons';
11+import {
22+ parseResourceUri,
33+ type ActorIdentifier,
44+ type Did,
55+ type Handle,
66+ type ResourceUri
77+} from '@atcute/lexicons';
28import { user } from './auth.svelte';
99+import type { AllowedCollection } from './settings';
310import {
411 CompositeDidDocumentResolver,
512 CompositeHandleResolver,
···916 WellKnownHandleResolver
1017} from '@atcute/identity-resolver';
1118import { Client, simpleFetchHandler } from '@atcute/client';
1212-import type { AppBskyActorDefs } from '@atcute/bluesky';
1313-import { redirect } from '@sveltejs/kit';
1919+import { type AppBskyActorDefs } from '@atcute/bluesky';
14201521export type Collection = `${string}.${string}.${string}`;
2222+import * as TID from '@atcute/tid';
16232424+/**
2525+ * Parses an AT Protocol URI into its components.
2626+ * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
2727+ * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri
2828+ */
1729export function parseUri(uri: string) {
1818- const [did, collection, rkey] = uri.replace('at://', '').split('/');
1919- return { did, collection, rkey } as {
2020- collection: `${string}.${string}.${string}`;
2121- rkey: string;
2222- did: Did;
2323- };
3030+ const parts = parseResourceUri(uri);
3131+ if (!parts.ok) return;
3232+ return parts.value;
2433}
25343535+/**
3636+ * Resolves a handle to a DID using DNS and HTTP methods.
3737+ * @param handle - The handle to resolve (e.g., "alice.bsky.social")
3838+ * @returns The DID associated with the handle
3939+ */
2640export async function resolveHandle({ handle }: { handle: Handle }) {
2741 const handleResolver = new CompositeHandleResolver({
2842 methods: {
···3145 }
3246 });
33473434- try {
3535- const data = await handleResolver.resolve(handle);
3636- return data;
3737- } catch (error) {
3838- redirect(307, '/?error=handle_not_found&handle=' + handle);
3939- }
4848+ const data = await handleResolver.resolve(handle);
4949+ return data;
4050}
41514252const didResolver = new CompositeDidDocumentResolver({
···4656 }
4757});
48585959+/**
6060+ * Gets the PDS (Personal Data Server) URL for a given DID.
6161+ * @param did - The DID to look up
6262+ * @returns The PDS service endpoint URL
6363+ * @throws If no PDS is found in the DID document
6464+ */
4965export async function getPDS(did: Did) {
5050- const doc = await didResolver.resolve(did as `did:plc:${string}` | `did:web:${string}`);
6666+ const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>);
5167 if (!doc.service) throw new Error('No PDS found');
5268 for (const service of doc.service) {
5369 if (service.id === '#atproto_pds') {
···5672 }
5773}
58747575+/**
7676+ * Fetches a detailed Bluesky profile for a user.
7777+ * @param data - Optional object with did and client
7878+ * @param data.did - The DID to fetch the profile for (defaults to current user)
7979+ * @param data.client - The client to use (defaults to public Bluesky API)
8080+ * @returns The profile data or undefined if not found
8181+ */
5982export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
6083 data ??= {};
6184 data.did ??= user.did;
···7093 params: { actor: data.did }
7194 });
72957373- if (!response.ok) return;
7474-7575- return response.data;
7676-}
7777-7878-export async function getAuthorFeed(data?: {
7979- did?: Did;
8080- client?: Client;
8181- filter?: string;
8282- limit?: number;
8383-}) {
8484- data ??= {};
8585- data.did ??= user.did;
8686-8787- if (!data.did) throw new Error('Error getting detailed profile: no did');
8888-8989- data.client ??= new Client({
9090- handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
9191- });
9292-9393- const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
9494- params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
9595- });
9696-9797- if (!response.ok) return;
9696+ if (!response.ok || response.data.handle === 'handle.invalid') {
9797+ const repo = await describeRepo({ did: data.did });
9898+ return { handle: repo?.handle ?? 'handle.invalid', did: data.did };
9999+ }
9810099101 return response.data;
100102}
101103104104+/**
105105+ * Creates an AT Protocol client for a user's PDS.
106106+ * @param did - The DID of the user
107107+ * @returns A client configured for the user's PDS
108108+ * @throws If the PDS cannot be found
109109+ */
102110export async function getClient({ did }: { did: Did }) {
103111 const pds = await getPDS(did);
104112 if (!pds) throw new Error('PDS not found');
···110118 return client;
111119}
112120121121+/**
122122+ * Lists records from a repository collection with pagination support.
123123+ * @param did - The DID of the repository (defaults to current user)
124124+ * @param collection - The collection to list records from
125125+ * @param cursor - Pagination cursor for continuing from a previous request
126126+ * @param limit - Maximum number of records to return (default 100, set to 0 for all records)
127127+ * @param client - The client to use (defaults to user's PDS client)
128128+ * @returns An array of records from the collection
129129+ */
113130export async function listRecords({
114131 did,
115132 collection,
116133 cursor,
117117- limit = 0,
134134+ limit = 100,
118135 client
119136}: {
120137 did?: Did;
···141158 params: {
142159 repo: did,
143160 collection,
144144- limit: limit || 100,
161161+ limit: !limit || limit > 100 ? 100 : limit,
145162 cursor: currentCursor
146163 }
147164 });
···157174 return allRecords;
158175}
159176177177+/**
178178+ * Fetches a single record from a repository.
179179+ * @param did - The DID of the repository (defaults to current user)
180180+ * @param collection - The collection the record belongs to
181181+ * @param rkey - The record key (defaults to "self")
182182+ * @param client - The client to use (defaults to user's PDS client)
183183+ * @returns The record data
184184+ */
160185export async function getRecord({
161186 did,
162187 collection,
163163- rkey,
188188+ rkey = 'self',
164189 client
165190}: {
166191 did?: Did;
···169194 client?: Client;
170195}) {
171196 did ??= user.did;
172172- rkey ??= 'self';
173197174198 if (!collection) {
175199 throw new Error('Missing parameters for getRecord');
···188212 }
189213 });
190214191191- if (!record.ok) return;
192192-193215 return JSON.parse(JSON.stringify(record.data));
194216}
195217218218+/**
219219+ * Creates or updates a record in the current user's repository.
220220+ * Only accepts collections that are configured in permissions.
221221+ * @param collection - The collection to write to (must be in permissions.collections)
222222+ * @param rkey - The record key (defaults to "self")
223223+ * @param record - The record data to write
224224+ * @returns The response from the PDS
225225+ * @throws If the user is not logged in
226226+ */
196227export async function putRecord({
197228 collection,
198198- rkey,
229229+ rkey = 'self',
199230 record
200231}: {
201201- collection: Collection;
202202- rkey: string;
232232+ collection: AllowedCollection;
233233+ rkey?: string;
203234 record: Record<string, unknown>;
204235}) {
205236 if (!user.client || !user.did) throw new Error('No rpc or did');
···218249 return response;
219250}
220251221221-export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) {
252252+/**
253253+ * Deletes a record from the current user's repository.
254254+ * Only accepts collections that are configured in permissions.
255255+ * @param collection - The collection the record belongs to (must be in permissions.collections)
256256+ * @param rkey - The record key (defaults to "self")
257257+ * @returns True if the deletion was successful
258258+ * @throws If the user is not logged in
259259+ */
260260+export async function deleteRecord({
261261+ collection,
262262+ rkey = 'self'
263263+}: {
264264+ collection: AllowedCollection;
265265+ rkey: string;
266266+}) {
222267 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
223268224269 const response = await user.client.post('com.atproto.repo.deleteRecord', {
···232277 return response.ok;
233278}
234279280280+/**
281281+ * Uploads a blob to the current user's PDS.
282282+ * @param blob - The blob data to upload
283283+ * @returns The blob metadata including ref, mimeType, and size, or undefined on failure
284284+ * @throws If the user is not logged in
285285+ */
235286export async function uploadBlob({ blob }: { blob: Blob }) {
236287 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
237288238289 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
239239- input: blob,
240240- data: {
290290+ params: {
241291 repo: user.did
242242- }
292292+ },
293293+ input: blob
243294 });
244295245245- if (!blobResponse?.ok) {
246246- return;
247247- }
296296+ if (!blobResponse?.ok) return;
248297249298 const blobInfo = blobResponse?.data.blob as {
250299 $type: 'blob';
···258307 return blobInfo;
259308}
260309310310+/**
311311+ * Gets metadata about a repository.
312312+ * @param client - The client to use
313313+ * @param did - The DID of the repository (defaults to current user)
314314+ * @returns Repository metadata or undefined on failure
315315+ */
261316export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
262317 did ??= user.did;
263318 if (!did) {
···275330 return repo.data;
276331}
277332333333+/**
334334+ * Constructs a URL to fetch a blob directly from a user's PDS.
335335+ * @param did - The DID of the user who owns the blob
336336+ * @param blob - The blob reference object
337337+ * @returns The URL to fetch the blob
338338+ */
278339export async function getBlobURL({
279340 did,
280341 blob
···291352 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
292353}
293354294294-export function getImageBlobUrl({
355355+/**
356356+ * Constructs a Bluesky CDN URL for an image blob.
357357+ * @param did - The DID of the user who owns the blob (defaults to current user)
358358+ * @param blob - The blob reference object
359359+ * @returns The CDN URL for the image in webp format
360360+ */
361361+export function getCDNImageBlobUrl({
295362 did,
296363 blob
297364}: {
298298- did: string;
365365+ did?: string;
299366 blob: {
300367 $type: 'blob';
301368 ref: {
···303370 };
304371 };
305372}) {
306306- if (!did || !blob?.ref?.$link) return '';
373373+ did ??= user.did;
374374+307375 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
308376}
309377378378+/**
379379+ * Searches for actors with typeahead/autocomplete functionality.
380380+ * @param q - The search query
381381+ * @param limit - Maximum number of results (default 10)
382382+ * @param host - The API host to use (defaults to public Bluesky API)
383383+ * @returns An object containing matching actors and the original query
384384+ */
310385export async function searchActorsTypeahead(
311386 q: string,
312387 limit: number = 10,
···329404330405 return { actors: response.data.actors, q };
331406}
407407+408408+/**
409409+ * Return a TID based on current time
410410+ *
411411+ * @returns TID for current time
412412+ */
413413+export function createTID() {
414414+ return TID.now();
415415+}
416416+417417+export async function getAuthorFeed(data?: {
418418+ did?: Did;
419419+ client?: Client;
420420+ filter?: string;
421421+ limit?: number;
422422+}) {
423423+ data ??= {};
424424+ data.did ??= user.did;
425425+426426+ if (!data.did) throw new Error('Error getting detailed profile: no did');
427427+428428+ data.client ??= new Client({
429429+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
430430+ });
431431+432432+ const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
433433+ params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
434434+ });
435435+436436+ if (!response.ok) return;
437437+438438+ return response.data;
439439+}
440440+441441+/**
442442+ * Fetches posts by their AT URIs.
443443+ * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
444444+ * @param client - The client to use (defaults to public Bluesky API)
445445+ * @returns Array of posts or undefined on failure
446446+ */
447447+export async function getPosts(data: { uris: string[]; client?: Client }) {
448448+ data.client ??= new Client({
449449+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
450450+ });
451451+452452+ const response = await data.client.get('app.bsky.feed.getPosts', {
453453+ params: { uris: data.uris as ResourceUri[] }
454454+ });
455455+456456+ if (!response.ok) return;
457457+458458+ return response.data.posts;
459459+}
460460+461461+export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier {
462462+ if (profile.handle && profile.handle !== 'handle.invalid') {
463463+ return profile.handle;
464464+ } else {
465465+ return profile.did;
466466+ }
467467+}
+51-15
src/lib/atproto/settings.ts
···11+import { dev } from '$app/environment';
22+13export const SITE = 'https://blento.app';
2433-export const collections: string[] = [
44- 'app.blento.card',
55- 'app.blento.page',
66- 'app.blento.settings',
77- 'app.blento.comment',
88- 'app.blento.guestbook.entry',
99- 'site.standard.publication',
1010- 'site.standard.document',
1111- 'xyz.statusphere.status'
1212-];
1313-1414-export const rpcCalls: Record<string, string | string[]> = {
1515- //'did:web:api.bsky.app#bsky_appview': ['app.bsky.actor.getProfile']
55+type Permissions = {
66+ collections: readonly string[];
77+ rpc: Record<string, string | string[]>;
88+ blobs: readonly string[];
169};
17101818-export const blobs = ['*/*'] as string | string[] | undefined;
1111+export const permissions = {
1212+ // collections you can create/delete/update
19132020-export const signUpPDS = 'https://pds.rip/';
1414+ // example: only allow create and delete
1515+ // collections: ['xyz.statusphere.status?action=create&action=update'],
1616+ collections: [
1717+ 'app.blento.card',
1818+ 'app.blento.page',
1919+ 'app.blento.settings',
2020+ 'app.blento.comment',
2121+ 'app.blento.guestbook.entry',
2222+ 'site.standard.publication',
2323+ 'site.standard.document',
2424+ 'xyz.statusphere.status'
2525+ ],
2626+2727+ // what types of authenticated proxied requests you can make to services
2828+2929+ // example: allow authenticated proxying to bsky appview to get a users liked posts
3030+ //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']}
3131+ rpc: {},
3232+3333+ // what types of blobs you can upload to a users PDS
3434+3535+ // example: allowing video and html uploads
3636+ // blobs: ['video/*', 'text/html']
3737+ // example: allowing all blob types
3838+ // blobs: ['*/*']
3939+ blobs: ['*/*']
4040+} as const satisfies Permissions;
4141+4242+// Extract base collection name (before any query params)
4343+type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T;
4444+4545+export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>;
4646+4747+// which PDS to use for signup
4848+// ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week
4949+const devPDS = 'https://pds.rip/';
5050+const prodPDS = 'https://selfhosted.social/';
5151+export const signUpPDS = dev ? devPDS : prodPDS;
5252+5353+// where to redirect after oauth login/signup, e.g. /oauth/callback
5454+export const REDIRECT_PATH = '/oauth/callback';
5555+5656+export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';