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 18 isEditing?: boolean; 19 19 showOutline?: boolean; 20 20 locked?: boolean; 21 + fillPage?: boolean; 21 22 } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 22 23 23 24 let { ··· 28 29 controls, 29 30 showOutline, 30 31 locked = false, 32 + fillPage = false, 31 33 class: className, 32 34 ...rest 33 35 }: BaseCardProps = $props(); ··· 38 40 <div 39 41 id={item.id} 40 42 data-flip-id={item.id} 43 + data-fill-page={fillPage ? 'true' : undefined} 41 44 bind:this={ref} 42 45 draggable={false} 43 46 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, 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) : '', 46 51 color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? color : '', 47 52 showOutline ? 'outline-2' : '', 48 53 className ··· 65 70 > 66 71 <div 67 72 class={[ 68 - 'text-base-900 dark:text-base-50 relative isolate h-full w-full overflow-hidden rounded-[23px]', 73 + 'text-base-900 dark:text-base-50 relative isolate h-full w-full overflow-hidden', 74 + !fillPage ? 'rounded-[23px]' : '', 69 75 color !== 'base' && color != 'transparent' ? 'light' : '' 70 76 ]} 71 77 > ··· 84 90 85 91 <style> 86 92 .card { 93 + container-name: card; 94 + container-type: size; 87 95 translate: calc((var(--mx) / var(--columns)) * 100cqw + var(--mm)) 88 96 calc((var(--my) / var(--columns)) * 100cqw + var(--mm)); 89 97 width: calc((var(--mw) / var(--columns)) * 100cqw - (var(--mm) * 2)); 90 98 height: calc((var(--mh) / var(--columns)) * 100cqw - (var(--mm) * 2)); 91 99 } 92 100 101 + .card[data-fill-page='true'] { 102 + translate: none; 103 + width: 100%; 104 + height: 100%; 105 + } 106 + 93 107 @container grid (width >= 42rem) { 94 - .card { 108 + .card:not([data-fill-page='true']) { 95 109 translate: calc((var(--dx) / var(--columns)) * 100cqw + var(--dm)) 96 110 calc((var(--dy) / var(--columns)) * 100cqw + var(--dm)); 97 111 width: calc((var(--dw) / var(--columns)) * 100cqw - (var(--dm) * 2));
+15 -5
src/lib/cards/core/LinkCard/EditingLinkCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment'; 3 3 import { getImage, compressImage } from '$lib/helper'; 4 - import { getDidContext, getIsMobile } from '$lib/website/context'; 4 + import { getDidContext } from '$lib/website/context'; 5 5 import type { ContentComponentProps } from '../../types'; 6 6 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 7 7 ··· 49 49 console.error('Failed to process image:', error); 50 50 } 51 51 } 52 - 53 - let isMobile = getIsMobile(); 54 52 55 53 let faviconHasError = $state(false); 56 54 let isFetchingMetadata = $state(false); ··· 291 289 </div> 292 290 </div> 293 291 294 - {#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4))} 292 + {#if hasFetched && browser} 295 293 <button 296 294 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" 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" 298 296 onclick={() => imageInputRef?.click()} 299 297 onmouseenter={() => (isHoveringImage = true)} 300 298 onmouseleave={() => (isHoveringImage = false)} ··· 357 355 {/if} 358 356 </div> 359 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 1 <script lang="ts"> 2 - import { browser } from '$app/environment'; 3 2 import { getImage } from '$lib/helper'; 4 - import { getDidContext, getIsMobile } from '$lib/website/context'; 3 + import { getDidContext } from '$lib/website/context'; 5 4 import type { ContentComponentProps } from '../../types'; 6 5 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 7 6 8 7 let { item, isEditing }: ContentComponentProps = $props(); 9 8 10 - let isMobile = getIsMobile(); 11 - 12 9 let faviconHasError = $state(false); 13 10 14 11 let did = getDidContext(); 15 12 </script> 16 13 17 14 {#if item.cardData.showBackgroundImage && item.cardData.image} 18 - <div class="relative flex h-full flex-col justify-end p-4"> 15 + <div class="link-card relative flex h-full flex-col justify-end p-4"> 19 16 <img 20 17 class="absolute inset-0 -z-10 size-full object-cover" 21 18 src={getImage(item.cardData, did)} ··· 27 24 <div class="text-accent-600 dark:text-accent-400 text-xs font-semibold"> 28 25 {item.cardData.domain} 29 26 </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 - > 27 + <div class="link-title text-base-900 dark:text-base-50 text-lg font-bold"> 36 28 {item.cardData.title} 37 29 </div> 38 30 {#if item.cardData.href && !isEditing} ··· 73 65 {/if} 74 66 </div> 75 67 {:else} 76 - <div class="flex h-full flex-col justify-between p-4"> 77 - <div> 68 + <div class="link-card flex h-full flex-col p-4"> 69 + <div class="link-content min-h-0"> 78 70 <div 79 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" 80 72 > ··· 102 94 </svg> 103 95 {/if} 104 96 </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 - > 97 + <div class="link-title text-base-900 dark:text-base-50 text-lg font-bold"> 111 98 {item.cardData.title} 112 99 </div> 113 100 <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> ··· 118 105 </div> 119 106 </div> 120 107 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 - /> 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> 127 116 {/if} 128 117 {#if item.cardData.href && !isEditing} 129 118 <a ··· 163 152 {/if} 164 153 </div> 165 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 48 <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 49 49 </svg> 50 50 </Button> 51 - <div class="flex items-center space-x- mt-4"> 51 + <div class="space-x- mt-4 flex items-center"> 52 52 <Checkbox 53 53 bind:checked={ 54 54 () => Boolean(item.cardData.showBackgroundImage), ··· 61 61 <Label 62 62 id="show-bg-image-label" 63 63 for="show-bg-image" 64 - class="text-sm leading-none ml-2 font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 64 + class="ml-2 text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 65 65 > 66 66 Show background image 67 67 </Label>
+16 -11
src/lib/cards/media/LivestreamCard/LivestreamCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import Icon from './Icon.svelte'; 4 - import { 5 - getAdditionalUserData, 6 - getDidContext, 7 - getHandleContext, 8 - getIsMobile 9 - } from '$lib/website/context'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 10 5 import type { ContentComponentProps } from '../../types'; 11 6 import { RelativeTime } from '@foxui/time'; 12 7 import { Badge } from '@foxui/core'; ··· 15 10 16 11 let { item = $bindable() }: ContentComponentProps = $props(); 17 12 18 - let isMobile = getIsMobile(); 19 - 20 13 let isLoaded = $state(false); 21 14 22 15 const data = getAdditionalUserData(); ··· 58 51 }); 59 52 </script> 60 53 61 - <div class="h-full overflow-y-scroll p-4"> 54 + <div class="livestream-card h-full overflow-y-scroll p-4"> 62 55 {#if latestLivestream} 63 56 <div class="flex min-h-full flex-col justify-between"> 64 57 <div> ··· 95 88 </a> 96 89 </div> 97 90 98 - {#if browser && ((isMobile() && item.mobileH >= 7) || (!isMobile() && item.h >= 4)) && latestLivestream?.thumb} 91 + {#if browser && latestLivestream?.thumb} 99 92 <a href={latestLivestream?.href} target="_blank" rel="noopener noreferrer"> 100 93 <img 101 - class="my-4 max-h-32 w-full rounded-xl object-cover" 94 + class="livestream-thumb my-4 max-h-32 w-full rounded-xl object-cover" 102 95 src={latestLivestream?.thumb} 103 96 alt="" 104 97 /> ··· 112 105 <div class="flex h-full w-full items-center justify-center">Looking for the latest stream</div> 113 106 {/if} 114 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 1 <script lang="ts"> 2 2 import type { Item } from '$lib/types'; 3 3 import { onMount } from 'svelte'; 4 - import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 5 import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; 6 6 import { loadGrainGalleryData } from './helpers'; 7 7 ··· 61 61 onclick?: () => void; 62 62 }[] 63 63 ); 64 - 65 - let isMobile = getIsMobile(); 66 64 </script> 67 65 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 - /> 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> 74 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 8 9 9 interface Artist { 10 10 artist: string; 11 + name?: string; 11 12 } 12 13 13 14 interface PlayValue {
+2 -1
src/lib/cards/media/TealFMPlaysCard/TealFMPlaysCard.svelte
··· 12 12 13 13 interface PlayValue { 14 14 releaseMbId?: string; 15 + releaseMbid?: string; 15 16 trackName: string; 16 17 playedTime?: string; 17 18 artists?: Artist[]; ··· 52 53 {#snippet musicItem(play: Play)} 53 54 <div class="flex w-full items-center gap-3"> 54 55 <div class="size-10 shrink-0"> 55 - <AlbumArt releaseMbid={play.value.releaseMbid} alt="" /> 56 + <AlbumArt releaseMbid={play.value.releaseMbid ?? play.value.releaseMbId} alt="" /> 56 57 </div> 57 58 <div class="min-w-0 flex-1"> 58 59 <div class="inline-flex w-full max-w-full justify-between gap-2">
+41 -12
src/lib/cards/social/EventCard/EventCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { Badge, Button } from '@foxui/core'; 4 - import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 5 import type { ContentComponentProps } from '../../types'; 6 6 import { CardDefinitionsByType } from '../..'; 7 7 import type { EventData } from '.'; ··· 12 12 13 13 let { item }: ContentComponentProps = $props(); 14 14 15 - let isMobile = getIsMobile(); 16 15 let isLoaded = $state(false); 17 16 let fetchedEventData = $state<EventData | undefined>(undefined); 18 17 ··· 109 108 }; 110 109 }); 111 110 112 - let showImage = $derived( 113 - browser && headerImage() && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) 114 - ); 111 + let showImage = $derived(browser && headerImage()); 115 112 </script> 116 113 117 - <div class="flex h-full flex-col justify-between overflow-hidden p-4"> 114 + <div class="event-card flex h-full flex-col justify-between overflow-hidden p-4"> 118 115 {#if eventData} 119 116 <div class="min-w-0 flex-1 overflow-hidden"> 120 117 <div class="mb-2 flex items-center justify-between gap-2"> ··· 142 139 </Badge> 143 140 </div> 144 141 145 - {#if isMobile() ? item.mobileW > 4 : item.w > 2} 146 - <Button href={eventUrl()} target="_blank" class="z-50">View event</Button> 147 - {/if} 142 + <div class="event-action z-50"> 143 + <Button href={eventUrl()} target="_blank">View event</Button> 144 + </div> 148 145 </div> 149 146 150 147 <h3 class="text-base-900 dark:text-base-50 mb-2 line-clamp-2 text-lg leading-tight font-bold"> ··· 203 200 </div> 204 201 {/if} 205 202 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"> 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 + > 208 207 {eventData.description} 209 208 </p> 210 209 {/if} ··· 213 212 {#if showImage} 214 213 {@const img = headerImage()} 215 214 {#if img} 216 - <img src={img.url} alt={img.alt} class="mt-3 aspect-3/1 w-full rounded-xl object-cover" /> 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 + /> 217 220 {/if} 218 221 {/if} 219 222 ··· 239 242 </div> 240 243 {/if} 241 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 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import type { ContentComponentProps } from '../../types'; 4 - import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context'; 4 + import { getAdditionalUserData, getCanEdit } from '$lib/website/context'; 5 5 import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 6 6 import type { FriendsProfile } from '.'; 7 7 import type { Did } from '@atcute/lexicons'; ··· 9 9 10 10 let { item }: ContentComponentProps = $props(); 11 11 12 - const isMobile = getIsMobile(); 13 12 const canEdit = getCanEdit(); 14 13 const additionalData = getAdditionalUserData(); 15 14 ··· 54 53 } 55 54 }); 56 55 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 56 function removeFriend(did: string) { 65 57 item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did); 66 58 } ··· 84 76 </span> 85 77 {/if} 86 78 {: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;"> 79 + <div class="friends-card"> 80 + <div class="friends-grid flex flex-wrap items-center justify-center"> 91 81 {#each profiles as profile (profile.did)} 92 - <div class="group relative" style="margin: -{olY}px 0 0 -{olX}px;"> 82 + <div class="friends-avatar group relative"> 93 83 <a 94 84 href={getLink(profile)} 95 85 class="accent:ring-accent-500 relative block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 96 86 > 97 - <Avatar 98 - src={profile.avatar} 99 - alt={profile.handle} 100 - class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'} 101 - /> 87 + <Avatar src={profile.avatar} alt={profile.handle} class="friends-avatar-image" /> 102 88 </a> 103 89 {#if canEdit()} 104 90 <button ··· 128 114 </div> 129 115 {/if} 130 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 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { siGithub } from 'simple-icons'; 4 - import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 5 import type { ContentComponentProps } from '../../types'; 6 6 import type { GithubProfileLoadedData } from '.'; 7 7 import GithubContributionsGraph from './GithubContributionsGraph.svelte'; ··· 34 34 } 35 35 } 36 36 }); 37 - 38 - let isMobile = getIsMobile(); 39 37 </script> 40 38 41 - <div class="h-full overflow-hidden p-4"> 39 + <div class="github-profile-card h-full overflow-hidden p-4"> 42 40 <div class="flex h-full flex-col justify-between"> 43 41 <!-- Header --> 44 42 <div class="flex justify-between"> ··· 56 54 </a> 57 55 </div> 58 56 59 - {#if isMobile() ? item.mobileW > 4 : item.w > 2} 57 + <div class="github-follow z-50"> 60 58 <Button 61 59 href="https://github.com/{item.cardData.user}" 62 60 target="_blank" 63 - rel="noopener noreferrer" 64 - class="z-50">Follow</Button 61 + rel="noopener noreferrer">Follow</Button 65 62 > 66 - {/if} 63 + </div> 67 64 </div> 68 65 69 66 {#if contributionsData && browser} 70 67 <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 - /> 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> 75 74 </div> 76 75 {/if} 77 76 </div> ··· 94 93 <span class="sr-only">Show on github</span> 95 94 </a> 96 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 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { Badge } from '@foxui/core'; 4 - import { 5 - getAdditionalUserData, 6 - getDidContext, 7 - getHandleContext, 8 - getIsMobile 9 - } from '$lib/website/context'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 10 5 import type { ContentComponentProps } from '../../types'; 11 6 import { UpcomingEventsCardDefinition } from '.'; 12 7 import type { EventData } from '../EventCard'; ··· 16 11 17 12 let { item }: ContentComponentProps = $props(); 18 13 19 - let isMobile = getIsMobile(); 20 14 let isLoaded = $state(false); 21 15 const data = getAdditionalUserData(); 22 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 1 import { getDetailedProfile, listRecords, resolveHandle, parseUri, getRecord } from '$lib/atproto'; 2 2 import { CardDefinitionsByType } from '$lib/cards'; 3 3 import type { CacheService } from '$lib/cache'; 4 + import { createEmptyCard } from '$lib/helper'; 4 5 import type { Item, WebsiteData } from '$lib/types'; 5 6 import { error } from '@sveltejs/kit'; 6 7 import type { ActorIdentifier, Did } from '@atcute/lexicons'; ··· 85 86 getDetailedProfile({ did }) 86 87 ]); 87 88 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 - } 89 + const additionalData = await loadAdditionalData( 90 + cards.map((v) => ({ ...v.value })) as Item[], 91 + { did, handle, cache }, 92 + env 93 + ); 124 94 125 95 const result = { 126 96 page: 'blento.' + page, ··· 157 127 return checkData(parsedResult); 158 128 } 159 129 160 - function migrateFromV0ToV1(data: WebsiteData): WebsiteData { 161 - for (const card of data.cards) { 162 - if (card.version) continue; 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) { 163 272 card.x *= 2; 164 273 card.y *= 2; 165 274 card.h *= 2; ··· 171 280 card.version = 1; 172 281 } 173 282 174 - return data; 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; 175 292 } 176 293 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; 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); 182 320 } 183 321 } 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 322 191 - if (!cardDef?.migrate) continue; 323 + await Promise.all(Object.values(additionDataPromises)); 192 324 193 - cardDef.migrate(card); 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 + } 194 332 } 195 - return data; 333 + 334 + return additionalData; 196 335 } 197 336 198 337 function checkData(data: WebsiteData): WebsiteData { ··· 214 353 } 215 354 216 355 function migrateData(data: WebsiteData): WebsiteData { 217 - return migrateCards(migrateFromV1ToV2(migrateFromV0ToV1(data))); 356 + for (const card of data.cards) { 357 + migrateCard(card); 358 + } 359 + return data; 218 360 }
+6 -1
src/routes/+layout.svelte
··· 11 11 import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 12 12 13 13 let { children, data } = $props(); 14 + let showThemeToggle = $derived( 15 + !/(?:\/card\/[^/]+|\/embed\/type\/[^/]+)$/.test(page.url.pathname) 16 + ); 14 17 15 18 const errorMessages: Record<string, (params: URLSearchParams) => string> = { 16 19 handle_not_found: (p) => `Handle ${p.get('handle') ?? ''} not found!` ··· 25 28 {@render children()} 26 29 </Tooltip.Provider> 27 30 28 - <ThemeToggle class="fixed top-2 left-2 z-10" /> 31 + {#if showThemeToggle} 32 + <ThemeToggle class="fixed top-2 left-2 z-10" /> 33 + {/if} 29 34 30 35 <Toaster /> 31 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} />