your personal website on atproto - mirror blento.app

update

Florian 07f5d93a 3998e404

+851 -179
+30 -1
README.md
··· 1 - # blento
··· 1 + # blento 2 + 3 + ## todo 4 + 5 + - add navbar buttons for cards 6 + - add basic cards 7 + - move other cards down 8 + - add sections 9 + - add mobile version 10 + - maybe add kv or db for caching? 11 + - add main page 12 + 13 + ## todo later 14 + 15 + - animations when dragging 16 + - add theming 17 + 18 + ## cards 19 + 20 + - link (title, domain, maybe photo) 21 + - photo/video (with optional caption, link) 22 + - text 23 + 24 + ### more cards 25 + 26 + - map 27 + - github 28 + - Goodreads 29 + - social icons 30 + - latest bluesky post
+1
package.json
··· 57 "@tiptap/starter-kit": "^2.12.0", 58 "bits-ui": "^2.14.4", 59 "clsx": "^2.1.1", 60 "marked": "^15.0.11", 61 "svelte-sonner": "^1.0.7", 62 "tailwind-merge": "^3.4.0",
··· 57 "@tiptap/starter-kit": "^2.12.0", 58 "bits-ui": "^2.14.4", 59 "clsx": "^2.1.1", 60 + "gsap": "^3.14.2", 61 "marked": "^15.0.11", 62 "svelte-sonner": "^1.0.7", 63 "tailwind-merge": "^3.4.0",
+8
pnpm-lock.yaml
··· 56 clsx: 57 specifier: ^2.1.1 58 version: 2.1.1 59 marked: 60 specifier: ^15.0.11 61 version: 15.0.11 ··· 1688 1689 graphemer@1.4.0: 1690 resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==, tarball: https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz} 1691 1692 has-flag@4.0.0: 1693 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz} ··· 4124 graceful-fs@4.2.11: {} 4125 4126 graphemer@1.4.0: {} 4127 4128 has-flag@4.0.0: {} 4129
··· 56 clsx: 57 specifier: ^2.1.1 58 version: 2.1.1 59 + gsap: 60 + specifier: ^3.14.2 61 + version: 3.14.2 62 marked: 63 specifier: ^15.0.11 64 version: 15.0.11 ··· 1691 1692 graphemer@1.4.0: 1693 resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==, tarball: https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz} 1694 + 1695 + gsap@3.14.2: 1696 + resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==, tarball: https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz} 1697 1698 has-flag@4.0.0: 1699 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz} ··· 4130 graceful-fs@4.2.11: {} 4131 4132 graphemer@1.4.0: {} 4133 + 4134 + gsap@3.14.2: {} 4135 4136 has-flag@4.0.0: {} 4137
+1 -1
src/app.html
··· 6 %sveltekit.head% 7 8 </head> 9 - <body data-sveltekit-preload-data="hover" class="bg-base-50 dark:bg-base-950"> 10 <div style="display: contents">%sveltekit.body%</div> 11 </body> 12 </html>
··· 6 %sveltekit.head% 7 8 </head> 9 + <body data-sveltekit-preload-data="hover" class="bg-base-100 dark:bg-base-950"> 10 <div style="display: contents">%sveltekit.body%</div> 11 </body> 12 </html>
+341
src/lib/EditableWebsite.svelte
···
··· 1 + <script lang="ts"> 2 + import { setContext } from 'svelte'; 3 + import { BlueskyLogin, Button, Navbar, toast, Toaster } from './website/foxui'; 4 + import { client, login } from '$lib/oauth/auth.svelte.js'; 5 + 6 + import { settingsModal } from './website/components/head/EditHead.svelte'; 7 + import HeadItem from './website/components/head/HeadItem.svelte'; 8 + import { 9 + setDataContext, 10 + setDidContext, 11 + setIsEditing, 12 + setUpdateFunctionsContext, 13 + type UpdateFunction 14 + } from './website/context'; 15 + 16 + import { margin } from '$lib'; 17 + import EditingImageCard from './cards/ImageCard/EditingImageCard.svelte'; 18 + import { cardsEqual, clamp, fixCollisions, overlaps, sortItems } from './helper'; 19 + import Profile from './Profile.svelte'; 20 + import type { Item } from './types'; 21 + import { deleteRecord, putRecord } from './oauth/atproto'; 22 + import { innerWidth } from 'svelte/reactivity/window'; 23 + import { TID } from '@atproto/common-web'; 24 + 25 + let { 26 + handle, 27 + did, 28 + data, 29 + items: originalItems 30 + }: { handle: string; did: string; data: any; items: Item[] } = $props(); 31 + 32 + // svelte-ignore state_referenced_locally 33 + let items: Item[] = $state(originalItems); 34 + 35 + let updateFunctions: UpdateFunction[] = $state([]); 36 + 37 + setIsEditing(true); 38 + // svelte-ignore state_referenced_locally 39 + setDidContext(data.did); 40 + setUpdateFunctionsContext(updateFunctions); 41 + // svelte-ignore state_referenced_locally 42 + setContext('current', data.current); 43 + // svelte-ignore state_referenced_locally 44 + setDataContext(data.data); 45 + 46 + let container: HTMLDivElement | undefined = $state(); 47 + 48 + let activeDragElement: { 49 + element: HTMLDivElement | null; 50 + item: Item | null; 51 + w: number; 52 + h: number; 53 + x: number; 54 + y: number; 55 + mouseDeltaX: number; 56 + mouseDeltaY: number; 57 + } = $state({ 58 + element: null, 59 + item: null, 60 + w: 0, 61 + h: 0, 62 + x: -1, 63 + y: -1, 64 + mouseDeltaX: 0, 65 + mouseDeltaY: 0 66 + }); 67 + 68 + let isMobile = $derived((innerWidth.current ?? 1000) < 768); 69 + 70 + const getX = (item: Item) => (isMobile ? (item.mobileX ?? item.x) : item.x); 71 + const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 72 + const getW = (item: Item) => (isMobile ? (item.mobileW ?? item.w) : item.w); 73 + const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 74 + 75 + let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 76 + $inspect(maxHeight); 77 + </script> 78 + 79 + <Profile {handle} {did} /> 80 + 81 + <div class="md:grid md:grid-cols-3"> 82 + <div></div> 83 + <!-- svelte-ignore a11y_no_static_element_interactions --> 84 + <div 85 + bind:this={container} 86 + ondragover={(e) => { 87 + e.preventDefault(); 88 + if (!container) return; 89 + 90 + const x = e.clientX + activeDragElement.mouseDeltaX; 91 + const y = e.clientY + activeDragElement.mouseDeltaY; 92 + const rect = container.getBoundingClientRect(); 93 + 94 + let gridX = clamp( 95 + Math.floor(((x - rect.left) / rect.width) * 4), 96 + 0, 97 + 4 - (activeDragElement.w ?? 0) 98 + ); 99 + let gridY = Math.max(Math.floor(((y - rect.top) / rect.width) * 4), 0); 100 + if (isMobile) { 101 + gridX = Math.floor(gridX / 2) * 2; 102 + gridY = Math.floor(gridY / 2) * 2; 103 + } 104 + 105 + activeDragElement.x = gridX; 106 + activeDragElement.y = gridY; 107 + }} 108 + ondragend={async (e) => { 109 + e.preventDefault(); 110 + if (!container) return; 111 + 112 + const x = e.clientX + activeDragElement.mouseDeltaX; 113 + const y = e.clientY + activeDragElement.mouseDeltaY; 114 + const rect = container.getBoundingClientRect(); 115 + 116 + let gridX = clamp( 117 + Math.floor(((x - rect.left) / rect.width) * 4), 118 + 0, 119 + 4 - (activeDragElement.w ?? 0) 120 + ); 121 + let gridY = Math.max(Math.floor(((y - rect.top) / rect.width) * 4), 0); 122 + if (isMobile) { 123 + gridX = Math.floor(gridX / 2) * 2; 124 + gridY = Math.floor(gridY / 2) * 2; 125 + } 126 + 127 + if (activeDragElement.item) { 128 + if (isMobile) { 129 + activeDragElement.item.mobileX = gridX; 130 + activeDragElement.item.mobileY = gridY; 131 + } else { 132 + activeDragElement.item.x = gridX; 133 + activeDragElement.item.y = gridY; 134 + } 135 + 136 + fixCollisions(items, activeDragElement.item, isMobile); 137 + } 138 + activeDragElement.x = -1; 139 + activeDragElement.y = -1; 140 + activeDragElement.element = null; 141 + return true; 142 + }} 143 + class="relative col-span-2 p-8" 144 + style="container-type: inline-size;" 145 + > 146 + {#each items.toSorted(sortItems) as item} 147 + <EditingImageCard 148 + ondragstart={(e) => { 149 + const target = e.target as HTMLDivElement; 150 + activeDragElement.element = target; 151 + activeDragElement.w = item.w; 152 + activeDragElement.h = item.h; 153 + activeDragElement.item = item; 154 + 155 + const rect = target.getBoundingClientRect(); 156 + activeDragElement.mouseDeltaX = rect.left + margin - e.clientX; 157 + activeDragElement.mouseDeltaY = rect.top - e.clientY; 158 + }} 159 + {item} 160 + ondelete={() => { 161 + items = items.filter((it) => it !== item); 162 + }} 163 + onsetsize={(newW: number, newH: number) => { 164 + if (isMobile) { 165 + item.mobileW = newW * 2; 166 + item.mobileH = newH * 2; 167 + } else { 168 + item.w = newW; 169 + item.h = newH; 170 + } 171 + 172 + fixCollisions(items, item, isMobile); 173 + }} 174 + onshowsettings={() => { 175 + toast('No settings available for this card yet.', { 176 + description: 'More settings will be added in the future.' 177 + }); 178 + }} 179 + /> 180 + {/each} 181 + 182 + {#if activeDragElement.element && activeDragElement.x >= 0 && activeDragElement.item} 183 + {@const item = activeDragElement} 184 + <div 185 + class={['bg-base-500/10 absolute aspect-square rounded-2xl']} 186 + style={`translate: calc(${(item.x / 4) * 100}cqw + ${margin / 2}px) calc(${(item.y / 4) * 100}cqw + ${margin / 2}px); 187 + 188 + width: calc(${(getW(activeDragElement.item) / 4) * 100}cqw - ${margin}px); 189 + height: calc(${(getH(activeDragElement.item) / 4) * 100}cqw - ${margin}px);`} 190 + ></div> 191 + {/if} 192 + <div style="height: {((maxHeight + 1) / 4) * 100}cqw;"></div> 193 + </div> 194 + </div> 195 + 196 + <HeadItem collection="com.example.head" /> 197 + 198 + <Navbar 199 + class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto" 200 + > 201 + <div class="flex items-center gap-2"> 202 + <Button 203 + size="iconLg" 204 + variant="ghost" 205 + class="backdrop-blur-none" 206 + onclick={() => (settingsModal.show = true)} 207 + > 208 + <svg 209 + xmlns="http://www.w3.org/2000/svg" 210 + fill="none" 211 + viewBox="0 0 24 24" 212 + stroke-width="1.5" 213 + stroke="currentColor" 214 + > 215 + <path 216 + stroke-linecap="round" 217 + stroke-linejoin="round" 218 + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 219 + /> 220 + <path 221 + stroke-linecap="round" 222 + stroke-linejoin="round" 223 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 224 + /> 225 + </svg> 226 + </Button> 227 + <Button 228 + size="iconLg" 229 + variant="ghost" 230 + class="backdrop-blur-none" 231 + onclick={() => { 232 + let newItem: Item = { 233 + id: TID.nextStr(), 234 + x: 0, 235 + y: 0, 236 + w: 1, 237 + h: 1, 238 + mobileH: 2, 239 + mobileW: 2, 240 + mobileX: 0, 241 + mobileY: 0, 242 + cardType: 'image', 243 + cardData: { 244 + image: `https://picsum.photos/seed/1${crypto.randomUUID()}/800/800`, 245 + href: 'https://example.com', 246 + hrefText: 'Visit example page' 247 + } 248 + }; 249 + 250 + let foundPosition = false; 251 + while (!foundPosition) { 252 + for (newItem.x = 0; newItem.x <= 4 - newItem.w; newItem.x++) { 253 + let collision = items.find((item) => overlaps(newItem, item)); 254 + console.log('checking position', newItem.x, newItem.y, 'collision:', collision); 255 + if (!collision) { 256 + foundPosition = true; 257 + break; 258 + } 259 + } 260 + if (!foundPosition) newItem.y += 1; 261 + } 262 + 263 + let foundMobilePosition = false; 264 + while (!foundMobilePosition) { 265 + for (newItem.mobileX = 0; newItem.mobileX <= 4 - newItem.mobileW; newItem.mobileX += 1) { 266 + let collision = items.find((item) => overlaps(newItem, item, true)); 267 + 268 + if (!collision) { 269 + foundMobilePosition = true; 270 + break; 271 + } 272 + } 273 + if (!foundMobilePosition) newItem.mobileY! += 2; 274 + } 275 + 276 + items = [...items, newItem]; 277 + }} 278 + > 279 + <svg 280 + xmlns="http://www.w3.org/2000/svg" 281 + fill="none" 282 + viewBox="0 0 24 24" 283 + stroke-width="1.5" 284 + stroke="currentColor" 285 + > 286 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 287 + </svg> 288 + </Button> 289 + </div> 290 + <div class="flex items-center gap-2"> 291 + {#if client.isInitializing}{:else if client.isLoggedIn} 292 + <Button 293 + onclick={async () => { 294 + // check if did is same 295 + if (client?.profile?.did !== data.did) { 296 + toast('Not authorized', { 297 + description: 'Please login with the correct account' 298 + }); 299 + return; 300 + } 301 + 302 + for (const updateFunction of updateFunctions) { 303 + await updateFunction(); 304 + } 305 + 306 + // find all cards that have been updated (where items differ from originalItems) 307 + for (let item of items) { 308 + const originalItem = originalItems.find((i) => cardsEqual(i, item)); 309 + 310 + if (!originalItem) { 311 + console.log('updated or new item', item); 312 + await putRecord({ collection: 'com.example.bento', rkey: item.id, record: item }); 313 + } 314 + } 315 + 316 + // delete items that are in originalItems but not in items 317 + for (let originalItem of originalItems) { 318 + const item = items.find((i) => i.id === originalItem.id); 319 + if (!item) { 320 + console.log('deleting item', originalItem); 321 + await deleteRecord({ collection: 'com.example.bento', rkey: originalItem.id, did }); 322 + } 323 + } 324 + 325 + toast('Saved', { 326 + description: 'Your website has been saved!' 327 + }); 328 + }}>Save</Button 329 + > 330 + {:else} 331 + <BlueskyLogin 332 + login={async (handle) => { 333 + await login(handle); 334 + return true; 335 + }} 336 + /> 337 + {/if} 338 + </div> 339 + </Navbar> 340 + 341 + <Toaster />
+9
src/lib/Favicon.svelte
···
··· 1 + <script lang="ts"> 2 + let { favicon }: { favicon: string | null } = $props(); 3 + </script> 4 + 5 + <svelte:head> 6 + {#if favicon} 7 + <link rel="icon" href={favicon} /> 8 + {/if} 9 + </svelte:head>
+33
src/lib/Profile.svelte
···
··· 1 + <script lang="ts"> 2 + import Favicon from './Favicon.svelte'; 3 + import { MarkdownText, SingleRecord } from './website/components'; 4 + 5 + let { handle, did }: { handle: string; did: string } = $props(); 6 + </script> 7 + 8 + <div class="flex px-12 py-24 md:fixed md:h-screen md:w-1/3"> 9 + <div class="flex flex-col gap-4"> 10 + <SingleRecord collection="app.bsky.actor.profile" rkey="self"> 11 + {#snippet child(data)} 12 + <Favicon 13 + favicon={'https://cdn.bsky.app/img/avatar/plain/' + 14 + did + 15 + '/' + 16 + data.value.avatar.ref.$link} 17 + /> 18 + <img 19 + class="rounded-fulll size-44 rounded-full" 20 + src={'https://cdn.bsky.app/img/avatar/plain/' + did + '/' + data.value.avatar.ref.$link} 21 + alt="" 22 + /> 23 + <div class="line-clamp-2 text-4xl font-bold wrap-anywhere">{handle}</div> 24 + 25 + <div 26 + class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline line-clamp-3" 27 + > 28 + <MarkdownText key="description" {data} /> 29 + </div> 30 + {/snippet} 31 + </SingleRecord> 32 + </div> 33 + </div>
+13 -160
src/lib/Website.svelte
··· 1 <script lang="ts"> 2 - import { MarkdownText, SingleRecord } from './website/components'; 3 4 - let { handle, did }: { handle: string; did: string } = $props(); 5 - 6 - let colors = [ 7 - 'bg-red-500', 8 - 'bg-orange-500', 9 - 'bg-amber-500', 10 - 'bg-yellow-500', 11 - 'bg-lime-500', 12 - 'bg-green-500', 13 - 'bg-emerald-500', 14 - 'bg-teal-500', 15 - 'bg-cyan-500', 16 - 'bg-sky-500', 17 - 'bg-blue-500', 18 - 'bg-indigo-500', 19 - 'bg-violet-500', 20 - 'bg-purple-500', 21 - 'bg-fuchsia-500', 22 - 'bg-pink-500', 23 - 'bg-rose-500' 24 - ]; 25 26 - const items = [ 27 - { id: '0', x: 0, y: 0, w: 2, h: 1 }, 28 - { id: '1', x: 2, y: 2, w: 2, h: 2 }, 29 - { id: '2', x: 2, y: 0, w: 1, h: 2 }, 30 - { id: '2', x: 1, y: 1, w: 1, h: 2 }, 31 - { id: '3', x: 0, y: 3, w: 2, h: 2 }, 32 - { id: '4', x: 3, y: 0, w: 1, h: 1 }, 33 - { id: '5', x: 2, y: 4, w: 2, h: 1 } 34 - ]; 35 - 36 - const combined = $state( 37 - items.map((item, index) => ({ 38 - ...item, 39 - color: colors[index % colors.length], 40 - id: Math.random().toFixed(4) 41 - })) 42 - ); 43 - 44 - let maxHeight = $derived(combined.reduce((max, item) => Math.max(max, item.y + item.h), 0)); 45 - 46 - const margin = 16; 47 48 let container: HTMLDivElement | undefined = $state(); 49 - 50 - type Item = { 51 - w: number; 52 - h: number; 53 - x: number; 54 - y: number; 55 - }; 56 - 57 - let activeDragElement: { 58 - element: HTMLDivElement | null; 59 - item: Item | null; 60 - w: number; 61 - h: number; 62 - x: number; 63 - y: number; 64 - mouseDeltaX: number; 65 - mouseDeltaY: number; 66 - } = $state({ 67 - element: null, 68 - item: null, 69 - w: 0, 70 - h: 0, 71 - x: -1, 72 - y: -1, 73 - mouseDeltaX: 0, 74 - mouseDeltaY: 0 75 - }); 76 </script> 77 78 - <div class="flex px-12 py-24 md:fixed md:h-screen md:w-1/3"> 79 - <div class="flex flex-col gap-4"> 80 - <SingleRecord collection="app.bsky.actor.profile" rkey="self"> 81 - {#snippet child(data)} 82 - <img 83 - class="rounded-fulll size-44 rounded-full" 84 - src={'https://cdn.bsky.app/img/avatar/plain/' + did + '/' + data.value.avatar.ref.$link} 85 - alt="" 86 - /> 87 - <div class="line-clamp-2 text-4xl font-bold wrap-anywhere">{handle}</div> 88 89 - <div 90 - class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline" 91 - > 92 - <MarkdownText key="description" {data} /> 93 - </div> 94 - {/snippet} 95 - </SingleRecord> 96 - </div> 97 - </div> 98 <div class="md:grid md:grid-cols-3"> 99 <div></div> 100 - <!-- svelte-ignore a11y_no_static_element_interactions --> 101 <div 102 bind:this={container} 103 - ondragover={(e) => { 104 - e.preventDefault(); 105 - if (!container) return; 106 - 107 - const x = e.clientX + activeDragElement.mouseDeltaX; 108 - const y = e.clientY + activeDragElement.mouseDeltaY; 109 - const rect = container.getBoundingClientRect(); 110 - 111 - const gridX = Math.floor(((x - rect.left) / rect.width) * 4); 112 - const gridY = Math.floor(((y - rect.top) / rect.width) * 4); 113 - 114 - activeDragElement.x = gridX; 115 - activeDragElement.y = gridY; 116 - }} 117 - ondragend={(e) => { 118 - e.preventDefault(); 119 - if (!container) return; 120 - 121 - const x = e.clientX + activeDragElement.mouseDeltaX; 122 - const y = e.clientY + activeDragElement.mouseDeltaY; 123 - const rect = container.getBoundingClientRect(); 124 - 125 - const gridX = Math.floor(((x - rect.left) / rect.width) * 4); 126 - const gridY = Math.floor(((y - rect.top) / rect.width) * 4); 127 - 128 - activeDragElement.item.x = gridX; 129 - activeDragElement.item.y = gridY; 130 - activeDragElement.x = -1; 131 - activeDragElement.y = -1; 132 - activeDragElement.element = null; 133 - return true; 134 - }} 135 - class="relative col-span-2 container h-fit w-full p-8" 136 style="container-type: inline-size;" 137 > 138 - {#each combined as item} 139 - <div 140 - ondragstart={(e) => { 141 - const target = e.target as HTMLDivElement; 142 - activeDragElement.element = target; 143 - activeDragElement.w = item.w; 144 - activeDragElement.h = item.h; 145 - activeDragElement.item = item; 146 - 147 - const rect = target.getBoundingClientRect(); 148 - 149 - activeDragElement.mouseDeltaX = rect.left + 16 - e.clientX; 150 - activeDragElement.mouseDeltaY = rect.top - e.clientY; 151 - console.log(activeDragElement.mouseDeltaY); 152 - 153 - console.log(e); 154 - }} 155 - draggable="true" 156 - class={['absolute aspect-square rounded-2xl', item.color]} 157 - style={`translate: calc(${(item.x / 4) * 100}cqw + ${margin}px) calc(${(item.y / 4) * 100}cqw + ${margin}px); 158 - width: calc(${(item.w / 4) * 100}cqw - ${margin * 2}px); 159 - height: calc(${(item.h / 4) * 100}cqw - ${margin * 2}px);`} 160 - > 161 - <a href="#" tabindex={item.y * 4 + item.x + 1}>test</a> 162 - </div> 163 {/each} 164 - 165 - {#if activeDragElement.element && activeDragElement.x >= 0} 166 - {@const item = activeDragElement} 167 - <div 168 - class={['bg-base-500/20 absolute aspect-square rounded-2xl']} 169 - style={`translate: calc(${(item.x / 4) * 100}cqw + ${margin / 2}px) calc(${(item.y / 4) * 100}cqw + ${margin / 2}px); 170 - 171 - width: calc(${(item.w / 4) * 100}cqw - ${margin}px); 172 - height: calc(${(item.h / 4) * 100}cqw - ${margin}px);`} 173 - ></div> 174 - {/if} 175 - <div style="height: {((maxHeight + 1) / 4) * 100}cqw;"></div> 176 </div> 177 </div>
··· 1 <script lang="ts"> 2 + import ImageCard from './cards/ImageCard/ImageCard.svelte'; 3 + import { sortItems } from './helper'; 4 + import Profile from './Profile.svelte'; 5 + import type { Item } from './types'; 6 7 + let { handle, did, items }: { handle: string; did: string, items: Item[] } = $props(); 8 9 + let maxHeight = $derived(items.reduce((max, item) => Math.max(max, item.y + item.h), 0)); 10 11 let container: HTMLDivElement | undefined = $state(); 12 </script> 13 14 + <Profile {handle} {did} /> 15 16 <div class="md:grid md:grid-cols-3"> 17 <div></div> 18 <div 19 bind:this={container} 20 + class="relative col-span-2 p-8" 21 style="container-type: inline-size;" 22 > 23 + {#each items.toSorted(sortItems) as item} 24 + <ImageCard 25 + {item} 26 + /> 27 {/each} 28 + <div style="height: {((maxHeight) / 4) * 100}cqw;"></div> 29 </div> 30 </div>
+49
src/lib/cards/Card/Card.svelte
···
··· 1 + <script lang="ts"> 2 + import { margin } from '$lib'; 3 + import type { Item } from '$lib/types'; 4 + import type { WithElementRef } from 'bits-ui'; 5 + import type { Snippet } from 'svelte'; 6 + import type { HTMLAttributes } from 'svelte/elements'; 7 + import { innerWidth } from 'svelte/reactivity/window'; 8 + 9 + export type CardProps = { 10 + item: Item; 11 + controls?: Snippet<[]>; 12 + isEditing?: boolean; 13 + } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 14 + 15 + let { 16 + item, 17 + children, 18 + ref = $bindable(null), 19 + isEditing = false, 20 + controls, 21 + ...rest 22 + }: CardProps = $props(); 23 + 24 + let isMobile = $derived((innerWidth.current ?? 1000) < 768); 25 + 26 + const getX = () => (isMobile ? (item.mobileX ?? item.x) : item.x); 27 + const getY = () => (isMobile ? (item.mobileY ?? item.y) : item.y); 28 + const getW = () => (isMobile ? (item.mobileW ?? item.w) : item.w); 29 + const getH = () => (isMobile ? (item.mobileH ?? item.h) : item.h); 30 + </script> 31 + 32 + <div 33 + id={item.id} 34 + data-flip-id={item.id} 35 + bind:this={ref} 36 + draggable={isEditing} 37 + class={[ 38 + 'card border-base-200 bg-base-50 group dark:border-base-800 dark:bg-base-900 focus-within:outline-accent-500 absolute z-0 rounded-2xl border outline-offset-2 focus-within:outline-2' 39 + ]} 40 + style={`translate: calc(${(getX() / 4) * 100}cqw + ${margin}px) calc(${(getY() / 4) * 100}cqw + ${margin}px); 41 + width: calc(${(getW() / 4) * 100}cqw - ${margin * 2}px); 42 + height: calc(${(getH() / 4) * 100}cqw - ${margin * 2}px);`} 43 + {...rest} 44 + > 45 + <div class="relative h-full w-full overflow-hidden rounded-[15px]"> 46 + {@render children?.()} 47 + </div> 48 + {@render controls?.()} 49 + </div>
+126
src/lib/cards/Card/EditingCard.svelte
···
··· 1 + <script lang="ts"> 2 + import type { WithElementRef } from 'bits-ui'; 3 + import type { HTMLAttributes } from 'svelte/elements'; 4 + import Card from './Card.svelte'; 5 + import type { Item } from '$lib/types'; 6 + 7 + export type EditingCardProps = { 8 + item: Item; 9 + ondelete: () => void; 10 + onsetsize: (newW: number, newH: number) => void; 11 + onshowsettings: () => void; 12 + } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 13 + 14 + let { 15 + item, 16 + children, 17 + ref = $bindable(null), 18 + onsetsize, 19 + onshowsettings, 20 + ondelete, 21 + ...rest 22 + }: EditingCardProps = $props(); 23 + </script> 24 + 25 + <Card {item} {...rest} isEditing={true} bind:ref> 26 + {@render children?.()} 27 + 28 + {#snippet controls()} 29 + <button 30 + onclick={() => { 31 + ondelete(); 32 + }} 33 + class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 absolute -top-3 -left-3 hidden cursor-pointer items-center justify-center rounded-full border p-2 shadow-lg group-focus-within:inline-flex group-hover:inline-flex" 34 + > 35 + <svg 36 + xmlns="http://www.w3.org/2000/svg" 37 + fill="none" 38 + viewBox="0 0 24 24" 39 + stroke-width="1.5" 40 + stroke="currentColor" 41 + class="size-4" 42 + > 43 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 44 + </svg> 45 + <span class="sr-only">Delete card</span> 46 + </button> 47 + 48 + <div 49 + class=" absolute -bottom-3 z-50 hidden w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover:inline-flex" 50 + > 51 + <div 52 + class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 inline-flex gap-0.5 rounded-2xl border p-1 px-2 shadow-lg" 53 + > 54 + <button 55 + onclick={() => { 56 + onsetsize?.(1, 1); 57 + }} 58 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 59 + > 60 + <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 61 + 62 + <span class="sr-only">set size to 1x1</span> 63 + </button> 64 + 65 + <button 66 + onclick={() => { 67 + onsetsize?.(2, 1); 68 + }} 69 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 70 + > 71 + <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 72 + <span class="sr-only">set size to 2x1</span> 73 + </button> 74 + <button 75 + onclick={() => { 76 + onsetsize?.(1, 2); 77 + }} 78 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 79 + > 80 + <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 81 + 82 + <span class="sr-only">set size to 1x2</span> 83 + </button> 84 + <button 85 + onclick={() => { 86 + onsetsize?.(2, 2); 87 + }} 88 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 89 + > 90 + <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 91 + 92 + <span class="sr-only">set size to 2x2</span> 93 + </button> 94 + 95 + <button 96 + onclick={() => { 97 + onshowsettings(); 98 + }} 99 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 100 + > 101 + <svg 102 + xmlns="http://www.w3.org/2000/svg" 103 + fill="none" 104 + viewBox="0 0 24 24" 105 + stroke-width="2" 106 + stroke="currentColor" 107 + class="size-5" 108 + > 109 + <path 110 + stroke-linecap="round" 111 + stroke-linejoin="round" 112 + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 113 + /> 114 + <path 115 + stroke-linecap="round" 116 + stroke-linejoin="round" 117 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 118 + /> 119 + </svg> 120 + 121 + <span class="sr-only">open card settings</span> 122 + </button> 123 + </div> 124 + </div> 125 + {/snippet} 126 + </Card>
+30
src/lib/cards/ImageCard/EditingImageCard.svelte
···
··· 1 + <script lang="ts"> 2 + import EditingCard, { type EditingCardProps } from '../Card/EditingCard.svelte'; 3 + 4 + let { item, ...rest }: EditingCardProps = $props(); 5 + </script> 6 + 7 + <EditingCard {item} {...rest}> 8 + {#key item.cardData.image} 9 + <img 10 + class={[ 11 + 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 12 + item.cardData.href ? 'group-hover:scale-105' : '' 13 + ]} 14 + src={item.cardData.image} 15 + alt="" 16 + /> 17 + {/key} 18 + {#if item.cardData.href} 19 + <a 20 + href={item.cardData.href} 21 + class="absolute inset-0 h-full w-full" 22 + target="_blank" 23 + rel="noopener noreferrer" 24 + > 25 + <span class="sr-only"> 26 + {item.cardData.hrefText ?? 'Learn more'} 27 + </span> 28 + </a> 29 + {/if} 30 + </EditingCard>
+49
src/lib/cards/ImageCard/ImageCard.svelte
···
··· 1 + <script lang="ts"> 2 + import Card, { type CardProps } from '../Card/Card.svelte'; 3 + 4 + let { item, ...rest }: CardProps = $props(); 5 + </script> 6 + 7 + <Card {item} {...rest}> 8 + {#key item.cardData.image} 9 + <img 10 + class={[ 11 + 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 12 + item.cardData.href ? 'group-hover:scale-105' : '' 13 + ]} 14 + src={item.cardData.image} 15 + alt="" 16 + /> 17 + {/key} 18 + {#if item.cardData.href} 19 + <a 20 + href={item.cardData.href} 21 + class="absolute inset-0 h-full w-full" 22 + target="_blank" 23 + rel="noopener noreferrer" 24 + > 25 + <span class="sr-only"> 26 + {item.cardData.hrefText ?? 'Learn more'} 27 + </span> 28 + 29 + <div 30 + class="bg-base-800/30 border-base-900/30 absolute top-2 right-2 rounded-full border p-1 text-white opacity-50 backdrop-blur-lg group-focus-within:opacity-100 group-hover:opacity-100" 31 + > 32 + <svg 33 + xmlns="http://www.w3.org/2000/svg" 34 + fill="none" 35 + viewBox="0 0 24 24" 36 + stroke-width="2.5" 37 + stroke="currentColor" 38 + class="size-4" 39 + > 40 + <path 41 + stroke-linecap="round" 42 + stroke-linejoin="round" 43 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 44 + /> 45 + </svg> 46 + </div> 47 + </a> 48 + {/if} 49 + </Card>
+3
src/lib/cards/ImageCard/index.ts
···
··· 1 + export const ImageCardDefinition = { 2 + type: 'card.image' 3 + };
+116
src/lib/helper.ts
···
··· 1 + import type { Item } from './types'; 2 + 3 + export function clamp(value: number, min: number, max: number): number { 4 + return Math.min(Math.max(value, min), max); 5 + } 6 + 7 + export const colors = [ 8 + 'bg-red-500', 9 + 'bg-orange-500', 10 + 'bg-amber-500', 11 + 'bg-yellow-500', 12 + 'bg-lime-500', 13 + 'bg-green-500', 14 + 'bg-emerald-500', 15 + 'bg-teal-500', 16 + 'bg-cyan-500', 17 + 'bg-sky-500', 18 + 'bg-blue-500', 19 + 'bg-indigo-500', 20 + 'bg-violet-500', 21 + 'bg-purple-500', 22 + 'bg-fuchsia-500', 23 + 'bg-pink-500', 24 + 'bg-rose-500' 25 + ]; 26 + 27 + export const overlaps = (a: Item, b: Item, mobile: boolean = false) => { 28 + if (a === b) return false; 29 + if (mobile) { 30 + return ( 31 + a.mobileX < b.mobileX + b.mobileW && 32 + a.mobileX + a.mobileW > b.mobileX && 33 + a.mobileY < b.mobileY + b.mobileH && 34 + a.mobileY + a.mobileH > b.mobileY 35 + ); 36 + } 37 + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; 38 + }; 39 + 40 + export function fixCollisions(items: Item[], movedItem: Item, mobile: boolean = false) { 41 + const COLS = 4; 42 + 43 + const clampX = (item: Item) => { 44 + if (mobile) item.mobileX = clamp(item.mobileX, 0, COLS - item.mobileW); 45 + else item.x = clamp(item.x, 0, COLS - item.w); 46 + }; 47 + 48 + // Push `target` down until it no longer overlaps with any item (including movedItem), 49 + // while keeping target.x fixed. Any item we collide with gets pushed down first (cascade). 50 + const pushDownCascade = (target: Item, blocker: Item) => { 51 + // Keep x fixed always when pushing down 52 + const fixedX = mobile ? target.mobileX : target.x; 53 + 54 + // We need target to move just below `blocker` 55 + const desiredY = mobile ? blocker.mobileY + blocker.mobileH : blocker.y + blocker.h; 56 + if (!mobile && target.y < desiredY) target.y = desiredY; 57 + if (mobile && target.mobileY < desiredY) target.mobileY = desiredY; 58 + 59 + // Now resolve any collisions that creates by pushing those items down first 60 + // Repeat until target is clean. 61 + while (true) { 62 + const hit = items.find((it) => it !== target && overlaps(target, it, mobile)); 63 + if (!hit) break; 64 + 65 + // push the hit item down first (cascade), keeping its x fixed 66 + pushDownCascade(hit, target); 67 + 68 + // after moving the hit item, target.x must remain fixed 69 + if (mobile) target.mobileX = fixedX; 70 + else target.x = fixedX; 71 + } 72 + }; 73 + 74 + // Ensure moved item is in bounds 75 + clampX(movedItem); 76 + 77 + // Find all items colliding with movedItem, and push them down in a stable order: 78 + // top-to-bottom so you get the nice chain reaction (0,0 -> 0,1 -> 0,2). 79 + const colliders = items 80 + .filter((it) => it !== movedItem && overlaps(movedItem, it, mobile)) 81 + .toSorted((a, b) => 82 + mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 83 + ); 84 + 85 + for (const it of colliders) { 86 + // keep x clamped, but do NOT change x during push (we rely on fixed x) 87 + clampX(it); 88 + 89 + // push it down just below movedItem; cascade handles the rest 90 + pushDownCascade(it, movedItem); 91 + 92 + // enforce "x stays the same" during pushing (clamp already applied) 93 + if (mobile) it.mobileX = clamp(it.mobileX, 0, COLS - it.mobileW); 94 + else it.x = clamp(it.x, 0, COLS - it.w); 95 + } 96 + } 97 + 98 + export function sortItems(a: Item, b: Item) { 99 + return a.y * 4 + a.x - b.y * 4 - b.x; 100 + } 101 + 102 + export function cardsEqual(a: Item, b: Item) { 103 + return ( 104 + a.id === b.id && 105 + a.cardType === b.cardType && 106 + JSON.stringify(a.cardData) === JSON.stringify(b.cardData) && 107 + a.w === b.w && 108 + a.h === b.h && 109 + a.mobileW === b.mobileW && 110 + a.mobileH === b.mobileH && 111 + a.x === b.x && 112 + a.y === b.y && 113 + a.mobileX === b.mobileX && 114 + a.mobileY === b.mobileY 115 + ); 116 + }
+1
src/lib/index.ts
··· 1 // place files you want to import through the `$lib` alias in this folder.
··· 1 // place files you want to import through the `$lib` alias in this folder. 2 + export const margin = 16;
+18
src/lib/types.ts
···
··· 1 + export type Item = { 2 + id: string; 3 + 4 + w: number; 5 + h: number; 6 + x: number; 7 + y: number; 8 + 9 + mobileW: number; 10 + mobileH: number; 11 + mobileX: number; 12 + mobileY: number; 13 + 14 + cardType: string; 15 + 16 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 + cardData: any; 18 + };
+1 -1
src/lib/website/EditingWebsiteWrapper.svelte
··· 27 28 <HeadItem collection="com.example.head" /> 29 30 - <Navbar class="bg-base-900 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto"> 31 <div class="flex items-center gap-2"> 32 <Button size="iconLg" variant="ghost" class="backdrop-blur-none" href={base + '/'}> 33 <span class="sr-only">home</span>
··· 27 28 <HeadItem collection="com.example.head" /> 29 30 + <Navbar class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto"> 31 <div class="flex items-center gap-2"> 32 <Button size="iconLg" variant="ghost" class="backdrop-blur-none" href={base + '/'}> 33 <span class="sr-only">home</span>
+1 -4
src/lib/website/foxui/navbar/Navbar.svelte
··· 13 14 <div 15 class={cn( 16 - 'dark header border-base-400/30 dark:border-base-300/10 fixed left-0 right-0 top-1 z-50 mx-1 flex h-16 items-center justify-between overflow-hidden rounded-2xl border p-2 shadow-2xl shadow-black', 17 hasSidebar ? 'lg:left-74' : '', 18 className 19 )} 20 {...restProps} 21 > 22 {@render children?.()} 23 - <div 24 - class="backdrop from-base-900/30 via-base-950/10 bg-linear-to-b pointer-events-none absolute inset-0 -z-10" 25 - ></div> 26 </div>
··· 13 14 <div 15 class={cn( 16 + 'border-base-400/30 dark:border-base-300/10 fixed left-0 right-0 top-1 z-50 mx-1 flex h-16 items-center justify-between overflow-hidden rounded-2xl border p-2 shadow-2xl', 17 hasSidebar ? 'lg:left-74' : '', 18 className 19 )} 20 {...restProps} 21 > 22 {@render children?.()} 23 </div>
+7 -6
src/routes/+layout.svelte
··· 1 <script lang="ts"> 2 - import { ThemeToggle } from '@foxui/core'; 3 4 import '../app.css'; 5 - import favicon from '$lib/assets/favicon.svg'; 6 import { onMount } from 'svelte'; 7 import { initClient } from '$lib/oauth'; 8 9 let { children } = $props(); 10 ··· 13 }); 14 </script> 15 16 - <svelte:head> 17 - <link rel="icon" href={favicon} /> 18 - </svelte:head> 19 20 {@render children()} 21 22 - <ThemeToggle class="fixed top-2 left-2 z-10" />
··· 1 <script lang="ts"> 2 + import { ThemeToggle } from '@foxui/core'; 3 4 import '../app.css'; 5 import { onMount } from 'svelte'; 6 import { initClient } from '$lib/oauth'; 7 + import { gsap } from 'gsap'; 8 + 9 + import { Flip } from 'gsap/Flip'; 10 + 11 + gsap.registerPlugin(Flip); 12 13 let { children } = $props(); 14 ··· 17 }); 18 </script> 19 20 21 {@render children()} 22 23 + <ThemeToggle class="fixed top-2 left-2 z-10" />
+6 -1
src/routes/[handle]/+page.svelte
··· 1 <script lang="ts"> 2 import { page } from '$app/state'; 3 import Website from '$lib/Website.svelte'; 4 import WebsiteWrapper from '$lib/website/WebsiteWrapper.svelte'; 5 ··· 7 </script> 8 9 <WebsiteWrapper {data} handle={page.params.handle}> 10 - <Website handle={page.params.handle} did={data.did} /> 11 </WebsiteWrapper>
··· 1 <script lang="ts"> 2 import { page } from '$app/state'; 3 + import { type Item } from '$lib/types.js'; 4 import Website from '$lib/Website.svelte'; 5 import WebsiteWrapper from '$lib/website/WebsiteWrapper.svelte'; 6 ··· 8 </script> 9 10 <WebsiteWrapper {data} handle={page.params.handle}> 11 + <Website 12 + handle={page.params.handle} 13 + did={data.did} 14 + items={Object.values(data.data['com.example.bento']).map((i) => i.value) as Item[]} 15 + /> 16 </WebsiteWrapper>
+8 -5
src/routes/[handle]/edit/+page.svelte
··· 1 <script lang="ts"> 2 import { page } from '$app/state'; 3 - import Website from '$lib/Website.svelte'; 4 - import EditingWebsiteWrapper from '$lib/website/EditingWebsiteWrapper.svelte'; 5 6 let { data } = $props(); 7 </script> 8 9 - <EditingWebsiteWrapper {data}> 10 - <Website handle={page.params.handle} did={data.did} /> 11 - </EditingWebsiteWrapper>
··· 1 <script lang="ts"> 2 import { page } from '$app/state'; 3 + import EditableWebsite from '$lib/EditableWebsite.svelte'; 4 + import { type Item } from '$lib/types.js'; 5 6 let { data } = $props(); 7 </script> 8 9 + <EditableWebsite 10 + handle={page.params.handle} 11 + did={data.did} 12 + {data} 13 + items={Object.values(data.data['com.example.bento']).map((i) => i.value) as Item[]} 14 + />