···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;
8181-8282- if (!data.did) throw new Error('Error getting detailed profile: no did');
8383-8484- 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- });
104104+export async function getBlentoOrBskyProfile(data: { did: Did; client?: Client }): Promise<
105105+ Awaited<ReturnType<typeof getDetailedProfile>> & {
106106+ hasBlento: boolean;
107107+ url?: string;
108108+ }
109109+> {
110110+ let blentoProfile;
111111+ try {
112112+ // try getting blento profile first
113113+ blentoProfile = await getRecord({
114114+ collection: 'site.standard.publication',
115115+ did: data?.did,
116116+ rkey: 'blento.self',
117117+ client: data?.client
118118+ });
119119+ } catch {
120120+ console.error('error getting blento profile, falling back to bsky profile');
121121+ }
911229292- if (!response.ok) return;
123123+ const response = await getDetailedProfile(data);
931249494- return response.data;
125125+ return {
126126+ did: data.did,
127127+ handle: response?.handle,
128128+ displayName: blentoProfile?.value?.name || response?.displayName || response?.handle,
129129+ avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) ||
130130+ response?.avatar) as `${string}:${string}`,
131131+ hasBlento: Boolean(blentoProfile.value),
132132+ url: blentoProfile?.value?.url as string | undefined
133133+ };
95134}
96135136136+/**
137137+ * Creates an AT Protocol client for a user's PDS.
138138+ * @param did - The DID of the user
139139+ * @returns A client configured for the user's PDS
140140+ * @throws If the PDS cannot be found
141141+ */
97142export async function getClient({ did }: { did: Did }) {
98143 const pds = await getPDS(did);
99144 if (!pds) throw new Error('PDS not found');
···105150 return client;
106151}
107152153153+/**
154154+ * Lists records from a repository collection with pagination support.
155155+ * @param did - The DID of the repository (defaults to current user)
156156+ * @param collection - The collection to list records from
157157+ * @param cursor - Pagination cursor for continuing from a previous request
158158+ * @param limit - Maximum number of records to return (default 100, set to 0 for all records)
159159+ * @param client - The client to use (defaults to user's PDS client)
160160+ * @returns An array of records from the collection
161161+ */
108162export async function listRecords({
109163 did,
110164 collection,
111165 cursor,
112112- limit = 0,
166166+ limit = 100,
113167 client
114168}: {
115169 did?: Did;
···136190 params: {
137191 repo: did,
138192 collection,
139139- limit: limit || 100,
193193+ limit: !limit || limit > 100 ? 100 : limit,
140194 cursor: currentCursor
141195 }
142196 });
···152206 return allRecords;
153207}
154208209209+/**
210210+ * Fetches a single record from a repository.
211211+ * @param did - The DID of the repository (defaults to current user)
212212+ * @param collection - The collection the record belongs to
213213+ * @param rkey - The record key (defaults to "self")
214214+ * @param client - The client to use (defaults to user's PDS client)
215215+ * @returns The record data
216216+ */
155217export async function getRecord({
156218 did,
157219 collection,
158158- rkey,
220220+ rkey = 'self',
159221 client
160222}: {
161223 did?: Did;
···164226 client?: Client;
165227}) {
166228 did ??= user.did;
167167- rkey ??= 'self';
168229169230 if (!collection) {
170231 throw new Error('Missing parameters for getRecord');
···186247 return JSON.parse(JSON.stringify(record.data));
187248}
188249250250+/**
251251+ * Creates or updates a record in the current user's repository.
252252+ * Only accepts collections that are configured in permissions.
253253+ * @param collection - The collection to write to (must be in permissions.collections)
254254+ * @param rkey - The record key (defaults to "self")
255255+ * @param record - The record data to write
256256+ * @returns The response from the PDS
257257+ * @throws If the user is not logged in
258258+ */
189259export async function putRecord({
190260 collection,
191191- rkey,
261261+ rkey = 'self',
192262 record
193263}: {
194194- collection: Collection;
195195- rkey: string;
264264+ collection: AllowedCollection;
265265+ rkey?: string;
196266 record: Record<string, unknown>;
197267}) {
198268 if (!user.client || !user.did) throw new Error('No rpc or did');
···211281 return response;
212282}
213283214214-export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) {
284284+/**
285285+ * Deletes a record from the current user's repository.
286286+ * Only accepts collections that are configured in permissions.
287287+ * @param collection - The collection the record belongs to (must be in permissions.collections)
288288+ * @param rkey - The record key (defaults to "self")
289289+ * @returns True if the deletion was successful
290290+ * @throws If the user is not logged in
291291+ */
292292+export async function deleteRecord({
293293+ collection,
294294+ rkey = 'self'
295295+}: {
296296+ collection: AllowedCollection;
297297+ rkey: string;
298298+}) {
215299 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
216300217301 const response = await user.client.post('com.atproto.repo.deleteRecord', {
···225309 return response.ok;
226310}
227311312312+/**
313313+ * Uploads a blob to the current user's PDS.
314314+ * @param blob - The blob data to upload
315315+ * @returns The blob metadata including ref, mimeType, and size, or undefined on failure
316316+ * @throws If the user is not logged in
317317+ */
228318export async function uploadBlob({ blob }: { blob: Blob }) {
229319 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
230320231321 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
232232- input: blob,
233233- data: {
322322+ params: {
234323 repo: user.did
235235- }
324324+ },
325325+ input: blob
236326 });
237327238238- if (!blobResponse?.ok) {
239239- return;
240240- }
328328+ if (!blobResponse?.ok) return;
241329242330 const blobInfo = blobResponse?.data.blob as {
243331 $type: 'blob';
···251339 return blobInfo;
252340}
253341342342+/**
343343+ * Gets metadata about a repository.
344344+ * @param client - The client to use
345345+ * @param did - The DID of the repository (defaults to current user)
346346+ * @returns Repository metadata or undefined on failure
347347+ */
254348export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
255349 did ??= user.did;
256350 if (!did) {
···268362 return repo.data;
269363}
270364365365+/**
366366+ * Constructs a URL to fetch a blob directly from a user's PDS.
367367+ * @param did - The DID of the user who owns the blob
368368+ * @param blob - The blob reference object
369369+ * @returns The URL to fetch the blob
370370+ */
271371export async function getBlobURL({
272372 did,
273373 blob
···284384 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
285385}
286386287287-export function getImageBlobUrl({
387387+/**
388388+ * Constructs a Bluesky CDN URL for an image blob.
389389+ * @param did - The DID of the user who owns the blob (defaults to current user)
390390+ * @param blob - The blob reference object
391391+ * @returns The CDN URL for the image in webp format
392392+ */
393393+export function getCDNImageBlobUrl({
288394 did,
289395 blob
290396}: {
291291- did: string;
397397+ did?: string;
292398 blob: {
293399 $type: 'blob';
294400 ref: {
···296402 };
297403 };
298404}) {
299299- if (!did || !blob?.ref?.$link) return '';
300300- return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@jpeg`;
405405+ if (!blob || !did) return;
406406+ did ??= user.did;
407407+408408+ return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
301409}
302410411411+/**
412412+ * Searches for actors with typeahead/autocomplete functionality.
413413+ * @param q - The search query
414414+ * @param limit - Maximum number of results (default 10)
415415+ * @param host - The API host to use (defaults to public Bluesky API)
416416+ * @returns An object containing matching actors and the original query
417417+ */
303418export async function searchActorsTypeahead(
304419 q: string,
305420 limit: number = 10,
···322437323438 return { actors: response.data.actors, q };
324439}
440440+441441+/**
442442+ * Return a TID based on current time
443443+ *
444444+ * @returns TID for current time
445445+ */
446446+export function createTID() {
447447+ return TID.now();
448448+}
449449+450450+export async function getAuthorFeed(data?: {
451451+ did?: Did;
452452+ client?: Client;
453453+ filter?: string;
454454+ limit?: number;
455455+ cursor?: string;
456456+}) {
457457+ data ??= {};
458458+ data.did ??= user.did;
459459+460460+ if (!data.did) throw new Error('Error getting detailed profile: no did');
461461+462462+ data.client ??= new Client({
463463+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
464464+ });
465465+466466+ const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
467467+ params: {
468468+ actor: data.did,
469469+ filter: data.filter ?? 'posts_with_media',
470470+ limit: data.limit || 100,
471471+ cursor: data.cursor
472472+ }
473473+ });
474474+475475+ if (!response.ok) return;
476476+477477+ return response.data;
478478+}
479479+480480+/**
481481+ * Fetches posts by their AT URIs.
482482+ * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
483483+ * @param client - The client to use (defaults to public Bluesky API)
484484+ * @returns Array of posts or undefined on failure
485485+ */
486486+export async function getPosts(data: { uris: string[]; client?: Client }) {
487487+ data.client ??= new Client({
488488+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
489489+ });
490490+491491+ const response = await data.client.get('app.bsky.feed.getPosts', {
492492+ params: { uris: data.uris as ResourceUri[] }
493493+ });
494494+495495+ if (!response.ok) return;
496496+497497+ return response.data.posts;
498498+}
499499+500500+export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier {
501501+ if (profile.handle && profile.handle !== 'handle.invalid') {
502502+ return profile.handle;
503503+ } else {
504504+ return profile.did;
505505+ }
506506+}
507507+508508+/**
509509+ * Fetches a post's thread including replies.
510510+ * @param uri - The AT URI of the post
511511+ * @param depth - How many levels of replies to fetch (default 1)
512512+ * @param client - The client to use (defaults to public Bluesky API)
513513+ * @returns The thread data or undefined on failure
514514+ */
515515+export async function getPostThread({
516516+ uri,
517517+ depth = 1,
518518+ client
519519+}: {
520520+ uri: string;
521521+ depth?: number;
522522+ client?: Client;
523523+}) {
524524+ client ??= new Client({
525525+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
526526+ });
527527+528528+ const response = await client.get('app.bsky.feed.getPostThread', {
529529+ params: { uri: uri as ResourceUri, depth }
530530+ });
531531+532532+ if (!response.ok) return;
533533+534534+ return response.data.thread;
535535+}
536536+537537+/**
538538+ * Creates a Bluesky post on the authenticated user's account.
539539+ * @param text - The post text
540540+ * @param facets - Optional rich text facets (links, mentions, etc.)
541541+ * @returns The response containing the post's URI and CID
542542+ * @throws If the user is not logged in
543543+ */
544544+export async function createPost({
545545+ text,
546546+ facets
547547+}: {
548548+ text: string;
549549+ facets?: Array<{
550550+ index: { byteStart: number; byteEnd: number };
551551+ features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>;
552552+ }>;
553553+}) {
554554+ if (!user.client || !user.did) throw new Error('No client or did');
555555+556556+ const record: Record<string, unknown> = {
557557+ $type: 'app.bsky.feed.post',
558558+ text,
559559+ createdAt: new Date().toISOString()
560560+ };
561561+562562+ if (facets) {
563563+ record.facets = facets;
564564+ }
565565+566566+ const response = await user.client.post('com.atproto.repo.createRecord', {
567567+ input: {
568568+ collection: 'app.bsky.feed.post',
569569+ repo: user.did,
570570+ record
571571+ }
572572+ });
573573+574574+ return response;
575575+}
+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 };