your personal website on atproto - mirror blento.app

Merge pull request #243 from flo-bit/cards-as-embeds

cards as embeds

authored by

Florian and committed by
GitHub
9e093310 8af94dac

+750 -163
+18 -4
src/lib/cards/_base/BaseCard/BaseCard.svelte
··· 18 isEditing?: boolean; 19 showOutline?: boolean; 20 locked?: boolean; 21 } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 22 23 let { ··· 28 controls, 29 showOutline, 30 locked = false, 31 class: className, 32 ...rest 33 }: BaseCardProps = $props(); ··· 38 <div 39 id={item.id} 40 data-flip-id={item.id} 41 bind:this={ref} 42 draggable={false} 43 class={[ 44 - 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2', 45 - color ? (colors[color] ?? colors.accent) : colors.base, 46 color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? color : '', 47 showOutline ? 'outline-2' : '', 48 className ··· 65 > 66 <div 67 class={[ 68 - 'text-base-900 dark:text-base-50 relative isolate h-full w-full overflow-hidden rounded-[23px]', 69 color !== 'base' && color != 'transparent' ? 'light' : '' 70 ]} 71 > ··· 84 85 <style> 86 .card { 87 translate: calc((var(--mx) / var(--columns)) * 100cqw + var(--mm)) 88 calc((var(--my) / var(--columns)) * 100cqw + var(--mm)); 89 width: calc((var(--mw) / var(--columns)) * 100cqw - (var(--mm) * 2)); 90 height: calc((var(--mh) / var(--columns)) * 100cqw - (var(--mm) * 2)); 91 } 92 93 @container grid (width >= 42rem) { 94 - .card { 95 translate: calc((var(--dx) / var(--columns)) * 100cqw + var(--dm)) 96 calc((var(--dy) / var(--columns)) * 100cqw + var(--dm)); 97 width: calc((var(--dw) / var(--columns)) * 100cqw - (var(--dm) * 2));
··· 18 isEditing?: boolean; 19 showOutline?: boolean; 20 locked?: boolean; 21 + fillPage?: boolean; 22 } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 23 24 let { ··· 29 controls, 30 showOutline, 31 locked = false, 32 + fillPage = false, 33 class: className, 34 ...rest 35 }: BaseCardProps = $props(); ··· 40 <div 41 id={item.id} 42 data-flip-id={item.id} 43 + data-fill-page={fillPage ? 'true' : undefined} 44 bind:this={ref} 45 draggable={false} 46 class={[ 47 + fillPage 48 + ? 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card relative isolate z-0 h-full w-full outline-offset-2 transition-all duration-200 focus-within:outline-2' 49 + : 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2', 50 + !fillPage ? (color ? (colors[color] ?? colors.accent) : colors.base) : '', 51 color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? color : '', 52 showOutline ? 'outline-2' : '', 53 className ··· 70 > 71 <div 72 class={[ 73 + 'text-base-900 dark:text-base-50 relative isolate h-full w-full overflow-hidden', 74 + !fillPage ? 'rounded-[23px]' : '', 75 color !== 'base' && color != 'transparent' ? 'light' : '' 76 ]} 77 > ··· 90 91 <style> 92 .card { 93 + container-name: card; 94 + container-type: size; 95 translate: calc((var(--mx) / var(--columns)) * 100cqw + var(--mm)) 96 calc((var(--my) / var(--columns)) * 100cqw + var(--mm)); 97 width: calc((var(--mw) / var(--columns)) * 100cqw - (var(--mm) * 2)); 98 height: calc((var(--mh) / var(--columns)) * 100cqw - (var(--mm) * 2)); 99 } 100 101 + .card[data-fill-page='true'] { 102 + translate: none; 103 + width: 100%; 104 + height: 100%; 105 + } 106 + 107 @container grid (width >= 42rem) { 108 + .card:not([data-fill-page='true']) { 109 translate: calc((var(--dx) / var(--columns)) * 100cqw + var(--dm)) 110 calc((var(--dy) / var(--columns)) * 100cqw + var(--dm)); 111 width: calc((var(--dw) / var(--columns)) * 100cqw - (var(--dm) * 2));
+15 -5
src/lib/cards/core/LinkCard/EditingLinkCard.svelte
··· 1 <script lang="ts"> 2 import { browser } from '$app/environment'; 3 import { getImage, compressImage } from '$lib/helper'; 4 - import { getDidContext, getIsMobile } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 7 ··· 49 console.error('Failed to process image:', error); 50 } 51 } 52 - 53 - let isMobile = getIsMobile(); 54 55 let faviconHasError = $state(false); 56 let isFetchingMetadata = $state(false); ··· 291 </div> 292 </div> 293 294 - {#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4))} 295 <button 296 type="button" 297 - class="hover:ring-accent-500 relative mb-2 aspect-2/1 w-full cursor-pointer overflow-hidden rounded-xl transition-all duration-200 hover:ring-2" 298 onclick={() => imageInputRef?.click()} 299 onmouseenter={() => (isHoveringImage = true)} 300 onmouseleave={() => (isHoveringImage = false)} ··· 357 {/if} 358 </div> 359 {/if}
··· 1 <script lang="ts"> 2 import { browser } from '$app/environment'; 3 import { getImage, compressImage } from '$lib/helper'; 4 + import { getDidContext } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 7 ··· 49 console.error('Failed to process image:', error); 50 } 51 } 52 53 let faviconHasError = $state(false); 54 let isFetchingMetadata = $state(false); ··· 289 </div> 290 </div> 291 292 + {#if hasFetched && browser} 293 <button 294 type="button" 295 + class="link-preview-editor hover:ring-accent-500 relative mb-2 aspect-2/1 w-full cursor-pointer overflow-hidden rounded-xl transition-all duration-200 hover:ring-2" 296 onclick={() => imageInputRef?.click()} 297 onmouseenter={() => (isHoveringImage = true)} 298 onmouseleave={() => (isHoveringImage = false)} ··· 355 {/if} 356 </div> 357 {/if} 358 + 359 + <style> 360 + .link-preview-editor { 361 + display: none; 362 + } 363 + 364 + @container card (height >= 18rem) { 365 + .link-preview-editor { 366 + display: block; 367 + } 368 + } 369 + </style>
+79 -25
src/lib/cards/core/LinkCard/LinkCard.svelte
··· 1 <script lang="ts"> 2 - import { browser } from '$app/environment'; 3 import { getImage } from '$lib/helper'; 4 - import { getDidContext, getIsMobile } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 7 8 let { item, isEditing }: ContentComponentProps = $props(); 9 10 - let isMobile = getIsMobile(); 11 - 12 let faviconHasError = $state(false); 13 14 let did = getDidContext(); 15 </script> 16 17 {#if item.cardData.showBackgroundImage && item.cardData.image} 18 - <div class="relative flex h-full flex-col justify-end p-4"> 19 <img 20 class="absolute inset-0 -z-10 size-full object-cover" 21 src={getImage(item.cardData, did)} ··· 27 <div class="text-accent-600 dark:text-accent-400 text-xs font-semibold"> 28 {item.cardData.domain} 29 </div> 30 - <div 31 - class={[ 32 - 'text-base-900 dark:text-base-50 text-lg font-bold', 33 - (isMobile() && item.mobileH < 8) || (!isMobile() && item.h < 4) ? 'line-clamp-2' : '' 34 - ]} 35 - > 36 {item.cardData.title} 37 </div> 38 {#if item.cardData.href && !isEditing} ··· 73 {/if} 74 </div> 75 {:else} 76 - <div class="flex h-full flex-col justify-between p-4"> 77 - <div> 78 <div 79 class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border" 80 > ··· 102 </svg> 103 {/if} 104 </div> 105 - <div 106 - class={[ 107 - 'text-base-900 dark:text-base-50 text-lg font-bold', 108 - (isMobile() && item.mobileH < 8) || (!isMobile() && item.h < 4) ? 'line-clamp-2' : '' 109 - ]} 110 - > 111 {item.cardData.title} 112 </div> 113 <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> ··· 118 </div> 119 </div> 120 121 - {#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 122 - <img 123 - class="mb-2 aspect-2/1 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 124 - src={getImage(item.cardData, did)} 125 - alt="" 126 - /> 127 {/if} 128 {#if item.cardData.href && !isEditing} 129 <a ··· 163 {/if} 164 </div> 165 {/if}
··· 1 <script lang="ts"> 2 import { getImage } from '$lib/helper'; 3 + import { getDidContext } from '$lib/website/context'; 4 import type { ContentComponentProps } from '../../types'; 5 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 6 7 let { item, isEditing }: ContentComponentProps = $props(); 8 9 let faviconHasError = $state(false); 10 11 let did = getDidContext(); 12 </script> 13 14 {#if item.cardData.showBackgroundImage && item.cardData.image} 15 + <div class="link-card relative flex h-full flex-col justify-end p-4"> 16 <img 17 class="absolute inset-0 -z-10 size-full object-cover" 18 src={getImage(item.cardData, did)} ··· 24 <div class="text-accent-600 dark:text-accent-400 text-xs font-semibold"> 25 {item.cardData.domain} 26 </div> 27 + <div class="link-title text-base-900 dark:text-base-50 text-lg font-bold"> 28 {item.cardData.title} 29 </div> 30 {#if item.cardData.href && !isEditing} ··· 65 {/if} 66 </div> 67 {:else} 68 + <div class="link-card flex h-full flex-col p-4"> 69 + <div class="link-content min-h-0"> 70 <div 71 class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border" 72 > ··· 94 </svg> 95 {/if} 96 </div> 97 + <div class="link-title text-base-900 dark:text-base-50 text-lg font-bold"> 98 {item.cardData.title} 99 </div> 100 <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> ··· 105 </div> 106 </div> 107 108 + {#if item.cardData.image} 109 + <div class="link-preview-wrap mt-auto"> 110 + <img 111 + class="link-preview mb-2 aspect-2/1 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 112 + src={getImage(item.cardData, did)} 113 + alt="" 114 + /> 115 + </div> 116 {/if} 117 {#if item.cardData.href && !isEditing} 118 <a ··· 152 {/if} 153 </div> 154 {/if} 155 + 156 + <style> 157 + .link-title { 158 + display: -webkit-box; 159 + line-clamp: 2; 160 + overflow: hidden; 161 + -webkit-box-orient: vertical; 162 + -webkit-line-clamp: 2; 163 + } 164 + 165 + .link-preview { 166 + display: none; 167 + width: 100%; 168 + object-fit: cover; 169 + } 170 + 171 + .link-preview-wrap { 172 + display: none; 173 + padding-top: 1rem; 174 + } 175 + 176 + @container card (height >= 18rem) { 177 + .link-title { 178 + display: block; 179 + line-clamp: unset; 180 + overflow: visible; 181 + -webkit-line-clamp: unset; 182 + } 183 + 184 + .link-preview-wrap, 185 + .link-preview { 186 + display: block; 187 + } 188 + } 189 + 190 + @container card (height >= 18rem) and (height < 22rem) { 191 + .link-content { 192 + padding-bottom: 1rem; 193 + } 194 + 195 + .link-preview-wrap { 196 + padding-top: 0; 197 + } 198 + 199 + .link-preview { 200 + aspect-ratio: 2.6 / 1; 201 + max-height: 4.5rem; 202 + } 203 + } 204 + 205 + @container card (height >= 22rem) { 206 + .link-content { 207 + padding-bottom: 0.5rem; 208 + } 209 + 210 + .link-preview-wrap { 211 + padding-top: 0; 212 + } 213 + 214 + .link-preview { 215 + aspect-ratio: 2 / 1; 216 + max-height: none; 217 + } 218 + } 219 + </style>
+2 -2
src/lib/cards/core/LinkCard/LinkCardSettings.svelte
··· 48 <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 49 </svg> 50 </Button> 51 - <div class="flex items-center space-x- mt-4"> 52 <Checkbox 53 bind:checked={ 54 () => Boolean(item.cardData.showBackgroundImage), ··· 61 <Label 62 id="show-bg-image-label" 63 for="show-bg-image" 64 - class="text-sm leading-none ml-2 font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 65 > 66 Show background image 67 </Label>
··· 48 <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 49 </svg> 50 </Button> 51 + <div class="space-x- mt-4 flex items-center"> 52 <Checkbox 53 bind:checked={ 54 () => Boolean(item.cardData.showBackgroundImage), ··· 61 <Label 62 id="show-bg-image-label" 63 for="show-bg-image" 64 + class="ml-2 text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 65 > 66 Show background image 67 </Label>
+16 -11
src/lib/cards/media/LivestreamCard/LivestreamCard.svelte
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import Icon from './Icon.svelte'; 4 - import { 5 - getAdditionalUserData, 6 - getDidContext, 7 - getHandleContext, 8 - getIsMobile 9 - } from '$lib/website/context'; 10 import type { ContentComponentProps } from '../../types'; 11 import { RelativeTime } from '@foxui/time'; 12 import { Badge } from '@foxui/core'; ··· 15 16 let { item = $bindable() }: ContentComponentProps = $props(); 17 18 - let isMobile = getIsMobile(); 19 - 20 let isLoaded = $state(false); 21 22 const data = getAdditionalUserData(); ··· 58 }); 59 </script> 60 61 - <div class="h-full overflow-y-scroll p-4"> 62 {#if latestLivestream} 63 <div class="flex min-h-full flex-col justify-between"> 64 <div> ··· 95 </a> 96 </div> 97 98 - {#if browser && ((isMobile() && item.mobileH >= 7) || (!isMobile() && item.h >= 4)) && latestLivestream?.thumb} 99 <a href={latestLivestream?.href} target="_blank" rel="noopener noreferrer"> 100 <img 101 - class="my-4 max-h-32 w-full rounded-xl object-cover" 102 src={latestLivestream?.thumb} 103 alt="" 104 /> ··· 112 <div class="flex h-full w-full items-center justify-center">Looking for the latest stream</div> 113 {/if} 114 </div>
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import Icon from './Icon.svelte'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import { RelativeTime } from '@foxui/time'; 7 import { Badge } from '@foxui/core'; ··· 10 11 let { item = $bindable() }: ContentComponentProps = $props(); 12 13 let isLoaded = $state(false); 14 15 const data = getAdditionalUserData(); ··· 51 }); 52 </script> 53 54 + <div class="livestream-card h-full overflow-y-scroll p-4"> 55 {#if latestLivestream} 56 <div class="flex min-h-full flex-col justify-between"> 57 <div> ··· 88 </a> 89 </div> 90 91 + {#if browser && latestLivestream?.thumb} 92 <a href={latestLivestream?.href} target="_blank" rel="noopener noreferrer"> 93 <img 94 + class="livestream-thumb my-4 max-h-32 w-full rounded-xl object-cover" 95 src={latestLivestream?.thumb} 96 alt="" 97 /> ··· 105 <div class="flex h-full w-full items-center justify-center">Looking for the latest stream</div> 106 {/if} 107 </div> 108 + 109 + <style> 110 + .livestream-thumb { 111 + display: none; 112 + } 113 + 114 + @container card (height >= 15rem) { 115 + .livestream-thumb { 116 + display: block; 117 + } 118 + } 119 + </style>
+24 -9
src/lib/cards/media/PhotoGalleryCard/PhotoGalleryCard.svelte
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import { onMount } from 'svelte'; 4 - import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 5 import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; 6 import { loadGrainGalleryData } from './helpers'; 7 ··· 61 onclick?: () => void; 62 }[] 63 ); 64 - 65 - let isMobile = getIsMobile(); 66 </script> 67 68 - <div class="z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4"> 69 - <ImageMasonry 70 - images={images ?? []} 71 - showNames={false} 72 - maxColumns={!isMobile() && item.w > 4 ? 3 : 2} 73 - /> 74 </div>
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import { onMount } from 'svelte'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; 6 import { loadGrainGalleryData } from './helpers'; 7 ··· 61 onclick?: () => void; 62 }[] 63 ); 64 </script> 65 66 + <div class="photo-gallery-card z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4"> 67 + <div class="gallery-compact"> 68 + <ImageMasonry images={images ?? []} showNames={false} maxColumns={2} /> 69 + </div> 70 + <div class="gallery-wide"> 71 + <ImageMasonry images={images ?? []} showNames={false} maxColumns={3} /> 72 + </div> 73 </div> 74 + 75 + <style> 76 + .gallery-wide { 77 + display: none; 78 + } 79 + 80 + @container card (width >= 28rem) { 81 + .gallery-compact { 82 + display: none; 83 + } 84 + 85 + .gallery-wide { 86 + display: block; 87 + } 88 + } 89 + </style>
+1
src/lib/cards/media/RockskyPlaysCard/RockskyPlaysCard.svelte
··· 8 9 interface Artist { 10 artist: string; 11 } 12 13 interface PlayValue {
··· 8 9 interface Artist { 10 artist: string; 11 + name?: string; 12 } 13 14 interface PlayValue {
+2 -1
src/lib/cards/media/TealFMPlaysCard/TealFMPlaysCard.svelte
··· 12 13 interface PlayValue { 14 releaseMbId?: string; 15 trackName: string; 16 playedTime?: string; 17 artists?: Artist[]; ··· 52 {#snippet musicItem(play: Play)} 53 <div class="flex w-full items-center gap-3"> 54 <div class="size-10 shrink-0"> 55 - <AlbumArt releaseMbid={play.value.releaseMbid} alt="" /> 56 </div> 57 <div class="min-w-0 flex-1"> 58 <div class="inline-flex w-full max-w-full justify-between gap-2">
··· 12 13 interface PlayValue { 14 releaseMbId?: string; 15 + releaseMbid?: string; 16 trackName: string; 17 playedTime?: string; 18 artists?: Artist[]; ··· 53 {#snippet musicItem(play: Play)} 54 <div class="flex w-full items-center gap-3"> 55 <div class="size-10 shrink-0"> 56 + <AlbumArt releaseMbid={play.value.releaseMbid ?? play.value.releaseMbId} alt="" /> 57 </div> 58 <div class="min-w-0 flex-1"> 59 <div class="inline-flex w-full max-w-full justify-between gap-2">
+41 -12
src/lib/cards/social/EventCard/EventCard.svelte
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { Badge, Button } from '@foxui/core'; 4 - import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import { CardDefinitionsByType } from '../..'; 7 import type { EventData } from '.'; ··· 12 13 let { item }: ContentComponentProps = $props(); 14 15 - let isMobile = getIsMobile(); 16 let isLoaded = $state(false); 17 let fetchedEventData = $state<EventData | undefined>(undefined); 18 ··· 109 }; 110 }); 111 112 - let showImage = $derived( 113 - browser && headerImage() && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) 114 - ); 115 </script> 116 117 - <div class="flex h-full flex-col justify-between overflow-hidden p-4"> 118 {#if eventData} 119 <div class="min-w-0 flex-1 overflow-hidden"> 120 <div class="mb-2 flex items-center justify-between gap-2"> ··· 142 </Badge> 143 </div> 144 145 - {#if isMobile() ? item.mobileW > 4 : item.w > 2} 146 - <Button href={eventUrl()} target="_blank" class="z-50">View event</Button> 147 - {/if} 148 </div> 149 150 <h3 class="text-base-900 dark:text-base-50 mb-2 line-clamp-2 text-lg leading-tight font-bold"> ··· 203 </div> 204 {/if} 205 206 - {#if eventData.description && ((isMobile() && item.mobileH >= 5) || (!isMobile() && item.h >= 3))} 207 - <p class="text-base-500 dark:text-base-400 accent:text-base-900 mb-3 line-clamp-3 text-sm"> 208 {eventData.description} 209 </p> 210 {/if} ··· 213 {#if showImage} 214 {@const img = headerImage()} 215 {#if img} 216 - <img src={img.url} alt={img.alt} class="mt-3 aspect-3/1 w-full rounded-xl object-cover" /> 217 {/if} 218 {/if} 219 ··· 239 </div> 240 {/if} 241 </div>
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { Badge, Button } from '@foxui/core'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import { CardDefinitionsByType } from '../..'; 7 import type { EventData } from '.'; ··· 12 13 let { item }: ContentComponentProps = $props(); 14 15 let isLoaded = $state(false); 16 let fetchedEventData = $state<EventData | undefined>(undefined); 17 ··· 108 }; 109 }); 110 111 + let showImage = $derived(browser && headerImage()); 112 </script> 113 114 + <div class="event-card flex h-full flex-col justify-between overflow-hidden p-4"> 115 {#if eventData} 116 <div class="min-w-0 flex-1 overflow-hidden"> 117 <div class="mb-2 flex items-center justify-between gap-2"> ··· 139 </Badge> 140 </div> 141 142 + <div class="event-action z-50"> 143 + <Button href={eventUrl()} target="_blank">View event</Button> 144 + </div> 145 </div> 146 147 <h3 class="text-base-900 dark:text-base-50 mb-2 line-clamp-2 text-lg leading-tight font-bold"> ··· 200 </div> 201 {/if} 202 203 + {#if eventData.description} 204 + <p 205 + class="event-description text-base-500 dark:text-base-400 accent:text-base-900 mb-3 line-clamp-3 text-sm" 206 + > 207 {eventData.description} 208 </p> 209 {/if} ··· 212 {#if showImage} 213 {@const img = headerImage()} 214 {#if img} 215 + <img 216 + src={img.url} 217 + alt={img.alt} 218 + class="event-image mt-3 aspect-3/1 w-full rounded-xl object-cover" 219 + /> 220 {/if} 221 {/if} 222 ··· 242 </div> 243 {/if} 244 </div> 245 + 246 + <style> 247 + .event-action, 248 + .event-description, 249 + .event-image { 250 + display: none; 251 + } 252 + 253 + @container card (width >= 18rem) { 254 + .event-action { 255 + display: inline-flex; 256 + } 257 + } 258 + 259 + @container card (height >= 12rem) { 260 + .event-description { 261 + display: block; 262 + } 263 + } 264 + 265 + @container card (height >= 15rem) { 266 + .event-image { 267 + display: block; 268 + } 269 + } 270 + </style>
+42 -19
src/lib/cards/social/FriendsCard/FriendsCard.svelte
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import type { ContentComponentProps } from '../../types'; 4 - import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context'; 5 import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 6 import type { FriendsProfile } from '.'; 7 import type { Did } from '@atcute/lexicons'; ··· 9 10 let { item }: ContentComponentProps = $props(); 11 12 - const isMobile = getIsMobile(); 13 const canEdit = getCanEdit(); 14 const additionalData = getAdditionalUserData(); 15 ··· 54 } 55 }); 56 57 - let sizeClass = $derived.by(() => { 58 - const w = isMobile() ? item.mobileW / 2 : item.w; 59 - if (w < 3) return 'sm'; 60 - if (w < 5) return 'md'; 61 - return 'lg'; 62 - }); 63 - 64 function removeFriend(did: string) { 65 item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did); 66 } ··· 84 </span> 85 {/if} 86 {:else} 87 - {@const olX = sizeClass === 'sm' ? 12 : sizeClass === 'md' ? 20 : 24} 88 - {@const olY = sizeClass === 'sm' ? 8 : sizeClass === 'md' ? 12 : 16} 89 - <div class=""> 90 - <div class="flex flex-wrap items-center justify-center" style="padding: {olY}px 0 0 {olX}px;"> 91 {#each profiles as profile (profile.did)} 92 - <div class="group relative" style="margin: -{olY}px 0 0 -{olX}px;"> 93 <a 94 href={getLink(profile)} 95 class="accent:ring-accent-500 relative block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 96 > 97 - <Avatar 98 - src={profile.avatar} 99 - alt={profile.handle} 100 - class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'} 101 - /> 102 </a> 103 {#if canEdit()} 104 <button ··· 128 </div> 129 {/if} 130 </div>
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import type { ContentComponentProps } from '../../types'; 4 + import { getAdditionalUserData, getCanEdit } from '$lib/website/context'; 5 import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 6 import type { FriendsProfile } from '.'; 7 import type { Did } from '@atcute/lexicons'; ··· 9 10 let { item }: ContentComponentProps = $props(); 11 12 const canEdit = getCanEdit(); 13 const additionalData = getAdditionalUserData(); 14 ··· 53 } 54 }); 55 56 function removeFriend(did: string) { 57 item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did); 58 } ··· 76 </span> 77 {/if} 78 {:else} 79 + <div class="friends-card"> 80 + <div class="friends-grid flex flex-wrap items-center justify-center"> 81 {#each profiles as profile (profile.did)} 82 + <div class="friends-avatar group relative"> 83 <a 84 href={getLink(profile)} 85 class="accent:ring-accent-500 relative block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 86 > 87 + <Avatar src={profile.avatar} alt={profile.handle} class="friends-avatar-image" /> 88 </a> 89 {#if canEdit()} 90 <button ··· 114 </div> 115 {/if} 116 </div> 117 + 118 + <style> 119 + .friends-card { 120 + --friends-overlap-x: 12px; 121 + --friends-overlap-y: 8px; 122 + --friends-avatar-size: 48px; 123 + } 124 + 125 + .friends-grid { 126 + padding: var(--friends-overlap-y) 0 0 var(--friends-overlap-x); 127 + } 128 + 129 + .friends-avatar { 130 + margin: calc(var(--friends-overlap-y) * -1) 0 0 calc(var(--friends-overlap-x) * -1); 131 + } 132 + 133 + :global(.friends-avatar-image) { 134 + width: var(--friends-avatar-size); 135 + height: var(--friends-avatar-size); 136 + } 137 + 138 + @container card (width >= 18rem) { 139 + .friends-card { 140 + --friends-overlap-x: 20px; 141 + --friends-overlap-y: 12px; 142 + --friends-avatar-size: 64px; 143 + } 144 + } 145 + 146 + @container card (width >= 26rem) { 147 + .friends-card { 148 + --friends-overlap-x: 24px; 149 + --friends-overlap-y: 16px; 150 + --friends-avatar-size: 80px; 151 + } 152 + } 153 + </style>
+40 -12
src/lib/cards/social/GitHubProfileCard/GitHubProfileCard.svelte
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { siGithub } from 'simple-icons'; 4 - import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import type { GithubProfileLoadedData } from '.'; 7 import GithubContributionsGraph from './GithubContributionsGraph.svelte'; ··· 34 } 35 } 36 }); 37 - 38 - let isMobile = getIsMobile(); 39 </script> 40 41 - <div class="h-full overflow-hidden p-4"> 42 <div class="flex h-full flex-col justify-between"> 43 <!-- Header --> 44 <div class="flex justify-between"> ··· 56 </a> 57 </div> 58 59 - {#if isMobile() ? item.mobileW > 4 : item.w > 2} 60 <Button 61 href="https://github.com/{item.cardData.user}" 62 target="_blank" 63 - rel="noopener noreferrer" 64 - class="z-50">Follow</Button 65 > 66 - {/if} 67 </div> 68 69 {#if contributionsData && browser} 70 <div class="flex opacity-100 transition-opacity duration-300 starting:opacity-0"> 71 - <GithubContributionsGraph 72 - data={contributionsData} 73 - isBig={isMobile() ? item.mobileH > 5 : item.h > 2} 74 - /> 75 </div> 76 {/if} 77 </div> ··· 94 <span class="sr-only">Show on github</span> 95 </a> 96 {/if}
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { siGithub } from 'simple-icons'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import type { GithubProfileLoadedData } from '.'; 7 import GithubContributionsGraph from './GithubContributionsGraph.svelte'; ··· 34 } 35 } 36 }); 37 </script> 38 39 + <div class="github-profile-card h-full overflow-hidden p-4"> 40 <div class="flex h-full flex-col justify-between"> 41 <!-- Header --> 42 <div class="flex justify-between"> ··· 54 </a> 55 </div> 56 57 + <div class="github-follow z-50"> 58 <Button 59 href="https://github.com/{item.cardData.user}" 60 target="_blank" 61 + rel="noopener noreferrer">Follow</Button 62 > 63 + </div> 64 </div> 65 66 {#if contributionsData && browser} 67 <div class="flex opacity-100 transition-opacity duration-300 starting:opacity-0"> 68 + <div class="github-graph github-graph-compact"> 69 + <GithubContributionsGraph data={contributionsData} isBig={false} /> 70 + </div> 71 + <div class="github-graph github-graph-expanded"> 72 + <GithubContributionsGraph data={contributionsData} isBig={true} /> 73 + </div> 74 </div> 75 {/if} 76 </div> ··· 93 <span class="sr-only">Show on github</span> 94 </a> 95 {/if} 96 + 97 + <style> 98 + .github-follow, 99 + .github-graph-expanded { 100 + display: none; 101 + } 102 + 103 + .github-graph-compact { 104 + display: flex; 105 + width: 100%; 106 + } 107 + 108 + @container card (width >= 18rem) { 109 + .github-follow { 110 + display: inline-flex; 111 + } 112 + } 113 + 114 + @container card (height >= 12rem) { 115 + .github-graph-compact { 116 + display: none; 117 + } 118 + 119 + .github-graph-expanded { 120 + display: flex; 121 + width: 100%; 122 + } 123 + } 124 + </style>
+1 -7
src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { Badge } from '@foxui/core'; 4 - import { 5 - getAdditionalUserData, 6 - getDidContext, 7 - getHandleContext, 8 - getIsMobile 9 - } from '$lib/website/context'; 10 import type { ContentComponentProps } from '../../types'; 11 import { UpcomingEventsCardDefinition } from '.'; 12 import type { EventData } from '../EventCard'; ··· 16 17 let { item }: ContentComponentProps = $props(); 18 19 - let isMobile = getIsMobile(); 20 let isLoaded = $state(false); 21 const data = getAdditionalUserData(); 22 const did = getDidContext();
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { Badge } from '@foxui/core'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import { UpcomingEventsCardDefinition } from '.'; 7 import type { EventData } from '../EventCard'; ··· 11 12 let { item }: ContentComponentProps = $props(); 13 14 let isLoaded = $state(false); 15 const data = getAdditionalUserData(); 16 const did = getDidContext();
+177
src/lib/website/EmbeddedCard.svelte
···
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + import { page } from '$app/state'; 4 + import { innerWidth } from 'svelte/reactivity/window'; 5 + import BaseCard from '$lib/cards/_base/BaseCard/BaseCard.svelte'; 6 + import Card from '$lib/cards/_base/Card/Card.svelte'; 7 + import { CardDefinitionsByType, getColor } from '$lib/cards'; 8 + import { getDescription, getImage, getName } from '$lib/helper'; 9 + import QRModalProvider from '$lib/components/qr/QRModalProvider.svelte'; 10 + import ImageViewerProvider from '$lib/components/image-viewer/ImageViewerProvider.svelte'; 11 + import type { WebsiteData } from '$lib/types'; 12 + import Context from './Context.svelte'; 13 + import Head from './Head.svelte'; 14 + import { setIsMobile } from './context'; 15 + 16 + let { data }: { data: WebsiteData } = $props(); 17 + 18 + let item = $derived(data.cards[0]); 19 + let embeddedItem = $derived({ 20 + ...item, 21 + x: 0, 22 + y: 0, 23 + mobileX: 0, 24 + mobileY: 0 25 + }); 26 + 27 + let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 28 + setIsMobile(() => isMobile); 29 + 30 + const colors = { 31 + base: 'bg-base-200/50 dark:bg-base-950/50', 32 + accent: 'bg-accent-400 dark:bg-accent-500 accent', 33 + transparent: 'bg-transparent' 34 + } as Record<string, string>; 35 + 36 + let color = $derived(getColor(item)); 37 + let backgroundClass = $derived(color ? (colors[color] ?? colors.accent) : colors.base); 38 + let pageColorClass = $derived( 39 + color !== 'accent' && item?.color !== 'base' && item?.color !== 'transparent' ? color : '' 40 + ); 41 + let cardWidth = $derived(Math.max(isMobile ? item.mobileW : item.w, 1)); 42 + let cardHeight = $derived(Math.max(isMobile ? item.mobileH : item.h, 1)); 43 + 44 + let title = $derived.by(() => { 45 + const label = item?.cardData?.label as string | undefined; 46 + const cardName = CardDefinitionsByType[item?.cardType ?? '']?.name; 47 + 48 + return label 49 + ? `${label} • ${getName(data)}` 50 + : cardName 51 + ? `${cardName} • ${getName(data)}` 52 + : getName(data); 53 + }); 54 + 55 + let description = $derived( 56 + (item?.cardData?.title as string | undefined) || 57 + (item?.cardData?.text as string | undefined) || 58 + getDescription(data) 59 + ); 60 + 61 + const safeJson = (value: string) => JSON.stringify(value).replace(/</g, '\\u003c'); 62 + 63 + let themeMode = $derived.by(() => { 64 + const theme = page.url.searchParams.get('theme'); 65 + return theme === 'dark' || theme === 'light' || theme === 'auto' ? theme : undefined; 66 + }); 67 + 68 + let themeScript = $derived.by(() => { 69 + if (!themeMode) return ''; 70 + 71 + return ( 72 + `<script>(function(){var theme=${safeJson(themeMode)};var el=document.documentElement;` + 73 + `var apply=function(mode){el.classList.remove('dark','light');` + 74 + `el.classList.add(mode==='auto'&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':mode==='auto'?'light':mode);};` + 75 + `apply(theme);})();<` + 76 + '/script>' 77 + ); 78 + }); 79 + 80 + $effect(() => { 81 + if (!browser || !themeMode) return; 82 + 83 + const root = document.documentElement; 84 + const previousHadDark = root.classList.contains('dark'); 85 + const previousHadLight = root.classList.contains('light'); 86 + 87 + const applyTheme = () => { 88 + root.classList.remove('dark', 'light'); 89 + 90 + if (themeMode === 'auto') { 91 + root.classList.add( 92 + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 93 + ); 94 + return; 95 + } 96 + 97 + root.classList.add(themeMode); 98 + }; 99 + 100 + applyTheme(); 101 + 102 + if (themeMode !== 'auto') { 103 + return () => { 104 + root.classList.remove('dark', 'light'); 105 + if (previousHadDark) root.classList.add('dark'); 106 + if (previousHadLight) root.classList.add('light'); 107 + }; 108 + } 109 + 110 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 111 + mediaQuery.addEventListener('change', applyTheme); 112 + 113 + return () => { 114 + mediaQuery.removeEventListener('change', applyTheme); 115 + root.classList.remove('dark', 'light'); 116 + if (previousHadDark) root.classList.add('dark'); 117 + if (previousHadLight) root.classList.add('light'); 118 + }; 119 + }); 120 + </script> 121 + 122 + <Head 123 + favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 124 + {title} 125 + {description} 126 + accentColor={data.publication?.preferences?.accentColor} 127 + baseColor={data.publication?.preferences?.baseColor} 128 + /> 129 + 130 + <svelte:head> 131 + <meta name="robots" content="noindex" /> 132 + {@html themeScript} 133 + </svelte:head> 134 + 135 + <Context {data}> 136 + <QRModalProvider /> 137 + <ImageViewerProvider /> 138 + 139 + <div class={[backgroundClass, pageColorClass, 'embed-page w-full']}> 140 + <div class="embed-stage @container/grid"> 141 + <div 142 + class="embed-content" 143 + style={`--embed-ratio: ${cardWidth / cardHeight}; aspect-ratio: ${cardWidth} / ${cardHeight};`} 144 + > 145 + <BaseCard item={embeddedItem} fillPage> 146 + <Card item={embeddedItem} /> 147 + </BaseCard> 148 + </div> 149 + </div> 150 + </div> 151 + </Context> 152 + 153 + <style> 154 + :global(html), 155 + :global(body) { 156 + min-height: 100%; 157 + } 158 + 159 + .embed-page { 160 + min-height: 100vh; 161 + } 162 + 163 + .embed-stage { 164 + min-height: 100vh; 165 + display: flex; 166 + align-items: center; 167 + justify-content: center; 168 + padding: clamp(12px, 3vw, 32px); 169 + container-type: inline-size; 170 + } 171 + 172 + .embed-content { 173 + width: min(100%, calc((100vh - clamp(24px, 6vw, 64px)) * var(--embed-ratio))); 174 + max-width: calc(100vw - clamp(24px, 6vw, 64px)); 175 + max-height: calc(100vh - clamp(24px, 6vw, 64px)); 176 + } 177 + </style>
+197 -55
src/lib/website/load.ts
··· 1 import { getDetailedProfile, listRecords, resolveHandle, parseUri, getRecord } from '$lib/atproto'; 2 import { CardDefinitionsByType } from '$lib/cards'; 3 import type { CacheService } from '$lib/cache'; 4 import type { Item, WebsiteData } from '$lib/types'; 5 import { error } from '@sveltejs/kit'; 6 import type { ActorIdentifier, Did } from '@atcute/lexicons'; ··· 85 getDetailedProfile({ did }) 86 ]); 87 88 - const cardTypes = new Set(cards.map((v) => v.value.cardType ?? '') as string[]); 89 - const cardTypesArray = Array.from(cardTypes); 90 - 91 - const additionDataPromises: Record<string, Promise<unknown>> = {}; 92 - 93 - const loadOptions = { did, handle, cache }; 94 - 95 - for (const cardType of cardTypesArray) { 96 - const cardDef = CardDefinitionsByType[cardType]; 97 - 98 - const items = cards.filter((v) => cardType === v.value.cardType).map((v) => v.value) as Item[]; 99 - 100 - try { 101 - if (cardDef?.loadDataServer) { 102 - additionDataPromises[cardType] = cardDef.loadDataServer(items, { 103 - ...loadOptions, 104 - env 105 - }); 106 - } else if (cardDef?.loadData) { 107 - additionDataPromises[cardType] = cardDef.loadData(items, loadOptions); 108 - } 109 - } catch { 110 - console.error('error getting additional data for', cardType); 111 - } 112 - } 113 - 114 - await Promise.all(Object.values(additionDataPromises)); 115 - 116 - const additionalData: Record<string, unknown> = {}; 117 - for (const [key, value] of Object.entries(additionDataPromises)) { 118 - try { 119 - additionalData[key] = await value; 120 - } catch (error) { 121 - console.log('error loading', key, error); 122 - } 123 - } 124 125 const result = { 126 page: 'blento.' + page, ··· 157 return checkData(parsedResult); 158 } 159 160 - function migrateFromV0ToV1(data: WebsiteData): WebsiteData { 161 - for (const card of data.cards) { 162 - if (card.version) continue; 163 card.x *= 2; 164 card.y *= 2; 165 card.h *= 2; ··· 171 card.version = 1; 172 } 173 174 - return data; 175 } 176 177 - function migrateFromV1ToV2(data: WebsiteData): WebsiteData { 178 - for (const card of data.cards) { 179 - if (!card.version || card.version < 2) { 180 - card.page = 'blento.self'; 181 - card.version = 2; 182 } 183 } 184 - return data; 185 - } 186 - 187 - function migrateCards(data: WebsiteData): WebsiteData { 188 - for (const card of data.cards) { 189 - const cardDef = CardDefinitionsByType[card.cardType]; 190 191 - if (!cardDef?.migrate) continue; 192 193 - cardDef.migrate(card); 194 } 195 - return data; 196 } 197 198 function checkData(data: WebsiteData): WebsiteData { ··· 214 } 215 216 function migrateData(data: WebsiteData): WebsiteData { 217 - return migrateCards(migrateFromV1ToV2(migrateFromV0ToV1(data))); 218 }
··· 1 import { getDetailedProfile, listRecords, resolveHandle, parseUri, getRecord } from '$lib/atproto'; 2 import { CardDefinitionsByType } from '$lib/cards'; 3 import type { CacheService } from '$lib/cache'; 4 + import { createEmptyCard } from '$lib/helper'; 5 import type { Item, WebsiteData } from '$lib/types'; 6 import { error } from '@sveltejs/kit'; 7 import type { ActorIdentifier, Did } from '@atcute/lexicons'; ··· 86 getDetailedProfile({ did }) 87 ]); 88 89 + const additionalData = await loadAdditionalData( 90 + cards.map((v) => ({ ...v.value })) as Item[], 91 + { did, handle, cache }, 92 + env 93 + ); 94 95 const result = { 96 page: 'blento.' + page, ··· 127 return checkData(parsedResult); 128 } 129 130 + export async function loadCardData( 131 + handle: ActorIdentifier, 132 + rkey: string, 133 + cache: CacheService | undefined, 134 + env?: Record<string, string | undefined> 135 + ): Promise<WebsiteData> { 136 + if (!handle) throw error(404); 137 + if (handle === 'favicon.ico') throw error(404); 138 + 139 + let did: Did | undefined = undefined; 140 + if (isHandle(handle)) { 141 + did = await resolveHandle({ handle }); 142 + } else if (isDid(handle)) { 143 + did = handle; 144 + } else { 145 + throw error(404); 146 + } 147 + 148 + const [cardRecord, profile] = await Promise.all([ 149 + getRecord({ 150 + did, 151 + collection: 'app.blento.card', 152 + rkey 153 + }).catch(() => undefined), 154 + getDetailedProfile({ did }) 155 + ]); 156 + 157 + if (!cardRecord?.value) { 158 + throw error(404, 'Card not found'); 159 + } 160 + 161 + const card = migrateCard(structuredClone(cardRecord.value) as Item); 162 + const page = card.page ?? 'blento.self'; 163 + 164 + const publication = await getRecord({ 165 + did, 166 + collection: page === 'blento.self' ? 'site.standard.publication' : 'app.blento.page', 167 + rkey: page 168 + }).catch(() => undefined); 169 + 170 + const cards = [card]; 171 + const resolvedHandle = profile?.handle || (isHandle(handle) ? handle : did); 172 + 173 + const additionalData = await loadAdditionalData( 174 + cards, 175 + { did, handle: resolvedHandle, cache }, 176 + env 177 + ); 178 + 179 + const result = { 180 + page, 181 + handle: resolvedHandle, 182 + did, 183 + cards, 184 + publication: 185 + publication?.value ?? 186 + ({ 187 + name: profile?.displayName || profile?.handle, 188 + description: profile?.description 189 + } as WebsiteData['publication']), 190 + additionalData, 191 + profile, 192 + updatedAt: Date.now(), 193 + version: CURRENT_CACHE_VERSION 194 + }; 195 + 196 + return result; 197 + } 198 + 199 + export async function loadCardTypeData( 200 + handle: ActorIdentifier, 201 + type: string, 202 + cardData: Record<string, unknown>, 203 + cache: CacheService | undefined, 204 + env?: Record<string, string | undefined> 205 + ): Promise<WebsiteData> { 206 + if (!handle) throw error(404); 207 + if (handle === 'favicon.ico') throw error(404); 208 + 209 + const cardDef = CardDefinitionsByType[type]; 210 + if (!cardDef) { 211 + throw error(404, 'Card type not found'); 212 + } 213 + 214 + let did: Did | undefined = undefined; 215 + if (isHandle(handle)) { 216 + did = await resolveHandle({ handle }); 217 + } else if (isDid(handle)) { 218 + did = handle; 219 + } else { 220 + throw error(404); 221 + } 222 + 223 + const [publication, profile] = await Promise.all([ 224 + getRecord({ 225 + did, 226 + collection: 'site.standard.publication', 227 + rkey: 'blento.self' 228 + }).catch(() => undefined), 229 + getDetailedProfile({ did }) 230 + ]); 231 + 232 + const card = createEmptyCard('blento.self'); 233 + card.cardType = type; 234 + 235 + cardDef.createNew?.(card); 236 + card.cardData = { 237 + ...card.cardData, 238 + ...cardData 239 + }; 240 + 241 + const cards = [card]; 242 + const resolvedHandle = profile?.handle || (isHandle(handle) ? handle : did); 243 + 244 + const additionalData = await loadAdditionalData( 245 + cards, 246 + { did, handle: resolvedHandle, cache }, 247 + env 248 + ); 249 + 250 + const result = { 251 + page: 'blento.self', 252 + handle: resolvedHandle, 253 + did, 254 + cards, 255 + publication: 256 + publication?.value ?? 257 + ({ 258 + name: profile?.displayName || profile?.handle, 259 + description: profile?.description 260 + } as WebsiteData['publication']), 261 + additionalData, 262 + profile, 263 + updatedAt: Date.now(), 264 + version: CURRENT_CACHE_VERSION 265 + }; 266 + 267 + return checkData(result); 268 + } 269 + 270 + function migrateCard(card: Item): Item { 271 + if (!card.version) { 272 card.x *= 2; 273 card.y *= 2; 274 card.h *= 2; ··· 280 card.version = 1; 281 } 282 283 + if (!card.version || card.version < 2) { 284 + card.page = 'blento.self'; 285 + card.version = 2; 286 + } 287 + 288 + const cardDef = CardDefinitionsByType[card.cardType]; 289 + cardDef?.migrate?.(card); 290 + 291 + return card; 292 } 293 294 + async function loadAdditionalData( 295 + cards: Item[], 296 + { did, handle, cache }: { did: Did; handle: string; cache?: CacheService }, 297 + env?: Record<string, string | undefined> 298 + ) { 299 + const cardTypes = new Set(cards.map((v) => v.cardType ?? '') as string[]); 300 + const cardTypesArray = Array.from(cardTypes); 301 + const additionDataPromises: Record<string, Promise<unknown>> = {}; 302 + 303 + for (const cardType of cardTypesArray) { 304 + const cardDef = CardDefinitionsByType[cardType]; 305 + const items = cards.filter((v) => cardType === v.cardType); 306 + 307 + try { 308 + if (cardDef?.loadDataServer) { 309 + additionDataPromises[cardType] = cardDef.loadDataServer(items, { 310 + did, 311 + handle, 312 + cache, 313 + env 314 + }); 315 + } else if (cardDef?.loadData) { 316 + additionDataPromises[cardType] = cardDef.loadData(items, { did, handle, cache }); 317 + } 318 + } catch { 319 + console.error('error getting additional data for', cardType); 320 } 321 } 322 323 + await Promise.all(Object.values(additionDataPromises)); 324 325 + const additionalData: Record<string, unknown> = {}; 326 + for (const [key, value] of Object.entries(additionDataPromises)) { 327 + try { 328 + additionalData[key] = await value; 329 + } catch (error) { 330 + console.log('error loading', key, error); 331 + } 332 } 333 + 334 + return additionalData; 335 } 336 337 function checkData(data: WebsiteData): WebsiteData { ··· 353 } 354 355 function migrateData(data: WebsiteData): WebsiteData { 356 + for (const card of data.cards) { 357 + migrateCard(card); 358 + } 359 + return data; 360 }
+6 -1
src/routes/+layout.svelte
··· 11 import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 12 13 let { children, data } = $props(); 14 15 const errorMessages: Record<string, (params: URLSearchParams) => string> = { 16 handle_not_found: (p) => `Handle ${p.get('handle') ?? ''} not found!` ··· 25 {@render children()} 26 </Tooltip.Provider> 27 28 - <ThemeToggle class="fixed top-2 left-2 z-10" /> 29 30 <Toaster /> 31
··· 11 import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 12 13 let { children, data } = $props(); 14 + let showThemeToggle = $derived( 15 + !/(?:\/card\/[^/]+|\/embed\/type\/[^/]+)$/.test(page.url.pathname) 16 + ); 17 18 const errorMessages: Record<string, (params: URLSearchParams) => string> = { 19 handle_not_found: (p) => `Handle ${p.get('handle') ?? ''} not found!` ··· 28 {@render children()} 29 </Tooltip.Provider> 30 31 + {#if showThemeToggle} 32 + <ThemeToggle class="fixed top-2 left-2 z-10" /> 33 + {/if} 34 35 <Toaster /> 36
+16
src/routes/[[actor=actor]]/card/[rkey]/+page.server.ts
···
··· 1 + import { createCache } from '$lib/cache'; 2 + import { getActor } from '$lib/actor'; 3 + import { loadCardData } from '$lib/website/load'; 4 + import { error } from '@sveltejs/kit'; 5 + import { env } from '$env/dynamic/private'; 6 + 7 + export async function load({ params, platform, request }) { 8 + const cache = createCache(platform); 9 + const actor = await getActor({ request, paramActor: params.actor, platform }); 10 + 11 + if (!actor) { 12 + throw error(404, 'Page not found'); 13 + } 14 + 15 + return await loadCardData(actor, params.rkey, cache, env); 16 + }
+7
src/routes/[[actor=actor]]/card/[rkey]/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import EmbeddedCard from '$lib/website/EmbeddedCard.svelte'; 3 + 4 + let { data } = $props(); 5 + </script> 6 + 7 + <EmbeddedCard {data} />
+59
src/routes/[[actor=actor]]/embed/type/[type]/+page.server.ts
···
··· 1 + import { createCache } from '$lib/cache'; 2 + import { getActor } from '$lib/actor'; 3 + import { loadCardTypeData } from '$lib/website/load'; 4 + import { error } from '@sveltejs/kit'; 5 + import { env } from '$env/dynamic/private'; 6 + 7 + function parseQueryParamValue(value: string): unknown { 8 + const trimmed = value.trim(); 9 + 10 + if (trimmed === 'true') return true; 11 + if (trimmed === 'false') return false; 12 + if (trimmed === 'null') return null; 13 + 14 + if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(trimmed)) { 15 + return Number(trimmed); 16 + } 17 + 18 + if ( 19 + (trimmed.startsWith('{') && trimmed.endsWith('}')) || 20 + (trimmed.startsWith('[') && trimmed.endsWith(']')) 21 + ) { 22 + try { 23 + return JSON.parse(trimmed); 24 + } catch { 25 + return value; 26 + } 27 + } 28 + 29 + return value; 30 + } 31 + 32 + function getCardDataFromSearchParams(searchParams: URLSearchParams) { 33 + const cardData: Record<string, unknown> = {}; 34 + const keys = new Set(searchParams.keys()); 35 + 36 + for (const key of keys) { 37 + const values = searchParams.getAll(key).map(parseQueryParamValue); 38 + cardData[key] = values.length === 1 ? values[0] : values; 39 + } 40 + 41 + return cardData; 42 + } 43 + 44 + export async function load({ params, platform, request, url }) { 45 + const cache = createCache(platform); 46 + const actor = await getActor({ request, paramActor: params.actor, platform }); 47 + 48 + if (!actor) { 49 + throw error(404, 'Page not found'); 50 + } 51 + 52 + return await loadCardTypeData( 53 + actor, 54 + params.type, 55 + getCardDataFromSearchParams(url.searchParams), 56 + cache, 57 + env 58 + ); 59 + }
+7
src/routes/[[actor=actor]]/embed/type/[type]/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import EmbeddedCard from '$lib/website/EmbeddedCard.svelte'; 3 + 4 + let { data } = $props(); 5 + </script> 6 + 7 + <EmbeddedCard {data} />