your personal website on atproto - mirror blento.app

Merge pull request #190 from flo-bit/moar-cards

Moar cards

authored by

Florian and committed by
GitHub
8fab21c0 1bc6705d

+669 -2
+5 -1
src/lib/cards/index.ts
··· 47 47 import { LastFMTopAlbumsCardDefinition } from './media/LastFMCard/LastFMTopAlbumsCard'; 48 48 import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard'; 49 49 import { PlyrFMCardDefinition } from './media/PlyrFMCard'; 50 + import { MarginCardDefinition } from './social/MarginCard'; 51 + import { SembleCollectionCardDefinition } from './social/SembleCollectionCard'; 50 52 // import { Model3DCardDefinition } from './visual/Model3DCard'; 51 53 52 54 export const AllCardDefinitions = [ ··· 98 100 LastFMTopTracksCardDefinition, 99 101 LastFMTopAlbumsCardDefinition, 100 102 LastFMProfileCardDefinition, 101 - PlyrFMCardDefinition 103 + PlyrFMCardDefinition, 104 + MarginCardDefinition, 105 + SembleCollectionCardDefinition 102 106 ] as const; 103 107 104 108 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+55 -1
src/lib/cards/social/BigSocialCard/index.ts
··· 169 169 170 170 mail: /(?:mailto:)/i, 171 171 172 - npmx: /(?:npmx\.dev)/i 172 + npmx: /(?:npmx\.dev)/i, 173 + 174 + roomy: /(?:roomy\.space)/i, 175 + 176 + blacksky: /(?:blacksky\.community)/i 173 177 }; 174 178 175 179 export const platformsData: Record<string, SimpleIcon> = { ··· 302 306 </clipPath> 303 307 </defs> 304 308 </svg>` 309 + }, 310 + 311 + blacksky: { 312 + slug: 'blacksy', 313 + path: '', 314 + title: 'blacksy', 315 + source: '', 316 + svg: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 317 + <g clip-path="url(#clip0_6_12)"> 318 + <g clip-path="url(#clip1_6_12)"> 319 + <mask id="mask0_6_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="2" width="24" height="20"> 320 + <path d="M24 2H0V22H24V2Z" fill="white"/> 321 + </mask> 322 + <g mask="url(#mask0_6_12)"> 323 + <path d="M12.5344 13.8981C12.5344 15.1481 13.5712 16.1615 14.8502 16.1615H17.4326V17.2975H14.8502C13.5712 17.2975 12.5344 18.3109 12.5344 19.5609V22.0037H11.4551V19.5609C11.4551 18.3109 10.4183 17.2975 9.13929 17.2975H6.55692V16.1615H9.13929C10.4183 16.1615 11.4551 15.1481 11.4551 13.8981V11.3742H12.5344V13.8981Z" fill="#F8FAF9"/> 324 + <path d="M14.3955 4.62359C13.4911 5.50749 13.4911 6.94058 14.3955 7.82449L16.2216 9.60919L15.3997 10.4125L13.5736 8.62778C12.6692 7.74388 11.2029 7.74388 10.2986 8.62778L8.53129 10.3551L7.76805 9.60913L9.53533 7.88183C10.4397 6.99793 10.4397 5.56484 9.53533 4.68093L7.70937 2.89629L8.53129 2.09299L10.3573 3.8777C11.2616 4.76159 12.7279 4.7616 13.6323 3.8777L15.4584 2.09299L16.2216 2.83889L14.3955 4.62359Z" fill="#F8FAF9"/> 325 + <path d="M6.65706 8.19966C6.32603 9.40709 7.05917 10.6482 8.29457 10.9717L10.789 11.6249L10.4882 12.7222L7.99383 12.069C6.75844 11.7454 5.48861 12.462 5.15759 13.6695L4.51068 16.0291L3.4681 15.7561L4.11498 13.3965C4.44601 12.1891 3.71286 10.948 2.47747 10.6244L-0.0169373 9.97117L0.283897 8.87385L2.7783 9.52709C4.0137 9.85063 5.28353 9.13407 5.61455 7.92665L6.28293 5.4887L7.32543 5.76171L6.65706 8.19966Z" fill="#F8FAF9"/> 326 + <path d="M18.3927 7.87843C18.7237 9.08584 19.9935 9.8024 21.229 9.47887L23.7234 8.82562L24.0242 9.92286L21.5298 10.5761C20.2943 10.8997 19.5613 12.1407 19.8922 13.3481L20.5391 15.7078L19.4966 15.9808L18.8498 13.6212C18.5187 12.4138 17.2489 11.6973 16.0135 12.0208L13.5191 12.6741L13.2183 11.5767L15.7127 10.9235C16.948 10.6 17.6812 9.35887 17.3502 8.15144L16.6818 5.71349L17.7243 5.44048L18.3927 7.87843Z" fill="#F8FAF9"/> 327 + </g> 328 + </g> 329 + </g> 330 + <defs> 331 + <clipPath id="clip0_6_12"> 332 + <rect width="24" height="24" fill="white"/> 333 + </clipPath> 334 + <clipPath id="clip1_6_12"> 335 + <rect width="24" height="20" fill="white" transform="translate(0 2)"/> 336 + </clipPath> 337 + </defs> 338 + </svg>`, 339 + hex: '080B0B' 340 + }, 341 + roomy: { 342 + slug: 'roomy', 343 + path: '', 344 + title: 'roomy', 345 + source: '', 346 + hex: 'FFFFFF', 347 + svg: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 348 + <path d="M5.43299 3.81932C8.28153 2.15962 12.2574 1.88862 15.9212 2.03406C18.0275 2.11767 19.5497 2.91979 20.5954 4.13692C21.6385 5.35126 22.2016 6.97176 22.4076 8.68652C22.6137 10.402 22.4633 12.2187 22.073 13.833C21.6831 15.4458 21.0516 16.8651 20.2886 17.781C17.2509 21.4271 13.2574 21.1504 10.152 20.82C8.59507 20.6543 6.70796 20.3097 5.09939 19.3146C3.48607 18.3166 2.1606 16.6693 1.72185 13.9167C1.29432 11.2344 1.50444 9.12701 2.18254 7.47984C2.8612 5.83136 4.00571 4.65094 5.43299 3.81932Z" fill="black" stroke="black" stroke-width="2" stroke-linecap="round"/> 349 + <mask id="mask0_6_30" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="1" width="24" height="21"> 350 + <path d="M5.57337 4.07788C8.32168 2.45794 12.1798 2.18491 15.7762 2.32933C17.7949 2.4104 19.24 3.18517 20.2317 4.35298C21.2257 5.52357 21.7701 7.09603 21.9698 8.77821C22.1695 10.4597 22.0239 12.2441 21.6448 13.8306C21.2652 15.4187 20.6538 16.8002 19.9284 17.6811C17.0122 21.2221 13.1866 20.963 10.1341 20.6344C8.61202 20.4706 6.793 20.1315 5.24976 19.1658C3.71115 18.2029 2.43916 16.6125 2.01619 13.9281C1.59961 11.2841 1.80725 9.22452 2.45764 7.62633C3.10749 6.02946 4.20219 4.8861 5.57337 4.07788Z" fill="black" stroke="black" stroke-width="2" stroke-linecap="round"/> 351 + </mask> 352 + <g mask="url(#mask0_6_30)"> 353 + <path d="M6.69589 10.5899C6.31679 13.9691 7.31745 17.8454 7.50663 21.0561L13.4209 21.7132L18.1425 20.1174C18.3274 17.0219 19.4036 5.8267 14.3655 5.66686C11.4505 5.57438 7.15923 6.45968 6.69589 10.5899Z" fill="white"/> 354 + <path d="M12.8204 7.33851C16.994 8.21772 15.9101 18.6466 15.9101 21.1724V23.5778L7.92337 23.161L7.92338 20.6862C7.78514 15.827 4.13767 5.85098 12.8204 7.33851Z" fill="black"/> 355 + <path d="M14.832 14.4676C15.1228 13.5864 14.2866 12.8589 13.6124 13.2869C12.4409 14.0306 14.4237 15.7051 14.832 14.4676Z" fill="white"/> 356 + </g> 357 + </svg> 358 + ` 305 359 } 306 360 }; 307 361
+185
src/lib/cards/social/MarginCard/MarginCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { 5 + getAdditionalUserData, 6 + getCanEdit, 7 + getDidContext, 8 + getHandleContext 9 + } from '$lib/website/context'; 10 + import { CardDefinitionsByType } from '../..'; 11 + import type { MarginEntry } from './index'; 12 + import { Button } from '@foxui/core'; 13 + 14 + let { item }: { item: Item } = $props(); 15 + 16 + const data = getAdditionalUserData(); 17 + // svelte-ignore state_referenced_locally 18 + let entries = $state(data[item.cardType] as MarginEntry[] | undefined); 19 + 20 + let did = getDidContext(); 21 + let handle = getHandleContext(); 22 + 23 + onMount(async () => { 24 + if (!entries) { 25 + entries = (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 26 + did, 27 + handle 28 + })) as MarginEntry[]; 29 + 30 + data[item.cardType] = entries; 31 + } 32 + }); 33 + 34 + let canEdit = getCanEdit(); 35 + 36 + let filtered = $derived( 37 + entries?.filter((e) => { 38 + if (e.type === 'bookmark' && item.cardData.showBookmarks === false) return false; 39 + if (e.type === 'annotation' && item.cardData.showAnnotations === false) return false; 40 + if (e.type === 'highlight' && item.cardData.showHighlights === false) return false; 41 + return true; 42 + }) 43 + ); 44 + 45 + function getMarginUrl(entry: MarginEntry) { 46 + const rkey = entry.uri.split('/').pop(); 47 + return `https://margin.at/${handle}/${entry.type}/${rkey}`; 48 + } 49 + 50 + function getDisplayUrl(url: string) { 51 + try { 52 + const u = new URL(url); 53 + return u.hostname + (u.pathname !== '/' ? u.pathname : ''); 54 + } catch { 55 + return url; 56 + } 57 + } 58 + 59 + function truncate(text: string, max: number) { 60 + if (text.length <= max) return text; 61 + return text.slice(0, max) + '…'; 62 + } 63 + </script> 64 + 65 + <div class={['flex h-full flex-col overflow-y-auto px-5 py-4', item.cardData.label ? 'pt-12' : '']}> 66 + {#if filtered && filtered.length > 0} 67 + <div class="flex flex-col gap-3"> 68 + {#each filtered as entry (entry.uri)} 69 + {@const source = 70 + entry.type === 'bookmark' ? entry.value.source : entry.value.target?.source} 71 + <a 72 + href={getMarginUrl(entry)} 73 + target="_blank" 74 + rel="noopener noreferrer" 75 + 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" 76 + > 77 + <div class="flex items-center gap-2 pb-1"> 78 + {#if entry.type === 'bookmark'} 79 + <svg 80 + xmlns="http://www.w3.org/2000/svg" 81 + fill="none" 82 + viewBox="0 0 24 24" 83 + stroke-width="2" 84 + stroke="currentColor" 85 + class="text-accent-500 accent:text-black size-3.5 shrink-0" 86 + > 87 + <path 88 + stroke-linecap="round" 89 + stroke-linejoin="round" 90 + d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" 91 + /> 92 + </svg> 93 + {:else if entry.type === 'annotation'} 94 + <svg 95 + xmlns="http://www.w3.org/2000/svg" 96 + fill="none" 97 + viewBox="0 0 24 24" 98 + stroke-width="2" 99 + stroke="currentColor" 100 + class="text-accent-500 accent:text-black size-3.5 shrink-0" 101 + > 102 + <path 103 + stroke-linecap="round" 104 + stroke-linejoin="round" 105 + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" 106 + /> 107 + </svg> 108 + {:else} 109 + <svg 110 + xmlns="http://www.w3.org/2000/svg" 111 + viewBox="0 0 24 24" 112 + fill="none" 113 + stroke="currentColor" 114 + stroke-width="2" 115 + stroke-linecap="round" 116 + stroke-linejoin="round" 117 + class="text-accent-500 accent:text-black size-3.5 shrink-0" 118 + > 119 + <path d="m9 11-6 6v3h9l3-3" /> 120 + <path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" /> 121 + </svg> 122 + {/if} 123 + <span class="text-base-500 dark:text-base-400 accent:text-black/80 text-xs capitalize" 124 + >{entry.type}</span 125 + > 126 + <span class="text-base-400 dark:text-base-500 accent:text-black/60 ml-auto text-xs"> 127 + {new Date(entry.createdAt).toLocaleDateString()} 128 + </span> 129 + </div> 130 + 131 + {#if entry.type === 'bookmark' && entry.value.title} 132 + <span 133 + class="text-base-900 dark:text-base-100 accent:text-black text-sm leading-snug font-medium" 134 + > 135 + {truncate(entry.value.title as string, 80)} 136 + </span> 137 + {/if} 138 + 139 + {#if entry.type === 'annotation' && entry.value.body?.value} 140 + <span 141 + class="text-base-900 dark:text-base-100 accent:text-black text-sm leading-snug font-medium" 142 + > 143 + {truncate(entry.value.body.value as string, 120)} 144 + </span> 145 + {/if} 146 + 147 + {#if entry.type === 'highlight' && entry.value.target?.selector} 148 + {@const selectors = Array.isArray(entry.value.target.selector) 149 + ? entry.value.target.selector 150 + : [entry.value.target.selector]} 151 + {@const quote = selectors.find((s: any) => s.exact)?.exact} 152 + {#if quote} 153 + <span 154 + 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" 155 + > 156 + {truncate(quote as string, 120)} 157 + </span> 158 + {/if} 159 + {/if} 160 + 161 + {#if source} 162 + <span class="text-base-400 dark:text-base-500 accent:text-black/60 truncate text-xs"> 163 + {getDisplayUrl(source as string)} 164 + </span> 165 + {/if} 166 + </a> 167 + {/each} 168 + </div> 169 + {:else if filtered} 170 + <div class="flex h-full w-full flex-col items-center justify-center gap-4 text-center text-sm"> 171 + No margin entries yet. 172 + {#if canEdit()} 173 + <Button href="https://margin.at" target="_blank" rel="noopener noreferrer"> 174 + Try Margin 175 + </Button> 176 + {/if} 177 + </div> 178 + {:else} 179 + <div 180 + 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" 181 + > 182 + Loading... 183 + </div> 184 + {/if} 185 + </div>
+63
src/lib/cards/social/MarginCard/MarginCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { SettingsComponentProps } from '../../types'; 3 + import { Checkbox, Label } from '@foxui/core'; 4 + 5 + let { item }: SettingsComponentProps = $props(); 6 + </script> 7 + 8 + <div class="flex flex-col gap-3"> 9 + <div class="flex items-center space-x-2"> 10 + <Checkbox 11 + bind:checked={ 12 + () => item.cardData.showBookmarks !== false, (val) => (item.cardData.showBookmarks = val) 13 + } 14 + id="show-bookmarks" 15 + aria-labelledby="show-bookmarks-label" 16 + variant="secondary" 17 + /> 18 + <Label 19 + id="show-bookmarks-label" 20 + for="show-bookmarks" 21 + class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 22 + > 23 + Bookmarks 24 + </Label> 25 + </div> 26 + 27 + <div class="flex items-center space-x-2"> 28 + <Checkbox 29 + bind:checked={ 30 + () => item.cardData.showAnnotations !== false, 31 + (val) => (item.cardData.showAnnotations = val) 32 + } 33 + id="show-annotations" 34 + aria-labelledby="show-annotations-label" 35 + variant="secondary" 36 + /> 37 + <Label 38 + id="show-annotations-label" 39 + for="show-annotations" 40 + class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 41 + > 42 + Annotations 43 + </Label> 44 + </div> 45 + 46 + <div class="flex items-center space-x-2"> 47 + <Checkbox 48 + bind:checked={ 49 + () => item.cardData.showHighlights !== false, (val) => (item.cardData.showHighlights = val) 50 + } 51 + id="show-highlights" 52 + aria-labelledby="show-highlights-label" 53 + variant="secondary" 54 + /> 55 + <Label 56 + id="show-highlights-label" 57 + for="show-highlights" 58 + class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 59 + > 60 + Highlights 61 + </Label> 62 + </div> 63 + </div>
+62
src/lib/cards/social/MarginCard/index.ts
··· 1 + import type { CardDefinition } from '../../types'; 2 + import { listRecords } from '$lib/atproto'; 3 + import MarginCard from './MarginCard.svelte'; 4 + import MarginCardSettings from './MarginCardSettings.svelte'; 5 + 6 + export type MarginEntry = { 7 + type: 'bookmark' | 'annotation' | 'highlight'; 8 + uri: string; 9 + value: any; 10 + createdAt: string; 11 + }; 12 + 13 + export const MarginCardDefinition = { 14 + type: 'margin', 15 + contentComponent: MarginCard, 16 + settingsComponent: MarginCardSettings, 17 + createNew: (card) => { 18 + card.w = 4; 19 + card.mobileW = 8; 20 + card.h = 4; 21 + card.mobileH = 6; 22 + }, 23 + loadData: async (_items, { did }) => { 24 + const [bookmarks, annotations, highlights] = await Promise.all([ 25 + listRecords({ did, collection: 'at.margin.bookmark' }).catch(() => []), 26 + listRecords({ did, collection: 'at.margin.annotation' }).catch(() => []), 27 + listRecords({ did, collection: 'at.margin.highlight' }).catch(() => []) 28 + ]); 29 + 30 + const entries: MarginEntry[] = [ 31 + ...bookmarks.map((r: any) => ({ 32 + type: 'bookmark' as const, 33 + uri: r.uri, 34 + value: r.value, 35 + createdAt: r.value.createdAt 36 + })), 37 + ...annotations.map((r: any) => ({ 38 + type: 'annotation' as const, 39 + uri: r.uri, 40 + value: r.value, 41 + createdAt: r.value.createdAt 42 + })), 43 + ...highlights.map((r: any) => ({ 44 + type: 'highlight' as const, 45 + uri: r.uri, 46 + value: r.value, 47 + createdAt: r.value.createdAt 48 + })) 49 + ]; 50 + 51 + entries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 52 + 53 + return entries; 54 + }, 55 + minH: 2, 56 + canHaveLabel: true, 57 + 58 + keywords: ['margin', 'bookmarks', 'annotations', 'highlights', 'reading', 'web'], 59 + groups: ['Social'], 60 + name: 'Margin highlights, bookmarks, annotations', 61 + 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="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" /></svg>` 62 + } as CardDefinition & { type: 'margin' };
+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' };