···3- site.standard
4 - move description to markdownDescription and set description as text only
5000000006- big button card
78- card with big call to action button
910-- link card allow changing favicon, og image (+ hide favicon)
11-12- video card?
1314- allow setting base and accent color
1516- ask to fill with some default cards on page creation
1718-- share button (copy share link to blento, maybe post to bluesky?)
19-20-- add icons to "change card to..." popover
21-22- when adding images try to add them in a size that best fits aspect ratio
2324- onboarding
25-26-- show alert when user tries to close window with unsaved changes
···3- site.standard
4 - move description to markdownDescription and set description as text only
56+- allow editing on mobile
7+8+- get automatic layout for mobile if only edited on desktop (and vice versa)
9+10+- add cards in middle of current position (both mobile and desktop version)
11+12+- show nsfw warnings
13+14- big button card
1516- card with big call to action button
170018- video card?
1920- allow setting base and accent color
2122- ask to fill with some default cards on page creation
23000024- when adding images try to add them in a size that best fits aspect ratio
2526- onboarding
00
+12-7
docs/CardIdeas.md
···3## media
45- general video card
6-- inline youtube video
7- cartoons: aka https://www.opendoodles.com/
8- excalidraw (/svg card)
9- latest blog post (e.g. leaflet)
10- fake 3d image (with depth map)
11-- fluid text effect (https://flo-bit.dev/projects/fluid-text-effect/)
12-- gifs
13-- little drawing app
14- css voxel art
15- 3d model
01617## social accounts
1819- instagram card (showing follow button, follower count, latest posts)
20-- github card (showing activity grid)
21- bluesky account card (showing follow button, follower count, avatar, name, cover image)
22- youtube channel card (showing channel name, latest videos, follow button?)
23- bluesky posts workcloud
···40- teal.fm
41 - [x] last played songs
42- tangled.sh
0043- popfeed.social
44 - reading goal
45 - [x] latest ratings
46 - lists
47-- smokesignal.events (https://pdsls.dev/at://did:plc:xbtmt2zjwlrfegqvch7fboei/events.smokesignal.calendar.event/3ltn2qrxf3626)
48-- statusphere.xyz (https://googlefonts.github.io/noto-emoji-animation/, https://gist.github.com/sanjacob/a0ccdf6d88f15bf158d8895090722d14)
0049- goals.garden
50- flashes.blue (https://pdsls.dev/at://did:plc:aytgljyikzbtgrnac2u4ccft/blue.flashes.actor.portfolio, https://app.flashes.blue/profile/j4ck.xyz)
51- room: flo-bit.dev/room
···3## media
45- general video card
6+- [x] inline youtube video
7- cartoons: aka https://www.opendoodles.com/
8- excalidraw (/svg card)
9- latest blog post (e.g. leaflet)
10- fake 3d image (with depth map)
11+- [x] fluid text effect (https://flo-bit.dev/projects/fluid-text-effect/)
12+- [x] gifs
13+- [x] little drawing app
14- css voxel art
15- 3d model
16+- spotify or apple music playlist
1718## social accounts
1920- instagram card (showing follow button, follower count, latest posts)
21+- [x] github card (showing activity grid)
22- bluesky account card (showing follow button, follower count, avatar, name, cover image)
23- youtube channel card (showing channel name, latest videos, follow button?)
24- bluesky posts workcloud
···41- teal.fm
42 - [x] last played songs
43- tangled.sh
44+ - pinned repos
45+ - activity heatmap?
46- popfeed.social
47 - reading goal
48 - [x] latest ratings
49 - lists
50+- smokesignal.events
51+ - [x] specific event
52+ - all future events i'm hosting/attending
53+- [x] statusphere.xyz (TODO: assing to specific record)
54- goals.garden
55- flashes.blue (https://pdsls.dev/at://did:plc:aytgljyikzbtgrnac2u4ccft/blue.flashes.actor.portfolio, https://app.flashes.blue/profile/j4ck.xyz)
56- room: flo-bit.dev/room
···1-import type { Did, Handle } from '@atcute/lexicons';
0000002import { user } from './auth.svelte';
03import {
4 CompositeDidDocumentResolver,
5 CompositeHandleResolver,
···9 WellKnownHandleResolver
10} from '@atcute/identity-resolver';
11import { Client, simpleFetchHandler } from '@atcute/client';
12-import type { AppBskyActorDefs } from '@atcute/bluesky';
13-import { redirect } from '@sveltejs/kit';
1415export type Collection = `${string}.${string}.${string}`;
0160000017export function parseUri(uri: string) {
18- const [did, collection, rkey] = uri.replace('at://', '').split('/');
19- return { did, collection, rkey } as {
20- collection: `${string}.${string}.${string}`;
21- rkey: string;
22- did: Did;
23- };
24}
250000026export async function resolveHandle({ handle }: { handle: Handle }) {
27 const handleResolver = new CompositeHandleResolver({
28 methods: {
···31 }
32 });
3334- try {
35- const data = await handleResolver.resolve(handle);
36- return data;
37- } catch (error) {
38- redirect(307, '/?error=handle_not_found&handle=' + handle);
39- }
40}
4142const didResolver = new CompositeDidDocumentResolver({
···46 }
47});
4800000049export async function getPDS(did: Did) {
50- const doc = await didResolver.resolve(did as `did:plc:${string}` | `did:web:${string}`);
51 if (!doc.service) throw new Error('No PDS found');
52 for (const service of doc.service) {
53 if (service.id === '#atproto_pds') {
···56 }
57}
58000000059export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
60 data ??= {};
61 data.did ??= user.did;
···70 params: { actor: data.did }
71 });
7273- if (!response.ok) return;
74-75- return response.data;
76-}
77-78-export async function getAuthorFeed(data?: {
79- did?: Did;
80- client?: Client;
81- filter?: string;
82- limit?: number;
83-}) {
84- data ??= {};
85- data.did ??= user.did;
86-87- if (!data.did) throw new Error('Error getting detailed profile: no did');
88-89- data.client ??= new Client({
90- handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
91- });
92-93- const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
94- params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
95- });
96-97- if (!response.ok) return;
9899 return response.data;
100}
101000000102export async function getClient({ did }: { did: Did }) {
103 const pds = await getPDS(did);
104 if (!pds) throw new Error('PDS not found');
···110 return client;
111}
112000000000113export async function listRecords({
114 did,
115 collection,
116 cursor,
117- limit = 0,
118 client
119}: {
120 did?: Did;
···141 params: {
142 repo: did,
143 collection,
144- limit: limit || 100,
145 cursor: currentCursor
146 }
147 });
···157 return allRecords;
158}
15900000000160export async function getRecord({
161 did,
162 collection,
163- rkey,
164 client
165}: {
166 did?: Did;
···169 client?: Client;
170}) {
171 did ??= user.did;
172- rkey ??= 'self';
173174 if (!collection) {
175 throw new Error('Missing parameters for getRecord');
···188 }
189 });
190191- if (!record.ok) return;
192-193 return JSON.parse(JSON.stringify(record.data));
194}
195000000000196export async function putRecord({
197 collection,
198- rkey,
199 record
200}: {
201- collection: Collection;
202- rkey: string;
203 record: Record<string, unknown>;
204}) {
205 if (!user.client || !user.did) throw new Error('No rpc or did');
···218 return response;
219}
220221-export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) {
00000000000000222 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
223224 const response = await user.client.post('com.atproto.repo.deleteRecord', {
···232 return response.ok;
233}
234000000235export async function uploadBlob({ blob }: { blob: Blob }) {
236 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
237238 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
239- input: blob,
240- data: {
241 repo: user.did
242- }
0243 });
244245- if (!blobResponse?.ok) {
246- return;
247- }
248249 const blobInfo = blobResponse?.data.blob as {
250 $type: 'blob';
···258 return blobInfo;
259}
260000000261export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
262 did ??= user.did;
263 if (!did) {
···275 return repo.data;
276}
277000000278export async function getBlobURL({
279 did,
280 blob
···291 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
292}
293294-export function getImageBlobUrl({
000000295 did,
296 blob
297}: {
298- did: string;
299 blob: {
300 $type: 'blob';
301 ref: {
···303 };
304 };
305}) {
306- if (!did || !blob?.ref?.$link) return '';
0307 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
308}
3090000000310export async function searchActorsTypeahead(
311 q: string,
312 limit: number = 10,
···329330 return { actors: response.data.actors, q };
331}
0000000000000000000000000000000000000000000000000000000000000
···1+import {
2+ parseResourceUri,
3+ type ActorIdentifier,
4+ type Did,
5+ type Handle,
6+ type ResourceUri
7+} from '@atcute/lexicons';
8import { user } from './auth.svelte';
9+import type { AllowedCollection } from './settings';
10import {
11 CompositeDidDocumentResolver,
12 CompositeHandleResolver,
···16 WellKnownHandleResolver
17} from '@atcute/identity-resolver';
18import { Client, simpleFetchHandler } from '@atcute/client';
19+import { type AppBskyActorDefs } from '@atcute/bluesky';
02021export type Collection = `${string}.${string}.${string}`;
22+import * as TID from '@atcute/tid';
2324+/**
25+ * Parses an AT Protocol URI into its components.
26+ * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
27+ * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri
28+ */
29export function parseUri(uri: string) {
30+ const parts = parseResourceUri(uri);
31+ if (!parts.ok) return;
32+ return parts.value;
00033}
3435+/**
36+ * Resolves a handle to a DID using DNS and HTTP methods.
37+ * @param handle - The handle to resolve (e.g., "alice.bsky.social")
38+ * @returns The DID associated with the handle
39+ */
40export async function resolveHandle({ handle }: { handle: Handle }) {
41 const handleResolver = new CompositeHandleResolver({
42 methods: {
···45 }
46 });
4748+ const data = await handleResolver.resolve(handle);
49+ return data;
000050}
5152const didResolver = new CompositeDidDocumentResolver({
···56 }
57});
5859+/**
60+ * Gets the PDS (Personal Data Server) URL for a given DID.
61+ * @param did - The DID to look up
62+ * @returns The PDS service endpoint URL
63+ * @throws If no PDS is found in the DID document
64+ */
65export async function getPDS(did: Did) {
66+ const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>);
67 if (!doc.service) throw new Error('No PDS found');
68 for (const service of doc.service) {
69 if (service.id === '#atproto_pds') {
···72 }
73}
7475+/**
76+ * Fetches a detailed Bluesky profile for a user.
77+ * @param data - Optional object with did and client
78+ * @param data.did - The DID to fetch the profile for (defaults to current user)
79+ * @param data.client - The client to use (defaults to public Bluesky API)
80+ * @returns The profile data or undefined if not found
81+ */
82export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
83 data ??= {};
84 data.did ??= user.did;
···93 params: { actor: data.did }
94 });
9596+ if (!response.ok || response.data.handle === 'handle.invalid') {
97+ const repo = await describeRepo({ did: data.did });
98+ return { handle: repo?.handle ?? 'handle.invalid', did: data.did };
99+ }
000000000000000000000100101 return response.data;
102}
103104+/**
105+ * Creates an AT Protocol client for a user's PDS.
106+ * @param did - The DID of the user
107+ * @returns A client configured for the user's PDS
108+ * @throws If the PDS cannot be found
109+ */
110export async function getClient({ did }: { did: Did }) {
111 const pds = await getPDS(did);
112 if (!pds) throw new Error('PDS not found');
···118 return client;
119}
120121+/**
122+ * Lists records from a repository collection with pagination support.
123+ * @param did - The DID of the repository (defaults to current user)
124+ * @param collection - The collection to list records from
125+ * @param cursor - Pagination cursor for continuing from a previous request
126+ * @param limit - Maximum number of records to return (default 100, set to 0 for all records)
127+ * @param client - The client to use (defaults to user's PDS client)
128+ * @returns An array of records from the collection
129+ */
130export async function listRecords({
131 did,
132 collection,
133 cursor,
134+ limit = 100,
135 client
136}: {
137 did?: Did;
···158 params: {
159 repo: did,
160 collection,
161+ limit: !limit || limit > 100 ? 100 : limit,
162 cursor: currentCursor
163 }
164 });
···174 return allRecords;
175}
176177+/**
178+ * Fetches a single record from a repository.
179+ * @param did - The DID of the repository (defaults to current user)
180+ * @param collection - The collection the record belongs to
181+ * @param rkey - The record key (defaults to "self")
182+ * @param client - The client to use (defaults to user's PDS client)
183+ * @returns The record data
184+ */
185export async function getRecord({
186 did,
187 collection,
188+ rkey = 'self',
189 client
190}: {
191 did?: Did;
···194 client?: Client;
195}) {
196 did ??= user.did;
0197198 if (!collection) {
199 throw new Error('Missing parameters for getRecord');
···212 }
213 });
21400215 return JSON.parse(JSON.stringify(record.data));
216}
217218+/**
219+ * Creates or updates a record in the current user's repository.
220+ * Only accepts collections that are configured in permissions.
221+ * @param collection - The collection to write to (must be in permissions.collections)
222+ * @param rkey - The record key (defaults to "self")
223+ * @param record - The record data to write
224+ * @returns The response from the PDS
225+ * @throws If the user is not logged in
226+ */
227export async function putRecord({
228 collection,
229+ rkey = 'self',
230 record
231}: {
232+ collection: AllowedCollection;
233+ rkey?: string;
234 record: Record<string, unknown>;
235}) {
236 if (!user.client || !user.did) throw new Error('No rpc or did');
···249 return response;
250}
251252+/**
253+ * Deletes a record from the current user's repository.
254+ * Only accepts collections that are configured in permissions.
255+ * @param collection - The collection the record belongs to (must be in permissions.collections)
256+ * @param rkey - The record key (defaults to "self")
257+ * @returns True if the deletion was successful
258+ * @throws If the user is not logged in
259+ */
260+export async function deleteRecord({
261+ collection,
262+ rkey = 'self'
263+}: {
264+ collection: AllowedCollection;
265+ rkey: string;
266+}) {
267 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
268269 const response = await user.client.post('com.atproto.repo.deleteRecord', {
···277 return response.ok;
278}
279280+/**
281+ * Uploads a blob to the current user's PDS.
282+ * @param blob - The blob data to upload
283+ * @returns The blob metadata including ref, mimeType, and size, or undefined on failure
284+ * @throws If the user is not logged in
285+ */
286export async function uploadBlob({ blob }: { blob: Blob }) {
287 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
288289 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
290+ params: {
0291 repo: user.did
292+ },
293+ input: blob
294 });
295296+ if (!blobResponse?.ok) return;
00297298 const blobInfo = blobResponse?.data.blob as {
299 $type: 'blob';
···307 return blobInfo;
308}
309310+/**
311+ * Gets metadata about a repository.
312+ * @param client - The client to use
313+ * @param did - The DID of the repository (defaults to current user)
314+ * @returns Repository metadata or undefined on failure
315+ */
316export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
317 did ??= user.did;
318 if (!did) {
···330 return repo.data;
331}
332333+/**
334+ * Constructs a URL to fetch a blob directly from a user's PDS.
335+ * @param did - The DID of the user who owns the blob
336+ * @param blob - The blob reference object
337+ * @returns The URL to fetch the blob
338+ */
339export async function getBlobURL({
340 did,
341 blob
···352 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
353}
354355+/**
356+ * Constructs a Bluesky CDN URL for an image blob.
357+ * @param did - The DID of the user who owns the blob (defaults to current user)
358+ * @param blob - The blob reference object
359+ * @returns The CDN URL for the image in webp format
360+ */
361+export function getCDNImageBlobUrl({
362 did,
363 blob
364}: {
365+ did?: string;
366 blob: {
367 $type: 'blob';
368 ref: {
···370 };
371 };
372}) {
373+ did ??= user.did;
374+375 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
376}
377378+/**
379+ * Searches for actors with typeahead/autocomplete functionality.
380+ * @param q - The search query
381+ * @param limit - Maximum number of results (default 10)
382+ * @param host - The API host to use (defaults to public Bluesky API)
383+ * @returns An object containing matching actors and the original query
384+ */
385export async function searchActorsTypeahead(
386 q: string,
387 limit: number = 10,
···404405 return { actors: response.data.actors, q };
406}
407+408+/**
409+ * Return a TID based on current time
410+ *
411+ * @returns TID for current time
412+ */
413+export function createTID() {
414+ return TID.now();
415+}
416+417+export async function getAuthorFeed(data?: {
418+ did?: Did;
419+ client?: Client;
420+ filter?: string;
421+ limit?: number;
422+}) {
423+ data ??= {};
424+ data.did ??= user.did;
425+426+ if (!data.did) throw new Error('Error getting detailed profile: no did');
427+428+ data.client ??= new Client({
429+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
430+ });
431+432+ const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
433+ params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
434+ });
435+436+ if (!response.ok) return;
437+438+ return response.data;
439+}
440+441+/**
442+ * Fetches posts by their AT URIs.
443+ * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
444+ * @param client - The client to use (defaults to public Bluesky API)
445+ * @returns Array of posts or undefined on failure
446+ */
447+export async function getPosts(data: { uris: string[]; client?: Client }) {
448+ data.client ??= new Client({
449+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
450+ });
451+452+ const response = await data.client.get('app.bsky.feed.getPosts', {
453+ params: { uris: data.uris as ResourceUri[] }
454+ });
455+456+ if (!response.ok) return;
457+458+ return response.data.posts;
459+}
460+461+export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier {
462+ if (profile.handle && profile.handle !== 'handle.invalid') {
463+ return profile.handle;
464+ } else {
465+ return profile.did;
466+ }
467+}
···1+import { dev } from '$app/environment';
2+3export const SITE = 'https://blento.app';
45+type Permissions = {
6+ collections: readonly string[];
7+ rpc: Record<string, string | string[]>;
8+ blobs: readonly string[];
0000000009};
1011+export const permissions = {
12+ // collections you can create/delete/update
1314+ // example: only allow create and delete
15+ // collections: ['xyz.statusphere.status?action=create&action=update'],
16+ collections: [
17+ 'app.blento.card',
18+ 'app.blento.page',
19+ 'app.blento.settings',
20+ 'app.blento.comment',
21+ 'app.blento.guestbook.entry',
22+ 'site.standard.publication',
23+ 'site.standard.document',
24+ 'xyz.statusphere.status'
25+ ],
26+27+ // what types of authenticated proxied requests you can make to services
28+29+ // example: allow authenticated proxying to bsky appview to get a users liked posts
30+ //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']}
31+ rpc: {},
32+33+ // what types of blobs you can upload to a users PDS
34+35+ // example: allowing video and html uploads
36+ // blobs: ['video/*', 'text/html']
37+ // example: allowing all blob types
38+ // blobs: ['*/*']
39+ blobs: ['*/*']
40+} as const satisfies Permissions;
41+42+// Extract base collection name (before any query params)
43+type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T;
44+45+export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>;
46+47+// which PDS to use for signup
48+// ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week
49+const devPDS = 'https://pds.rip/';
50+const prodPDS = 'https://selfhosted.social/';
51+export const signUpPDS = dev ? devPDS : prodPDS;
52+53+// where to redirect after oauth login/signup, e.g. /oauth/callback
54+export const REDIRECT_PATH = '/oauth/callback';
55+56+export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
···1+export { default as SelectTheme } from './SelectTheme.svelte';
2+export { default as SelectThemePopover } from './SelectThemePopover.svelte';
+2-3
src/lib/helper.ts
···1import type { Item, WebsiteData } from './types';
2import { COLUMNS, margin, mobileMargin } from '$lib';
3import { CardDefinitionsByType } from './cards';
4-import { deleteRecord, getImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto';
5-import { toast } from '@foxui/core';
6import * as TID from '@atcute/tid';
78export function clamp(value: number, min: number, max: number): number {
···627 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl;
628629 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') {
630- return getImageBlobUrl({ did, blob: objectWithImage[key] });
631 }
632 return objectWithImage[key];
633}
···1import type { Item, WebsiteData } from './types';
2import { COLUMNS, margin, mobileMargin } from '$lib';
3import { CardDefinitionsByType } from './cards';
4+import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto';
05import * as TID from '@atcute/tid';
67export function clamp(value: number, min: number, max: number): number {
···626 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl;
627628 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') {
629+ return getCDNImageBlobUrl({ did, blob: objectWithImage[key] });
630 }
631 return objectWithImage[key];
632}
+4
src/lib/types.ts
···5152 // 'side' (default on desktop) or 'top' (always top like mobile view)
53 profilePosition?: 'side' | 'top';
000054 };
55 };
56 profile: AppBskyActorDefs.ProfileViewDetailed;
···5152 // 'side' (default on desktop) or 'top' (always top like mobile view)
53 profilePosition?: 'side' | 'top';
54+55+ // theme colors
56+ accentColor?: string;
57+ baseColor?: string;
58 };
59 };
60 profile: AppBskyActorDefs.ProfileViewDetailed;
+2-2
src/lib/website/Account.svelte
···2 import { user, login, logout } from '$lib/atproto';
3 import type { WebsiteData } from '$lib/types';
4 import type { ActorIdentifier } from '@atcute/lexicons';
5- import { Button, Popover } from '@foxui/core';
67 let {
8 data
···18 <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900">
19 {#snippet child({ props })}
20 <button {...props}>
21- <img src={user.profile?.avatar} alt="" class="size-15 rounded-full" />
22 </button>
23 {/snippet}
24
···2 import { user, login, logout } from '$lib/atproto';
3 import type { WebsiteData } from '$lib/types';
4 import type { ActorIdentifier } from '@atcute/lexicons';
5+ import { Avatar, Button, Popover } from '@foxui/core';
67 let {
8 data
···18 <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900">
19 {#snippet child({ props })}
20 <button {...props}>
21+ <Avatar src={user.profile?.avatar} alt="" class="size-15 rounded-full" />
22 </button>
23 {/snippet}
24