your personal website on atproto - mirror blento.app

add semble collection card

Florian 303be860 5ec0e9de

+305 -2
+3 -1
src/lib/cards/index.ts
··· 48 48 import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard'; 49 49 import { PlyrFMCardDefinition } from './media/PlyrFMCard'; 50 50 import { MarginCardDefinition } from './social/MarginCard'; 51 + import { SembleCollectionCardDefinition } from './social/SembleCollectionCard'; 51 52 // import { Model3DCardDefinition } from './visual/Model3DCard'; 52 53 53 54 export const AllCardDefinitions = [ ··· 100 101 LastFMTopAlbumsCardDefinition, 101 102 LastFMProfileCardDefinition, 102 103 PlyrFMCardDefinition, 103 - MarginCardDefinition 104 + MarginCardDefinition, 105 + SembleCollectionCardDefinition 104 106 ] as const; 105 107 106 108 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+3 -1
src/lib/cards/social/MarginCard/MarginCard.svelte
··· 137 137 {/if} 138 138 139 139 {#if entry.type === 'annotation' && entry.value.body?.value} 140 - <span class="text-base-900 dark:text-base-100 accent:text-black text-sm leading-snug font-medium"> 140 + <span 141 + class="text-base-900 dark:text-base-100 accent:text-black text-sm leading-snug font-medium" 142 + > 141 143 {truncate(entry.value.body.value as string, 120)} 142 144 </span> 143 145 {/if}
+43
src/lib/cards/social/SembleCollectionCard/CreateSembleCollectionCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let url = $state(''); 8 + let errorMessage = $state(''); 9 + 10 + function checkUrl() { 11 + errorMessage = ''; 12 + const match = url.match(/^https?:\/\/semble\.so\/profile\/([^/]+)\/collections\/([a-z0-9]+)$/); 13 + if (!match) { 14 + errorMessage = 'Please enter a valid Semble collection URL.'; 15 + return false; 16 + } 17 + 18 + item.cardData.handle = match[1]; 19 + item.cardData.collectionRkey = match[2]; 20 + item.cardData.href = url; 21 + return true; 22 + } 23 + </script> 24 + 25 + <Modal open={true} closeButton={false}> 26 + <Subheading>Enter a Semble collection URL</Subheading> 27 + <Input bind:value={url} placeholder="https://semble.so/profile/.../collections/..." /> 28 + 29 + {#if errorMessage} 30 + <Alert type="error" title="Invalid URL"><span>{errorMessage}</span></Alert> 31 + {/if} 32 + 33 + <div class="mt-4 flex justify-end gap-2"> 34 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 35 + <Button 36 + onclick={() => { 37 + if (checkUrl()) oncreate(); 38 + }} 39 + > 40 + Create 41 + </Button> 42 + </div> 43 + </Modal>
+123
src/lib/cards/social/SembleCollectionCard/SembleCollectionCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 + import { CardDefinitionsByType } from '../..'; 6 + import type { SembleCollectionData } from './index'; 7 + 8 + let { item }: { item: Item } = $props(); 9 + 10 + const additionalData = getAdditionalUserData(); 11 + let did = getDidContext(); 12 + let handle = getHandleContext(); 13 + 14 + let key = $derived(`${item.cardData.handle}/${item.cardData.collectionRkey}`); 15 + 16 + // svelte-ignore state_referenced_locally 17 + let collectionData = $state( 18 + additionalData[item.cardType] != null 19 + ? (additionalData[item.cardType] as Record<string, SembleCollectionData>)[key] 20 + : undefined 21 + ); 22 + 23 + onMount(async () => { 24 + if (!collectionData) { 25 + const result = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 26 + did, 27 + handle 28 + })) as Record<string, SembleCollectionData>; 29 + 30 + if (result) { 31 + additionalData[item.cardType] = { 32 + ...((additionalData[item.cardType] as Record<string, SembleCollectionData>) ?? {}), 33 + ...result 34 + }; 35 + collectionData = result[key]; 36 + } 37 + } 38 + }); 39 + 40 + function getDisplayUrl(url: string) { 41 + try { 42 + const u = new URL(url); 43 + return u.hostname + (u.pathname !== '/' ? u.pathname : ''); 44 + } catch { 45 + return url; 46 + } 47 + } 48 + 49 + function truncate(text: string, max: number) { 50 + if (text.length <= max) return text; 51 + return text.slice(0, max) + '…'; 52 + } 53 + </script> 54 + 55 + <div class={['flex h-full flex-col overflow-y-auto px-5 py-4', item.cardData.label ? 'pt-12' : '']}> 56 + {#if collectionData} 57 + <div class="mb-3 flex flex-col gap-1"> 58 + <h3 class="text-base-900 dark:text-base-100 accent:text-black text-sm font-semibold"> 59 + {collectionData.name} 60 + </h3> 61 + {#if collectionData.description} 62 + <p class="text-base-500 dark:text-base-400 accent:text-black/60 text-xs"> 63 + {collectionData.description} 64 + </p> 65 + {/if} 66 + </div> 67 + 68 + {#if collectionData.cards.length > 0} 69 + <div class="flex flex-col gap-3"> 70 + {#each collectionData.cards as card (card.uri)} 71 + {#if card.type === 'URL' && card.url} 72 + <a 73 + href={card.url} 74 + target="_blank" 75 + rel="noopener noreferrer" 76 + class="bg-base-100 dark:bg-base-800 accent:bg-black/10 hover:bg-base-200 dark:hover:bg-base-700 accent:hover:bg-black/15 flex flex-col gap-1.5 rounded-xl px-5 py-3 transition-colors" 77 + > 78 + {#if card.title} 79 + <span 80 + class="text-base-900 dark:text-base-100 accent:text-black text-sm leading-snug font-medium" 81 + > 82 + {truncate(card.title, 80)} 83 + </span> 84 + {/if} 85 + {#if card.description} 86 + <span 87 + class="text-base-600 dark:text-base-400 accent:text-black/70 text-xs leading-snug" 88 + > 89 + {truncate(card.description, 120)} 90 + </span> 91 + {/if} 92 + <span class="text-base-400 dark:text-base-500 accent:text-black/60 truncate text-xs"> 93 + {getDisplayUrl(card.url)} 94 + </span> 95 + </a> 96 + {:else if card.type === 'NOTE' && card.text} 97 + <div 98 + class="bg-base-100 dark:bg-base-800 accent:bg-black/10 flex flex-col gap-1.5 rounded-xl px-5 py-3" 99 + > 100 + <span 101 + class="text-base-700 dark:text-base-300 accent:text-black/80 border-accent-500 accent:border-black/60 border-l-2 pl-3 text-sm leading-snug italic" 102 + > 103 + {truncate(card.text, 200)} 104 + </span> 105 + </div> 106 + {/if} 107 + {/each} 108 + </div> 109 + {:else} 110 + <div 111 + class="text-base-500 dark:text-base-400 accent:text-black/60 flex flex-1 items-center justify-center text-center text-sm" 112 + > 113 + No cards in this collection yet. 114 + </div> 115 + {/if} 116 + {:else} 117 + <div 118 + class="text-base-500 dark:text-base-400 accent:text-black/60 flex h-full w-full items-center justify-center text-center text-sm" 119 + > 120 + Loading... 121 + </div> 122 + {/if} 123 + </div>
+133
src/lib/cards/social/SembleCollectionCard/index.ts
··· 1 + import type { CardDefinition } from '../../types'; 2 + import { listRecords, getRecord, resolveHandle } from '$lib/atproto'; 3 + import SembleCollectionCard from './SembleCollectionCard.svelte'; 4 + import CreateSembleCollectionCardModal from './CreateSembleCollectionCardModal.svelte'; 5 + 6 + export type SembleCard = { 7 + uri: string; 8 + type: 'URL' | 'NOTE'; 9 + url?: string; 10 + title?: string; 11 + description?: string; 12 + imageUrl?: string; 13 + siteName?: string; 14 + text?: string; 15 + createdAt?: string; 16 + }; 17 + 18 + export type SembleCollectionData = { 19 + name: string; 20 + description?: string; 21 + cards: SembleCard[]; 22 + }; 23 + 24 + function parseSembleUrl(url: string) { 25 + const match = url.match(/^https?:\/\/semble\.so\/profile\/([^/]+)\/collections\/([a-z0-9]+)$/); 26 + if (!match) return null; 27 + return { handle: match[1], rkey: match[2] }; 28 + } 29 + 30 + async function loadCollectionData( 31 + handle: string, 32 + collectionRkey: string 33 + ): Promise<SembleCollectionData | undefined> { 34 + const did = await resolveHandle({ handle: handle as `${string}.${string}` }); 35 + if (!did) return undefined; 36 + 37 + const collectionUri = `at://${did}/network.cosmik.collection/${collectionRkey}`; 38 + 39 + const [collection, allLinks, allCards] = await Promise.all([ 40 + getRecord({ 41 + did, 42 + collection: 'network.cosmik.collection', 43 + rkey: collectionRkey 44 + }).catch(() => undefined), 45 + listRecords({ did, collection: 'network.cosmik.collectionLink' }).catch(() => []), 46 + listRecords({ did, collection: 'network.cosmik.card' }).catch(() => []) 47 + ]); 48 + 49 + if (!collection) return undefined; 50 + 51 + const linkedCardUris = new Set( 52 + allLinks 53 + .filter((link: any) => link.value.collection?.uri === collectionUri) 54 + .map((link: any) => link.value.card?.uri) 55 + ); 56 + 57 + const cards: SembleCard[] = allCards 58 + .filter((card: any) => linkedCardUris.has(card.uri)) 59 + .map((card: any) => { 60 + const v = card.value; 61 + const content = v.content; 62 + if (v.type === 'URL') { 63 + return { 64 + uri: card.uri, 65 + type: 'URL' as const, 66 + url: content?.url, 67 + title: content?.metadata?.title, 68 + description: content?.metadata?.description, 69 + imageUrl: content?.metadata?.imageUrl, 70 + siteName: content?.metadata?.siteName, 71 + createdAt: v.createdAt 72 + }; 73 + } 74 + return { 75 + uri: card.uri, 76 + type: 'NOTE' as const, 77 + text: content?.text, 78 + createdAt: v.createdAt 79 + }; 80 + }); 81 + 82 + return { 83 + name: collection.value.name as string, 84 + description: collection.value.description as string | undefined, 85 + cards 86 + }; 87 + } 88 + 89 + export const SembleCollectionCardDefinition = { 90 + type: 'sembleCollection', 91 + contentComponent: SembleCollectionCard, 92 + creationModalComponent: CreateSembleCollectionCardModal, 93 + createNew: (card) => { 94 + card.w = 4; 95 + card.mobileW = 8; 96 + card.h = 4; 97 + card.mobileH = 6; 98 + }, 99 + loadData: async (items) => { 100 + const results: Record<string, SembleCollectionData> = {}; 101 + for (const item of items) { 102 + const handle = item.cardData.handle; 103 + const rkey = item.cardData.collectionRkey; 104 + if (!handle || !rkey) continue; 105 + try { 106 + const data = await loadCollectionData(handle, rkey); 107 + if (data) results[`${handle}/${rkey}`] = data; 108 + } catch { 109 + // skip failed fetches 110 + } 111 + } 112 + return results; 113 + }, 114 + onUrlHandler: (url, item) => { 115 + const parsed = parseSembleUrl(url); 116 + if (!parsed) return null; 117 + item.cardData.handle = parsed.handle; 118 + item.cardData.collectionRkey = parsed.rkey; 119 + item.cardData.href = url; 120 + item.w = 4; 121 + item.mobileW = 8; 122 + item.h = 4; 123 + item.mobileH = 6; 124 + return item; 125 + }, 126 + urlHandlerPriority: 5, 127 + minH: 2, 128 + 129 + keywords: ['semble', 'collection', 'bookmarks', 'links', 'cards', 'cosmik'], 130 + groups: ['Social'], 131 + name: 'Semble Collection', 132 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>` 133 + } as CardDefinition & { type: 'sembleCollection' };