···5353**Card System (`src/lib/cards/`):**
54545555- `CardDefinition` type in `types.ts` defines the interface for card types
5656-- Each card type exports a definition with: `type`, `contentComponent`, optional `editingContentComponent`, `creationModalComponent`, `sidebarComponent`, `loadData`, `upload` (see more info and description in `src/lib/cards/types.ts`)
5656+- Each card type exports a definition with: `type`, `contentComponent`, optional `editingContentComponent`, `creationModalComponent`, `sidebarButtonText`, `loadData`, `upload` (see more info and description in `src/lib/cards/types.ts`)
5757- Card types include Text, Link, Image, Bluesky, Embed, Map, Livestream, ATProto collections, and special cards (see `src/lib/cards`).
5858- `AllCardDefinitions` and `CardDefinitionsByType` in `index.ts` aggregate all card types
5959- See e.g. `src/lib/cards/EmbedCard/` and `src/lib/cards/LivestreamCard/` for examples of implementation.
···6464- `auth.svelte.ts` - OAuth client state and login/logout flow using `@atcute/oauth-browser-client`
6565- `atproto.ts` - ATProto API helpers: `resolveHandle`, `listRecords`, `getRecord`, `putRecord`, `deleteRecord`, `uploadImage`
6666- Data is stored in user's PDS under collection `app.blento.card`
6767+- **Important**: ATProto does not allow floating point numbers in records. All numeric values must be integers.
67686869**Data Loading (`src/lib/website/`):**
6970
-33
docs/Beta.md
···11-# Todo for beta version
22-33-- fix opengraph image stuff (caching issue?)
44-55-- site.standard
66- - move subpages to own lexicon (app.blento.page)
77- - move description to markdownDescription and set description as text only
88-99-- card with big call to action button
1010-1111-- link card: save favicon and og image as blobs
1212-1313-- video card?
1414-1515-- allow changing profile picture
1616-1717-- allow editing profile stuff inline (in sidebar profile)
1818-1919-- allow setting base and accent color
2020-2121-- ask to fill with some default cards on page creation
2222-2323-- share button (copy share link to blento, maybe post to bluesky?)
2424-2525-- add icons to "change card to..." popover
2626-2727-- show social icon instead of favicon in link card if platform found for link
2828-2929-- when adding images try to add them in a size that best fits aspect ratio
3030-3131-- onboarding
3232-3333-- 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
+3-3
docs/Selfhosting.md
···2424]
2525```
26262727-5. (maybe necessary? will improve performance at least) create your own kv store by running `npx wrangler kv namespace create USER_DATA_CACHE` and when asked add it to the `wrangler.jsonc`
2727+5. optionally to improve performance: create your own kv store by running `npx wrangler kv namespace create USER_DATA_CACHE` and when asked add it to the `wrangler.jsonc`
28282929DONE :) your blento should be live after a minute or two at `your-cloudflare-worker-or-custom-domain.com` and you can edit it by signing in with your bluesky account at `your-cloudflare-worker-or-custom-domain.com/edit`
303031316. some cards need their own additional env keys, if you have these cards in your profile, create your keys and add them to your cloudflare worker
32323333-- github profile: GITHUB_TOKEN
3434-- map: PUBLIC_MAPBOX_TOKEN3333+- github profile: GITHUB_TOKEN (token with public_repo access)
3434+- map: PUBLIC_MAPBOX_TOKEN
···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';
1919+import { type AppBskyActorDefs } from '@atcute/bluesky';
13201421export type Collection = `${string}.${string}.${string}`;
2222+import * as TID from '@atcute/tid';
15232424+/**
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+ */
1629export function parseUri(uri: string) {
1717- const [did, collection, rkey] = uri.replace('at://', '').split('/');
1818- return { did, collection, rkey } as {
1919- collection: `${string}.${string}.${string}`;
2020- rkey: string;
2121- did: Did;
2222- };
3030+ const parts = parseResourceUri(uri);
3131+ if (!parts.ok) return;
3232+ return parts.value;
2333}
24343535+/**
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+ */
2540export async function resolveHandle({ handle }: { handle: Handle }) {
2641 const handleResolver = new CompositeHandleResolver({
2742 methods: {
···4156 }
4257});
43585959+/**
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+ */
4465export async function getPDS(did: Did) {
4545- 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'>);
4667 if (!doc.service) throw new Error('No PDS found');
4768 for (const service of doc.service) {
4869 if (service.id === '#atproto_pds') {
···5172 }
5273}
53747575+/**
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+ */
5482export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
5583 data ??= {};
5684 data.did ??= user.did;
···6593 params: { actor: data.did }
6694 });
67956868- 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+ }
6910070101 return response.data;
71102}
721037373-export async function getAuthorFeed(data?: {
7474- did?: Did;
7575- client?: Client;
7676- filter?: string;
7777- limit?: number;
7878-}) {
7979- data ??= {};
8080- data.did ??= user.did;
104104+export async function getBlentoOrBskyProfile(data: { did: Did; client?: Client }): Promise<
105105+ Awaited<ReturnType<typeof getDetailedProfile>> & {
106106+ hasBlento: boolean;
107107+ }
108108+> {
109109+ let blentoProfile;
110110+ try {
111111+ // try getting blento profile first
112112+ blentoProfile = await getRecord({
113113+ collection: 'site.standard.publication',
114114+ did: data?.did,
115115+ rkey: 'blento.self',
116116+ client: data?.client
117117+ });
118118+ } catch {
119119+ console.error('error getting blento profile, falling back to bsky profile');
120120+ }
811218282- if (!data.did) throw new Error('Error getting detailed profile: no did');
122122+ const response = await getDetailedProfile(data);
831238484- data.client ??= new Client({
8585- handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
8686- });
8787-8888- const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
8989- params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
9090- });
9191-9292- if (!response.ok) return;
9393-9494- return response.data;
124124+ return {
125125+ did: data.did,
126126+ handle: response?.handle,
127127+ displayName: blentoProfile?.value?.name || response?.displayName || response?.handle,
128128+ avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) ||
129129+ response?.avatar) as `${string}:${string}`,
130130+ hasBlento: Boolean(blentoProfile.value)
131131+ };
95132}
96133134134+/**
135135+ * Creates an AT Protocol client for a user's PDS.
136136+ * @param did - The DID of the user
137137+ * @returns A client configured for the user's PDS
138138+ * @throws If the PDS cannot be found
139139+ */
97140export async function getClient({ did }: { did: Did }) {
98141 const pds = await getPDS(did);
99142 if (!pds) throw new Error('PDS not found');
···105148 return client;
106149}
107150151151+/**
152152+ * Lists records from a repository collection with pagination support.
153153+ * @param did - The DID of the repository (defaults to current user)
154154+ * @param collection - The collection to list records from
155155+ * @param cursor - Pagination cursor for continuing from a previous request
156156+ * @param limit - Maximum number of records to return (default 100, set to 0 for all records)
157157+ * @param client - The client to use (defaults to user's PDS client)
158158+ * @returns An array of records from the collection
159159+ */
108160export async function listRecords({
109161 did,
110162 collection,
111163 cursor,
112112- limit = 0,
164164+ limit = 100,
113165 client
114166}: {
115167 did?: Did;
···136188 params: {
137189 repo: did,
138190 collection,
139139- limit: limit || 100,
191191+ limit: !limit || limit > 100 ? 100 : limit,
140192 cursor: currentCursor
141193 }
142194 });
···152204 return allRecords;
153205}
154206207207+/**
208208+ * Fetches a single record from a repository.
209209+ * @param did - The DID of the repository (defaults to current user)
210210+ * @param collection - The collection the record belongs to
211211+ * @param rkey - The record key (defaults to "self")
212212+ * @param client - The client to use (defaults to user's PDS client)
213213+ * @returns The record data
214214+ */
155215export async function getRecord({
156216 did,
157217 collection,
158158- rkey,
218218+ rkey = 'self',
159219 client
160220}: {
161221 did?: Did;
···164224 client?: Client;
165225}) {
166226 did ??= user.did;
167167- rkey ??= 'self';
168227169228 if (!collection) {
170229 throw new Error('Missing parameters for getRecord');
···186245 return JSON.parse(JSON.stringify(record.data));
187246}
188247248248+/**
249249+ * Creates or updates a record in the current user's repository.
250250+ * Only accepts collections that are configured in permissions.
251251+ * @param collection - The collection to write to (must be in permissions.collections)
252252+ * @param rkey - The record key (defaults to "self")
253253+ * @param record - The record data to write
254254+ * @returns The response from the PDS
255255+ * @throws If the user is not logged in
256256+ */
189257export async function putRecord({
190258 collection,
191191- rkey,
259259+ rkey = 'self',
192260 record
193261}: {
194194- collection: Collection;
195195- rkey: string;
262262+ collection: AllowedCollection;
263263+ rkey?: string;
196264 record: Record<string, unknown>;
197265}) {
198266 if (!user.client || !user.did) throw new Error('No rpc or did');
···211279 return response;
212280}
213281214214-export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) {
282282+/**
283283+ * Deletes a record from the current user's repository.
284284+ * Only accepts collections that are configured in permissions.
285285+ * @param collection - The collection the record belongs to (must be in permissions.collections)
286286+ * @param rkey - The record key (defaults to "self")
287287+ * @returns True if the deletion was successful
288288+ * @throws If the user is not logged in
289289+ */
290290+export async function deleteRecord({
291291+ collection,
292292+ rkey = 'self'
293293+}: {
294294+ collection: AllowedCollection;
295295+ rkey: string;
296296+}) {
215297 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
216298217299 const response = await user.client.post('com.atproto.repo.deleteRecord', {
···225307 return response.ok;
226308}
227309310310+/**
311311+ * Uploads a blob to the current user's PDS.
312312+ * @param blob - The blob data to upload
313313+ * @returns The blob metadata including ref, mimeType, and size, or undefined on failure
314314+ * @throws If the user is not logged in
315315+ */
228316export async function uploadBlob({ blob }: { blob: Blob }) {
229317 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
230318231319 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
232232- input: blob,
233233- data: {
320320+ params: {
234321 repo: user.did
235235- }
322322+ },
323323+ input: blob
236324 });
237325238238- if (!blobResponse?.ok) {
239239- return;
240240- }
326326+ if (!blobResponse?.ok) return;
241327242328 const blobInfo = blobResponse?.data.blob as {
243329 $type: 'blob';
···251337 return blobInfo;
252338}
253339340340+/**
341341+ * Gets metadata about a repository.
342342+ * @param client - The client to use
343343+ * @param did - The DID of the repository (defaults to current user)
344344+ * @returns Repository metadata or undefined on failure
345345+ */
254346export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
255347 did ??= user.did;
256348 if (!did) {
···268360 return repo.data;
269361}
270362363363+/**
364364+ * Constructs a URL to fetch a blob directly from a user's PDS.
365365+ * @param did - The DID of the user who owns the blob
366366+ * @param blob - The blob reference object
367367+ * @returns The URL to fetch the blob
368368+ */
271369export async function getBlobURL({
272370 did,
273371 blob
···284382 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
285383}
286384287287-export function getImageBlobUrl({
385385+/**
386386+ * Constructs a Bluesky CDN URL for an image blob.
387387+ * @param did - The DID of the user who owns the blob (defaults to current user)
388388+ * @param blob - The blob reference object
389389+ * @returns The CDN URL for the image in webp format
390390+ */
391391+export function getCDNImageBlobUrl({
288392 did,
289393 blob
290394}: {
291291- did: string;
395395+ did?: string;
292396 blob: {
293397 $type: 'blob';
294398 ref: {
···296400 };
297401 };
298402}) {
299299- if (!did || !blob?.ref?.$link) return '';
300300- return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@jpeg`;
403403+ if (!blob || !did) return;
404404+ did ??= user.did;
405405+406406+ return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
301407}
302408409409+/**
410410+ * Searches for actors with typeahead/autocomplete functionality.
411411+ * @param q - The search query
412412+ * @param limit - Maximum number of results (default 10)
413413+ * @param host - The API host to use (defaults to public Bluesky API)
414414+ * @returns An object containing matching actors and the original query
415415+ */
303416export async function searchActorsTypeahead(
304417 q: string,
305418 limit: number = 10,
···322435323436 return { actors: response.data.actors, q };
324437}
438438+439439+/**
440440+ * Return a TID based on current time
441441+ *
442442+ * @returns TID for current time
443443+ */
444444+export function createTID() {
445445+ return TID.now();
446446+}
447447+448448+export async function getAuthorFeed(data?: {
449449+ did?: Did;
450450+ client?: Client;
451451+ filter?: string;
452452+ limit?: number;
453453+ cursor?: string;
454454+}) {
455455+ data ??= {};
456456+ data.did ??= user.did;
457457+458458+ if (!data.did) throw new Error('Error getting detailed profile: no did');
459459+460460+ data.client ??= new Client({
461461+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
462462+ });
463463+464464+ const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
465465+ params: {
466466+ actor: data.did,
467467+ filter: data.filter ?? 'posts_with_media',
468468+ limit: data.limit || 100,
469469+ cursor: data.cursor
470470+ }
471471+ });
472472+473473+ if (!response.ok) return;
474474+475475+ return response.data;
476476+}
477477+478478+/**
479479+ * Fetches posts by their AT URIs.
480480+ * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
481481+ * @param client - The client to use (defaults to public Bluesky API)
482482+ * @returns Array of posts or undefined on failure
483483+ */
484484+export async function getPosts(data: { uris: string[]; client?: Client }) {
485485+ data.client ??= new Client({
486486+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
487487+ });
488488+489489+ const response = await data.client.get('app.bsky.feed.getPosts', {
490490+ params: { uris: data.uris as ResourceUri[] }
491491+ });
492492+493493+ if (!response.ok) return;
494494+495495+ return response.data.posts;
496496+}
497497+498498+export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier {
499499+ if (profile.handle && profile.handle !== 'handle.invalid') {
500500+ return profile.handle;
501501+ } else {
502502+ return profile.did;
503503+ }
504504+}
505505+506506+/**
507507+ * Fetches a post's thread including replies.
508508+ * @param uri - The AT URI of the post
509509+ * @param depth - How many levels of replies to fetch (default 1)
510510+ * @param client - The client to use (defaults to public Bluesky API)
511511+ * @returns The thread data or undefined on failure
512512+ */
513513+export async function getPostThread({
514514+ uri,
515515+ depth = 1,
516516+ client
517517+}: {
518518+ uri: string;
519519+ depth?: number;
520520+ client?: Client;
521521+}) {
522522+ client ??= new Client({
523523+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
524524+ });
525525+526526+ const response = await client.get('app.bsky.feed.getPostThread', {
527527+ params: { uri: uri as ResourceUri, depth }
528528+ });
529529+530530+ if (!response.ok) return;
531531+532532+ return response.data.thread;
533533+}
534534+535535+/**
536536+ * Creates a Bluesky post on the authenticated user's account.
537537+ * @param text - The post text
538538+ * @param facets - Optional rich text facets (links, mentions, etc.)
539539+ * @returns The response containing the post's URI and CID
540540+ * @throws If the user is not logged in
541541+ */
542542+export async function createPost({
543543+ text,
544544+ facets
545545+}: {
546546+ text: string;
547547+ facets?: Array<{
548548+ index: { byteStart: number; byteEnd: number };
549549+ features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>;
550550+ }>;
551551+}) {
552552+ if (!user.client || !user.did) throw new Error('No client or did');
553553+554554+ const record: Record<string, unknown> = {
555555+ $type: 'app.bsky.feed.post',
556556+ text,
557557+ createdAt: new Date().toISOString()
558558+ };
559559+560560+ if (facets) {
561561+ record.facets = facets;
562562+ }
563563+564564+ const response = await user.client.post('com.atproto.repo.createRecord', {
565565+ input: {
566566+ collection: 'app.bsky.feed.post',
567567+ repo: user.did,
568568+ record
569569+ }
570570+ });
571571+572572+ return response;
573573+}
+53-15
src/lib/atproto/settings.ts
···11-export const SITE = 'https://blento.app';
11+import { dev } from '$app/environment';
22+import { env } from '$env/dynamic/public';
2333-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-];
44+export const SITE = env.PUBLIC_DOMAIN;
1351414-export const rpcCalls: Record<string, string | string[]> = {
1515- //'did:web:api.bsky.app#bsky_appview': ['app.bsky.actor.getProfile']
66+type Permissions = {
77+ collections: readonly string[];
88+ rpc: Record<string, string | string[]>;
99+ blobs: readonly string[];
1610};
17111818-export const blobs = ['*/*'] as string | string[] | undefined;
1212+export const permissions = {
1313+ // collections you can create/delete/update
19142020-export const signUpPDS = 'https://pds.rip/';
1515+ // example: only allow create and delete
1616+ // collections: ['xyz.statusphere.status?action=create&action=update'],
1717+ collections: [
1818+ 'app.blento.card',
1919+ 'app.blento.page',
2020+ 'app.blento.settings',
2121+ 'app.blento.comment',
2222+ 'app.blento.guestbook.entry',
2323+ 'app.bsky.feed.post?action=create',
2424+ 'site.standard.publication',
2525+ 'site.standard.document',
2626+ 'xyz.statusphere.status'
2727+ ],
2828+2929+ // what types of authenticated proxied requests you can make to services
3030+3131+ // example: allow authenticated proxying to bsky appview to get a users liked posts
3232+ //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']}
3333+ rpc: {},
3434+3535+ // what types of blobs you can upload to a users PDS
3636+3737+ // example: allowing video and html uploads
3838+ // blobs: ['video/*', 'text/html']
3939+ // example: allowing all blob types
4040+ // blobs: ['*/*']
4141+ blobs: ['*/*']
4242+} as const satisfies Permissions;
4343+4444+// Extract base collection name (before any query params)
4545+type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T;
4646+4747+export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>;
4848+4949+// which PDS to use for signup
5050+// ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week
5151+const devPDS = 'https://pds.rip/';
5252+const prodPDS = 'https://selfhosted.social/';
5353+export const signUpPDS = dev ? devPDS : prodPDS;
5454+5555+// where to redirect after oauth login/signup, e.g. /oauth/callback
5656+export const REDIRECT_PATH = '/oauth/callback';
5757+5858+export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
···55 return getComputedStyle(document.body).getPropertyValue(variable).trim();
66}
7788+export function getHexCSSVar(variable: string) {
99+ return convertCSSToHex(getCSSVar(variable));
1010+}
1111+812/**
913 * Converts a CSS color string to a hue value in the 0-1 range
1014 */
···1519}
16201721export function getHexOfCardColor(item: Item) {
1818- let color =
2222+ const color =
1923 !item.color || item.color === 'transparent' || item.color === 'base' ? 'accent' : item.color;
20242125 return convertCSSToHex(getCSSVar(`--color-${color}-500`));
+36-4
src/lib/cards/index.ts
···33import { BigSocialCardDefinition } from './BigSocialCard';
44import { BlueskyMediaCardDefinition } from './BlueskyMediaCard';
55import { BlueskyPostCardDefinition } from './BlueskyPostCard';
66+import { BlueskyFeedCardDefinition } from './BlueskyFeedCard';
77+import { LatestBlueskyPostCardDefinition } from './LatestBlueskyPostCard';
68import { DinoGameCardDefinition } from './GameCards/DinoGameCard';
79import { EmbedCardDefinition } from './EmbedCard';
810import { TetrisCardDefinition } from './GameCards/TetrisCard';
···1416import { UpdatedBlentosCardDefitition } from './SpecialCards/UpdatedBlentos';
1517import { TextCardDefinition } from './TextCard';
1618import type { CardDefinition } from './types';
1717-import { VideoCardDefinition } from './VideoCard';
1819import { YoutubeCardDefinition } from './YoutubeVideoCard';
1920import { BlueskyProfileCardDefinition } from './BlueskyProfileCard';
2021import { GithubProfileCardDefitition } from './GitHubProfileCard';
···2526import { PhotoGalleryCardDefinition } from './PhotoGalleryCard';
2627import { StandardSiteDocumentListCardDefinition } from './StandardSiteDocumentListCard';
2728import { StatusphereCardDefinition } from './StatusphereCard';
2929+import { EventCardDefinition } from './EventCard';
3030+import { VCardCardDefinition } from './VCardCard';
3131+import { DrawCardDefinition } from './DrawCard';
3232+import { TimerCardDefinition } from './TimerCard';
3333+import { ClockCardDefinition } from './ClockCard';
3434+import { CountdownCardDefinition } from './CountdownCard';
3535+import { SpotifyCardDefinition } from './SpotifyCard';
3636+import { AppleMusicCardDefinition } from './AppleMusicCard';
3737+import { ButtonCardDefinition } from './ButtonCard';
3838+import { GuestbookCardDefinition } from './GuestbookCard';
3939+import { FriendsCardDefinition } from './FriendsCard';
4040+import { GitHubContributorsCardDefinition } from './GitHubContributorsCard';
4141+import { ProductHuntCardDefinition } from './ProductHuntCard';
4242+import { KickstarterCardDefinition } from './KickstarterCard';
4343+// import { Model3DCardDefinition } from './Model3DCard';
28442945export const AllCardDefinitions = [
4646+ GuestbookCardDefinition,
4747+ ButtonCardDefinition,
3048 ImageCardDefinition,
3131- VideoCardDefinition,
3249 TextCardDefinition,
3350 LinkCardDefinition,
3451 BigSocialCardDefinition,
3552 UpdatedBlentosCardDefitition,
3653 YoutubeCardDefinition,
3754 BlueskyPostCardDefinition,
5555+ LatestBlueskyPostCardDefinition,
5656+ BlueskyFeedCardDefinition,
3857 LivestreamCardDefitition,
3958 LivestreamEmbedCardDefitition,
4040- EmbedCardDefinition,
5959+ // EmbedCardDefinition,
4160 MapCardDefinition,
4261 ATProtoCollectionsCardDefinition,
4362 SectionCardDefinition,
···5271 TealFMPlaysCardDefinition,
5372 PhotoGalleryCardDefinition,
5473 StandardSiteDocumentListCardDefinition,
5555- StatusphereCardDefinition
7474+ StatusphereCardDefinition,
7575+ EventCardDefinition,
7676+ VCardCardDefinition,
7777+ DrawCardDefinition,
7878+ TimerCardDefinition,
7979+ ClockCardDefinition,
8080+ CountdownCardDefinition,
8181+ SpotifyCardDefinition,
8282+ AppleMusicCardDefinition,
8383+ // Model3DCardDefinition
8484+ FriendsCardDefinition,
8585+ GitHubContributorsCardDefinition,
8686+ ProductHuntCardDefinition,
8787+ KickstarterCardDefinition
5688] as const;
57895890export const CardDefinitionsByType = AllCardDefinitions.reduce(
+11-8
src/lib/cards/types.ts
···1313 onclose: () => void;
1414};
15151616-export type SidebarComponentProps = {
1717- onclick: () => void;
1818-};
1919-2016export type ContentComponentProps = {
2117 item: Item;
1818+ isEditing?: boolean;
2219};
23202421export type CardDefinition = {
···3128 creationModalComponent?: Component<CreationModalComponentProps>;
32293330 upload?: (item: Item) => Promise<Item>; // optionally upload some other data needed for this card
3434-3535- // one of those two has to be set for a card to appear in the sidebar
3636- sidebarComponent?: Component<SidebarComponentProps>;
3737- sidebarButtonText?: string;
38313932 // if this component exists, a settings button with a popover will be shown containing this component
4033 settingsComponent?: Component<SettingsComponentProps>;
···6962 change?: (item: Item) => Item;
70637164 name?: string;
6565+6666+ canHaveLabel?: boolean;
6767+6868+ migrate?: (item: Item) => void;
6969+7070+ groups?: string[];
7171+7272+ keywords?: string[];
7373+7474+ icon?: string;
7275};
···11-import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core';
22-import { Link } from '@tiptap/extension-link';
33-44-import type { LinkOptions } from '@tiptap/extension-link';
55-66-/**
77- * The input regex for Markdown links with title support, and multiple quotation marks (required
88- * in case the `Typography` extension is being included).
99- */
1010-const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["โ](.+)["โ])?\)$/i;
1111-1212-/**
1313- * The paste regex for Markdown links with title support, and multiple quotation marks (required
1414- * in case the `Typography` extension is being included).
1515- */
1616-const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["โ](.+)["โ])?\)/gi;
1717-1818-/**
1919- * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in
2020- * parentheses (e.g., `(https://doist.dev)`).
2121- *
2222- * @see https://github.com/ueberdosis/tiptap/discussions/1865
2323- */
2424-function linkInputRule(config: Parameters<typeof markInputRule>[0]) {
2525- const defaultMarkInputRule = markInputRule(config);
2626-2727- return new InputRule({
2828- find: config.find,
2929- handler(props) {
3030- const { tr } = props.state;
3131-3232- defaultMarkInputRule.handler(props);
3333- tr.setMeta('preventAutolink', true);
3434- }
3535- });
3636-}
3737-3838-/**
3939- * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in
4040- * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple
4141- * implementations found in a Tiptap discussion at GitHub.
4242- *
4343- * @see https://github.com/ueberdosis/tiptap/discussions/1865
4444- */
4545-function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) {
4646- const defaultMarkPasteRule = markPasteRule(config);
4747-4848- return new PasteRule({
4949- find: config.find,
5050- handler(props) {
5151- const { tr } = props.state;
5252-5353- defaultMarkPasteRule.handler(props);
5454- tr.setMeta('preventAutolink', true);
5555- }
5656- });
5757-}
5858-5959-/**
6060- * The options available to customize the `RichTextLink` extension.
6161- */
6262-type RichTextLinkOptions = LinkOptions;
6363-6464-/**
6565- * Custom extension that extends the built-in `Link` extension to add additional input/paste rules
6666- * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also
6767- * adds support for the `title` attribute.
6868- */
6969-const RichTextLink = Link.extend<RichTextLinkOptions>({
7070- inclusive: false,
7171- addOptions(): LinkOptions {
7272- return {
7373- ...this.parent?.(),
7474- openOnClick: 'whenNotEditable'
7575- } as LinkOptions;
7676- },
7777- addAttributes() {
7878- return {
7979- ...this.parent?.(),
8080- title: {
8181- default: null
8282- }
8383- };
8484- },
8585- addInputRules() {
8686- return [
8787- linkInputRule({
8888- find: inputRegex,
8989- type: this.type,
9090-9191- // We need to use `pop()` to remove the last capture groups from the match to
9292- // satisfy Tiptap's `markPasteRule` expectation of having the content as the last
9393- // capture group in the match (this makes the attribute order important)
9494- getAttributes(match) {
9595- return {
9696- title: match.pop()?.trim(),
9797- href: match.pop()?.trim()
9898- };
9999- }
100100- })
101101- ];
102102- },
103103- addPasteRules() {
104104- return [
105105- linkPasteRule({
106106- find: pasteRegex,
107107- type: this.type,
108108-109109- // We need to use `pop()` to remove the last capture groups from the match to
110110- // satisfy Tiptap's `markInputRule` expectation of having the content as the last
111111- // capture group in the match (this makes the attribute order important)
112112- getAttributes(match) {
113113- return {
114114- title: match.pop()?.trim(),
115115- href: match.pop()?.trim()
116116- };
117117- }
118118- })
119119- ];
120120- }
121121-});
122122-123123-export { RichTextLink };
124124-125125-export type { RichTextLinkOptions };
···11+import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core';
22+import { Link } from '@tiptap/extension-link';
33+44+import type { LinkOptions } from '@tiptap/extension-link';
55+66+/**
77+ * The input regex for Markdown links with title support, and multiple quotation marks (required
88+ * in case the `Typography` extension is being included).
99+ */
1010+const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["โ](.+)["โ])?\)$/i;
1111+1212+/**
1313+ * The paste regex for Markdown links with title support, and multiple quotation marks (required
1414+ * in case the `Typography` extension is being included).
1515+ */
1616+const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["โ](.+)["โ])?\)/gi;
1717+1818+/**
1919+ * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in
2020+ * parentheses (e.g., `(https://doist.dev)`).
2121+ *
2222+ * @see https://github.com/ueberdosis/tiptap/discussions/1865
2323+ */
2424+function linkInputRule(config: Parameters<typeof markInputRule>[0]) {
2525+ const defaultMarkInputRule = markInputRule(config);
2626+2727+ return new InputRule({
2828+ find: config.find,
2929+ handler(props) {
3030+ const { tr } = props.state;
3131+3232+ defaultMarkInputRule.handler(props);
3333+ tr.setMeta('preventAutolink', true);
3434+ }
3535+ });
3636+}
3737+3838+/**
3939+ * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in
4040+ * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple
4141+ * implementations found in a Tiptap discussion at GitHub.
4242+ *
4343+ * @see https://github.com/ueberdosis/tiptap/discussions/1865
4444+ */
4545+function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) {
4646+ const defaultMarkPasteRule = markPasteRule(config);
4747+4848+ return new PasteRule({
4949+ find: config.find,
5050+ handler(props) {
5151+ const { tr } = props.state;
5252+5353+ defaultMarkPasteRule.handler(props);
5454+ tr.setMeta('preventAutolink', true);
5555+ }
5656+ });
5757+}
5858+5959+/**
6060+ * The options available to customize the `RichTextLink` extension.
6161+ */
6262+type RichTextLinkOptions = LinkOptions;
6363+6464+/**
6565+ * Custom extension that extends the built-in `Link` extension to add additional input/paste rules
6666+ * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also
6767+ * adds support for the `title` attribute.
6868+ */
6969+const RichTextLink = Link.extend<RichTextLinkOptions>({
7070+ inclusive: false,
7171+ addOptions(): LinkOptions {
7272+ return {
7373+ ...this.parent?.(),
7474+ openOnClick: 'whenNotEditable'
7575+ } as LinkOptions;
7676+ },
7777+ addAttributes() {
7878+ return {
7979+ ...this.parent?.(),
8080+ title: {
8181+ default: null
8282+ }
8383+ };
8484+ },
8585+ addInputRules() {
8686+ return [
8787+ linkInputRule({
8888+ find: inputRegex,
8989+ type: this.type,
9090+9191+ // We need to use `pop()` to remove the last capture groups from the match to
9292+ // satisfy Tiptap's `markPasteRule` expectation of having the content as the last
9393+ // capture group in the match (this makes the attribute order important)
9494+ getAttributes(match) {
9595+ return {
9696+ title: match.pop()?.trim(),
9797+ href: match.pop()?.trim()
9898+ };
9999+ }
100100+ })
101101+ ];
102102+ },
103103+ addPasteRules() {
104104+ return [
105105+ linkPasteRule({
106106+ find: pasteRegex,
107107+ type: this.type,
108108+109109+ // We need to use `pop()` to remove the last capture groups from the match to
110110+ // satisfy Tiptap's `markInputRule` expectation of having the content as the last
111111+ // capture group in the match (this makes the attribute order important)
112112+ getAttributes(match) {
113113+ return {
114114+ title: match.pop()?.trim(),
115115+ href: match.pop()?.trim()
116116+ };
117117+ }
118118+ })
119119+ ];
120120+ }
121121+});
122122+123123+export { RichTextLink };
124124+125125+export type { RichTextLinkOptions };