your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+11182 -2186
+17 -1
.claude/settings.local.json
··· 11 11 "Bash(npx svelte-kit:*)", 12 12 "Bash(ls:*)", 13 13 "Bash(pnpm format:*)", 14 - "Bash(pnpm add:*)" 14 + "Bash(pnpm add:*)", 15 + "WebSearch", 16 + "WebFetch(domain:github.com)", 17 + "WebFetch(domain:flipclockjs.com)", 18 + "WebFetch(domain:codepen.io)", 19 + "WebFetch(domain:flo-bit.dev)", 20 + "Bash(pnpm install)", 21 + "Bash(pnpm install:*)", 22 + "Bash(pnpm config:*)", 23 + "Bash(lsof:*)", 24 + "Bash(pnpm dev)", 25 + "Bash(pnpm exec svelte-kit:*)", 26 + "Bash(pnpm build:*)", 27 + "Bash(pnpm remove:*)", 28 + "Bash(grep:*)", 29 + "Bash(find:*)", 30 + "Bash(npx prettier:*)" 15 31 ] 16 32 } 17 33 }
+28
.github/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + pull_request: 8 + 9 + # cancel in-progress runs on new commits to same PR (gitub.event.number) 10 + concurrency: 11 + group: ${{ github.workflow }}-${{ github.event.number || github.sha }} 12 + cancel-in-progress: true 13 + 14 + permissions: 15 + contents: read # to fetch code (actions/checkout) 16 + 17 + jobs: 18 + lint: 19 + runs-on: ubuntu-latest 20 + steps: 21 + - uses: actions/checkout@v4 22 + - uses: pnpm/action-setup@v4 23 + - uses: actions/setup-node@v4 24 + with: 25 + node-version: 24 26 + cache: pnpm 27 + - run: pnpm install --frozen-lockfile 28 + - run: pnpm lint
+2 -1
CLAUDE.md
··· 53 53 **Card System (`src/lib/cards/`):** 54 54 55 55 - `CardDefinition` type in `types.ts` defines the interface for card types 56 - - Each card type exports a definition with: `type`, `contentComponent`, optional `editingContentComponent`, `creationModalComponent`, `sidebarComponent`, `loadData`, `upload` (see more info and description in `src/lib/cards/types.ts`) 56 + - Each card type exports a definition with: `type`, `contentComponent`, optional `editingContentComponent`, `creationModalComponent`, `sidebarButtonText`, `loadData`, `upload` (see more info and description in `src/lib/cards/types.ts`) 57 57 - Card types include Text, Link, Image, Bluesky, Embed, Map, Livestream, ATProto collections, and special cards (see `src/lib/cards`). 58 58 - `AllCardDefinitions` and `CardDefinitionsByType` in `index.ts` aggregate all card types 59 59 - See e.g. `src/lib/cards/EmbedCard/` and `src/lib/cards/LivestreamCard/` for examples of implementation. ··· 64 64 - `auth.svelte.ts` - OAuth client state and login/logout flow using `@atcute/oauth-browser-client` 65 65 - `atproto.ts` - ATProto API helpers: `resolveHandle`, `listRecords`, `getRecord`, `putRecord`, `deleteRecord`, `uploadImage` 66 66 - Data is stored in user's PDS under collection `app.blento.card` 67 + - **Important**: ATProto does not allow floating point numbers in records. All numeric values must be integers. 67 68 68 69 **Data Loading (`src/lib/website/`):** 69 70
-33
docs/Beta.md
··· 1 - # Todo for beta version 2 - 3 - - fix opengraph image stuff (caching issue?) 4 - 5 - - site.standard 6 - - move subpages to own lexicon (app.blento.page) 7 - - move description to markdownDescription and set description as text only 8 - 9 - - card with big call to action button 10 - 11 - - link card: save favicon and og image as blobs 12 - 13 - - video card? 14 - 15 - - allow changing profile picture 16 - 17 - - allow editing profile stuff inline (in sidebar profile) 18 - 19 - - allow setting base and accent color 20 - 21 - - ask to fill with some default cards on page creation 22 - 23 - - share button (copy share link to blento, maybe post to bluesky?) 24 - 25 - - add icons to "change card to..." popover 26 - 27 - - show social icon instead of favicon in link card if platform found for link 28 - 29 - - when adding images try to add them in a size that best fits aspect ratio 30 - 31 - - onboarding 32 - 33 - - show alert when user tries to close window with unsaved changes
+12 -7
docs/CardIdeas.md
··· 3 3 ## media 4 4 5 5 - general video card 6 - - inline youtube video 6 + - [x] inline youtube video 7 7 - cartoons: aka https://www.opendoodles.com/ 8 8 - excalidraw (/svg card) 9 9 - latest blog post (e.g. leaflet) 10 10 - fake 3d image (with depth map) 11 - - fluid text effect (https://flo-bit.dev/projects/fluid-text-effect/) 12 - - gifs 13 - - little drawing app 11 + - [x] fluid text effect (https://flo-bit.dev/projects/fluid-text-effect/) 12 + - [x] gifs 13 + - [x] little drawing app 14 14 - css voxel art 15 15 - 3d model 16 + - spotify or apple music playlist 16 17 17 18 ## social accounts 18 19 19 20 - instagram card (showing follow button, follower count, latest posts) 20 - - github card (showing activity grid) 21 + - [x] github card (showing activity grid) 21 22 - bluesky account card (showing follow button, follower count, avatar, name, cover image) 22 23 - youtube channel card (showing channel name, latest videos, follow button?) 23 24 - bluesky posts workcloud ··· 40 41 - teal.fm 41 42 - [x] last played songs 42 43 - tangled.sh 44 + - pinned repos 45 + - activity heatmap? 43 46 - popfeed.social 44 47 - reading goal 45 48 - [x] latest ratings 46 49 - lists 47 - - smokesignal.events (https://pdsls.dev/at://did:plc:xbtmt2zjwlrfegqvch7fboei/events.smokesignal.calendar.event/3ltn2qrxf3626) 48 - - statusphere.xyz (https://googlefonts.github.io/noto-emoji-animation/, https://gist.github.com/sanjacob/a0ccdf6d88f15bf158d8895090722d14) 50 + - smokesignal.events 51 + - [x] specific event 52 + - all future events i'm hosting/attending 53 + - [x] statusphere.xyz (TODO: assing to specific record) 49 54 - goals.garden 50 55 - flashes.blue (https://pdsls.dev/at://did:plc:aytgljyikzbtgrnac2u4ccft/blue.flashes.actor.portfolio, https://app.flashes.blue/profile/j4ck.xyz) 51 56 - room: flo-bit.dev/room
+3 -3
docs/Selfhosting.md
··· 24 24 ] 25 25 ``` 26 26 27 - 5. (maybe necessary? will improve performance at least) create your own kv store by running `npx wrangler kv namespace create USER_DATA_CACHE` and when asked add it to the `wrangler.jsonc` 27 + 5. optionally to improve performance: create your own kv store by running `npx wrangler kv namespace create USER_DATA_CACHE` and when asked add it to the `wrangler.jsonc` 28 28 29 29 DONE :) your blento should be live after a minute or two at `your-cloudflare-worker-or-custom-domain.com` and you can edit it by signing in with your bluesky account at `your-cloudflare-worker-or-custom-domain.com/edit` 30 30 31 31 6. some cards need their own additional env keys, if you have these cards in your profile, create your keys and add them to your cloudflare worker 32 32 33 - - github profile: GITHUB_TOKEN 34 - - map: PUBLIC_MAPBOX_TOKEN 33 + - github profile: GITHUB_TOKEN (token with public_repo access) 34 + - map: PUBLIC_MAPBOX_TOKEN
+29 -2
eslint.config.js
··· 7 7 import ts from 'typescript-eslint'; 8 8 const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); 9 9 10 - export default ts.config( 10 + export default [ 11 11 includeIgnoreFile(gitignorePath), 12 12 js.configs.recommended, 13 13 ...ts.configs.recommended, ··· 30 30 parser: ts.parser 31 31 } 32 32 } 33 + }, 34 + { 35 + files: ['**/*.svelte.ts'], 36 + 37 + languageOptions: { 38 + parser: ts.parser 39 + } 40 + }, 41 + { 42 + rules: { 43 + 'svelte/no-navigation-without-resolve': 'off', 44 + 'svelte/no-at-html-tags': 'off', 45 + '@typescript-eslint/no-explicit-any': 'off', 46 + 'no-unused-vars': 'off', 47 + '@typescript-eslint/no-unused-vars': [ 48 + 'warn', 49 + { 50 + vars: 'all', 51 + varsIgnorePattern: '.*', 52 + args: 'none', 53 + caughtErrors: 'none', 54 + enableAutofixRemoval: { 55 + imports: true 56 + } 57 + } 58 + ] 59 + } 33 60 } 34 - ); 61 + ];
+12 -1
package.json
··· 10 10 "prepare": "svelte-kit sync || echo ''", 11 11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 - "format": "prettier --write .", 14 13 "lint": "prettier --check . && eslint .", 14 + "format": "eslint --fix . && prettier --write .", 15 15 "deploy": "pnpm run build && wrangler deploy", 16 16 "cf-typegen": "wrangler types ./src/worker-configuration.d.ts" 17 17 }, ··· 50 50 "@atcute/tid": "^1.1.1", 51 51 "@cloudflare/workers-types": "^4.20260123.0", 52 52 "@ethercorps/sveltekit-og": "^4.2.1", 53 + "@foxui/3d": "^0.4.7", 53 54 "@foxui/colors": "^0.4.7", 54 55 "@foxui/core": "^0.4.7", 55 56 "@foxui/social": "^0.4.7", 56 57 "@foxui/time": "^0.4.7", 57 58 "@foxui/visual": "^0.4.7", 59 + "@number-flow/svelte": "^0.3.10", 58 60 "@tailwindcss/typography": "^0.5.19", 61 + "@threlte/core": "^8.3.1", 62 + "@threlte/extras": "^9.7.1", 59 63 "@tiptap/core": "^3.16.0", 60 64 "@tiptap/extension-document": "^3.16.0", 61 65 "@tiptap/extension-image": "^3.16.0", ··· 64 68 "@tiptap/extension-placeholder": "^3.16.0", 65 69 "@tiptap/extension-text": "^3.16.0", 66 70 "@tiptap/starter-kit": "^3.16.0", 71 + "@types/three": "^0.176.0", 67 72 "bits-ui": "^2.15.4", 68 73 "clsx": "^2.1.1", 74 + "dompurify": "^3.3.1", 69 75 "gsap": "^3.14.2", 70 76 "hls.js": "^1.6.15", 71 77 "leaflet": "^1.9.4", 72 78 "link-preview-js": "^4.0.0", 73 79 "mapbox-gl": "^3.18.1", 74 80 "marked": "^17.0.1", 81 + "perfect-freehand": "^1.2.2", 75 82 "plyr": "^3.8.4", 83 + "qr-code-styling": "^1.8.6", 76 84 "simple-icons": "^16.6.0", 77 85 "svelte-sonner": "^1.0.7", 78 86 "tailwind-merge": "^3.4.0", 79 87 "tailwind-variants": "^3.2.2", 88 + "tailwindcss-animate": "^1.0.7", 89 + "three": "^0.176.0", 80 90 "turndown": "^7.2.2", 81 91 "wrangler": "^4.60.0" 82 92 }, 93 + "packageManager": "pnpm@9.15.0", 83 94 "license": "MIT" 84 95 }
+451 -139
pnpm-lock.yaml
··· 41 41 '@ethercorps/sveltekit-og': 42 42 specifier: ^4.2.1 43 43 version: 4.2.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2))) 44 + '@foxui/3d': 45 + specifier: ^0.4.7 46 + version: 0.4.7(svelte@5.48.0)(tailwindcss@4.1.18) 44 47 '@foxui/colors': 45 48 specifier: ^0.4.7 46 49 version: 0.4.7(svelte@5.48.0)(tailwindcss@4.1.18) ··· 56 59 '@foxui/visual': 57 60 specifier: ^0.4.7 58 61 version: 0.4.7(svelte@5.48.0)(tailwindcss@4.1.18) 62 + '@number-flow/svelte': 63 + specifier: ^0.3.10 64 + version: 0.3.10(svelte@5.48.0) 59 65 '@tailwindcss/typography': 60 66 specifier: ^0.5.19 61 67 version: 0.5.19(tailwindcss@4.1.18) 68 + '@threlte/core': 69 + specifier: ^8.3.1 70 + version: 8.3.1(svelte@5.48.0)(three@0.176.0) 71 + '@threlte/extras': 72 + specifier: ^9.7.1 73 + version: 9.7.1(@types/three@0.176.0)(svelte@5.48.0)(three@0.176.0) 62 74 '@tiptap/core': 63 75 specifier: ^3.16.0 64 76 version: 3.16.0(@tiptap/pm@3.16.0) ··· 83 95 '@tiptap/starter-kit': 84 96 specifier: ^3.16.0 85 97 version: 3.16.0 98 + '@types/three': 99 + specifier: ^0.176.0 100 + version: 0.176.0 86 101 bits-ui: 87 102 specifier: ^2.15.4 88 103 version: 2.15.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0) 89 104 clsx: 90 105 specifier: ^2.1.1 91 106 version: 2.1.1 107 + dompurify: 108 + specifier: ^3.3.1 109 + version: 3.3.1 92 110 gsap: 93 111 specifier: ^3.14.2 94 112 version: 3.14.2 ··· 107 125 marked: 108 126 specifier: ^17.0.1 109 127 version: 17.0.1 128 + perfect-freehand: 129 + specifier: ^1.2.2 130 + version: 1.2.2 110 131 plyr: 111 132 specifier: ^3.8.4 112 133 version: 3.8.4 134 + qr-code-styling: 135 + specifier: ^1.8.6 136 + version: 1.9.2 113 137 simple-icons: 114 138 specifier: ^16.6.0 115 139 version: 16.6.0 ··· 122 146 tailwind-variants: 123 147 specifier: ^3.2.2 124 148 version: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) 149 + tailwindcss-animate: 150 + specifier: ^1.0.7 151 + version: 1.0.7(tailwindcss@4.1.18) 152 + three: 153 + specifier: ^0.176.0 154 + version: 0.176.0 125 155 turndown: 126 156 specifier: ^7.2.2 127 157 version: 7.2.2 ··· 284 314 optional: true 285 315 286 316 '@cloudflare/workerd-darwin-64@1.20260120.0': 287 - resolution: {integrity: sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==} 317 + resolution: {integrity: sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260120.0.tgz} 288 318 engines: {node: '>=16'} 289 319 cpu: [x64] 290 320 os: [darwin] 291 321 292 322 '@cloudflare/workerd-darwin-arm64@1.20260120.0': 293 - resolution: {integrity: sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==} 323 + resolution: {integrity: sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260120.0.tgz} 294 324 engines: {node: '>=16'} 295 325 cpu: [arm64] 296 326 os: [darwin] 297 327 298 328 '@cloudflare/workerd-linux-64@1.20260120.0': 299 - resolution: {integrity: sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==} 329 + resolution: {integrity: sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260120.0.tgz} 300 330 engines: {node: '>=16'} 301 331 cpu: [x64] 302 332 os: [linux] 303 333 304 334 '@cloudflare/workerd-linux-arm64@1.20260120.0': 305 - resolution: {integrity: sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==} 335 + resolution: {integrity: sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260120.0.tgz} 306 336 engines: {node: '>=16'} 307 337 cpu: [arm64] 308 338 os: [linux] 309 339 310 340 '@cloudflare/workerd-windows-64@1.20260120.0': 311 - resolution: {integrity: sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==} 341 + resolution: {integrity: sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260120.0.tgz} 312 342 engines: {node: '>=16'} 313 343 cpu: [x64] 314 344 os: [win32] ··· 320 350 resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} 321 351 engines: {node: '>=12'} 322 352 353 + '@dimforge/rapier3d-compat@0.12.0': 354 + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} 355 + 323 356 '@emnapi/runtime@1.8.1': 324 - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} 357 + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==, tarball: https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz} 325 358 326 359 '@esbuild/aix-ppc64@0.27.0': 327 - resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} 360 + resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz} 328 361 engines: {node: '>=18'} 329 362 cpu: [ppc64] 330 363 os: [aix] 331 364 332 365 '@esbuild/aix-ppc64@0.27.2': 333 - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} 366 + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz} 334 367 engines: {node: '>=18'} 335 368 cpu: [ppc64] 336 369 os: [aix] 337 370 338 371 '@esbuild/android-arm64@0.27.0': 339 - resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} 372 + resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz} 340 373 engines: {node: '>=18'} 341 374 cpu: [arm64] 342 375 os: [android] 343 376 344 377 '@esbuild/android-arm64@0.27.2': 345 - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} 378 + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz} 346 379 engines: {node: '>=18'} 347 380 cpu: [arm64] 348 381 os: [android] 349 382 350 383 '@esbuild/android-arm@0.27.0': 351 - resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} 384 + resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz} 352 385 engines: {node: '>=18'} 353 386 cpu: [arm] 354 387 os: [android] 355 388 356 389 '@esbuild/android-arm@0.27.2': 357 - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} 390 + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz} 358 391 engines: {node: '>=18'} 359 392 cpu: [arm] 360 393 os: [android] 361 394 362 395 '@esbuild/android-x64@0.27.0': 363 - resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} 396 + resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz} 364 397 engines: {node: '>=18'} 365 398 cpu: [x64] 366 399 os: [android] 367 400 368 401 '@esbuild/android-x64@0.27.2': 369 - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} 402 + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz} 370 403 engines: {node: '>=18'} 371 404 cpu: [x64] 372 405 os: [android] 373 406 374 407 '@esbuild/darwin-arm64@0.27.0': 375 - resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} 408 + resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz} 376 409 engines: {node: '>=18'} 377 410 cpu: [arm64] 378 411 os: [darwin] 379 412 380 413 '@esbuild/darwin-arm64@0.27.2': 381 - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} 414 + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz} 382 415 engines: {node: '>=18'} 383 416 cpu: [arm64] 384 417 os: [darwin] 385 418 386 419 '@esbuild/darwin-x64@0.27.0': 387 - resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} 420 + resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz} 388 421 engines: {node: '>=18'} 389 422 cpu: [x64] 390 423 os: [darwin] 391 424 392 425 '@esbuild/darwin-x64@0.27.2': 393 - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} 426 + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz} 394 427 engines: {node: '>=18'} 395 428 cpu: [x64] 396 429 os: [darwin] 397 430 398 431 '@esbuild/freebsd-arm64@0.27.0': 399 - resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} 432 + resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz} 400 433 engines: {node: '>=18'} 401 434 cpu: [arm64] 402 435 os: [freebsd] 403 436 404 437 '@esbuild/freebsd-arm64@0.27.2': 405 - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} 438 + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz} 406 439 engines: {node: '>=18'} 407 440 cpu: [arm64] 408 441 os: [freebsd] 409 442 410 443 '@esbuild/freebsd-x64@0.27.0': 411 - resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} 444 + resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz} 412 445 engines: {node: '>=18'} 413 446 cpu: [x64] 414 447 os: [freebsd] 415 448 416 449 '@esbuild/freebsd-x64@0.27.2': 417 - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} 450 + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz} 418 451 engines: {node: '>=18'} 419 452 cpu: [x64] 420 453 os: [freebsd] 421 454 422 455 '@esbuild/linux-arm64@0.27.0': 423 - resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} 456 + resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz} 424 457 engines: {node: '>=18'} 425 458 cpu: [arm64] 426 459 os: [linux] 427 460 428 461 '@esbuild/linux-arm64@0.27.2': 429 - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} 462 + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz} 430 463 engines: {node: '>=18'} 431 464 cpu: [arm64] 432 465 os: [linux] 433 466 434 467 '@esbuild/linux-arm@0.27.0': 435 - resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} 468 + resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz} 436 469 engines: {node: '>=18'} 437 470 cpu: [arm] 438 471 os: [linux] 439 472 440 473 '@esbuild/linux-arm@0.27.2': 441 - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} 474 + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz} 442 475 engines: {node: '>=18'} 443 476 cpu: [arm] 444 477 os: [linux] 445 478 446 479 '@esbuild/linux-ia32@0.27.0': 447 - resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} 480 + resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz} 448 481 engines: {node: '>=18'} 449 482 cpu: [ia32] 450 483 os: [linux] 451 484 452 485 '@esbuild/linux-ia32@0.27.2': 453 - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} 486 + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz} 454 487 engines: {node: '>=18'} 455 488 cpu: [ia32] 456 489 os: [linux] 457 490 458 491 '@esbuild/linux-loong64@0.27.0': 459 - resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} 492 + resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz} 460 493 engines: {node: '>=18'} 461 494 cpu: [loong64] 462 495 os: [linux] 463 496 464 497 '@esbuild/linux-loong64@0.27.2': 465 - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} 498 + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz} 466 499 engines: {node: '>=18'} 467 500 cpu: [loong64] 468 501 os: [linux] 469 502 470 503 '@esbuild/linux-mips64el@0.27.0': 471 - resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} 504 + resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz} 472 505 engines: {node: '>=18'} 473 506 cpu: [mips64el] 474 507 os: [linux] 475 508 476 509 '@esbuild/linux-mips64el@0.27.2': 477 - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} 510 + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz} 478 511 engines: {node: '>=18'} 479 512 cpu: [mips64el] 480 513 os: [linux] 481 514 482 515 '@esbuild/linux-ppc64@0.27.0': 483 - resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} 516 + resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz} 484 517 engines: {node: '>=18'} 485 518 cpu: [ppc64] 486 519 os: [linux] 487 520 488 521 '@esbuild/linux-ppc64@0.27.2': 489 - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} 522 + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz} 490 523 engines: {node: '>=18'} 491 524 cpu: [ppc64] 492 525 os: [linux] 493 526 494 527 '@esbuild/linux-riscv64@0.27.0': 495 - resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} 528 + resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz} 496 529 engines: {node: '>=18'} 497 530 cpu: [riscv64] 498 531 os: [linux] 499 532 500 533 '@esbuild/linux-riscv64@0.27.2': 501 - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} 534 + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz} 502 535 engines: {node: '>=18'} 503 536 cpu: [riscv64] 504 537 os: [linux] 505 538 506 539 '@esbuild/linux-s390x@0.27.0': 507 - resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} 540 + resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz} 508 541 engines: {node: '>=18'} 509 542 cpu: [s390x] 510 543 os: [linux] 511 544 512 545 '@esbuild/linux-s390x@0.27.2': 513 - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} 546 + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz} 514 547 engines: {node: '>=18'} 515 548 cpu: [s390x] 516 549 os: [linux] 517 550 518 551 '@esbuild/linux-x64@0.27.0': 519 - resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} 552 + resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz} 520 553 engines: {node: '>=18'} 521 554 cpu: [x64] 522 555 os: [linux] 523 556 524 557 '@esbuild/linux-x64@0.27.2': 525 - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} 558 + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz} 526 559 engines: {node: '>=18'} 527 560 cpu: [x64] 528 561 os: [linux] 529 562 530 563 '@esbuild/netbsd-arm64@0.27.0': 531 - resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} 564 + resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz} 532 565 engines: {node: '>=18'} 533 566 cpu: [arm64] 534 567 os: [netbsd] 535 568 536 569 '@esbuild/netbsd-arm64@0.27.2': 537 - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} 570 + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz} 538 571 engines: {node: '>=18'} 539 572 cpu: [arm64] 540 573 os: [netbsd] 541 574 542 575 '@esbuild/netbsd-x64@0.27.0': 543 - resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} 576 + resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz} 544 577 engines: {node: '>=18'} 545 578 cpu: [x64] 546 579 os: [netbsd] 547 580 548 581 '@esbuild/netbsd-x64@0.27.2': 549 - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} 582 + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz} 550 583 engines: {node: '>=18'} 551 584 cpu: [x64] 552 585 os: [netbsd] 553 586 554 587 '@esbuild/openbsd-arm64@0.27.0': 555 - resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} 588 + resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz} 556 589 engines: {node: '>=18'} 557 590 cpu: [arm64] 558 591 os: [openbsd] 559 592 560 593 '@esbuild/openbsd-arm64@0.27.2': 561 - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} 594 + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz} 562 595 engines: {node: '>=18'} 563 596 cpu: [arm64] 564 597 os: [openbsd] 565 598 566 599 '@esbuild/openbsd-x64@0.27.0': 567 - resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} 600 + resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz} 568 601 engines: {node: '>=18'} 569 602 cpu: [x64] 570 603 os: [openbsd] 571 604 572 605 '@esbuild/openbsd-x64@0.27.2': 573 - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} 606 + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz} 574 607 engines: {node: '>=18'} 575 608 cpu: [x64] 576 609 os: [openbsd] 577 610 578 611 '@esbuild/openharmony-arm64@0.27.0': 579 - resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} 612 + resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz} 580 613 engines: {node: '>=18'} 581 614 cpu: [arm64] 582 615 os: [openharmony] 583 616 584 617 '@esbuild/openharmony-arm64@0.27.2': 585 - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} 618 + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz} 586 619 engines: {node: '>=18'} 587 620 cpu: [arm64] 588 621 os: [openharmony] 589 622 590 623 '@esbuild/sunos-x64@0.27.0': 591 - resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} 624 + resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz} 592 625 engines: {node: '>=18'} 593 626 cpu: [x64] 594 627 os: [sunos] 595 628 596 629 '@esbuild/sunos-x64@0.27.2': 597 - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} 630 + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz} 598 631 engines: {node: '>=18'} 599 632 cpu: [x64] 600 633 os: [sunos] 601 634 602 635 '@esbuild/win32-arm64@0.27.0': 603 - resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} 636 + resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz} 604 637 engines: {node: '>=18'} 605 638 cpu: [arm64] 606 639 os: [win32] 607 640 608 641 '@esbuild/win32-arm64@0.27.2': 609 - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} 642 + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz} 610 643 engines: {node: '>=18'} 611 644 cpu: [arm64] 612 645 os: [win32] 613 646 614 647 '@esbuild/win32-ia32@0.27.0': 615 - resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} 648 + resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz} 616 649 engines: {node: '>=18'} 617 650 cpu: [ia32] 618 651 os: [win32] 619 652 620 653 '@esbuild/win32-ia32@0.27.2': 621 - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} 654 + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz} 622 655 engines: {node: '>=18'} 623 656 cpu: [ia32] 624 657 os: [win32] 625 658 626 659 '@esbuild/win32-x64@0.27.0': 627 - resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} 660 + resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz} 628 661 engines: {node: '>=18'} 629 662 cpu: [x64] 630 663 os: [win32] 631 664 632 665 '@esbuild/win32-x64@0.27.2': 633 - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} 666 + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz} 634 667 engines: {node: '>=18'} 635 668 cpu: [x64] 636 669 os: [win32] ··· 700 733 '@floating-ui/utils@0.2.10': 701 734 resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} 702 735 736 + '@foxui/3d@0.4.7': 737 + resolution: {integrity: sha512-6aFWbdvMPbVqvRlr1gc438RoWE/1+B7/dB1b682tl+K1ycMhWkgMzicckxafLY2A1haOv8bgAArZJ+Lt63RMNg==} 738 + peerDependencies: 739 + svelte: '>=5' 740 + tailwindcss: '>=3' 741 + 703 742 '@foxui/colors@0.4.7': 704 743 resolution: {integrity: sha512-P40GBuwerikysWctVL1KSUrjxzlAf5j1jZ0z6+pzkWdfTv8wGxXlJcvJCkA1MkTeatLkyBrHLrIF17u2iRrMkw==} 705 744 peerDependencies: ··· 751 790 engines: {node: '>=18'} 752 791 753 792 '@img/sharp-darwin-arm64@0.34.5': 754 - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} 793 + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==, tarball: https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz} 755 794 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 756 795 cpu: [arm64] 757 796 os: [darwin] 758 797 759 798 '@img/sharp-darwin-x64@0.34.5': 760 - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} 799 + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==, tarball: https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz} 761 800 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 762 801 cpu: [x64] 763 802 os: [darwin] 764 803 765 804 '@img/sharp-libvips-darwin-arm64@1.2.4': 766 - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} 805 + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz} 767 806 cpu: [arm64] 768 807 os: [darwin] 769 808 770 809 '@img/sharp-libvips-darwin-x64@1.2.4': 771 - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} 810 + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz} 772 811 cpu: [x64] 773 812 os: [darwin] 774 813 775 814 '@img/sharp-libvips-linux-arm64@1.2.4': 776 - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} 815 + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz} 777 816 cpu: [arm64] 778 817 os: [linux] 779 818 780 819 '@img/sharp-libvips-linux-arm@1.2.4': 781 - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} 820 + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz} 782 821 cpu: [arm] 783 822 os: [linux] 784 823 785 824 '@img/sharp-libvips-linux-ppc64@1.2.4': 786 - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} 825 + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz} 787 826 cpu: [ppc64] 788 827 os: [linux] 789 828 790 829 '@img/sharp-libvips-linux-riscv64@1.2.4': 791 - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} 830 + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz} 792 831 cpu: [riscv64] 793 832 os: [linux] 794 833 795 834 '@img/sharp-libvips-linux-s390x@1.2.4': 796 - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} 835 + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz} 797 836 cpu: [s390x] 798 837 os: [linux] 799 838 800 839 '@img/sharp-libvips-linux-x64@1.2.4': 801 - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} 840 + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz} 802 841 cpu: [x64] 803 842 os: [linux] 804 843 805 844 '@img/sharp-libvips-linuxmusl-arm64@1.2.4': 806 - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} 845 + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz} 807 846 cpu: [arm64] 808 847 os: [linux] 809 848 810 849 '@img/sharp-libvips-linuxmusl-x64@1.2.4': 811 - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} 850 + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz} 812 851 cpu: [x64] 813 852 os: [linux] 814 853 815 854 '@img/sharp-linux-arm64@0.34.5': 816 - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} 855 + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz} 817 856 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 818 857 cpu: [arm64] 819 858 os: [linux] 820 859 821 860 '@img/sharp-linux-arm@0.34.5': 822 - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} 861 + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz} 823 862 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 824 863 cpu: [arm] 825 864 os: [linux] 826 865 827 866 '@img/sharp-linux-ppc64@0.34.5': 828 - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} 867 + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==, tarball: https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz} 829 868 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 830 869 cpu: [ppc64] 831 870 os: [linux] 832 871 833 872 '@img/sharp-linux-riscv64@0.34.5': 834 - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} 873 + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==, tarball: https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz} 835 874 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 836 875 cpu: [riscv64] 837 876 os: [linux] 838 877 839 878 '@img/sharp-linux-s390x@0.34.5': 840 - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} 879 + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==, tarball: https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz} 841 880 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 842 881 cpu: [s390x] 843 882 os: [linux] 844 883 845 884 '@img/sharp-linux-x64@0.34.5': 846 - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} 885 + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==, tarball: https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz} 847 886 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 848 887 cpu: [x64] 849 888 os: [linux] 850 889 851 890 '@img/sharp-linuxmusl-arm64@0.34.5': 852 - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} 891 + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz} 853 892 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 854 893 cpu: [arm64] 855 894 os: [linux] 856 895 857 896 '@img/sharp-linuxmusl-x64@0.34.5': 858 - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} 897 + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz} 859 898 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 860 899 cpu: [x64] 861 900 os: [linux] 862 901 863 902 '@img/sharp-wasm32@0.34.5': 864 - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} 903 + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==, tarball: https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz} 865 904 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 866 905 cpu: [wasm32] 867 906 868 907 '@img/sharp-win32-arm64@0.34.5': 869 - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} 908 + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==, tarball: https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz} 870 909 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 871 910 cpu: [arm64] 872 911 os: [win32] 873 912 874 913 '@img/sharp-win32-ia32@0.34.5': 875 - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} 914 + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==, tarball: https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz} 876 915 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 877 916 cpu: [ia32] 878 917 os: [win32] 879 918 880 919 '@img/sharp-win32-x64@0.34.5': 881 - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} 920 + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==, tarball: https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz} 882 921 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 883 922 cpu: [x64] 884 923 os: [win32] ··· 956 995 engines: {node: '>= 10'} 957 996 958 997 '@rollup/rollup-android-arm-eabi@4.56.0': 959 - resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} 998 + resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz} 960 999 cpu: [arm] 961 1000 os: [android] 962 1001 963 1002 '@rollup/rollup-android-arm64@4.56.0': 964 - resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} 1003 + resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz} 965 1004 cpu: [arm64] 966 1005 os: [android] 967 1006 968 1007 '@rollup/rollup-darwin-arm64@4.56.0': 969 - resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} 1008 + resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz} 970 1009 cpu: [arm64] 971 1010 os: [darwin] 972 1011 973 1012 '@rollup/rollup-darwin-x64@4.56.0': 974 - resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} 1013 + resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz} 975 1014 cpu: [x64] 976 1015 os: [darwin] 977 1016 978 1017 '@rollup/rollup-freebsd-arm64@4.56.0': 979 - resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} 1018 + resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz} 980 1019 cpu: [arm64] 981 1020 os: [freebsd] 982 1021 983 1022 '@rollup/rollup-freebsd-x64@4.56.0': 984 - resolution: {integrity: sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==} 1023 + resolution: {integrity: sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz} 985 1024 cpu: [x64] 986 1025 os: [freebsd] 987 1026 988 1027 '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 989 - resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} 1028 + resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz} 990 1029 cpu: [arm] 991 1030 os: [linux] 992 1031 993 1032 '@rollup/rollup-linux-arm-musleabihf@4.56.0': 994 - resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} 1033 + resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz} 995 1034 cpu: [arm] 996 1035 os: [linux] 997 1036 998 1037 '@rollup/rollup-linux-arm64-gnu@4.56.0': 999 - resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} 1038 + resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz} 1000 1039 cpu: [arm64] 1001 1040 os: [linux] 1002 1041 1003 1042 '@rollup/rollup-linux-arm64-musl@4.56.0': 1004 - resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} 1043 + resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz} 1005 1044 cpu: [arm64] 1006 1045 os: [linux] 1007 1046 1008 1047 '@rollup/rollup-linux-loong64-gnu@4.56.0': 1009 - resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} 1048 + resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz} 1010 1049 cpu: [loong64] 1011 1050 os: [linux] 1012 1051 1013 1052 '@rollup/rollup-linux-loong64-musl@4.56.0': 1014 - resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} 1053 + resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz} 1015 1054 cpu: [loong64] 1016 1055 os: [linux] 1017 1056 1018 1057 '@rollup/rollup-linux-ppc64-gnu@4.56.0': 1019 - resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} 1058 + resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz} 1020 1059 cpu: [ppc64] 1021 1060 os: [linux] 1022 1061 1023 1062 '@rollup/rollup-linux-ppc64-musl@4.56.0': 1024 - resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} 1063 + resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz} 1025 1064 cpu: [ppc64] 1026 1065 os: [linux] 1027 1066 1028 1067 '@rollup/rollup-linux-riscv64-gnu@4.56.0': 1029 - resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} 1068 + resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz} 1030 1069 cpu: [riscv64] 1031 1070 os: [linux] 1032 1071 1033 1072 '@rollup/rollup-linux-riscv64-musl@4.56.0': 1034 - resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} 1073 + resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz} 1035 1074 cpu: [riscv64] 1036 1075 os: [linux] 1037 1076 1038 1077 '@rollup/rollup-linux-s390x-gnu@4.56.0': 1039 - resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} 1078 + resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz} 1040 1079 cpu: [s390x] 1041 1080 os: [linux] 1042 1081 1043 1082 '@rollup/rollup-linux-x64-gnu@4.56.0': 1044 - resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} 1083 + resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz} 1045 1084 cpu: [x64] 1046 1085 os: [linux] 1047 1086 1048 1087 '@rollup/rollup-linux-x64-musl@4.56.0': 1049 - resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} 1088 + resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz} 1050 1089 cpu: [x64] 1051 1090 os: [linux] 1052 1091 1053 1092 '@rollup/rollup-openbsd-x64@4.56.0': 1054 - resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} 1093 + resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==, tarball: https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz} 1055 1094 cpu: [x64] 1056 1095 os: [openbsd] 1057 1096 1058 1097 '@rollup/rollup-openharmony-arm64@4.56.0': 1059 - resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} 1098 + resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==, tarball: https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz} 1060 1099 cpu: [arm64] 1061 1100 os: [openharmony] 1062 1101 1063 1102 '@rollup/rollup-win32-arm64-msvc@4.56.0': 1064 - resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} 1103 + resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz} 1065 1104 cpu: [arm64] 1066 1105 os: [win32] 1067 1106 1068 1107 '@rollup/rollup-win32-ia32-msvc@4.56.0': 1069 - resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} 1108 + resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz} 1070 1109 cpu: [ia32] 1071 1110 os: [win32] 1072 1111 1073 1112 '@rollup/rollup-win32-x64-gnu@4.56.0': 1074 - resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} 1113 + resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz} 1075 1114 cpu: [x64] 1076 1115 os: [win32] 1077 1116 1078 1117 '@rollup/rollup-win32-x64-msvc@4.56.0': 1079 - resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} 1118 + resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz} 1080 1119 cpu: [x64] 1081 1120 os: [win32] 1082 1121 ··· 1149 1188 resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} 1150 1189 1151 1190 '@tailwindcss/oxide-android-arm64@4.1.18': 1152 - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} 1191 + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz} 1153 1192 engines: {node: '>= 10'} 1154 1193 cpu: [arm64] 1155 1194 os: [android] 1156 1195 1157 1196 '@tailwindcss/oxide-darwin-arm64@4.1.18': 1158 - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} 1197 + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz} 1159 1198 engines: {node: '>= 10'} 1160 1199 cpu: [arm64] 1161 1200 os: [darwin] 1162 1201 1163 1202 '@tailwindcss/oxide-darwin-x64@4.1.18': 1164 - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} 1203 + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz} 1165 1204 engines: {node: '>= 10'} 1166 1205 cpu: [x64] 1167 1206 os: [darwin] 1168 1207 1169 1208 '@tailwindcss/oxide-freebsd-x64@4.1.18': 1170 - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} 1209 + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz} 1171 1210 engines: {node: '>= 10'} 1172 1211 cpu: [x64] 1173 1212 os: [freebsd] 1174 1213 1175 1214 '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': 1176 - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} 1215 + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz} 1177 1216 engines: {node: '>= 10'} 1178 1217 cpu: [arm] 1179 1218 os: [linux] 1180 1219 1181 1220 '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': 1182 - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} 1221 + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz} 1183 1222 engines: {node: '>= 10'} 1184 1223 cpu: [arm64] 1185 1224 os: [linux] 1186 1225 1187 1226 '@tailwindcss/oxide-linux-arm64-musl@4.1.18': 1188 - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} 1227 + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz} 1189 1228 engines: {node: '>= 10'} 1190 1229 cpu: [arm64] 1191 1230 os: [linux] 1192 1231 1193 1232 '@tailwindcss/oxide-linux-x64-gnu@4.1.18': 1194 - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} 1233 + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz} 1195 1234 engines: {node: '>= 10'} 1196 1235 cpu: [x64] 1197 1236 os: [linux] 1198 1237 1199 1238 '@tailwindcss/oxide-linux-x64-musl@4.1.18': 1200 - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} 1239 + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz} 1201 1240 engines: {node: '>= 10'} 1202 1241 cpu: [x64] 1203 1242 os: [linux] 1204 1243 1205 1244 '@tailwindcss/oxide-wasm32-wasi@4.1.18': 1206 - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} 1245 + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz} 1207 1246 engines: {node: '>=14.0.0'} 1208 1247 cpu: [wasm32] 1209 1248 bundledDependencies: ··· 1215 1254 - tslib 1216 1255 1217 1256 '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': 1218 - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} 1257 + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz} 1219 1258 engines: {node: '>= 10'} 1220 1259 cpu: [arm64] 1221 1260 os: [win32] 1222 1261 1223 1262 '@tailwindcss/oxide-win32-x64-msvc@4.1.18': 1224 - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} 1263 + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz} 1225 1264 engines: {node: '>= 10'} 1226 1265 cpu: [x64] 1227 1266 os: [win32] ··· 1241 1280 vite: ^5.2.0 || ^6 || ^7 1242 1281 1243 1282 '@takumi-rs/core-darwin-arm64@0.55.4': 1244 - resolution: {integrity: sha512-LH/X/ul19DActLGcBpXnxH3OBEq8qOgPD56hNHAJMbnCRxAO6TDaIh2U7WqPVliSkFk3jZfikbD21SIEpZrp8A==} 1283 + resolution: {integrity: sha512-LH/X/ul19DActLGcBpXnxH3OBEq8qOgPD56hNHAJMbnCRxAO6TDaIh2U7WqPVliSkFk3jZfikbD21SIEpZrp8A==, tarball: https://registry.npmjs.org/@takumi-rs/core-darwin-arm64/-/core-darwin-arm64-0.55.4.tgz} 1245 1284 engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} 1246 1285 cpu: [arm64] 1247 1286 os: [darwin] 1248 1287 1249 1288 '@takumi-rs/core-darwin-x64@0.55.4': 1250 - resolution: {integrity: sha512-UW7ovR/D1Qp8n8bJOo6JLqZZUDFWWtGRXEZZUZhzUeMSzJ4k3C6ef/DEc75bUTGeBKqCeypMPcvtkQAjcVwwhw==} 1289 + resolution: {integrity: sha512-UW7ovR/D1Qp8n8bJOo6JLqZZUDFWWtGRXEZZUZhzUeMSzJ4k3C6ef/DEc75bUTGeBKqCeypMPcvtkQAjcVwwhw==, tarball: https://registry.npmjs.org/@takumi-rs/core-darwin-x64/-/core-darwin-x64-0.55.4.tgz} 1251 1290 engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} 1252 1291 cpu: [x64] 1253 1292 os: [darwin] 1254 1293 1255 1294 '@takumi-rs/core-linux-arm64-gnu@0.55.4': 1256 - resolution: {integrity: sha512-y1d5yuPapKlmt77TpE+XrtULj7LZ51leBqWSg6qMNKxhpvRqmjI/SYjHmk5YvshnrTkdKmRQiXJiiN5EzOhbmA==} 1295 + resolution: {integrity: sha512-y1d5yuPapKlmt77TpE+XrtULj7LZ51leBqWSg6qMNKxhpvRqmjI/SYjHmk5YvshnrTkdKmRQiXJiiN5EzOhbmA==, tarball: https://registry.npmjs.org/@takumi-rs/core-linux-arm64-gnu/-/core-linux-arm64-gnu-0.55.4.tgz} 1257 1296 engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} 1258 1297 cpu: [arm64] 1259 1298 os: [linux] 1260 1299 1261 1300 '@takumi-rs/core-linux-arm64-musl@0.55.4': 1262 - resolution: {integrity: sha512-VRbQqbMeoPlrMmaqPwn30Sw82LYya+o4ru9dqV/7BKExozWj/pX9ahexlJdHsZ6wqmsr+ZxexZivK1mPum9ang==} 1301 + resolution: {integrity: sha512-VRbQqbMeoPlrMmaqPwn30Sw82LYya+o4ru9dqV/7BKExozWj/pX9ahexlJdHsZ6wqmsr+ZxexZivK1mPum9ang==, tarball: https://registry.npmjs.org/@takumi-rs/core-linux-arm64-musl/-/core-linux-arm64-musl-0.55.4.tgz} 1263 1302 engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} 1264 1303 cpu: [arm64] 1265 1304 os: [linux] 1266 1305 1267 1306 '@takumi-rs/core-linux-x64-gnu@0.55.4': 1268 - resolution: {integrity: sha512-ecCUtNgOe6mCWKf+SE7cbJXWd6D6TQoCnKZAJAGrJkJLAdy/gBhCFhOyPz8M7q/4uWHUATentqi35KAp+jxBiQ==} 1307 + resolution: {integrity: sha512-ecCUtNgOe6mCWKf+SE7cbJXWd6D6TQoCnKZAJAGrJkJLAdy/gBhCFhOyPz8M7q/4uWHUATentqi35KAp+jxBiQ==, tarball: https://registry.npmjs.org/@takumi-rs/core-linux-x64-gnu/-/core-linux-x64-gnu-0.55.4.tgz} 1269 1308 engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} 1270 1309 cpu: [x64] 1271 1310 os: [linux] 1272 1311 1273 1312 '@takumi-rs/core-linux-x64-musl@0.55.4': 1274 - resolution: {integrity: sha512-YBM2zPrGE/1sfHoFZvOsCvCuK9PfaxzePN/GnnlaAvpvgeRHiAU4PJkLGDpjMFfsWUAEdjly/b0HSAjVQ7NL6Q==} 1313 + resolution: {integrity: sha512-YBM2zPrGE/1sfHoFZvOsCvCuK9PfaxzePN/GnnlaAvpvgeRHiAU4PJkLGDpjMFfsWUAEdjly/b0HSAjVQ7NL6Q==, tarball: https://registry.npmjs.org/@takumi-rs/core-linux-x64-musl/-/core-linux-x64-musl-0.55.4.tgz} 1275 1314 engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} 1276 1315 cpu: [x64] 1277 1316 os: [linux] 1278 1317 1279 1318 '@takumi-rs/core-win32-arm64-msvc@0.55.4': 1280 - resolution: {integrity: sha512-VcgLCWnmyWuhwLv0Tpob8Hv5IFPreFVykoHruPGwXDVVoUcCo+lQ8oCO5EYTB8B/tBAXl2S0xUL0nMDbyLzMxQ==} 1319 + resolution: {integrity: sha512-VcgLCWnmyWuhwLv0Tpob8Hv5IFPreFVykoHruPGwXDVVoUcCo+lQ8oCO5EYTB8B/tBAXl2S0xUL0nMDbyLzMxQ==, tarball: https://registry.npmjs.org/@takumi-rs/core-win32-arm64-msvc/-/core-win32-arm64-msvc-0.55.4.tgz} 1281 1320 engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} 1282 1321 cpu: [arm64] 1283 1322 os: [win32] 1284 1323 1285 1324 '@takumi-rs/core-win32-x64-msvc@0.55.4': 1286 - resolution: {integrity: sha512-ta9g1gUybS2V4mHaccJHcMeBb+w1P6pgZuqHzLoQzBIEK9a/KncHPfnR48cz4sGfg4atorfSa6UBffa2FqijyQ==} 1325 + resolution: {integrity: sha512-ta9g1gUybS2V4mHaccJHcMeBb+w1P6pgZuqHzLoQzBIEK9a/KncHPfnR48cz4sGfg4atorfSa6UBffa2FqijyQ==, tarball: https://registry.npmjs.org/@takumi-rs/core-win32-x64-msvc/-/core-win32-x64-msvc-0.55.4.tgz} 1287 1326 engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} 1288 1327 cpu: [x64] 1289 1328 os: [win32] ··· 1304 1343 '@texel/color@1.1.11': 1305 1344 resolution: {integrity: sha512-/3kKgfBqzrRfLl4RsEccx+Yfj1kVL6Bh6DejVWZ+DPg/jJdcfdYZ5fpD1nXFwWd8OQNATjz+WqsfQfUynSsgRg==} 1306 1345 1346 + '@threejs-kit/instanced-sprite-mesh@2.5.1': 1347 + resolution: {integrity: sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA==} 1348 + peerDependencies: 1349 + three: '>=0.170.0' 1350 + 1351 + '@threlte/core@8.3.1': 1352 + resolution: {integrity: sha512-qKjjNCQ+40hyeBcfOMh/8ef5x/j5PG5Wmo/L9Ye0aDCcdD6fCewWxfp7tV/J3CxPzX1dEp1JGK7sjyc7ntZSrg==} 1353 + peerDependencies: 1354 + svelte: '>=5' 1355 + three: '>=0.160' 1356 + 1357 + '@threlte/extras@9.7.1': 1358 + resolution: {integrity: sha512-SGm59HDCdHxADFHuweHfFDknwubkCPodyK0pbfsVtOWWOX26gE2xfK7aKolh6YFDiPAjWjGxN0jIgkNbbr1ohg==} 1359 + peerDependencies: 1360 + svelte: '>=5' 1361 + three: '>=0.160' 1362 + 1307 1363 '@tiptap/core@3.16.0': 1308 1364 resolution: {integrity: sha512-XegRaNuoQ/guzBQU2xHxOwFXXrtoXW9tiyXDhssSqylvZmBVSlRIPNHA6ArkHBKm6ehLf6+6Y9fF3uky1yCXYQ==} 1309 1365 peerDependencies: ··· 1440 1496 '@tiptap/starter-kit@3.16.0': 1441 1497 resolution: {integrity: sha512-eWi+77SgKyhSx91Hmn32ER+gPN6FfInGtod4A+XxSG+LqS/sn6kpUEdowYrnqiZzhUXZCSTSJvC+UcMUZHOkxQ==} 1442 1498 1499 + '@tweenjs/tween.js@23.1.3': 1500 + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} 1501 + 1443 1502 '@types/bun@1.3.6': 1444 1503 resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==} 1445 1504 ··· 1476 1535 '@types/pbf@3.0.5': 1477 1536 resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} 1478 1537 1538 + '@types/stats.js@0.17.4': 1539 + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} 1540 + 1479 1541 '@types/supercluster@7.1.3': 1480 1542 resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} 1481 1543 1544 + '@types/three@0.176.0': 1545 + resolution: {integrity: sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw==} 1546 + 1547 + '@types/trusted-types@2.0.7': 1548 + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, tarball: https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz} 1549 + 1482 1550 '@types/turndown@5.0.6': 1483 1551 resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} 1484 1552 1553 + '@types/webxr@0.5.24': 1554 + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} 1555 + 1485 1556 '@typescript-eslint/eslint-plugin@8.53.1': 1486 1557 resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==} 1487 1558 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} ··· 1546 1617 1547 1618 '@use-gesture/vanilla@10.3.1': 1548 1619 resolution: {integrity: sha512-lT4scGLu59ovA3zmtUonukAGcA0AdOOh+iwNDS05Bsu7Lq9aZToDHhI6D8Q2qvsVraovtsLLYwPrWdG/noMAKw==} 1620 + 1621 + '@webgpu/types@0.1.69': 1622 + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} 1549 1623 1550 1624 acorn-jsx@5.3.2: 1551 1625 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} ··· 1585 1659 resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} 1586 1660 engines: {node: '>= 0.4'} 1587 1661 1662 + bidi-js@1.0.3: 1663 + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} 1664 + 1588 1665 bits-ui@1.8.0: 1589 1666 resolution: {integrity: sha512-CXD6Orp7l8QevNDcRPLXc/b8iMVgxDWT2LyTwsdLzJKh9CxesOmPuNePSPqAxKoT59FIdU4aFPS1k7eBdbaCxg==} 1590 1667 engines: {node: '>=18', pnpm: '>=8.7.0'} ··· 1620 1697 camelize@1.0.1: 1621 1698 resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} 1622 1699 1700 + camera-controls@3.1.2: 1701 + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} 1702 + engines: {node: '>=22.0.0', npm: '>=10.5.1'} 1703 + peerDependencies: 1704 + three: '>=0.126.1' 1705 + 1623 1706 canvas-confetti@1.9.4: 1624 1707 resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} 1625 1708 ··· 1744 1827 devalue@5.6.2: 1745 1828 resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} 1746 1829 1830 + diet-sprite@0.0.1: 1831 + resolution: {integrity: sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A==} 1832 + 1747 1833 dom-serializer@2.0.0: 1748 1834 resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} 1749 1835 ··· 1754 1840 resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} 1755 1841 engines: {node: '>= 4'} 1756 1842 1843 + dompurify@3.3.1: 1844 + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==, tarball: https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz} 1845 + 1757 1846 domutils@3.2.2: 1758 1847 resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 1848 + 1849 + earcut@2.2.4: 1850 + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} 1759 1851 1760 1852 earcut@3.0.2: 1761 1853 resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} ··· 1893 1985 fflate@0.7.4: 1894 1986 resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} 1895 1987 1988 + fflate@0.8.2: 1989 + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} 1990 + 1896 1991 file-entry-cache@8.0.0: 1897 1992 resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} 1898 1993 engines: {node: '>=16.0.0'} ··· 1909 2004 resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} 1910 2005 1911 2006 fsevents@2.3.3: 1912 - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 2007 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} 1913 2008 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 1914 2009 os: [darwin] 1915 2010 ··· 2045 2140 engines: {node: '>= 0.8.0'} 2046 2141 2047 2142 lightningcss-android-arm64@1.30.2: 2048 - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} 2143 + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==, tarball: https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz} 2049 2144 engines: {node: '>= 12.0.0'} 2050 2145 cpu: [arm64] 2051 2146 os: [android] 2052 2147 2053 2148 lightningcss-darwin-arm64@1.30.2: 2054 - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} 2149 + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==, tarball: https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz} 2055 2150 engines: {node: '>= 12.0.0'} 2056 2151 cpu: [arm64] 2057 2152 os: [darwin] 2058 2153 2059 2154 lightningcss-darwin-x64@1.30.2: 2060 - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} 2155 + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==, tarball: https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz} 2061 2156 engines: {node: '>= 12.0.0'} 2062 2157 cpu: [x64] 2063 2158 os: [darwin] 2064 2159 2065 2160 lightningcss-freebsd-x64@1.30.2: 2066 - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} 2161 + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==, tarball: https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz} 2067 2162 engines: {node: '>= 12.0.0'} 2068 2163 cpu: [x64] 2069 2164 os: [freebsd] 2070 2165 2071 2166 lightningcss-linux-arm-gnueabihf@1.30.2: 2072 - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} 2167 + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==, tarball: https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz} 2073 2168 engines: {node: '>= 12.0.0'} 2074 2169 cpu: [arm] 2075 2170 os: [linux] 2076 2171 2077 2172 lightningcss-linux-arm64-gnu@1.30.2: 2078 - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} 2173 + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz} 2079 2174 engines: {node: '>= 12.0.0'} 2080 2175 cpu: [arm64] 2081 2176 os: [linux] 2082 2177 2083 2178 lightningcss-linux-arm64-musl@1.30.2: 2084 - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} 2179 + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz} 2085 2180 engines: {node: '>= 12.0.0'} 2086 2181 cpu: [arm64] 2087 2182 os: [linux] 2088 2183 2089 2184 lightningcss-linux-x64-gnu@1.30.2: 2090 - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} 2185 + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz} 2091 2186 engines: {node: '>= 12.0.0'} 2092 2187 cpu: [x64] 2093 2188 os: [linux] 2094 2189 2095 2190 lightningcss-linux-x64-musl@1.30.2: 2096 - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} 2191 + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz} 2097 2192 engines: {node: '>= 12.0.0'} 2098 2193 cpu: [x64] 2099 2194 os: [linux] 2100 2195 2101 2196 lightningcss-win32-arm64-msvc@1.30.2: 2102 - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} 2197 + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==, tarball: https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz} 2103 2198 engines: {node: '>= 12.0.0'} 2104 2199 cpu: [arm64] 2105 2200 os: [win32] 2106 2201 2107 2202 lightningcss-win32-x64-msvc@1.30.2: 2108 - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} 2203 + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==, tarball: https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz} 2109 2204 engines: {node: '>= 12.0.0'} 2110 2205 cpu: [x64] 2111 2206 os: [win32] ··· 2148 2243 resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} 2149 2244 hasBin: true 2150 2245 2246 + maath@0.10.8: 2247 + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} 2248 + peerDependencies: 2249 + '@types/three': '>=0.134.0' 2250 + three: '>=0.134.0' 2251 + 2151 2252 magic-string@0.30.21: 2152 2253 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 2153 2254 ··· 2169 2270 mdurl@2.0.0: 2170 2271 resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} 2171 2272 2273 + meshoptimizer@0.18.1: 2274 + resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} 2275 + 2172 2276 mini-svg-data-uri@1.4.4: 2173 2277 resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} 2174 2278 hasBin: true ··· 2184 2288 minimatch@9.0.5: 2185 2289 resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 2186 2290 engines: {node: '>=16 || 14 >=14.17'} 2291 + 2292 + mitt@3.0.1: 2293 + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} 2187 2294 2188 2295 mlly@1.8.0: 2189 2296 resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} ··· 2287 2394 pbf@4.0.1: 2288 2395 resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} 2289 2396 hasBin: true 2397 + 2398 + perfect-freehand@1.2.2: 2399 + resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==} 2290 2400 2291 2401 picocolors@1.1.1: 2292 2402 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} ··· 2485 2595 resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 2486 2596 engines: {node: '>=6'} 2487 2597 2598 + qr-code-styling@1.9.2: 2599 + resolution: {integrity: sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==} 2600 + engines: {node: '>=18.18.0'} 2601 + 2602 + qrcode-generator@1.5.2: 2603 + resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} 2604 + 2488 2605 quickselect@3.0.0: 2489 2606 resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} 2490 2607 ··· 2498 2615 regexparam@3.0.0: 2499 2616 resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} 2500 2617 engines: {node: '>=8'} 2618 + 2619 + require-from-string@2.0.2: 2620 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 2621 + engines: {node: '>=0.10.0'} 2501 2622 2502 2623 resolve-from@4.0.0: 2503 2624 resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} ··· 2682 2803 tailwind-merge: 2683 2804 optional: true 2684 2805 2806 + tailwindcss-animate@1.0.7: 2807 + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==, tarball: https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz} 2808 + peerDependencies: 2809 + tailwindcss: '>=3.0.0 || insiders' 2810 + 2685 2811 tailwindcss@4.1.18: 2686 2812 resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} 2687 2813 ··· 2689 2815 resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} 2690 2816 engines: {node: '>=6'} 2691 2817 2818 + three-instanced-uniforms-mesh@0.52.4: 2819 + resolution: {integrity: sha512-YwDBy05hfKZQtU+Rp0KyDf9yH4GxfhxMbVt9OYruxdgLfPwmDG5oAbGoW0DrKtKZSM3BfFcCiejiOHCjFBTeng==} 2820 + peerDependencies: 2821 + three: '>=0.125.0' 2822 + 2823 + three-mesh-bvh@0.9.7: 2824 + resolution: {integrity: sha512-EYSJbykeAjhVxwZjuUYq/kelIbqBoV9sbAgvZ+j1xCgZyNYSkr51WDJWS4WIfK2OX6YcjBGoTicX4RoOVQzx0g==} 2825 + peerDependencies: 2826 + three: '>= 0.159.0' 2827 + 2828 + three-perf@1.0.11: 2829 + resolution: {integrity: sha512-OgBpZjwL+csQKGKZjpkH/QHdbGFMxqngMbSEJeSnVNfXDYd6On7WHNv/GhUZH4YxIpNMwMahBWrNnsJvnbSJHQ==} 2830 + peerDependencies: 2831 + three: '>=0.170' 2832 + 2833 + three-viewport-gizmo@2.2.0: 2834 + resolution: {integrity: sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q==} 2835 + peerDependencies: 2836 + three: '>=0.162.0 <1.0.0' 2837 + 2838 + three@0.176.0: 2839 + resolution: {integrity: sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==} 2840 + 2692 2841 tiny-inflate@1.0.3: 2693 2842 resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} 2694 2843 ··· 2707 2856 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 2708 2857 engines: {node: '>=6'} 2709 2858 2859 + troika-three-text@0.52.4: 2860 + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} 2861 + peerDependencies: 2862 + three: '>=0.125.0' 2863 + 2864 + troika-three-utils@0.52.4: 2865 + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} 2866 + peerDependencies: 2867 + three: '>=0.125.0' 2868 + 2869 + troika-worker-utils@0.52.0: 2870 + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} 2871 + 2710 2872 ts-api-utils@2.4.0: 2711 2873 resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} 2712 2874 engines: {node: '>=18.12'} ··· 2718 2880 2719 2881 turndown@7.2.2: 2720 2882 resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} 2883 + 2884 + tweakpane@3.1.10: 2885 + resolution: {integrity: sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ==} 2721 2886 2722 2887 type-check@0.4.0: 2723 2888 resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} ··· 2830 2995 w3c-keyname@2.2.8: 2831 2996 resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} 2832 2997 2998 + webgl-sdf-generator@1.1.1: 2999 + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} 3000 + 2833 3001 whatwg-encoding@3.1.1: 2834 3002 resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} 2835 3003 engines: {node: '>=18'} ··· 3058 3226 '@cspotcode/source-map-support@0.8.1': 3059 3227 dependencies: 3060 3228 '@jridgewell/trace-mapping': 0.3.9 3229 + 3230 + '@dimforge/rapier3d-compat@0.12.0': {} 3061 3231 3062 3232 '@emnapi/runtime@1.8.1': 3063 3233 dependencies: ··· 3298 3468 '@floating-ui/utils': 0.2.10 3299 3469 3300 3470 '@floating-ui/utils@0.2.10': {} 3471 + 3472 + '@foxui/3d@0.4.7(svelte@5.48.0)(tailwindcss@4.1.18)': 3473 + dependencies: 3474 + '@foxui/core': 0.4.7(svelte@5.48.0)(tailwindcss@4.1.18) 3475 + '@threlte/core': 8.3.1(svelte@5.48.0)(three@0.176.0) 3476 + '@threlte/extras': 9.7.1(@types/three@0.176.0)(svelte@5.48.0)(three@0.176.0) 3477 + '@types/three': 0.176.0 3478 + bits-ui: 1.8.0(svelte@5.48.0) 3479 + svelte: 5.48.0 3480 + tailwindcss: 4.1.18 3481 + three: 0.176.0 3301 3482 3302 3483 '@foxui/colors@0.4.7(svelte@5.48.0)(tailwindcss@4.1.18)': 3303 3484 dependencies: ··· 3795 3976 3796 3977 '@texel/color@1.1.11': {} 3797 3978 3979 + '@threejs-kit/instanced-sprite-mesh@2.5.1(@types/three@0.176.0)(three@0.176.0)': 3980 + dependencies: 3981 + diet-sprite: 0.0.1 3982 + earcut: 2.2.4 3983 + maath: 0.10.8(@types/three@0.176.0)(three@0.176.0) 3984 + three: 0.176.0 3985 + three-instanced-uniforms-mesh: 0.52.4(three@0.176.0) 3986 + troika-three-utils: 0.52.4(three@0.176.0) 3987 + transitivePeerDependencies: 3988 + - '@types/three' 3989 + 3990 + '@threlte/core@8.3.1(svelte@5.48.0)(three@0.176.0)': 3991 + dependencies: 3992 + mitt: 3.0.1 3993 + svelte: 5.48.0 3994 + three: 0.176.0 3995 + 3996 + '@threlte/extras@9.7.1(@types/three@0.176.0)(svelte@5.48.0)(three@0.176.0)': 3997 + dependencies: 3998 + '@threejs-kit/instanced-sprite-mesh': 2.5.1(@types/three@0.176.0)(three@0.176.0) 3999 + camera-controls: 3.1.2(three@0.176.0) 4000 + svelte: 5.48.0 4001 + three: 0.176.0 4002 + three-mesh-bvh: 0.9.7(three@0.176.0) 4003 + three-perf: 1.0.11(three@0.176.0) 4004 + three-viewport-gizmo: 2.2.0(three@0.176.0) 4005 + troika-three-text: 0.52.4(three@0.176.0) 4006 + transitivePeerDependencies: 4007 + - '@types/three' 4008 + 3798 4009 '@tiptap/core@3.16.0(@tiptap/pm@3.16.0)': 3799 4010 dependencies: 3800 4011 '@tiptap/pm': 3.16.0 ··· 3949 4160 '@tiptap/extensions': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 3950 4161 '@tiptap/pm': 3.16.0 3951 4162 4163 + '@tweenjs/tween.js@23.1.3': {} 4164 + 3952 4165 '@types/bun@1.3.6': 3953 4166 dependencies: 3954 4167 bun-types: 1.3.6 ··· 3982 4195 3983 4196 '@types/pbf@3.0.5': {} 3984 4197 4198 + '@types/stats.js@0.17.4': {} 4199 + 3985 4200 '@types/supercluster@7.1.3': 3986 4201 dependencies: 3987 4202 '@types/geojson': 7946.0.16 3988 4203 4204 + '@types/three@0.176.0': 4205 + dependencies: 4206 + '@dimforge/rapier3d-compat': 0.12.0 4207 + '@tweenjs/tween.js': 23.1.3 4208 + '@types/stats.js': 0.17.4 4209 + '@types/webxr': 0.5.24 4210 + '@webgpu/types': 0.1.69 4211 + fflate: 0.8.2 4212 + meshoptimizer: 0.18.1 4213 + 4214 + '@types/trusted-types@2.0.7': 4215 + optional: true 4216 + 3989 4217 '@types/turndown@5.0.6': {} 4218 + 4219 + '@types/webxr@0.5.24': {} 3990 4220 3991 4221 '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': 3992 4222 dependencies: ··· 4085 4315 dependencies: 4086 4316 '@use-gesture/core': 10.3.1 4087 4317 4318 + '@webgpu/types@0.1.69': {} 4319 + 4088 4320 acorn-jsx@5.3.2(acorn@8.15.0): 4089 4321 dependencies: 4090 4322 acorn: 8.15.0 ··· 4113 4345 balanced-match@1.0.2: {} 4114 4346 4115 4347 base64-js@0.0.8: {} 4348 + 4349 + bidi-js@1.0.3: 4350 + dependencies: 4351 + require-from-string: 2.0.2 4116 4352 4117 4353 bits-ui@1.8.0(svelte@5.48.0): 4118 4354 dependencies: ··· 4160 4396 4161 4397 camelize@1.0.1: {} 4162 4398 4399 + camera-controls@3.1.2(three@0.176.0): 4400 + dependencies: 4401 + three: 0.176.0 4402 + 4163 4403 canvas-confetti@1.9.4: {} 4164 4404 4165 4405 chalk@4.1.2: ··· 4279 4519 4280 4520 devalue@5.6.2: {} 4281 4521 4522 + diet-sprite@0.0.1: {} 4523 + 4282 4524 dom-serializer@2.0.0: 4283 4525 dependencies: 4284 4526 domelementtype: 2.3.0 ··· 4291 4533 dependencies: 4292 4534 domelementtype: 2.3.0 4293 4535 4536 + dompurify@3.3.1: 4537 + optionalDependencies: 4538 + '@types/trusted-types': 2.0.7 4539 + 4294 4540 domutils@3.2.2: 4295 4541 dependencies: 4296 4542 dom-serializer: 2.0.0 4297 4543 domelementtype: 2.3.0 4298 4544 domhandler: 5.0.3 4545 + 4546 + earcut@2.2.4: {} 4299 4547 4300 4548 earcut@3.0.2: {} 4301 4549 ··· 4493 4741 4494 4742 fflate@0.7.4: {} 4495 4743 4744 + fflate@0.8.2: {} 4745 + 4496 4746 file-entry-cache@8.0.0: 4497 4747 dependencies: 4498 4748 flat-cache: 4.0.1 ··· 4694 4944 4695 4945 lz-string@1.5.0: {} 4696 4946 4947 + maath@0.10.8(@types/three@0.176.0)(three@0.176.0): 4948 + dependencies: 4949 + '@types/three': 0.176.0 4950 + three: 0.176.0 4951 + 4697 4952 magic-string@0.30.21: 4698 4953 dependencies: 4699 4954 '@jridgewell/sourcemap-codec': 1.5.5 ··· 4746 5001 4747 5002 mdurl@2.0.0: {} 4748 5003 5004 + meshoptimizer@0.18.1: {} 5005 + 4749 5006 mini-svg-data-uri@1.4.4: {} 4750 5007 4751 5008 miniflare@4.20260120.0: ··· 4769 5026 dependencies: 4770 5027 brace-expansion: 2.0.2 4771 5028 5029 + mitt@3.0.1: {} 5030 + 4772 5031 mlly@1.8.0: 4773 5032 dependencies: 4774 5033 acorn: 8.15.0 ··· 4864 5123 pbf@4.0.1: 4865 5124 dependencies: 4866 5125 resolve-protobuf-schema: 2.1.0 5126 + 5127 + perfect-freehand@1.2.2: {} 4867 5128 4868 5129 picocolors@1.1.1: {} 4869 5130 ··· 5047 5308 punycode.js@2.3.1: {} 5048 5309 5049 5310 punycode@2.3.1: {} 5311 + 5312 + qr-code-styling@1.9.2: 5313 + dependencies: 5314 + qrcode-generator: 1.5.2 5315 + 5316 + qrcode-generator@1.5.2: {} 5050 5317 5051 5318 quickselect@3.0.0: {} 5052 5319 ··· 5056 5323 5057 5324 regexparam@3.0.0: {} 5058 5325 5326 + require-from-string@2.0.2: {} 5327 + 5059 5328 resolve-from@4.0.0: {} 5060 5329 5061 5330 resolve-protobuf-schema@2.1.0: ··· 5300 5569 optionalDependencies: 5301 5570 tailwind-merge: 3.4.0 5302 5571 5572 + tailwindcss-animate@1.0.7(tailwindcss@4.1.18): 5573 + dependencies: 5574 + tailwindcss: 4.1.18 5575 + 5303 5576 tailwindcss@4.1.18: {} 5304 5577 5305 5578 tapable@2.3.0: {} 5306 5579 5580 + three-instanced-uniforms-mesh@0.52.4(three@0.176.0): 5581 + dependencies: 5582 + three: 0.176.0 5583 + troika-three-utils: 0.52.4(three@0.176.0) 5584 + 5585 + three-mesh-bvh@0.9.7(three@0.176.0): 5586 + dependencies: 5587 + three: 0.176.0 5588 + 5589 + three-perf@1.0.11(three@0.176.0): 5590 + dependencies: 5591 + three: 0.176.0 5592 + troika-three-text: 0.52.4(three@0.176.0) 5593 + tweakpane: 3.1.10 5594 + 5595 + three-viewport-gizmo@2.2.0(three@0.176.0): 5596 + dependencies: 5597 + three: 0.176.0 5598 + 5599 + three@0.176.0: {} 5600 + 5307 5601 tiny-inflate@1.0.3: {} 5308 5602 5309 5603 tinyglobby@0.2.15: ··· 5317 5611 5318 5612 totalist@3.0.1: {} 5319 5613 5614 + troika-three-text@0.52.4(three@0.176.0): 5615 + dependencies: 5616 + bidi-js: 1.0.3 5617 + three: 0.176.0 5618 + troika-three-utils: 0.52.4(three@0.176.0) 5619 + troika-worker-utils: 0.52.0 5620 + webgl-sdf-generator: 1.1.1 5621 + 5622 + troika-three-utils@0.52.4(three@0.176.0): 5623 + dependencies: 5624 + three: 0.176.0 5625 + 5626 + troika-worker-utils@0.52.0: {} 5627 + 5320 5628 ts-api-utils@2.4.0(typescript@5.9.3): 5321 5629 dependencies: 5322 5630 typescript: 5.9.3 ··· 5326 5634 turndown@7.2.2: 5327 5635 dependencies: 5328 5636 '@mixmark-io/domino': 2.2.0 5637 + 5638 + tweakpane@3.1.10: {} 5329 5639 5330 5640 type-check@0.4.0: 5331 5641 dependencies: ··· 5407 5717 vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2) 5408 5718 5409 5719 w3c-keyname@2.2.8: {} 5720 + 5721 + webgl-sdf-generator@1.1.1: {} 5410 5722 5411 5723 whatwg-encoding@3.1.1: 5412 5724 dependencies:
+2
src/app.css
··· 3 3 @plugin '@tailwindcss/forms'; 4 4 @plugin '@tailwindcss/typography'; 5 5 6 + @plugin 'tailwindcss-animate'; 7 + 6 8 @source '../node_modules/@foxui'; 7 9 8 10 @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
+22
src/lib/atproto/UI/Button.svelte
··· 1 + <script lang="ts"> 2 + import type { HTMLButtonAttributes } from 'svelte/elements'; 3 + 4 + type Props = HTMLButtonAttributes & { 5 + children: () => any; 6 + ref?: HTMLButtonElement | null; 7 + }; 8 + 9 + let { children, ref = $bindable(), class: className, ...props }: Props = $props(); 10 + </script> 11 + 12 + <button 13 + bind:this={ref} 14 + class={[ 15 + 'bg-accent-600 hover:bg-accent-500 focus-visible:outline-accent-600 text-white', 16 + 'inline-flex cursor-pointer justify-center rounded-full px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 17 + className 18 + ]} 19 + {...props} 20 + > 21 + {@render children()} 22 + </button>
+91
src/lib/atproto/UI/HandleInput.svelte
··· 1 + <script lang="ts"> 2 + import { AppBskyActorDefs } from '@atcute/bluesky'; 3 + import { Combobox } from 'bits-ui'; 4 + import { searchActorsTypeahead } from '$lib/atproto'; 5 + import { Avatar } from '@foxui/core'; 6 + 7 + let results: AppBskyActorDefs.ProfileViewBasic[] = $state([]); 8 + 9 + async function search(q: string) { 10 + if (!q || q.length < 2) { 11 + results = []; 12 + return; 13 + } 14 + results = (await searchActorsTypeahead(q, 5)).actors; 15 + } 16 + let open = $state(false); 17 + 18 + let { 19 + value = $bindable(), 20 + onselected, 21 + ref = $bindable() 22 + }: { 23 + value: string; 24 + onselected: (actor: AppBskyActorDefs.ProfileViewBasic) => void; 25 + ref?: HTMLInputElement | null; 26 + } = $props(); 27 + </script> 28 + 29 + <Combobox.Root 30 + type="single" 31 + onOpenChangeComplete={(o) => { 32 + if (!o) results = []; 33 + }} 34 + bind:value={ 35 + () => { 36 + return value; 37 + }, 38 + (val) => { 39 + const profile = results.find((v) => v.handle === val); 40 + if (profile) onselected?.(profile); 41 + // Only update if val has content - prevents Combobox from clearing on Enter 42 + if (val) value = val; 43 + } 44 + } 45 + bind:open={ 46 + () => { 47 + return open && results.length > 0; 48 + }, 49 + (val) => { 50 + open = val; 51 + } 52 + } 53 + > 54 + <Combobox.Input 55 + bind:ref 56 + oninput={(e) => { 57 + value = e.currentTarget.value; 58 + search(e.currentTarget.value); 59 + }} 60 + onkeydown={(e) => { 61 + if (e.key === 'Enter') e.currentTarget.form?.requestSubmit(); 62 + }} 63 + class="focus-within:outline-accent-600 dark:focus-within:outline-accent-500 dark:placeholder:text-base-400 w-full touch-none rounded-full border-0 bg-white ring-0 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 dark:bg-white/5 dark:outline-white/10" 64 + placeholder="handle" 65 + id="" 66 + aria-label="enter your handle" 67 + /> 68 + <Combobox.Content 69 + class="border-base-300 bg-base-50 dark:bg-base-900 dark:border-base-800 z-100 max-h-[30dvh] w-full rounded-2xl border shadow-lg" 70 + sideOffset={10} 71 + align="start" 72 + side="top" 73 + > 74 + <Combobox.Viewport class="w-full p-1"> 75 + {#each results as actor (actor.did)} 76 + <Combobox.Item 77 + class="rounded-button data-highlighted:bg-accent-100 dark:data-highlighted:bg-accent-600/30 my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2" 78 + value={actor.handle} 79 + label={actor.handle} 80 + > 81 + <Avatar 82 + src={actor.avatar?.replace('avatar', 'avatar_thumbnail')} 83 + alt="" 84 + class="size-6 rounded-full" 85 + /> 86 + {actor.handle} 87 + </Combobox.Item> 88 + {/each} 89 + </Combobox.Viewport> 90 + </Combobox.Content> 91 + </Combobox.Root>
+267
src/lib/atproto/UI/LoginModal.svelte
··· 1 + <script lang="ts" module> 2 + export const loginModalState = $state({ 3 + visible: false, 4 + show: () => (loginModalState.visible = true), 5 + hide: () => (loginModalState.visible = false) 6 + }); 7 + </script> 8 + 9 + <script lang="ts"> 10 + import { login, signup } from '$lib/atproto'; 11 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 12 + import Button from './Button.svelte'; 13 + import { onMount, tick } from 'svelte'; 14 + import SecondaryButton from './SecondaryButton.svelte'; 15 + import HandleInput from './HandleInput.svelte'; 16 + import { AppBskyActorDefs } from '@atcute/bluesky'; 17 + import { Avatar } from '@foxui/core'; 18 + 19 + let { signUp = true, loginOnSelect = true }: { signUp?: boolean; loginOnSelect?: boolean } = 20 + $props(); 21 + 22 + let value = $state(''); 23 + let error: string | null = $state(null); 24 + let loadingLogin = $state(false); 25 + let loadingSignup = $state(false); 26 + 27 + async function onSubmit(event?: Event) { 28 + event?.preventDefault(); 29 + if (loadingLogin) return; 30 + 31 + error = null; 32 + loadingLogin = true; 33 + 34 + try { 35 + await login(value as ActorIdentifier); 36 + } catch (err) { 37 + error = err instanceof Error ? err.message : String(err); 38 + } finally { 39 + loadingLogin = false; 40 + } 41 + } 42 + 43 + let input: HTMLInputElement | null = $state(null); 44 + let submitButton: HTMLButtonElement | null = $state(null); 45 + 46 + $effect(() => { 47 + if (!loginModalState.visible) { 48 + error = null; 49 + value = ''; 50 + loadingLogin = false; 51 + selectedActor = undefined; 52 + } else { 53 + focusInput(); 54 + } 55 + }); 56 + 57 + function focusInput() { 58 + tick().then(() => { 59 + input?.focus(); 60 + }); 61 + } 62 + function focusSubmit() { 63 + tick().then(() => { 64 + submitButton?.focus(); 65 + }); 66 + } 67 + 68 + let selectedActor: AppBskyActorDefs.ProfileViewBasic | undefined = $state(); 69 + 70 + let recentLogins: Record<Did, AppBskyActorDefs.ProfileViewBasic> = $state({}); 71 + 72 + onMount(() => { 73 + try { 74 + recentLogins = JSON.parse(localStorage.getItem('recent-logins') || '{}'); 75 + } catch { 76 + console.error('Failed to load recent logins'); 77 + } 78 + }); 79 + 80 + function removeRecentLogin(did: Did) { 81 + try { 82 + delete recentLogins[did]; 83 + 84 + localStorage.setItem('recent-logins', JSON.stringify(recentLogins)); 85 + } catch { 86 + console.error('Failed to remove recent login'); 87 + } 88 + } 89 + 90 + let recentLoginsView = $state(true); 91 + 92 + let showRecentLogins = $derived( 93 + Object.keys(recentLogins).length > 0 && !loadingLogin && !selectedActor && recentLoginsView 94 + ); 95 + </script> 96 + 97 + {#if loginModalState.visible} 98 + <div 99 + class="fixed inset-0 z-100 w-screen overflow-y-auto" 100 + aria-labelledby="modal-title" 101 + role="dialog" 102 + aria-modal="true" 103 + > 104 + <div 105 + class="bg-base-50/90 dark:bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity" 106 + onclick={() => (loginModalState.visible = false)} 107 + aria-hidden="true" 108 + ></div> 109 + 110 + <div class="pointer-events-none fixed inset-0 isolate z-10 w-screen overflow-y-auto"> 111 + <div 112 + class="flex min-h-full w-screen items-end justify-center p-4 text-center sm:items-center sm:p-0" 113 + > 114 + <div 115 + class="border-base-200 bg-base-100 dark:border-base-700 dark:bg-base-800 pointer-events-auto relative w-full transform overflow-hidden rounded-2xl border px-4 pt-4 pb-4 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6" 116 + > 117 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="modal-title"> 118 + Login with your internet handle 119 + </h3> 120 + 121 + <div class="text-base-800 dark:text-base-200 mt-2 mb-2 text-xs font-light"> 122 + e.g. your bluesky account 123 + </div> 124 + 125 + <form onsubmit={onSubmit} class="mt-2 flex w-full flex-col gap-2"> 126 + {#if showRecentLogins} 127 + <div class="mt-2 mb-2 text-sm font-medium">Recent logins</div> 128 + <div class="flex flex-col gap-2"> 129 + {#each Object.values(recentLogins) 130 + .filter((l) => l.handle && l.handle !== 'handle.invalid') 131 + .slice(0, 4) as recentLogin (recentLogin.did)} 132 + <div class="group"> 133 + <div 134 + class="group-hover:bg-base-300 bg-base-200 dark:bg-base-700 dark:hover:bg-base-600 dark:border-base-500/50 border-base-300 relative flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold transition-colors duration-100" 135 + > 136 + <div class="flex items-center gap-2"> 137 + <Avatar class="size-6" src={recentLogin.avatar} /> 138 + {recentLogin.handle} 139 + </div> 140 + <button 141 + class="z-20 cursor-pointer" 142 + onclick={() => { 143 + value = recentLogin.handle; 144 + selectedActor = recentLogin; 145 + if (loginOnSelect) onSubmit(); 146 + else focusSubmit(); 147 + }} 148 + > 149 + <div class="absolute inset-0 h-full w-full"></div> 150 + <span class="sr-only">login</span> 151 + </button> 152 + 153 + <button 154 + onclick={() => { 155 + removeRecentLogin(recentLogin.did); 156 + }} 157 + class="z-30 cursor-pointer rounded-full p-0.5" 158 + > 159 + <svg 160 + xmlns="http://www.w3.org/2000/svg" 161 + fill="none" 162 + viewBox="0 0 24 24" 163 + stroke-width="1.5" 164 + stroke="currentColor" 165 + class="size-3" 166 + > 167 + <path 168 + stroke-linecap="round" 169 + stroke-linejoin="round" 170 + d="M6 18 18 6M6 6l12 12" 171 + /> 172 + </svg> 173 + <span class="sr-only">sign in with other account</span> 174 + </button> 175 + </div> 176 + </div> 177 + {/each} 178 + </div> 179 + {:else if !selectedActor} 180 + <div class="mt-4 w-full"> 181 + <HandleInput 182 + bind:value 183 + onselected={(a) => { 184 + selectedActor = a; 185 + value = a.handle; 186 + if (loginOnSelect) onSubmit(); 187 + else focusSubmit(); 188 + }} 189 + bind:ref={input} 190 + /> 191 + </div> 192 + {:else} 193 + <div 194 + class="bg-base-200 dark:bg-base-700 border-base-300 dark:border-base-600 mt-4 flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold" 195 + > 196 + <div class="flex items-center gap-2"> 197 + <Avatar class="size-6" src={selectedActor.avatar} /> 198 + {selectedActor.handle} 199 + </div> 200 + 201 + <button 202 + onclick={() => { 203 + selectedActor = undefined; 204 + value = ''; 205 + }} 206 + class="cursor-pointer rounded-full p-0.5" 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 + class="size-3" 215 + > 216 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 217 + </svg> 218 + <span class="sr-only">sign in with other account</span> 219 + </button> 220 + </div> 221 + {/if} 222 + 223 + {#if error} 224 + <p class="text-accent-500 text-sm font-semibold">{error}</p> 225 + {/if} 226 + 227 + <div class="mt-4"> 228 + {#if showRecentLogins} 229 + <div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div> 230 + 231 + <Button 232 + onclick={() => { 233 + recentLoginsView = false; 234 + focusInput(); 235 + }} 236 + class="w-full">Login with new handle</Button 237 + > 238 + {:else} 239 + <Button bind:ref={submitButton} type="submit" disabled={loadingLogin} class="w-full" 240 + >{loadingLogin ? 'Loading...' : 'Login'}</Button 241 + > 242 + {/if} 243 + </div> 244 + 245 + {#if signUp} 246 + <div 247 + class="border-base-200 dark:border-base-700 text-base-800 dark:text-base-200 mt-4 border-t pt-4 text-sm leading-7" 248 + > 249 + Don't have an account? 250 + <div class="mt-3"> 251 + <SecondaryButton 252 + onclick={async () => { 253 + loadingSignup = true; 254 + await signup(); 255 + }} 256 + disabled={loadingSignup} 257 + class="w-full">{loadingSignup ? 'Loading...' : 'Sign Up'}</SecondaryButton 258 + > 259 + </div> 260 + </div> 261 + {/if} 262 + </form> 263 + </div> 264 + </div> 265 + </div> 266 + </div> 267 + {/if}
+20
src/lib/atproto/UI/SecondaryButton.svelte
··· 1 + <script lang="ts"> 2 + import type { HTMLButtonAttributes } from 'svelte/elements'; 3 + 4 + type Props = HTMLButtonAttributes & { 5 + children: () => any; 6 + }; 7 + 8 + let { children, class: className, ...props }: Props = $props(); 9 + </script> 10 + 11 + <button 12 + class={[ 13 + 'bg-base-300 dark:bg-base-700 dark:text-base-50 dark:hover:bg-base-600 hover:bg-base-200 focus-visible:outline-base-600 text-black transition-colors duration-100', 14 + 'inline-flex cursor-pointer justify-center rounded-full px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 15 + className 16 + ]} 17 + {...props} 18 + > 19 + {@render children()} 20 + </button>
+22 -10
src/lib/atproto/auth.svelte.ts
··· 7 7 deleteStoredSession 8 8 } from '@atcute/oauth-browser-client'; 9 9 import { AppBskyActorDefs } from '@atcute/bluesky'; 10 - import type { ActorIdentifier, Did } from '@atcute/lexicons'; 11 10 import { 12 11 CompositeDidDocumentResolver, 13 12 CompositeHandleResolver, ··· 23 22 import { replaceState } from '$app/navigation'; 24 23 25 24 import { metadata } from './metadata'; 26 - import { getDetailedProfile } from './methods'; 27 - import { signUpPDS } from './settings'; 25 + import { describeRepo, getDetailedProfile } from './methods'; 26 + import { DOH_RESOLVER, REDIRECT_PATH, signUpPDS } from './settings'; 27 + import { SvelteURLSearchParams } from 'svelte/reactivity'; 28 + 29 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 28 30 29 31 export const user = $state({ 30 32 agent: null as OAuthUserAgent | null, ··· 40 42 41 43 const clientId = dev 42 44 ? `http://localhost` + 43 - `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179/oauth/callback')}` + 45 + `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179' + REDIRECT_PATH)}` + 44 46 `&scope=${encodeURIComponent(metadata.scope)}` 45 47 : metadata.client_id; 46 48 47 49 const handleResolver = new CompositeHandleResolver({ 48 50 methods: { 49 - dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 51 + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), 50 52 http: new WellKnownHandleResolver() 51 53 } 52 54 }); ··· 54 56 configureOAuth({ 55 57 metadata: { 56 58 client_id: clientId, 57 - redirect_uri: dev ? 'http://127.0.0.1:5179/oauth/callback' : metadata.redirect_uris[0] 59 + redirect_uri: dev ? 'http://127.0.0.1:5179' + REDIRECT_PATH : metadata.redirect_uris[0] 58 60 }, 59 61 identityResolver: new LocalActorResolver({ 60 62 handleResolver: handleResolver, ··· 67 69 }) 68 70 }); 69 71 70 - const params = new URLSearchParams(location.hash.slice(1)); 72 + const params = new SvelteURLSearchParams(location.hash.slice(1)); 71 73 72 74 const did = (localStorage.getItem('current-login') as Did) ?? undefined; 73 75 ··· 150 152 } 151 153 } 152 154 153 - async function finalizeLogin(params: URLSearchParams, did?: Did) { 155 + async function finalizeLogin(params: SvelteURLSearchParams, did?: Did) { 154 156 try { 155 157 const { session } = await finalizeAuthorization(params); 156 158 replaceState(location.pathname + location.search, {}); ··· 222 224 223 225 const response = await getDetailedProfile(); 224 226 225 - user.profile = response; 226 - localStorage.setItem(`profile-${actor}`, JSON.stringify(response)); 227 + if (!response || response.handle === 'handle.invalid') { 228 + console.log('invalid handle or no profile from bsky, fetching from repo description'); 229 + const repo = await describeRepo({ did: actor }); 230 + user.profile = { 231 + did: actor, 232 + handle: repo?.handle || 'handle.invalid' 233 + }; 234 + localStorage.setItem(`profile-${actor}`, JSON.stringify(user.profile)); 235 + } else { 236 + user.profile = response; 237 + localStorage.setItem(`profile-${actor}`, JSON.stringify(response)); 238 + } 227 239 }
+152
src/lib/atproto/image-helper.ts
··· 1 + import { getCDNImageBlobUrl, uploadBlob } from './methods'; 2 + 3 + export function compressImage( 4 + file: File | Blob, 5 + maxSize: number = 900 * 1024, 6 + maxDimension: number = 2048 7 + ): Promise<{ 8 + blob: Blob; 9 + aspectRatio: { 10 + width: number; 11 + height: number; 12 + }; 13 + }> { 14 + return new Promise((resolve, reject) => { 15 + const img = new Image(); 16 + const reader = new FileReader(); 17 + 18 + reader.onload = (e) => { 19 + if (!e.target?.result) { 20 + return reject(new Error('Failed to read file.')); 21 + } 22 + img.src = e.target.result as string; 23 + }; 24 + 25 + reader.onerror = (err) => reject(err); 26 + reader.readAsDataURL(file); 27 + 28 + img.onload = () => { 29 + let width = img.width; 30 + let height = img.height; 31 + 32 + // If image is already small enough, return original 33 + if (file.size <= maxSize) { 34 + console.log('skipping compression+resizing, already small enough'); 35 + return resolve({ 36 + blob: file, 37 + aspectRatio: { 38 + width, 39 + height 40 + } 41 + }); 42 + } 43 + 44 + if (width > maxDimension || height > maxDimension) { 45 + if (width > height) { 46 + height = Math.round((maxDimension / width) * height); 47 + width = maxDimension; 48 + } else { 49 + width = Math.round((maxDimension / height) * width); 50 + height = maxDimension; 51 + } 52 + } 53 + 54 + // Create a canvas to draw the image 55 + const canvas = document.createElement('canvas'); 56 + canvas.width = width; 57 + canvas.height = height; 58 + const ctx = canvas.getContext('2d'); 59 + if (!ctx) return reject(new Error('Failed to get canvas context.')); 60 + ctx.drawImage(img, 0, 0, width, height); 61 + 62 + // Use WebP for both compression and transparency support 63 + let quality = 0.9; 64 + 65 + function attemptCompression() { 66 + canvas.toBlob( 67 + (blob) => { 68 + if (!blob) { 69 + return reject(new Error('Compression failed.')); 70 + } 71 + if (blob.size <= maxSize || quality < 0.3) { 72 + resolve({ 73 + blob, 74 + aspectRatio: { 75 + width, 76 + height 77 + } 78 + }); 79 + } else { 80 + quality -= 0.1; 81 + attemptCompression(); 82 + } 83 + }, 84 + 'image/webp', 85 + quality 86 + ); 87 + } 88 + 89 + attemptCompression(); 90 + }; 91 + 92 + img.onerror = (err) => reject(err); 93 + }); 94 + } 95 + 96 + export async function checkAndUploadImage( 97 + recordWithImage: Record<string, any>, 98 + key: string = 'image', 99 + // e.g. /api/image-proxy?url= 100 + imageProxy?: string 101 + ) { 102 + if (!recordWithImage[key]) return; 103 + 104 + // Already uploaded as blob 105 + if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { 106 + return; 107 + } 108 + 109 + if (typeof recordWithImage[key] === 'string' && imageProxy) { 110 + const proxyUrl = imageProxy + encodeURIComponent(recordWithImage[key]); 111 + const response = await fetch(proxyUrl); 112 + if (!response.ok) { 113 + throw Error('failed to get image from image proxy'); 114 + } 115 + 116 + const blob = await response.blob(); 117 + const compressedBlob = await compressImage(blob); 118 + 119 + recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); 120 + 121 + return; 122 + } 123 + 124 + if (recordWithImage[key]?.blob) { 125 + if (recordWithImage[key].objectUrl) { 126 + URL.revokeObjectURL(recordWithImage[key].objectUrl); 127 + } 128 + const compressedBlob = await compressImage(recordWithImage[key].blob); 129 + recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); 130 + } 131 + } 132 + 133 + export function getImageFromRecord( 134 + recordWithImage: Record<string, any> | undefined, 135 + did: string, 136 + key: string = 'image' 137 + ): string | undefined { 138 + if (!recordWithImage?.[key]) return; 139 + 140 + if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { 141 + return getCDNImageBlobUrl({ did, blob: recordWithImage[key] }); 142 + } 143 + 144 + if (recordWithImage[key].objectUrl) return recordWithImage[key].objectUrl; 145 + 146 + if (recordWithImage[key].blob) { 147 + recordWithImage[key].objectUrl = URL.createObjectURL(recordWithImage[key].blob); 148 + return recordWithImage[key].objectUrl; 149 + } 150 + 151 + return recordWithImage[key]; 152 + }
+5 -2
src/lib/atproto/index.ts
··· 14 14 uploadBlob, 15 15 describeRepo, 16 16 getBlobURL, 17 - getImageBlobUrl, 18 - searchActorsTypeahead 17 + getCDNImageBlobUrl, 18 + searchActorsTypeahead, 19 + getAuthorFeed, 20 + getPostThread, 21 + createPost 19 22 } from './methods';
+9 -8
src/lib/atproto/metadata.ts
··· 1 1 import { resolve } from '$app/paths'; 2 - import { blobs, collections, rpcCalls, SITE } from './settings'; 2 + import { permissions, REDIRECT_PATH, SITE } from './settings'; 3 3 4 4 function constructScope() { 5 - const repos = collections.map((collection) => 'repo:' + collection).join(' '); 5 + const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); 6 6 7 7 let rpcs = ''; 8 - for (const [key, value] of Object.entries(rpcCalls)) { 8 + for (const [key, value] of Object.entries(permissions.rpc ?? {})) { 9 9 if (Array.isArray(value)) { 10 10 rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); 11 11 } else { 12 12 rpcs += 'rpc?lxm=' + value + '&aud=' + key; 13 13 } 14 14 } 15 + 15 16 let blobScope: string | undefined = undefined; 16 - if (Array.isArray(blobs)) { 17 - blobScope = 'blob?' + blobs.map((b) => 'accept=' + b).join('&'); 18 - } else if (blobs) { 19 - blobScope = 'blob:' + blobs; 17 + if (Array.isArray(permissions.blobs) && permissions.blobs.length > 0) { 18 + blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); 19 + } else if (permissions.blobs && permissions.blobs.length > 0) { 20 + blobScope = 'blob:' + permissions.blobs; 20 21 } 21 22 22 23 const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' '); ··· 25 26 26 27 export const metadata = { 27 28 client_id: SITE + resolve('/oauth-client-metadata.json'), 28 - redirect_uris: [SITE + resolve('/oauth/callback')], 29 + redirect_uris: [SITE + resolve(REDIRECT_PATH)], 29 30 scope: constructScope(), 30 31 grant_types: ['authorization_code', 'refresh_token'], 31 32 response_types: ['code'],
+297 -48
src/lib/atproto/methods.ts
··· 1 - import type { Did, Handle } from '@atcute/lexicons'; 1 + import { 2 + parseResourceUri, 3 + type ActorIdentifier, 4 + type Did, 5 + type Handle, 6 + type ResourceUri 7 + } from '@atcute/lexicons'; 2 8 import { user } from './auth.svelte'; 9 + import type { AllowedCollection } from './settings'; 3 10 import { 4 11 CompositeDidDocumentResolver, 5 12 CompositeHandleResolver, ··· 9 16 WellKnownHandleResolver 10 17 } from '@atcute/identity-resolver'; 11 18 import { Client, simpleFetchHandler } from '@atcute/client'; 12 - import type { AppBskyActorDefs } from '@atcute/bluesky'; 19 + import { type AppBskyActorDefs } from '@atcute/bluesky'; 13 20 14 21 export type Collection = `${string}.${string}.${string}`; 22 + import * as TID from '@atcute/tid'; 15 23 24 + /** 25 + * Parses an AT Protocol URI into its components. 26 + * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 27 + * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri 28 + */ 16 29 export function parseUri(uri: string) { 17 - const [did, collection, rkey] = uri.replace('at://', '').split('/'); 18 - return { did, collection, rkey } as { 19 - collection: `${string}.${string}.${string}`; 20 - rkey: string; 21 - did: Did; 22 - }; 30 + const parts = parseResourceUri(uri); 31 + if (!parts.ok) return; 32 + return parts.value; 23 33 } 24 34 35 + /** 36 + * Resolves a handle to a DID using DNS and HTTP methods. 37 + * @param handle - The handle to resolve (e.g., "alice.bsky.social") 38 + * @returns The DID associated with the handle 39 + */ 25 40 export async function resolveHandle({ handle }: { handle: Handle }) { 26 41 const handleResolver = new CompositeHandleResolver({ 27 42 methods: { ··· 41 56 } 42 57 }); 43 58 59 + /** 60 + * Gets the PDS (Personal Data Server) URL for a given DID. 61 + * @param did - The DID to look up 62 + * @returns The PDS service endpoint URL 63 + * @throws If no PDS is found in the DID document 64 + */ 44 65 export async function getPDS(did: Did) { 45 - const doc = await didResolver.resolve(did as `did:plc:${string}` | `did:web:${string}`); 66 + const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); 46 67 if (!doc.service) throw new Error('No PDS found'); 47 68 for (const service of doc.service) { 48 69 if (service.id === '#atproto_pds') { ··· 51 72 } 52 73 } 53 74 75 + /** 76 + * Fetches a detailed Bluesky profile for a user. 77 + * @param data - Optional object with did and client 78 + * @param data.did - The DID to fetch the profile for (defaults to current user) 79 + * @param data.client - The client to use (defaults to public Bluesky API) 80 + * @returns The profile data or undefined if not found 81 + */ 54 82 export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 55 83 data ??= {}; 56 84 data.did ??= user.did; ··· 65 93 params: { actor: data.did } 66 94 }); 67 95 68 - if (!response.ok) return; 96 + if (!response.ok || response.data.handle === 'handle.invalid') { 97 + const repo = await describeRepo({ did: data.did }); 98 + return { handle: repo?.handle ?? 'handle.invalid', did: data.did }; 99 + } 69 100 70 101 return response.data; 71 102 } 72 103 73 - export async function getAuthorFeed(data?: { 74 - did?: Did; 75 - client?: Client; 76 - filter?: string; 77 - limit?: number; 78 - }) { 79 - data ??= {}; 80 - data.did ??= user.did; 104 + export async function getBlentoOrBskyProfile(data: { did: Did; client?: Client }): Promise< 105 + Awaited<ReturnType<typeof getDetailedProfile>> & { 106 + hasBlento: boolean; 107 + } 108 + > { 109 + let blentoProfile; 110 + try { 111 + // try getting blento profile first 112 + blentoProfile = await getRecord({ 113 + collection: 'site.standard.publication', 114 + did: data?.did, 115 + rkey: 'blento.self', 116 + client: data?.client 117 + }); 118 + } catch { 119 + console.error('error getting blento profile, falling back to bsky profile'); 120 + } 81 121 82 - if (!data.did) throw new Error('Error getting detailed profile: no did'); 122 + const response = await getDetailedProfile(data); 83 123 84 - data.client ??= new Client({ 85 - handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 86 - }); 87 - 88 - const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 89 - params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } 90 - }); 91 - 92 - if (!response.ok) return; 93 - 94 - return response.data; 124 + return { 125 + did: data.did, 126 + handle: response?.handle, 127 + displayName: blentoProfile?.value?.name || response?.displayName || response?.handle, 128 + avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) || 129 + response?.avatar) as `${string}:${string}`, 130 + hasBlento: Boolean(blentoProfile.value) 131 + }; 95 132 } 96 133 134 + /** 135 + * Creates an AT Protocol client for a user's PDS. 136 + * @param did - The DID of the user 137 + * @returns A client configured for the user's PDS 138 + * @throws If the PDS cannot be found 139 + */ 97 140 export async function getClient({ did }: { did: Did }) { 98 141 const pds = await getPDS(did); 99 142 if (!pds) throw new Error('PDS not found'); ··· 105 148 return client; 106 149 } 107 150 151 + /** 152 + * Lists records from a repository collection with pagination support. 153 + * @param did - The DID of the repository (defaults to current user) 154 + * @param collection - The collection to list records from 155 + * @param cursor - Pagination cursor for continuing from a previous request 156 + * @param limit - Maximum number of records to return (default 100, set to 0 for all records) 157 + * @param client - The client to use (defaults to user's PDS client) 158 + * @returns An array of records from the collection 159 + */ 108 160 export async function listRecords({ 109 161 did, 110 162 collection, 111 163 cursor, 112 - limit = 0, 164 + limit = 100, 113 165 client 114 166 }: { 115 167 did?: Did; ··· 136 188 params: { 137 189 repo: did, 138 190 collection, 139 - limit: limit || 100, 191 + limit: !limit || limit > 100 ? 100 : limit, 140 192 cursor: currentCursor 141 193 } 142 194 }); ··· 152 204 return allRecords; 153 205 } 154 206 207 + /** 208 + * Fetches a single record from a repository. 209 + * @param did - The DID of the repository (defaults to current user) 210 + * @param collection - The collection the record belongs to 211 + * @param rkey - The record key (defaults to "self") 212 + * @param client - The client to use (defaults to user's PDS client) 213 + * @returns The record data 214 + */ 155 215 export async function getRecord({ 156 216 did, 157 217 collection, 158 - rkey, 218 + rkey = 'self', 159 219 client 160 220 }: { 161 221 did?: Did; ··· 164 224 client?: Client; 165 225 }) { 166 226 did ??= user.did; 167 - rkey ??= 'self'; 168 227 169 228 if (!collection) { 170 229 throw new Error('Missing parameters for getRecord'); ··· 186 245 return JSON.parse(JSON.stringify(record.data)); 187 246 } 188 247 248 + /** 249 + * Creates or updates a record in the current user's repository. 250 + * Only accepts collections that are configured in permissions. 251 + * @param collection - The collection to write to (must be in permissions.collections) 252 + * @param rkey - The record key (defaults to "self") 253 + * @param record - The record data to write 254 + * @returns The response from the PDS 255 + * @throws If the user is not logged in 256 + */ 189 257 export async function putRecord({ 190 258 collection, 191 - rkey, 259 + rkey = 'self', 192 260 record 193 261 }: { 194 - collection: Collection; 195 - rkey: string; 262 + collection: AllowedCollection; 263 + rkey?: string; 196 264 record: Record<string, unknown>; 197 265 }) { 198 266 if (!user.client || !user.did) throw new Error('No rpc or did'); ··· 211 279 return response; 212 280 } 213 281 214 - export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) { 282 + /** 283 + * Deletes a record from the current user's repository. 284 + * Only accepts collections that are configured in permissions. 285 + * @param collection - The collection the record belongs to (must be in permissions.collections) 286 + * @param rkey - The record key (defaults to "self") 287 + * @returns True if the deletion was successful 288 + * @throws If the user is not logged in 289 + */ 290 + export async function deleteRecord({ 291 + collection, 292 + rkey = 'self' 293 + }: { 294 + collection: AllowedCollection; 295 + rkey: string; 296 + }) { 215 297 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 216 298 217 299 const response = await user.client.post('com.atproto.repo.deleteRecord', { ··· 225 307 return response.ok; 226 308 } 227 309 310 + /** 311 + * Uploads a blob to the current user's PDS. 312 + * @param blob - The blob data to upload 313 + * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 314 + * @throws If the user is not logged in 315 + */ 228 316 export async function uploadBlob({ blob }: { blob: Blob }) { 229 317 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 230 318 231 319 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 232 - input: blob, 233 - data: { 320 + params: { 234 321 repo: user.did 235 - } 322 + }, 323 + input: blob 236 324 }); 237 325 238 - if (!blobResponse?.ok) { 239 - return; 240 - } 326 + if (!blobResponse?.ok) return; 241 327 242 328 const blobInfo = blobResponse?.data.blob as { 243 329 $type: 'blob'; ··· 251 337 return blobInfo; 252 338 } 253 339 340 + /** 341 + * Gets metadata about a repository. 342 + * @param client - The client to use 343 + * @param did - The DID of the repository (defaults to current user) 344 + * @returns Repository metadata or undefined on failure 345 + */ 254 346 export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 255 347 did ??= user.did; 256 348 if (!did) { ··· 268 360 return repo.data; 269 361 } 270 362 363 + /** 364 + * Constructs a URL to fetch a blob directly from a user's PDS. 365 + * @param did - The DID of the user who owns the blob 366 + * @param blob - The blob reference object 367 + * @returns The URL to fetch the blob 368 + */ 271 369 export async function getBlobURL({ 272 370 did, 273 371 blob ··· 284 382 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 285 383 } 286 384 287 - export function getImageBlobUrl({ 385 + /** 386 + * Constructs a Bluesky CDN URL for an image blob. 387 + * @param did - The DID of the user who owns the blob (defaults to current user) 388 + * @param blob - The blob reference object 389 + * @returns The CDN URL for the image in webp format 390 + */ 391 + export function getCDNImageBlobUrl({ 288 392 did, 289 393 blob 290 394 }: { 291 - did: string; 395 + did?: string; 292 396 blob: { 293 397 $type: 'blob'; 294 398 ref: { ··· 296 400 }; 297 401 }; 298 402 }) { 299 - if (!did || !blob?.ref?.$link) return ''; 300 - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@jpeg`; 403 + if (!blob || !did) return; 404 + did ??= user.did; 405 + 406 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 301 407 } 302 408 409 + /** 410 + * Searches for actors with typeahead/autocomplete functionality. 411 + * @param q - The search query 412 + * @param limit - Maximum number of results (default 10) 413 + * @param host - The API host to use (defaults to public Bluesky API) 414 + * @returns An object containing matching actors and the original query 415 + */ 303 416 export async function searchActorsTypeahead( 304 417 q: string, 305 418 limit: number = 10, ··· 322 435 323 436 return { actors: response.data.actors, q }; 324 437 } 438 + 439 + /** 440 + * Return a TID based on current time 441 + * 442 + * @returns TID for current time 443 + */ 444 + export function createTID() { 445 + return TID.now(); 446 + } 447 + 448 + export async function getAuthorFeed(data?: { 449 + did?: Did; 450 + client?: Client; 451 + filter?: string; 452 + limit?: number; 453 + cursor?: string; 454 + }) { 455 + data ??= {}; 456 + data.did ??= user.did; 457 + 458 + if (!data.did) throw new Error('Error getting detailed profile: no did'); 459 + 460 + data.client ??= new Client({ 461 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 462 + }); 463 + 464 + const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 465 + params: { 466 + actor: data.did, 467 + filter: data.filter ?? 'posts_with_media', 468 + limit: data.limit || 100, 469 + cursor: data.cursor 470 + } 471 + }); 472 + 473 + if (!response.ok) return; 474 + 475 + return response.data; 476 + } 477 + 478 + /** 479 + * Fetches posts by their AT URIs. 480 + * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 481 + * @param client - The client to use (defaults to public Bluesky API) 482 + * @returns Array of posts or undefined on failure 483 + */ 484 + export async function getPosts(data: { uris: string[]; client?: Client }) { 485 + data.client ??= new Client({ 486 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 487 + }); 488 + 489 + const response = await data.client.get('app.bsky.feed.getPosts', { 490 + params: { uris: data.uris as ResourceUri[] } 491 + }); 492 + 493 + if (!response.ok) return; 494 + 495 + return response.data.posts; 496 + } 497 + 498 + export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 499 + if (profile.handle && profile.handle !== 'handle.invalid') { 500 + return profile.handle; 501 + } else { 502 + return profile.did; 503 + } 504 + } 505 + 506 + /** 507 + * Fetches a post's thread including replies. 508 + * @param uri - The AT URI of the post 509 + * @param depth - How many levels of replies to fetch (default 1) 510 + * @param client - The client to use (defaults to public Bluesky API) 511 + * @returns The thread data or undefined on failure 512 + */ 513 + export async function getPostThread({ 514 + uri, 515 + depth = 1, 516 + client 517 + }: { 518 + uri: string; 519 + depth?: number; 520 + client?: Client; 521 + }) { 522 + client ??= new Client({ 523 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 524 + }); 525 + 526 + const response = await client.get('app.bsky.feed.getPostThread', { 527 + params: { uri: uri as ResourceUri, depth } 528 + }); 529 + 530 + if (!response.ok) return; 531 + 532 + return response.data.thread; 533 + } 534 + 535 + /** 536 + * Creates a Bluesky post on the authenticated user's account. 537 + * @param text - The post text 538 + * @param facets - Optional rich text facets (links, mentions, etc.) 539 + * @returns The response containing the post's URI and CID 540 + * @throws If the user is not logged in 541 + */ 542 + export async function createPost({ 543 + text, 544 + facets 545 + }: { 546 + text: string; 547 + facets?: Array<{ 548 + index: { byteStart: number; byteEnd: number }; 549 + features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; 550 + }>; 551 + }) { 552 + if (!user.client || !user.did) throw new Error('No client or did'); 553 + 554 + const record: Record<string, unknown> = { 555 + $type: 'app.bsky.feed.post', 556 + text, 557 + createdAt: new Date().toISOString() 558 + }; 559 + 560 + if (facets) { 561 + record.facets = facets; 562 + } 563 + 564 + const response = await user.client.post('com.atproto.repo.createRecord', { 565 + input: { 566 + collection: 'app.bsky.feed.post', 567 + repo: user.did, 568 + record 569 + } 570 + }); 571 + 572 + return response; 573 + }
+53 -15
src/lib/atproto/settings.ts
··· 1 - export const SITE = 'https://blento.app'; 1 + import { dev } from '$app/environment'; 2 + import { env } from '$env/dynamic/public'; 2 3 3 - export const collections: string[] = [ 4 - 'app.blento.card', 5 - 'app.blento.page', 6 - 'app.blento.settings', 7 - 'app.blento.comment', 8 - 'app.blento.guestbook.entry', 9 - 'site.standard.publication', 10 - 'site.standard.document', 11 - 'xyz.statusphere.status' 12 - ]; 4 + export const SITE = env.PUBLIC_DOMAIN; 13 5 14 - export const rpcCalls: Record<string, string | string[]> = { 15 - //'did:web:api.bsky.app#bsky_appview': ['app.bsky.actor.getProfile'] 6 + type Permissions = { 7 + collections: readonly string[]; 8 + rpc: Record<string, string | string[]>; 9 + blobs: readonly string[]; 16 10 }; 17 11 18 - export const blobs = ['*/*'] as string | string[] | undefined; 12 + export const permissions = { 13 + // collections you can create/delete/update 19 14 20 - export const signUpPDS = 'https://pds.rip/'; 15 + // example: only allow create and delete 16 + // collections: ['xyz.statusphere.status?action=create&action=update'], 17 + collections: [ 18 + 'app.blento.card', 19 + 'app.blento.page', 20 + 'app.blento.settings', 21 + 'app.blento.comment', 22 + 'app.blento.guestbook.entry', 23 + 'app.bsky.feed.post?action=create', 24 + 'site.standard.publication', 25 + 'site.standard.document', 26 + 'xyz.statusphere.status' 27 + ], 28 + 29 + // what types of authenticated proxied requests you can make to services 30 + 31 + // example: allow authenticated proxying to bsky appview to get a users liked posts 32 + //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']} 33 + rpc: {}, 34 + 35 + // what types of blobs you can upload to a users PDS 36 + 37 + // example: allowing video and html uploads 38 + // blobs: ['video/*', 'text/html'] 39 + // example: allowing all blob types 40 + // blobs: ['*/*'] 41 + blobs: ['*/*'] 42 + } as const satisfies Permissions; 43 + 44 + // Extract base collection name (before any query params) 45 + type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T; 46 + 47 + export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; 48 + 49 + // which PDS to use for signup 50 + // ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week 51 + const devPDS = 'https://pds.rip/'; 52 + const prodPDS = 'https://selfhosted.social/'; 53 + export const signUpPDS = dev ? devPDS : prodPDS; 54 + 55 + // where to redirect after oauth login/signup, e.g. /oauth/callback 56 + export const REDIRECT_PATH = '/oauth/callback'; 57 + 58 + export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
+19 -5
src/lib/cards/ATProtoCollectionsCard/ATProtoCollectionsCard.svelte
··· 39 39 <Badge size="md" class="accent:text-accent-950">{collections.length}</Badge> 40 40 {/if} 41 41 </div> 42 - <div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4"> 43 - {#each collections ?? [] as collection (collection)} 44 - <Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button> 45 - {/each} 46 - </div> 42 + {#if collections && collections.length > 0} 43 + <div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4"> 44 + {#each collections as collection (collection)} 45 + <Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button> 46 + {/each} 47 + </div> 48 + {:else if collections} 49 + <div 50 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 51 + > 52 + No collections found. 53 + </div> 54 + {:else} 55 + <div 56 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 57 + > 58 + Loading collections... 59 + </div> 60 + {/if} 47 61 </div>
-29
src/lib/cards/ATProtoCollectionsCard/SidebarItemATProtoCollectionsCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <path 15 - d="M21 6.375c0 2.692-4.03 4.875-9 4.875S3 9.067 3 6.375 7.03 1.5 12 1.5s9 2.183 9 4.875Z" 16 - /> 17 - <path 18 - d="M12 12.75c2.685 0 5.19-.586 7.078-1.609a8.283 8.283 0 0 0 1.897-1.384c.016.121.025.244.025.368C21 12.817 16.97 15 12 15s-9-2.183-9-4.875c0-.124.009-.247.025-.368a8.285 8.285 0 0 0 1.897 1.384C6.809 12.164 9.315 12.75 12 12.75Z" 19 - /> 20 - <path 21 - d="M12 16.5c2.685 0 5.19-.586 7.078-1.609a8.282 8.282 0 0 0 1.897-1.384c.016.121.025.244.025.368 0 2.692-4.03 4.875-9 4.875s-9-2.183-9-4.875c0-.124.009-.247.025-.368a8.284 8.284 0 0 0 1.897 1.384C6.809 15.914 9.315 16.5 12 16.5Z" 22 - /> 23 - <path 24 - d="M12 20.25c2.685 0 5.19-.586 7.078-1.609a8.282 8.282 0 0 0 1.897-1.384c.016.121.025.244.025.368 0 2.692-4.03 4.875-9 4.875s-9-2.183-9-4.875c0-.124.009-.247.025-.368a8.284 8.284 0 0 0 1.897 1.384C6.809 19.664 9.315 20.25 12 20.25Z" 25 - /> 26 - </svg> 27 - 28 - AT Proto Collections 29 - </Button>
+5 -2
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 1 1 import { describeRepo } from '$lib/atproto'; 2 2 import type { CardDefinition } from '../types'; 3 3 import ATProtoCollectionsCard from './ATProtoCollectionsCard.svelte'; 4 - import SidebarItemATProtoCollectionsCard from './SidebarItemATProtoCollectionsCard.svelte'; 5 4 6 5 export const ATProtoCollectionsCardDefinition = { 7 6 type: 'atprotocollections', ··· 20 19 item.w = 4; 21 20 item.mobileW = 8; 22 21 }, 23 - sidebarComponent: SidebarItemATProtoCollectionsCard 22 + name: 'ATProto Collections', 23 + 24 + keywords: ['bluesky', 'records', 'pds', 'data'], 25 + groups: ['Social'], 26 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>` 24 27 } as CardDefinition & { type: 'atprotocollections' };
+22
src/lib/cards/AppleMusicCard/AppleMusicCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../types'; 3 + 4 + let { item, isEditing }: ContentComponentProps = $props(); 5 + </script> 6 + 7 + {#if item.cardData?.appleMusicType && item.cardData?.appleMusicId && item.cardData?.appleMusicStorefront} 8 + <div class="absolute inset-0 p-2"> 9 + <iframe 10 + class={['h-full w-full rounded-2xl', isEditing && 'pointer-events-none']} 11 + src="https://embed.music.apple.com/{item.cardData.appleMusicStorefront}/{item.cardData 12 + .appleMusicType}/{item.cardData.appleMusicId}" 13 + sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation" 14 + loading="lazy" 15 + title="Apple Music {item.cardData.appleMusicType}" 16 + ></iframe> 17 + </div> 18 + {:else} 19 + <div class="flex h-full items-center justify-center p-4 text-center opacity-50"> 20 + Missing Apple Music data 21 + </div> 22 + {/if}
+52
src/lib/cards/AppleMusicCard/CreateAppleMusicCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + 9 + function checkUrl() { 10 + errorMessage = ''; 11 + 12 + const pattern = /music\.apple\.com\/([a-z]{2})\/(album|playlist)\/[^/]+\/([a-zA-Z0-9.]+)/; 13 + const match = item.cardData.href?.match(pattern); 14 + 15 + if (!match) { 16 + errorMessage = 'Please enter a valid Apple Music album or playlist URL'; 17 + return false; 18 + } 19 + 20 + item.cardData.appleMusicStorefront = match[1]; 21 + item.cardData.appleMusicType = match[2]; 22 + item.cardData.appleMusicId = match[3]; 23 + 24 + return true; 25 + } 26 + </script> 27 + 28 + <Modal open={true} closeButton={false}> 29 + <Subheading>Enter an Apple Music album or playlist URL</Subheading> 30 + <Input 31 + bind:value={item.cardData.href} 32 + placeholder="https://music.apple.com/us/album/..." 33 + onkeydown={(e) => { 34 + if (e.key === 'Enter' && checkUrl()) oncreate(); 35 + }} 36 + /> 37 + 38 + {#if errorMessage} 39 + <Alert type="error" title="Invalid URL"><span>{errorMessage}</span></Alert> 40 + {/if} 41 + 42 + <div class="mt-4 flex justify-end gap-2"> 43 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 44 + <Button 45 + onclick={() => { 46 + if (checkUrl()) oncreate(); 47 + }} 48 + > 49 + Create 50 + </Button> 51 + </div> 52 + </Modal>
+70
src/lib/cards/AppleMusicCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CreateAppleMusicCardModal from './CreateAppleMusicCardModal.svelte'; 3 + import AppleMusicCard from './AppleMusicCard.svelte'; 4 + 5 + const cardType = 'apple-music-embed'; 6 + 7 + export const AppleMusicCardDefinition = { 8 + type: cardType, 9 + contentComponent: AppleMusicCard, 10 + creationModalComponent: CreateAppleMusicCardModal, 11 + createNew: (item) => { 12 + item.cardType = cardType; 13 + item.cardData = {}; 14 + item.w = 4; 15 + item.mobileW = 8; 16 + item.h = 5; 17 + item.mobileH = 10; 18 + }, 19 + 20 + onUrlHandler: (url, item) => { 21 + const match = matchAppleMusicUrl(url); 22 + if (!match) return null; 23 + 24 + item.cardData.appleMusicType = match.type; 25 + item.cardData.appleMusicId = match.id; 26 + item.cardData.appleMusicStorefront = match.storefront; 27 + item.cardData.href = url; 28 + 29 + item.w = 4; 30 + item.mobileW = 8; 31 + item.h = 5; 32 + item.mobileH = 10; 33 + 34 + return item; 35 + }, 36 + 37 + urlHandlerPriority: 2, 38 + 39 + name: 'Apple Music Embed', 40 + canResize: true, 41 + minW: 4, 42 + minH: 5, 43 + 44 + keywords: ['music', 'apple', 'playlist', 'album'], 45 + groups: ['Media'], 46 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M23.994 6.124a9.23 9.23 0 00-.24-2.19c-.317-1.31-1.062-2.31-2.18-3.043a5.022 5.022 0 00-1.877-.726 10.496 10.496 0 00-1.564-.15c-.04-.003-.083-.01-.124-.013H5.986c-.152.01-.303.017-.455.026-.747.043-1.49.123-2.193.4-1.336.53-2.3 1.452-2.865 2.78-.192.448-.292.925-.363 1.408-.056.392-.088.785-.1 1.18 0 .032-.007.062-.01.093v12.223c.01.14.017.283.027.424.05.815.154 1.624.497 2.373.65 1.42 1.738 2.353 3.234 2.802.42.127.856.187 1.293.228.555.053 1.11.06 1.667.06h11.03a12.5 12.5 0 001.57-.1c.822-.106 1.596-.35 2.295-.81a5.046 5.046 0 001.88-2.207c.186-.42.293-.87.37-1.324.113-.675.138-1.358.137-2.04-.002-3.8 0-7.595-.003-11.393zm-6.423 3.99v5.712c0 .417-.058.827-.244 1.206-.29.59-.76.962-1.388 1.14-.35.1-.706.157-1.07.173-.95.042-1.8-.6-1.965-1.483-.18-.965.46-1.97 1.553-2.142.238-.037.48-.065.72-.082.39-.03.78-.056 1.168-.1.207-.02.357-.127.404-.334a1.14 1.14 0 00.025-.26V9.97a.48.48 0 00-.357-.47c-.107-.033-.218-.06-.33-.073-.565-.065-1.13-.118-1.696-.18l-3.535-.38c-.043-.004-.088-.005-.13 0a.334.334 0 00-.32.334c-.003.06 0 .12 0 .18v7.63c0 .4-.046.793-.216 1.16-.293.635-.792 1.03-1.466 1.205-.32.082-.647.136-.978.152-.93.043-1.764-.585-1.95-1.443-.2-.924.39-1.893 1.397-2.1.36-.073.724-.118 1.088-.158.274-.03.55-.06.82-.105.164-.027.3-.1.367-.27a.77.77 0 00.048-.268V7.762c0-.282.07-.53.275-.735a1.09 1.09 0 01.49-.282c.333-.093.674-.143 1.012-.18l3.384-.38c.56-.063 1.12-.123 1.68-.187.321-.037.642-.063.96-.04.37.03.658.2.86.518.088.138.135.292.148.453.016.224.02.448.02.672v2.533z"/></svg>` 47 + } as CardDefinition & { type: typeof cardType }; 48 + 49 + // Match Apple Music album and playlist URLs 50 + // Examples: 51 + // https://music.apple.com/us/album/midnights/1649434004 52 + // https://music.apple.com/us/playlist/todays-hits/pl.f4d106fed2bd41149aaacabb233eb5eb 53 + function matchAppleMusicUrl( 54 + url: string | undefined 55 + ): { type: 'album' | 'playlist'; id: string; storefront: string } | null { 56 + if (!url) return null; 57 + 58 + const pattern = /music\.apple\.com\/([a-z]{2})\/(album|playlist)\/[^/]+\/([a-zA-Z0-9.]+)/; 59 + const match = url.match(pattern); 60 + 61 + if (match) { 62 + return { 63 + storefront: match[1], 64 + type: match[2] as 'album' | 'playlist', 65 + id: match[3] 66 + }; 67 + } 68 + 69 + return null; 70 + }
+21 -1
src/lib/cards/BaseCard/BaseCard.svelte
··· 5 5 import type { Snippet } from 'svelte'; 6 6 import type { HTMLAttributes } from 'svelte/elements'; 7 7 import { getColor } from '..'; 8 + import { getIsCoarse } from '$lib/website/context'; 9 + 10 + function tryGetIsCoarse(): (() => boolean) | undefined { 11 + try { 12 + return getIsCoarse(); 13 + } catch { 14 + return undefined; 15 + } 16 + } 17 + const isCoarse = tryGetIsCoarse(); 8 18 9 19 const colors = { 10 20 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 17 27 controls?: Snippet<[]>; 18 28 isEditing?: boolean; 19 29 showOutline?: boolean; 30 + locked?: boolean; 20 31 } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 21 32 22 33 let { ··· 26 37 isEditing = false, 27 38 controls, 28 39 showOutline, 40 + locked = false, 29 41 class: className, 30 42 ...rest 31 43 }: BaseCardProps = $props(); ··· 37 49 id={item.id} 38 50 data-flip-id={item.id} 39 51 bind:this={ref} 40 - draggable={isEditing} 52 + draggable={isEditing && !locked && !isCoarse?.()} 41 53 class={[ 42 54 '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', 43 55 color ? (colors[color] ?? colors.accent) : colors.base, ··· 68 80 ]} 69 81 > 70 82 {@render children?.()} 83 + 84 + {#if !isEditing && item.cardData.label} 85 + <div 86 + class="text-base-900 dark:text-base-50 bg-base-200/50 dark:bg-base-900/50 absolute top-2 left-2 z-30 max-w-[calc(100%-1rem)] rounded-xl p-1 px-2 text-base font-semibold backdrop-blur-md" 87 + > 88 + {item.cardData.label} 89 + </div> 90 + {/if} 71 91 </div> 72 92 {@render controls?.()} 73 93 </div>
+60 -16
src/lib/cards/BaseCard/BaseEditingCard.svelte
··· 3 3 import type { HTMLAttributes } from 'svelte/elements'; 4 4 import BaseCard from './BaseCard.svelte'; 5 5 import type { Item } from '$lib/types'; 6 - import { Button, Label, Popover } from '@foxui/core'; 6 + import { Button, cn, Label, Popover } from '@foxui/core'; 7 7 import { ColorSelect } from '@foxui/colors'; 8 8 import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..'; 9 9 import { COLUMNS } from '$lib'; 10 - import { getCanEdit, getIsMobile } from '$lib/website/context'; 10 + import { 11 + getCanEdit, 12 + getIsCoarse, 13 + getIsMobile, 14 + getSelectedCardId, 15 + getSelectCard 16 + } from '$lib/website/context'; 17 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 18 + import { fixAllCollisions, fixCollisions } from '$lib/helper'; 11 19 12 20 let colorsChoices = [ 13 21 { class: 'text-base-500', label: 'base' }, ··· 51 59 52 60 let canEdit = getCanEdit(); 53 61 let isMobile = getIsMobile(); 62 + let isCoarse = getIsCoarse(); 63 + 64 + let selectedCardId = getSelectedCardId(); 65 + let selectCard = getSelectCard(); 66 + let isSelected = $derived(selectedCardId?.() === item.id); 67 + let isDimmed = $derived(isCoarse?.() && selectedCardId?.() != null && !isSelected); 54 68 55 69 let colorPopoverOpen = $state(false); 56 70 57 - const cardDef = $derived(CardDefinitionsByType[item.cardType]); 71 + const cardDef = $derived(CardDefinitionsByType[item.cardType] ?? {}); 58 72 59 73 const minW = $derived(cardDef.minW ?? (isMobile() ? 2 : 2)); 60 74 const minH = $derived(cardDef.minH ?? (isMobile() ? 2 : 2)); ··· 151 165 let settingsPopoverOpen = $state(false); 152 166 let changePopoverOpen = $state(false); 153 167 154 - const changeOptions = $derived( 155 - AllCardDefinitions.filter((def) => def.canChange?.(item)) 156 - ); 168 + const changeOptions = $derived(AllCardDefinitions.filter((def) => def.canChange?.(item))); 157 169 158 170 function applyChange(def: (typeof AllCardDefinitions)[number]) { 159 171 const updated = def.change ? def.change(item) : item; ··· 173 185 {item} 174 186 isEditing={true} 175 187 bind:ref 176 - showOutline={isResizing} 177 - class="scale-100 opacity-100 starting:scale-0 starting:opacity-0" 188 + showOutline={isResizing || (isCoarse?.() && isSelected)} 189 + locked={item.cardData?.locked} 190 + class={[ 191 + 'scale-100 starting:scale-0 starting:opacity-0', 192 + isCoarse?.() && isSelected ? 'ring-accent-500 z-10 ring-2 ring-offset-2' : '', 193 + isDimmed ? 'opacity-70' : 'opacity-100' 194 + ]} 178 195 {...rest} 179 196 > 180 - <div class="absolute inset-0 cursor-grab"></div> 197 + {#if isCoarse?.() ? !isSelected : !item.cardData?.locked} 198 + <!-- svelte-ignore a11y_click_events_have_key_events --> 199 + <div 200 + role="button" 201 + tabindex="-1" 202 + class={['absolute inset-0', isCoarse?.() ? 'z-20 cursor-pointer' : 'cursor-grab']} 203 + onclick={(e) => { 204 + if (isCoarse?.()) { 205 + e.stopPropagation(); 206 + selectCard?.(item.id); 207 + } 208 + }} 209 + ></div> 210 + {/if} 181 211 {@render children?.()} 182 212 213 + {#if cardDef.canHaveLabel} 214 + <div 215 + class={cn( 216 + 'bg-base-200/50 dark:bg-base-900/50 absolute top-2 left-2 z-100 w-fit max-w-[calc(100%-1rem)] rounded-xl p-1 px-2 backdrop-blur-md', 217 + !item.cardData.label && 'hidden lg:group-hover/card:block' 218 + )} 219 + > 220 + <PlainTextEditor 221 + class="text-base-900 dark:text-base-50 w-fit text-base font-semibold" 222 + key="label" 223 + bind:contentDict={item.cardData} 224 + placeholder="Label" 225 + /> 226 + </div> 227 + {/if} 228 + 183 229 {#snippet controls()} 184 230 <!-- 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/card:inline-flex" --> 185 231 {#if canEdit()} 186 232 {#if changeOptions.length > 1} 187 233 <div 188 234 class={[ 189 - 'absolute -top-3 -right-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex', 235 + 'absolute -top-3 -right-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 190 236 changePopoverOpen ? 'inline-flex' : '' 191 237 ]} 192 238 > ··· 214 260 215 261 <div class="flex min-w-36 flex-col gap-1"> 216 262 <Label class="mb-2">Card type</Label> 217 - {#each changeOptions as changeDef} 263 + {#each changeOptions as changeDef, i (i)} 218 264 <Button 219 265 class="justify-start" 220 266 variant={changeDef.type === item.cardType ? 'primary' : 'ghost'} ··· 234 280 onclick={() => { 235 281 ondelete(); 236 282 }} 237 - class="absolute -top-3 -left-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex" 283 + class="absolute -top-3 -left-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex" 238 284 > 239 285 <svg 240 286 xmlns="http://www.w3.org/2000/svg" ··· 255 301 256 302 <div 257 303 class={[ 258 - 'absolute -bottom-7 w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover/card:inline-flex', 304 + 'absolute -bottom-7 w-full items-center justify-center text-xs lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 259 305 colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden' 260 306 ]} 261 307 > ··· 390 436 391 437 {#if cardDef.canResize !== false} 392 438 <!-- Resize handle at bottom right corner --> 393 - <!-- svelte-ignore a11y_no_static_element_interactions --> 394 - 395 439 <div 396 440 onpointerdown={handleResizeStart} 397 - class="bg-base-300/70 dark:bg-base-900/70 pointer-events-auto absolute right-0.5 bottom-0.5 hidden cursor-se-resize rounded-md rounded-br-3xl p-1 group-hover/card:block" 441 + class="bg-base-300/70 dark:bg-base-900/70 pointer-events-auto absolute right-0.5 bottom-0.5 hidden cursor-se-resize rounded-md rounded-br-3xl p-1 lg:group-hover/card:block" 398 442 > 399 443 <svg 400 444 xmlns="http://www.w3.org/2000/svg"
+24 -7
src/lib/cards/BigSocialCard/BigSocialCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { platformsData } from '.'; 3 3 import type { ContentComponentProps } from '../types'; 4 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 4 5 5 - let { item }: ContentComponentProps = $props(); 6 + let { item, isEditing }: ContentComponentProps = $props(); 6 7 7 8 const platform = $derived(item.cardData.platform as string); 9 + const platformData = $derived(platformsData[platform]); 8 10 </script> 9 11 10 - <a 11 - href={item.cardData.href} 12 - target="_blank" 13 - rel="noopener noreferrer" 12 + <div 14 13 class="flex h-full w-full items-center justify-center p-10" 15 14 style={`background-color: #${item.cardData.color}`} 16 15 > 17 16 <div 18 17 class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white" 19 18 > 20 - {@html platformsData[platform].svg} 19 + {@html platformData?.svg} 21 20 </div> 22 - </a> 21 + </div> 22 + 23 + {#if !isEditing} 24 + <a 25 + href={item.cardData.href} 26 + target="_blank" 27 + rel="noopener noreferrer" 28 + use:qrOverlay={{ 29 + context: { 30 + title: platformData?.title, 31 + icon: platformData?.svg, 32 + iconColor: platformData?.hex 33 + } 34 + }} 35 + > 36 + <div class="absolute inset-0 z-50"></div> 37 + <span class="sr-only">open {platformData?.title}</span> 38 + </a> 39 + {/if}
+36 -2
src/lib/cards/BigSocialCard/index.ts
··· 50 50 51 51 return item; 52 52 }, 53 - urlHandlerPriority: 1 53 + urlHandlerPriority: 1, 54 + canHaveLabel: true, 55 + 56 + keywords: [ 57 + 'twitter', 58 + 'instagram', 59 + 'tiktok', 60 + 'youtube', 61 + 'github', 62 + 'discord', 63 + 'linkedin', 64 + 'mastodon', 65 + 'kickstarter' 66 + ], 67 + groups: ['Social'], 68 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z" /></svg>` 54 69 } as CardDefinition & { type: 'bigsocial' }; 55 70 56 71 import { ··· 89 104 siWechat, 90 105 siLine, 91 106 siArchiveofourown, 107 + siKickstarter, 92 108 type SimpleIcon 93 109 } from 'simple-icons'; 94 110 ··· 145 161 146 162 ao3: /(?:archiveofourown\.org)/i, 147 163 164 + kickstarter: /(?:kickstarter\.com)/i, 165 + 148 166 germ: /(?:ger\.mx)/i, 149 167 150 - tangled: /(?:tangled\.org)/i 168 + tangled: /(?:tangled\.org)/i, 169 + 170 + mail: /(?:mailto:)/i 151 171 }; 152 172 153 173 export const platformsData: Record<string, SimpleIcon> = { ··· 209 229 </svg>` 210 230 }, 211 231 232 + mail: { 233 + slug: 'mail', 234 + path: '', 235 + title: 'Mail', 236 + hex: '0a0a0a', 237 + source: '', 238 + svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> 239 + <path d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z" /> 240 + <path d="M22.5 6.908V6.75a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3v.158l9.714 5.978a1.5 1.5 0 0 0 1.572 0L22.5 6.908Z" /> 241 + </svg>` 242 + }, 243 + 212 244 // support / monetization 213 245 patreon: siPatreon, 214 246 kofi: siKofi, ··· 230 262 line: siLine, 231 263 232 264 ao3: siArchiveofourown, 265 + 266 + kickstarter: siKickstarter, 233 267 234 268 tangled: { 235 269 slug: 'tangled',
+96
src/lib/cards/BlueskyFeedCard/BlueskyFeedCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { BlueskyPost } from '../../components/bluesky-post'; 5 + import { getAdditionalUserData, getDidContext } from '$lib/website/context'; 6 + import { resolveHandle, getAuthorFeed } from '$lib/atproto/methods'; 7 + import type { Did, Handle } from '@atcute/lexicons'; 8 + 9 + let { item }: { item: Item } = $props(); 10 + 11 + const data = getAdditionalUserData(); 12 + const did = getDidContext(); 13 + 14 + // svelte-ignore state_referenced_locally 15 + const lookupKey = (item.cardData.did as string) || (item.cardData.handle as string) || did; 16 + // svelte-ignore state_referenced_locally 17 + const preloaded = (data[item.cardType] as Record<string, any>)?.[lookupKey]; 18 + let feed: any[] | undefined = $state(preloaded?.feed); 19 + let cursor = $state<string | undefined>(preloaded?.cursor); 20 + // svelte-ignore state_referenced_locally 21 + let targetDid = $state<Did | undefined>(item.cardData.did ? (item.cardData.did as Did) : did); 22 + let loading = $state(false); 23 + 24 + async function loadMore() { 25 + if (loading || !cursor || !targetDid) return; 26 + loading = true; 27 + try { 28 + const result = await getAuthorFeed({ 29 + did: targetDid, 30 + filter: 'posts_no_replies', 31 + limit: 20, 32 + cursor 33 + }); 34 + if (result?.feed) { 35 + feed = [...(feed ?? []), ...result.feed]; 36 + } 37 + cursor = result?.cursor; 38 + } finally { 39 + loading = false; 40 + } 41 + } 42 + 43 + function handleScroll(e: Event) { 44 + const el = e.currentTarget as HTMLElement; 45 + if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) { 46 + loadMore(); 47 + } 48 + } 49 + 50 + onMount(async () => { 51 + if (feed) return; 52 + 53 + // Resolve handle to DID if needed 54 + if (item.cardData.handle && !item.cardData.did) { 55 + try { 56 + targetDid = await resolveHandle({ handle: item.cardData.handle as Handle }); 57 + } catch { 58 + // fall back to context did 59 + } 60 + } 61 + 62 + try { 63 + const result = await getAuthorFeed({ 64 + did: targetDid, 65 + filter: 'posts_no_replies', 66 + limit: 20 67 + }); 68 + feed = result?.feed; 69 + cursor = result?.cursor; 70 + } catch { 71 + // failed to fetch feed 72 + } 73 + }); 74 + </script> 75 + 76 + <div class="flex h-full flex-col overflow-x-hidden overflow-y-auto p-3" onscroll={handleScroll}> 77 + {#if feed && feed.length > 0} 78 + <div class={[item.cardData.label ? 'pt-8' : '']}> 79 + {#each feed as feedItem, i (feedItem.post?.uri ?? i)} 80 + <BlueskyPost showAvatar compact feedViewPost={feedItem.post} /> 81 + {#if i < feed.length - 1} 82 + <div 83 + class="border-base-200 dark:border-base-800 accent:border-base-50/5 my-3 border-t" 84 + ></div> 85 + {/if} 86 + {/each} 87 + </div> 88 + {#if loading} 89 + <div class="text-base-400 py-2 text-center text-xs">Loading...</div> 90 + {/if} 91 + {:else} 92 + <div class="text-base-500 flex h-full items-center justify-center text-sm"> 93 + No posts to show 94 + </div> 95 + {/if} 96 + </div>
+99
src/lib/cards/BlueskyFeedCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import BlueskyFeedCard from './BlueskyFeedCard.svelte'; 3 + import { getAuthorFeed, resolveHandle } from '$lib/atproto/methods'; 4 + import type { Did, Handle } from '@atcute/lexicons'; 5 + import { isDid } from '@atcute/lexicons/syntax'; 6 + 7 + export const BlueskyFeedCardDefinition = { 8 + type: 'blueskyFeed', 9 + contentComponent: BlueskyFeedCard, 10 + createNew: (card) => { 11 + card.cardType = 'blueskyFeed'; 12 + card.w = 4; 13 + card.mobileW = 8; 14 + card.h = 6; 15 + card.mobileH = 10; 16 + }, 17 + 18 + onUrlHandler: (url, item) => { 19 + const match = url.match(/bsky\.app\/profile\/([^/]+)\/?$/); 20 + if (!match) return null; 21 + 22 + const actor = match[1]; 23 + if (isDid(actor)) { 24 + item.cardData.did = actor; 25 + } else { 26 + item.cardData.handle = actor; 27 + } 28 + 29 + item.w = 4; 30 + item.mobileW = 8; 31 + item.h = 6; 32 + item.mobileH = 10; 33 + 34 + return item; 35 + }, 36 + urlHandlerPriority: 1, 37 + 38 + loadData: async (items, { did }) => { 39 + // Map from original key (handle or did from cardData) to resolved DID 40 + const keysToDid = new Map<string, Did>(); 41 + 42 + for (const item of items) { 43 + if (item.cardData?.did) { 44 + const d = item.cardData.did as Did; 45 + keysToDid.set(d, d); 46 + } else if (item.cardData?.handle) { 47 + try { 48 + const resolved = await resolveHandle({ handle: item.cardData.handle as Handle }); 49 + keysToDid.set(item.cardData.handle as string, resolved); 50 + } catch { 51 + // skip unresolvable handles 52 + } 53 + } else { 54 + keysToDid.set(did, did); 55 + } 56 + } 57 + 58 + const result: Record<string, unknown> = {}; 59 + const fetched = new Set<string>(); 60 + 61 + await Promise.all( 62 + Array.from(keysToDid.entries()).map(async ([key, fetchDid]) => { 63 + try { 64 + let feedData; 65 + if (!fetched.has(fetchDid)) { 66 + feedData = await getAuthorFeed({ 67 + did: fetchDid, 68 + filter: 'posts_no_replies', 69 + limit: 20 70 + }); 71 + result[fetchDid] = feedData; 72 + fetched.add(fetchDid); 73 + } else { 74 + feedData = result[fetchDid]; 75 + } 76 + // Also store under original key so the component can look it up 77 + if (key !== fetchDid) { 78 + result[key] = feedData; 79 + } 80 + } catch { 81 + // skip failed fetches 82 + } 83 + }) 84 + ); 85 + 86 + return result; 87 + }, 88 + 89 + minW: 4, 90 + minH: 4, 91 + 92 + name: 'Bluesky Feed', 93 + 94 + canHaveLabel: true, 95 + 96 + keywords: ['bsky', 'atproto', 'feed', 'timeline', 'posts'], 97 + groups: ['Social'], 98 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>` 99 + } as CardDefinition & { type: 'blueskyFeed' };
-1
src/lib/cards/BlueskyMediaCard/CreateBlueskyMediaCardModal.svelte
··· 62 62 {#each mediaList as media (media.thumbnail || media.playlist)} 63 63 <button 64 64 onclick={() => { 65 - console.log(media); 66 65 selected = media; 67 66 if (media.isVideo) { 68 67 item.cardData = {
-29
src/lib/cards/BlueskyMediaCard/SidebarItemBlueskyMediaCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - class="text-accent-600 dark:text-accent-400" 11 - viewBox="0 0 24 24" 12 - fill="none" 13 - stroke="currentColor" 14 - stroke-width="2" 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - ><path d="m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16" /><path 18 - d="M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2" 19 - /><circle cx="13" cy="7" r="1" fill="currentColor" /><rect 20 - x="8" 21 - y="2" 22 - width="14" 23 - height="14" 24 - rx="2" 25 - /></svg 26 - > 27 - 28 - Bluesky media 29 - </Button>
+7 -3
src/lib/cards/BlueskyMediaCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import BlueskyMediaCard from './BlueskyMediaCard.svelte'; 3 3 import CreateBlueskyMediaCardModal from './CreateBlueskyMediaCardModal.svelte'; 4 - import SidebarItemBlueskyMediaCard from './SidebarItemBlueskyMediaCard.svelte'; 5 4 6 5 export const BlueskyMediaCardDefinition = { 7 6 type: 'blueskyMedia', 8 7 contentComponent: BlueskyMediaCard, 9 8 createNew: () => {}, 10 9 creationModalComponent: CreateBlueskyMediaCardModal, 11 - sidebarButtonText: 'Bluesky Media', 12 - sidebarComponent: SidebarItemBlueskyMediaCard 10 + canHaveLabel: true, 11 + 12 + keywords: ['bsky', 'atproto', 'media', 'feed'], 13 + groups: ['Media'], 14 + 15 + name: 'Video/Image from Bluesky', 16 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0 1 18 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0 1 18 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 0 1 6 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" /></svg>` 13 17 } as CardDefinition & { type: 'blueskyMedia' };
+28 -24
src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte
··· 2 2 import type { Item } from '$lib/types'; 3 3 import { onMount } from 'svelte'; 4 4 import { BlueskyPost } from '../../components/bluesky-post'; 5 - import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 6 - import { CardDefinitionsByType } from '..'; 5 + import { getAdditionalUserData } from '$lib/website/context'; 6 + import { getPosts, resolveHandle } from '$lib/atproto/methods'; 7 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 8 + import type { Handle } from '@atcute/lexicons'; 9 + import { isDid } from '@atcute/lexicons/syntax'; 10 + import { resolveUri } from './utils'; 7 11 8 12 let { item }: { item: Item } = $props(); 9 13 10 14 const data = getAdditionalUserData(); 11 - // svelte-ignore state_referenced_locally 12 - let feed = $state((data[item.cardType] as any)?.feed); 15 + let uri = $derived(item.cardData.uri as string); 13 16 14 - let did = getDidContext(); 15 - let handle = getHandleContext(); 17 + // svelte-ignore state_referenced_locally 18 + let post = $state((data['blueskyPost'] as Record<string, PostView>)?.[uri]); 16 19 17 20 onMount(async () => { 18 - if (!feed) { 19 - feed = ( 20 - (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 21 - did, 22 - handle 23 - })) as any 24 - ).feed; 25 - 26 - console.log(feed); 21 + if (!post && uri) { 22 + // Resolve handle to DID if needed 23 + const resolvedUri = await resolveUri(uri); 27 24 28 - data[item.cardType] = feed; 25 + const posts = await getPosts({ uris: [resolvedUri] }); 26 + if (posts && posts.length > 0) { 27 + post = posts[0]; 28 + // Store in data for future use (keyed by resolved URI) 29 + if (!data['blueskyPost']) { 30 + data['blueskyPost'] = {}; 31 + } 32 + (data['blueskyPost'] as Record<string, PostView>)[resolvedUri] = post; 33 + // Also store under original URI for lookup 34 + (data['blueskyPost'] as Record<string, PostView>)[uri] = post; 35 + } 29 36 } 30 37 }); 31 38 </script> 32 39 33 40 <div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4"> 34 - <div 35 - class="accent:text-base-950 bg-base-200/50 dark:bg-base-700/30 mx-auto mb-6 w-fit rounded-xl p-1 px-2 text-2xl font-semibold" 36 - > 37 - My latest bluesky post 38 - </div> 39 - {#if feed?.[0]?.post} 40 - <BlueskyPost showLogo feedViewPost={feed?.[0].post}></BlueskyPost> 41 + {#if post} 42 + <div class={[item.cardData.label ? 'pt-8' : '']}> 43 + <BlueskyPost showLogo feedViewPost={post}></BlueskyPost> 44 + </div> 41 45 <div class="h-4 w-full"></div> 42 46 {:else} 43 - Your latest bluesky post will appear here. 47 + <p class="text-base-600 dark:text-base-400 text-center">A bluesky post will appear here</p> 44 48 {/if} 45 49 </div>
+75
src/lib/cards/BlueskyPostCard/CreateBlueskyPostCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { parseBlueskyPostUrl } from './utils'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let isValidating = $state(false); 9 + let errorMessage = $state(''); 10 + let postUrl = $state(''); 11 + 12 + async function validateAndCreate() { 13 + errorMessage = ''; 14 + isValidating = true; 15 + 16 + try { 17 + const parsed = parseBlueskyPostUrl(postUrl.trim()); 18 + 19 + if (!parsed) { 20 + throw new Error('Invalid URL format'); 21 + } 22 + 23 + // Construct AT URI using handle (will be resolved to DID when loading) 24 + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; 25 + item.cardData.href = postUrl.trim(); 26 + 27 + return true; 28 + } catch (err) { 29 + errorMessage = 30 + err instanceof Error && err.message === 'Post not found' 31 + ? "Couldn't find that post. Please check the URL and try again." 32 + : err instanceof Error && err.message === 'Could not resolve handle' 33 + ? "Couldn't find that user. Please check the URL and try again." 34 + : 'Invalid URL. Please enter a valid Bluesky post URL (e.g., https://bsky.app/profile/handle/post/rkey).'; 35 + return false; 36 + } finally { 37 + isValidating = false; 38 + } 39 + } 40 + </script> 41 + 42 + <Modal open={true} closeButton={false}> 43 + <form 44 + onsubmit={async () => { 45 + if (await validateAndCreate()) oncreate(); 46 + }} 47 + class="flex flex-col gap-2" 48 + > 49 + <Subheading>Enter a Bluesky post URL</Subheading> 50 + <Input 51 + bind:value={postUrl} 52 + placeholder="https://bsky.app/profile/handle/post/..." 53 + class="mt-4" 54 + /> 55 + 56 + {#if errorMessage} 57 + <Alert type="error" title="Failed to create post card"><span>{errorMessage}</span></Alert> 58 + {/if} 59 + 60 + <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 61 + Paste a URL from <a 62 + href="https://bsky.app" 63 + class="text-accent-800 dark:text-accent-300" 64 + target="_blank">bsky.app</a 65 + > to embed a Bluesky post. 66 + </p> 67 + 68 + <div class="mt-4 flex justify-end gap-2"> 69 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 70 + <Button type="submit" disabled={isValidating || !postUrl.trim()} 71 + >{isValidating ? 'Creating...' : 'Create'}</Button 72 + > 73 + </div> 74 + </form> 75 + </Modal>
-20
src/lib/cards/BlueskyPostCard/SidebarItemBlueskyPostCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - version="1.1" 11 - class="text-accent-600 dark:text-accent-400 size-4" 12 - viewBox="0 0 600 530" 13 - > 14 - <path 15 - d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 16 - fill="currentColor" 17 - /> 18 - </svg> 19 - Latest Bluesky Post 20 - </Button>
+59 -10
src/lib/cards/BlueskyPostCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import BlueskyPostCard from './BlueskyPostCard.svelte'; 3 - import SidebarItemBlueskyPostCard from './SidebarItemBlueskyPostCard.svelte'; 4 - import { getAuthorFeed } from '$lib/atproto/methods'; 3 + import CreateBlueskyPostCardModal from './CreateBlueskyPostCardModal.svelte'; 4 + import { getPosts } from '$lib/atproto/methods'; 5 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 6 + import { parseBlueskyPostUrl, resolveUri } from './utils'; 5 7 6 8 export const BlueskyPostCardDefinition = { 7 - type: 'latestPost', 9 + type: 'blueskyPost', 8 10 contentComponent: BlueskyPostCard, 11 + creationModalComponent: CreateBlueskyPostCardModal, 9 12 createNew: (card) => { 10 - card.cardType = 'latestPost'; 13 + card.cardType = 'blueskyPost'; 11 14 card.w = 4; 12 15 card.mobileW = 8; 13 16 card.h = 4; 14 17 card.mobileH = 8; 15 18 }, 16 - sidebarComponent: SidebarItemBlueskyPostCard, 17 - loadData: async (items, { did }) => { 18 - const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); 19 + 20 + onUrlHandler: (url, item) => { 21 + const parsed = parseBlueskyPostUrl(url); 22 + if (!parsed) return null; 23 + 24 + // Construct AT URI using handle (will be resolved to DID when loading) 25 + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; 26 + item.cardData.href = url; 27 + 28 + item.w = 4; 29 + item.mobileW = 8; 30 + item.h = 4; 31 + item.mobileH = 8; 32 + 33 + return item; 34 + }, 35 + urlHandlerPriority: 2, 36 + 37 + loadData: async (items) => { 38 + // Collect all unique URIs from blueskyPost cards 39 + const originalUris = items 40 + .filter((item) => item.cardData?.uri) 41 + .map((item) => item.cardData.uri as string); 42 + 43 + if (originalUris.length === 0) return {}; 44 + 45 + // Resolve handles to DIDs 46 + const resolvedUris = await Promise.all(originalUris.map(resolveUri)); 47 + 48 + const posts = await getPosts({ uris: resolvedUris }); 49 + if (!posts) return {}; 19 50 20 - return JSON.parse(JSON.stringify(authorFeed)); 51 + // Create a map of URI -> PostView (keyed by both original and resolved URIs) 52 + const postsMap: Record<string, PostView> = {}; 53 + for (let i = 0; i < posts.length; i++) { 54 + const post = posts[i]; 55 + postsMap[post.uri] = post; 56 + // Also map by original URI for lookup 57 + if (originalUris[i] && originalUris[i] !== post.uri) { 58 + postsMap[originalUris[i]] = post; 59 + } 60 + } 61 + 62 + return postsMap; 21 63 }, 22 - minW: 4 23 - } as CardDefinition & { type: 'latestPost' }; 64 + minW: 4, 65 + name: 'Bluesky Post', 66 + 67 + canHaveLabel: true, 68 + 69 + keywords: ['skeet', 'bsky', 'atproto', 'post'], 70 + groups: ['Social'], 71 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>` 72 + } as CardDefinition & { type: 'blueskyPost' };
+37
src/lib/cards/BlueskyPostCard/utils.ts
··· 1 + import { resolveHandle } from '$lib/atproto'; 2 + import type { Handle } from '@atcute/lexicons'; 3 + 4 + // Matches URLs like https://bsky.app/profile/jyc.dev/post/3mdfjepjpls24 5 + const blueskyPostUrlPattern = 6 + /^https?:\/\/(?:www\.)?bsky\.app\/profile\/([^/]+)\/post\/([A-Za-z0-9]+)\/?$/; 7 + 8 + /** 9 + * Extract handle and rkey from a Bluesky post URL 10 + * @param url URL to parse 11 + * @returns Object with handle and rkey, or undefined if not a valid Bluesky post URL 12 + */ 13 + export function parseBlueskyPostUrl(url: string): { handle: string; rkey: string } | undefined { 14 + const match = url.match(blueskyPostUrlPattern); 15 + if (!match) return undefined; 16 + return { handle: match[1], rkey: match[2] }; 17 + } 18 + 19 + // Resolve handle to DID if URI contains a handle (not starting with did:) 20 + export async function resolveUri(atUri: string): Promise<string> { 21 + const match = atUri.match(/^at:\/\/([^/]+)\/(.+)$/); 22 + if (!match) return atUri; 23 + 24 + const [, authority, rest] = match; 25 + 26 + // If already a DID, return as-is 27 + if (authority.startsWith('did:')) return atUri; 28 + 29 + // Resolve handle to DID 30 + try { 31 + const did = await resolveHandle({ handle: authority as Handle }); 32 + if (!did) return atUri; 33 + return `at://${did}/${rest}`; 34 + } catch { 35 + return atUri; 36 + } 37 + }
+12 -3
src/lib/cards/BlueskyProfileCard/BlueskyProfileCard.svelte
··· 1 1 <script lang="ts"> 2 - import type { Item } from '$lib/types'; 2 + import type { ContentComponentProps } from '../types'; 3 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 3 4 4 - let { item }: { item: Item } = $props(); 5 + let { item, isEditing }: ContentComponentProps = $props(); 6 + 7 + const profileUrl = $derived(`https://bsky.app/profile/${item.cardData.handle}`); 5 8 </script> 6 9 7 10 <a 8 11 target="_blank" 9 - href="/{item.cardData.handle}" 12 + href={profileUrl} 10 13 class="flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl p-2 transition-colors duration-150" 14 + use:qrOverlay={{ 15 + disabled: isEditing, 16 + context: { 17 + title: item.cardData.displayName || item.cardData.handle 18 + } 19 + }} 11 20 > 12 21 <img 13 22 src={item.cardData.avatar}
+1
src/lib/cards/BlueskyProfileCard/index.ts
··· 4 4 export const BlueskyProfileCardDefinition = { 5 5 type: 'blueskyProfile', 6 6 contentComponent: BlueskyProfileCard, 7 + keywords: ['bsky', 'atproto', 'account', 'user'], 7 8 createNew: () => {} 8 9 } as CardDefinition & { type: 'blueskyProfile' };
+53
src/lib/cards/ButtonCard/ButtonCard.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { user } from '$lib/atproto'; 4 + import { getHandleOrDid } from '$lib/atproto/methods'; 5 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 6 + import { cn } from '@foxui/core'; 7 + import type { ContentComponentProps } from '../types'; 8 + 9 + let { item }: ContentComponentProps = $props(); 10 + </script> 11 + 12 + {#snippet content()} 13 + <span 14 + class={cn( 15 + 'text-base-950 dark:text-base-50 line-clamp-1 inline-flex items-center justify-center px-4 text-2xl font-semibold', 16 + item.color === 'transparent' 17 + ? 'bg-accent-400 dark:bg-accent-500 hover:bg-accent-400 dark:text-base-950 rounded-2xl px-5 py-2.5 text-xl transition-colors duration-100' 18 + : '' 19 + )} 20 + > 21 + {item.cardData.text || 'Click me'} 22 + </span> 23 + {/snippet} 24 + 25 + {#if item.cardData.href === '#login'} 26 + <button 27 + onclick={() => { 28 + if (user.isLoggedIn && user.profile) { 29 + goto('/' + getHandleOrDid(user.profile) + '/edit', {}); 30 + } else { 31 + loginModalState.show(); 32 + } 33 + }} 34 + class={[ 35 + 'flex h-full w-full cursor-pointer flex-col items-center justify-center transition-colors duration-100', 36 + item.color === 'transparent' ? 'hover:bg-transparent' : 'hover:bg-accent-100/20' 37 + ]} 38 + > 39 + {@render content()} 40 + </button> 41 + {:else} 42 + <a 43 + href={item.cardData.href || '#'} 44 + target="_blank" 45 + rel="noopener noreferrer" 46 + class={[ 47 + 'flex h-full w-full flex-col items-center justify-center transition-colors duration-100', 48 + item.color === 'transparent' ? 'hover:bg-transparent' : 'hover:bg-accent-100/20' 49 + ]} 50 + > 51 + {@render content()} 52 + </a> 53 + {/if}
+34
src/lib/cards/ButtonCard/ButtonCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import type { SettingsComponentProps } from '../types'; 4 + import { Input, Label } from '@foxui/core'; 5 + 6 + let { item = $bindable<Item>(), onclose }: SettingsComponentProps = $props(); 7 + 8 + function confirmUrl() { 9 + let href = item.cardData.href?.trim() || ''; 10 + if (href && !/^https?:\/\//i.test(href) && !href.startsWith('#')) { 11 + href = 'https://' + href; 12 + } 13 + item.cardData.href = href; 14 + onclose(); 15 + } 16 + </script> 17 + 18 + <div class="flex flex-col gap-3"> 19 + <div class="flex flex-col gap-1"> 20 + <Label for="button-href" class="text-sm">Link</Label> 21 + <Input 22 + id="button-href" 23 + bind:value={item.cardData.href} 24 + placeholder="youtube.com" 25 + class="mt-2 text-sm" 26 + onkeydown={(event) => { 27 + if (event.code === 'Enter') { 28 + event.preventDefault(); 29 + confirmUrl(); 30 + } 31 + }} 32 + /> 33 + </div> 34 + </div>
+23
src/lib/cards/ButtonCard/EditingButtonCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../types'; 3 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 4 + import { cn } from '@foxui/core'; 5 + 6 + let { item = $bindable() }: ContentComponentProps = $props(); 7 + </script> 8 + 9 + <div 10 + class="text-base-950 dark:text-base-50 flex h-full w-full flex-col items-center justify-center gap-2 px-4" 11 + > 12 + <PlainTextEditor 13 + key="text" 14 + bind:contentDict={item.cardData} 15 + placeholder="Button text" 16 + class={cn( 17 + 'line-clamp-1 text-center text-2xl font-semibold', 18 + item.color === 'transparent' 19 + ? 'bg-accent-400 dark:bg-accent-500 hover:bg-accent-400 dark:text-base-950 rounded-2xl px-5 py-2.5 text-xl transition-colors duration-100' 20 + : '' 21 + )} 22 + /> 23 + </div>
+34
src/lib/cards/ButtonCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import ButtonCard from './ButtonCard.svelte'; 3 + import EditingButtonCard from './EditingButtonCard.svelte'; 4 + import ButtonCardSettings from './ButtonCardSettings.svelte'; 5 + 6 + export const ButtonCardDefinition: CardDefinition = { 7 + type: 'button', 8 + contentComponent: ButtonCard, 9 + editingContentComponent: EditingButtonCard, 10 + settingsComponent: ButtonCardSettings, 11 + createNew: (card) => { 12 + card.cardData = { 13 + text: 'Click me' 14 + }; 15 + card.w = 2; 16 + card.h = 1; 17 + card.mobileW = 4; 18 + card.mobileH = 2; 19 + }, 20 + 21 + defaultColor: 'transparent', 22 + allowSetColor: true, 23 + canHaveLabel: false, 24 + 25 + minW: 2, 26 + minH: 1, 27 + maxW: 8, 28 + maxH: 4, 29 + 30 + keywords: ['cta', 'action', 'click', 'link'], 31 + groups: ['Utilities'], 32 + name: 'Button', 33 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>` 34 + };
+1 -1
src/lib/cards/Card/Card.svelte
··· 7 7 8 8 {#if CardDefinitionsByType[item.cardType]} 9 9 {@const cardDef = CardDefinitionsByType[item.cardType]} 10 - <cardDef.contentComponent {item} {...rest} /> 10 + <cardDef.contentComponent isEditing={false} {item} {...rest} /> 11 11 {:else} 12 12 <div class="m-4">Unsupported card type: {item.cardType}</div> 13 13 {/if}
+2 -2
src/lib/cards/Card/EditingCard.svelte
··· 8 8 {#if CardDefinitionsByType[item.cardType]} 9 9 {@const cardDef = CardDefinitionsByType[item.cardType]} 10 10 {#if cardDef.editingContentComponent} 11 - <cardDef.editingContentComponent bind:item /> 11 + <cardDef.editingContentComponent bind:item isEditing /> 12 12 {:else} 13 - <cardDef.contentComponent bind:item /> 13 + <cardDef.contentComponent bind:item isEditing /> 14 14 {/if} 15 15 {:else} 16 16 <div class="m-4">Unsupported card type: {item.cardType}</div>
+87
src/lib/cards/ClockCard/ClockCard.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import type { ClockCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let cardData = $derived(item.cardData as ClockCardData); 10 + 11 + let now = $state(new Date()); 12 + 13 + onMount(() => { 14 + const interval = setInterval(() => { 15 + now = new Date(); 16 + }, 1000); 17 + return () => clearInterval(interval); 18 + }); 19 + 20 + let clockParts = $derived.by(() => { 21 + try { 22 + return new Intl.DateTimeFormat('en-US', { 23 + timeZone: cardData.timezone || 'UTC', 24 + hour: '2-digit', 25 + minute: '2-digit', 26 + second: '2-digit', 27 + hour12: false 28 + }).formatToParts(now); 29 + } catch { 30 + return null; 31 + } 32 + }); 33 + 34 + let clockHours = $derived( 35 + clockParts ? parseInt(clockParts.find((p) => p.type === 'hour')?.value || '0') : 0 36 + ); 37 + let clockMinutes = $derived( 38 + clockParts ? parseInt(clockParts.find((p) => p.type === 'minute')?.value || '0') : 0 39 + ); 40 + let clockSeconds = $derived( 41 + clockParts ? parseInt(clockParts.find((p) => p.type === 'second')?.value || '0') : 0 42 + ); 43 + 44 + let timezoneDisplay = $derived.by(() => { 45 + if (!cardData.timezone) return ''; 46 + try { 47 + const formatter = new Intl.DateTimeFormat('en-US', { 48 + timeZone: cardData.timezone, 49 + timeZoneName: 'short' 50 + }); 51 + const parts = formatter.formatToParts(now); 52 + return parts.find((p) => p.type === 'timeZoneName')?.value || cardData.timezone; 53 + } catch { 54 + return cardData.timezone; 55 + } 56 + }); 57 + </script> 58 + 59 + <div class="@container flex h-full w-full flex-col items-center justify-center p-4"> 60 + <NumberFlowGroup> 61 + <div 62 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-center text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 63 + style="font-variant-numeric: tabular-nums;" 64 + > 65 + <NumberFlow value={clockHours} format={{ minimumIntegerDigits: 2 }} /> 66 + <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 67 + <NumberFlow 68 + value={clockMinutes} 69 + format={{ minimumIntegerDigits: 2 }} 70 + digits={{ 1: { max: 5 } }} 71 + trend={1} 72 + /> 73 + <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 74 + <NumberFlow 75 + value={clockSeconds} 76 + format={{ minimumIntegerDigits: 2 }} 77 + digits={{ 1: { max: 5 } }} 78 + trend={1} 79 + /> 80 + </div> 81 + </NumberFlowGroup> 82 + {#if timezoneDisplay} 83 + <div class="text-base-500 dark:text-base-400 accent:text-base-600 mt-1 text-xs @sm:text-sm"> 84 + {timezoneDisplay} 85 + </div> 86 + {/if} 87 + </div>
+74
src/lib/cards/ClockCard/ClockCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Button, Label } from '@foxui/core'; 4 + import type { ClockCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: { item: Item; onclose: () => void } = $props(); 8 + 9 + let cardData = $derived(item.cardData as ClockCardData); 10 + 11 + const timezoneOptions = [ 12 + { value: 'Pacific/Midway', label: 'UTC-11 (Midway)' }, 13 + { value: 'Pacific/Honolulu', label: 'UTC-10 (Honolulu)' }, 14 + { value: 'America/Anchorage', label: 'UTC-9 (Anchorage)' }, 15 + { value: 'America/Los_Angeles', label: 'UTC-8 (Los Angeles)' }, 16 + { value: 'America/Denver', label: 'UTC-7 (Denver)' }, 17 + { value: 'America/Chicago', label: 'UTC-6 (Chicago)' }, 18 + { value: 'America/New_York', label: 'UTC-5 (New York)' }, 19 + { value: 'America/Halifax', label: 'UTC-4 (Halifax)' }, 20 + { value: 'America/Sao_Paulo', label: 'UTC-3 (Sรฃo Paulo)' }, 21 + { value: 'Atlantic/South_Georgia', label: 'UTC-2 (South Georgia)' }, 22 + { value: 'Atlantic/Azores', label: 'UTC-1 (Azores)' }, 23 + { value: 'UTC', label: 'UTC+0 (London)' }, 24 + { value: 'Europe/Paris', label: 'UTC+1 (Paris)' }, 25 + { value: 'Europe/Helsinki', label: 'UTC+2 (Helsinki)' }, 26 + { value: 'Europe/Moscow', label: 'UTC+3 (Moscow)' }, 27 + { value: 'Asia/Dubai', label: 'UTC+4 (Dubai)' }, 28 + { value: 'Asia/Karachi', label: 'UTC+5 (Karachi)' }, 29 + { value: 'Asia/Kolkata', label: 'UTC+5:30 (Mumbai)' }, 30 + { value: 'Asia/Dhaka', label: 'UTC+6 (Dhaka)' }, 31 + { value: 'Asia/Bangkok', label: 'UTC+7 (Bangkok)' }, 32 + { value: 'Asia/Shanghai', label: 'UTC+8 (Shanghai)' }, 33 + { value: 'Asia/Tokyo', label: 'UTC+9 (Tokyo)' }, 34 + { value: 'Australia/Sydney', label: 'UTC+10 (Sydney)' }, 35 + { value: 'Pacific/Noumea', label: 'UTC+11 (Noumea)' }, 36 + { value: 'Pacific/Auckland', label: 'UTC+12 (Auckland)' } 37 + ]; 38 + 39 + onMount(() => { 40 + if (!cardData.timezone) { 41 + try { 42 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 43 + } catch { 44 + item.cardData.timezone = 'UTC'; 45 + } 46 + } 47 + }); 48 + 49 + function useLocalTimezone() { 50 + try { 51 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 52 + } catch { 53 + item.cardData.timezone = 'UTC'; 54 + } 55 + } 56 + </script> 57 + 58 + <div class="flex flex-col gap-4"> 59 + <div class="flex flex-col gap-2"> 60 + <Label>Timezone</Label> 61 + <div class="flex gap-2"> 62 + <select 63 + value={cardData.timezone || 'UTC'} 64 + onchange={(e) => (item.cardData.timezone = e.currentTarget.value)} 65 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 flex-1 rounded-xl border px-3 py-2" 66 + > 67 + {#each timezoneOptions as tz (tz.value)} 68 + <option value={tz.value}>{tz.label}</option> 69 + {/each} 70 + </select> 71 + <Button size="sm" variant="ghost" onclick={useLocalTimezone}>Local</Button> 72 + </div> 73 + </div> 74 + </div>
+31
src/lib/cards/ClockCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import ClockCard from './ClockCard.svelte'; 3 + import ClockCardSettings from './ClockCardSettings.svelte'; 4 + 5 + export type ClockCardData = { 6 + timezone?: string; 7 + }; 8 + 9 + export const ClockCardDefinition = { 10 + type: 'clock', 11 + contentComponent: ClockCard, 12 + settingsComponent: ClockCardSettings, 13 + 14 + createNew: (card) => { 15 + card.w = 4; 16 + card.h = 2; 17 + card.mobileW = 8; 18 + card.mobileH = 3; 19 + card.cardData = { 20 + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone 21 + } as ClockCardData; 22 + }, 23 + 24 + allowSetColor: true, 25 + name: 'Clock', 26 + minW: 4, 27 + canHaveLabel: true, 28 + groups: ['Utilities'], 29 + keywords: ['time', 'timezone', 'watch'], 30 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>` 31 + } as CardDefinition & { type: 'clock' };
+185
src/lib/cards/CountdownCard/CountdownCard.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import type { CountdownCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let cardData = $derived(item.cardData as CountdownCardData); 10 + 11 + let now = $state(new Date()); 12 + 13 + onMount(() => { 14 + const interval = setInterval(() => { 15 + now = new Date(); 16 + }, 1000); 17 + return () => clearInterval(interval); 18 + }); 19 + 20 + // Countdown to target date 21 + let eventDiff = $derived.by(() => { 22 + if (!cardData.targetDate) return null; 23 + const target = new Date(cardData.targetDate); 24 + return Math.max(0, target.getTime() - now.getTime()); 25 + }); 26 + 27 + let eventDays = $derived(eventDiff !== null ? Math.floor(eventDiff / (1000 * 60 * 60 * 24)) : 0); 28 + let eventHours = $derived( 29 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0 30 + ); 31 + let eventMinutes = $derived( 32 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0 33 + ); 34 + let eventSeconds = $derived( 35 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60)) / 1000) : 0 36 + ); 37 + 38 + // Check if event is in the past (elapsed mode) 39 + let isEventPast = $derived.by(() => { 40 + if (!cardData.targetDate) return false; 41 + return now.getTime() > new Date(cardData.targetDate).getTime(); 42 + }); 43 + 44 + // Elapsed time since past event 45 + let elapsedDiff = $derived.by(() => { 46 + if (!isEventPast || !cardData.targetDate) return null; 47 + return now.getTime() - new Date(cardData.targetDate).getTime(); 48 + }); 49 + 50 + let elapsedYears = $derived( 51 + elapsedDiff !== null ? Math.floor(elapsedDiff / (1000 * 60 * 60 * 24 * 365)) : 0 52 + ); 53 + let elapsedDays = $derived( 54 + elapsedDiff !== null 55 + ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24)) 56 + : 0 57 + ); 58 + let elapsedHours = $derived( 59 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0 60 + ); 61 + let elapsedMinutes = $derived( 62 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0 63 + ); 64 + let elapsedSeconds = $derived( 65 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60)) / 1000) : 0 66 + ); 67 + </script> 68 + 69 + <div class="@container flex h-full w-full flex-col items-center justify-center p-4"> 70 + {#if isEventPast && elapsedDiff !== null} 71 + <!-- Elapsed time since past event --> 72 + <NumberFlowGroup> 73 + <div 74 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 75 + style="font-variant-numeric: tabular-nums;" 76 + > 77 + {#if elapsedYears > 0} 78 + <div class="flex flex-col items-center"> 79 + <NumberFlow 80 + value={elapsedYears} 81 + trend={1} 82 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 83 + /> 84 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 85 + >{elapsedYears === 1 ? 'year' : 'years'}</span 86 + > 87 + </div> 88 + {/if} 89 + {#if elapsedYears > 0 || elapsedDays > 0} 90 + <div class="flex flex-col items-center"> 91 + <NumberFlow 92 + value={elapsedDays} 93 + trend={1} 94 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 95 + /> 96 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 97 + >{elapsedDays === 1 ? 'day' : 'days'}</span 98 + > 99 + </div> 100 + {/if} 101 + <div class="flex flex-col items-center"> 102 + <NumberFlow 103 + value={elapsedHours} 104 + trend={1} 105 + format={{ minimumIntegerDigits: 2 }} 106 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 107 + /> 108 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span> 109 + </div> 110 + <div class="flex flex-col items-center"> 111 + <NumberFlow 112 + value={elapsedMinutes} 113 + trend={1} 114 + format={{ minimumIntegerDigits: 2 }} 115 + digits={{ 1: { max: 5 } }} 116 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 117 + /> 118 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span> 119 + </div> 120 + <div class="flex flex-col items-center"> 121 + <NumberFlow 122 + value={elapsedSeconds} 123 + trend={1} 124 + format={{ minimumIntegerDigits: 2 }} 125 + digits={{ 1: { max: 5 } }} 126 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 127 + /> 128 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span> 129 + </div> 130 + </div> 131 + </NumberFlowGroup> 132 + {:else if eventDiff !== null} 133 + <!-- Countdown to future event --> 134 + <NumberFlowGroup> 135 + <div 136 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 137 + style="font-variant-numeric: tabular-nums;" 138 + > 139 + {#if eventDays > 0} 140 + <div class="flex flex-col items-center"> 141 + <NumberFlow 142 + value={eventDays} 143 + trend={-1} 144 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 145 + /> 146 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 147 + >{eventDays === 1 ? 'day' : 'days'}</span 148 + > 149 + </div> 150 + {/if} 151 + <div class="flex flex-col items-center"> 152 + <NumberFlow 153 + value={eventHours} 154 + trend={-1} 155 + format={{ minimumIntegerDigits: 2 }} 156 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 157 + /> 158 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span> 159 + </div> 160 + <div class="flex flex-col items-center"> 161 + <NumberFlow 162 + value={eventMinutes} 163 + trend={-1} 164 + format={{ minimumIntegerDigits: 2 }} 165 + digits={{ 1: { max: 5 } }} 166 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 167 + /> 168 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span> 169 + </div> 170 + <div class="flex flex-col items-center"> 171 + <NumberFlow 172 + value={eventSeconds} 173 + trend={-1} 174 + format={{ minimumIntegerDigits: 2 }} 175 + digits={{ 1: { max: 5 } }} 176 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 177 + /> 178 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span> 179 + </div> 180 + </div> 181 + </NumberFlowGroup> 182 + {:else} 183 + <div class="text-base-500 text-sm">Set a target date in settings</div> 184 + {/if} 185 + </div>
+44
src/lib/cards/CountdownCard/CountdownCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Input, Label } from '@foxui/core'; 4 + import type { CountdownCardData } from './index'; 5 + 6 + let { item }: { item: Item; onclose: () => void } = $props(); 7 + 8 + let cardData = $derived(item.cardData as CountdownCardData); 9 + 10 + let targetDateValue = $derived.by(() => { 11 + if (!cardData.targetDate) return ''; 12 + return new Date(cardData.targetDate).toISOString().split('T')[0]; 13 + }); 14 + 15 + let targetTimeValue = $derived.by(() => { 16 + if (!cardData.targetDate) return '12:00'; 17 + return new Date(cardData.targetDate).toTimeString().slice(0, 5); 18 + }); 19 + 20 + function updateTargetDate(dateStr: string, timeStr: string) { 21 + if (!dateStr) return; 22 + item.cardData.targetDate = new Date(`${dateStr}T${timeStr}`).toISOString(); 23 + } 24 + </script> 25 + 26 + <div class="flex flex-col gap-4"> 27 + <div class="flex flex-col gap-2"> 28 + <Label>Target Date & Time</Label> 29 + <div class="flex gap-2"> 30 + <Input 31 + type="date" 32 + value={targetDateValue} 33 + onchange={(e) => updateTargetDate(e.currentTarget.value, targetTimeValue)} 34 + class="flex-1" 35 + /> 36 + <Input 37 + type="time" 38 + value={targetTimeValue} 39 + onchange={(e) => updateTargetDate(targetDateValue, e.currentTarget.value)} 40 + class="w-28" 41 + /> 42 + </div> 43 + </div> 44 + </div>
+29
src/lib/cards/CountdownCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CountdownCard from './CountdownCard.svelte'; 3 + import CountdownCardSettings from './CountdownCardSettings.svelte'; 4 + 5 + export type CountdownCardData = { 6 + targetDate?: string; 7 + }; 8 + 9 + export const CountdownCardDefinition = { 10 + type: 'countdown', 11 + contentComponent: CountdownCard, 12 + settingsComponent: CountdownCardSettings, 13 + 14 + createNew: (card) => { 15 + card.w = 4; 16 + card.h = 2; 17 + card.mobileW = 8; 18 + card.mobileH = 3; 19 + card.cardData = {} as CountdownCardData; 20 + }, 21 + 22 + allowSetColor: true, 23 + name: 'Countdown', 24 + minW: 4, 25 + canHaveLabel: true, 26 + groups: ['Utilities'], 27 + keywords: ['timer', 'event', 'date', 'countdown'], 28 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z M19.5 4.5l-1.5 1.5M4.5 4.5l1.5 1.5M12 2.25V3.75M9 2.25h6" /></svg>` 29 + } as CardDefinition & { type: 'countdown' };
+54
src/lib/cards/DrawCard/DrawCard.svelte
··· 1 + <script lang="ts"> 2 + import { getStroke } from 'perfect-freehand'; 3 + import type { ContentComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), isEditing }: ContentComponentProps = $props(); 6 + 7 + type Stroke = { 8 + points: [number, number, number][]; 9 + size?: number; 10 + }; 11 + 12 + function getStrokeOptions(size: number) { 13 + return { size, thinning: 0.5, smoothing: 0.5, streamline: 0.5 }; 14 + } 15 + 16 + function getSvgPathFromStroke(stroke: number[][]): string { 17 + if (!stroke.length) return ''; 18 + 19 + const d = stroke.reduce( 20 + (acc, [x0, y0], i, arr) => { 21 + const [x1, y1] = arr[(i + 1) % arr.length]; 22 + acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); 23 + return acc; 24 + }, 25 + ['M', ...stroke[0], 'Q'] as (string | number)[] 26 + ); 27 + 28 + d.push('Z'); 29 + return d.join(' '); 30 + } 31 + 32 + // Parse strokes from JSON string stored in cardData 33 + function parseStrokes(): Stroke[] { 34 + const strokesJson = item.cardData.strokesJson as string | undefined; 35 + if (!strokesJson) return []; 36 + try { 37 + return JSON.parse(strokesJson) as Stroke[]; 38 + } catch { 39 + return []; 40 + } 41 + } 42 + 43 + let strokes = $derived(parseStrokes()); 44 + let viewBox = $derived((item.cardData.viewBox as string) || '0 0 100 100'); 45 + </script> 46 + 47 + <svg class="absolute inset-0 h-full w-full" {viewBox} preserveAspectRatio="xMidYMid meet"> 48 + {#each strokes as stroke, index (index)} 49 + {@const pathData = getSvgPathFromStroke( 50 + getStroke(stroke.points, getStrokeOptions(stroke.size ?? 3)) 51 + )} 52 + <path d={pathData} class="accent:fill-white fill-black dark:fill-white" /> 53 + {/each} 54 + </svg>
+261
src/lib/cards/DrawCard/EditingDrawCard.svelte
··· 1 + <script lang="ts"> 2 + import { getStroke } from 'perfect-freehand'; 3 + import type { ContentComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), isEditing }: ContentComponentProps = $props(); 6 + 7 + type Stroke = { 8 + points: [number, number, number][]; 9 + size?: number; 10 + }; 11 + 12 + let currentStroke = $state<[number, number, number][]>([]); 13 + let isDrawing = $state(false); 14 + let svgElement: SVGSVGElement | undefined = $state(); 15 + 16 + const strokeSizes = [4, 8, 16] as const; 17 + let strokeWidth = $derived((item.cardData.strokeWidth as number) ?? 1); 18 + 19 + function getStrokeOptions(size: number) { 20 + return { size, thinning: 0.5, smoothing: 0.5, streamline: 0.5 }; 21 + } 22 + 23 + let isLocked = $derived(item.cardData?.locked ?? true); 24 + 25 + function toggleLock() { 26 + item.cardData.locked = !item.cardData.locked; 27 + } 28 + 29 + function setStrokeWidth(index: number) { 30 + item.cardData.strokeWidth = index; 31 + } 32 + 33 + function getSvgPathFromStroke(stroke: number[][]): string { 34 + if (!stroke.length) return ''; 35 + 36 + const d = stroke.reduce( 37 + (acc, [x0, y0], i, arr) => { 38 + const [x1, y1] = arr[(i + 1) % arr.length]; 39 + acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); 40 + return acc; 41 + }, 42 + ['M', ...stroke[0], 'Q'] as (string | number)[] 43 + ); 44 + 45 + d.push('Z'); 46 + return d.join(' '); 47 + } 48 + 49 + // Parse strokes from JSON string stored in cardData 50 + function parseStrokes(): Stroke[] { 51 + const strokesJson = item.cardData.strokesJson as string | undefined; 52 + if (!strokesJson) return []; 53 + try { 54 + return JSON.parse(strokesJson) as Stroke[]; 55 + } catch { 56 + return []; 57 + } 58 + } 59 + 60 + // Save strokes as JSON string to cardData 61 + function saveStrokes(strokes: Stroke[]) { 62 + item.cardData.strokesJson = JSON.stringify(strokes); 63 + } 64 + 65 + let strokes = $derived(parseStrokes()); 66 + let viewBox = $derived((item.cardData.viewBox as string) || '0 0 100 100'); 67 + 68 + function getPointerPosition(event: PointerEvent): [number, number, number] { 69 + if (!svgElement) return [0, 0, 0.5]; 70 + const rect = svgElement.getBoundingClientRect(); 71 + 72 + // Get the current viewBox dimensions 73 + const [, , vbWidth, vbHeight] = (item.cardData.viewBox as string)?.split(' ').map(Number) || [ 74 + 0, 0, 100, 100 75 + ]; 76 + 77 + // Calculate the scale and offset for xMidYMid meet 78 + const scaleX = rect.width / vbWidth; 79 + const scaleY = rect.height / vbHeight; 80 + const scale = Math.min(scaleX, scaleY); // "meet" uses the smaller scale 81 + 82 + // Calculate the actual rendered size of the viewBox content 83 + const renderedWidth = vbWidth * scale; 84 + const renderedHeight = vbHeight * scale; 85 + 86 + // Calculate centering offsets (xMid, yMid) 87 + const offsetX = (rect.width - renderedWidth) / 2; 88 + const offsetY = (rect.height - renderedHeight) / 2; 89 + 90 + // Map screen coordinates to viewBox coordinates, accounting for centering 91 + const x = ((event.clientX - rect.left - offsetX) / renderedWidth) * vbWidth; 92 + const y = ((event.clientY - rect.top - offsetY) / renderedHeight) * vbHeight; 93 + const pressure = event.pressure || 0.5; 94 + return [x, y, pressure]; 95 + } 96 + 97 + function initViewBox() { 98 + if (!svgElement || item.cardData.viewBox) return; 99 + const rect = svgElement.getBoundingClientRect(); 100 + item.cardData.viewBox = `0 0 ${Math.round(rect.width)} ${Math.round(rect.height)}`; 101 + } 102 + 103 + function handlePointerDown(event: PointerEvent) { 104 + isDrawing = true; 105 + initViewBox(); 106 + const point = getPointerPosition(event); 107 + currentStroke = [point]; 108 + (event.target as Element)?.setPointerCapture?.(event.pointerId); 109 + } 110 + 111 + function handlePointerMove(event: PointerEvent) { 112 + if (!isDrawing) return; 113 + const point = getPointerPosition(event); 114 + currentStroke = [...currentStroke, point]; 115 + } 116 + 117 + function handlePointerUp(event: PointerEvent) { 118 + if (!isDrawing) return; 119 + isDrawing = false; 120 + if (currentStroke.length > 0) { 121 + const newStroke: Stroke = { 122 + points: currentStroke, 123 + size: strokeSizes[strokeWidth] 124 + }; 125 + saveStrokes([...strokes, newStroke]); 126 + } 127 + currentStroke = []; 128 + (event.target as Element)?.releasePointerCapture?.(event.pointerId); 129 + } 130 + 131 + function clearStrokes() { 132 + saveStrokes([]); 133 + item.cardData.viewBox = ''; 134 + } 135 + </script> 136 + 137 + <div class={['absolute inset-0', isLocked ? 'touch-none' : '']}> 138 + <svg 139 + bind:this={svgElement} 140 + class={[ 141 + 'absolute inset-0 h-full w-full', 142 + isLocked ? 'pointer-events-auto cursor-crosshair' : 'pointer-events-none' 143 + ]} 144 + {viewBox} 145 + preserveAspectRatio="xMidYMid meet" 146 + onpointerdown={isLocked ? handlePointerDown : undefined} 147 + onpointermove={isLocked ? handlePointerMove : undefined} 148 + onpointerup={isLocked ? handlePointerUp : undefined} 149 + onpointerleave={isLocked ? handlePointerUp : undefined} 150 + > 151 + {#each strokes as stroke, index (index)} 152 + {@const pathData = getSvgPathFromStroke( 153 + getStroke(stroke.points, getStrokeOptions(stroke.size ?? 3)) 154 + )} 155 + <path d={pathData} class="accent:fill-white fill-black dark:fill-white" /> 156 + {/each} 157 + {#if currentStroke.length > 0} 158 + {@const pathData = getSvgPathFromStroke( 159 + getStroke(currentStroke, getStrokeOptions(strokeSizes[strokeWidth])) 160 + )} 161 + <path d={pathData} class="accent:fill-white fill-black dark:fill-white" /> 162 + {/if} 163 + </svg> 164 + 165 + {#if !isLocked && strokes.length === 0} 166 + <div 167 + class="text-base-500 pointer-events-none absolute inset-0 flex items-center justify-center text-sm" 168 + > 169 + Lock to draw 170 + </div> 171 + {/if} 172 + 173 + <div class="absolute top-2 right-2 flex gap-1"> 174 + {#if isLocked} 175 + <div class="bg-base-100/80 dark:bg-base-800/80 flex items-center gap-0.5 rounded-full px-1"> 176 + {#each strokeSizes as size, index (size)} 177 + <button 178 + type="button" 179 + class={[ 180 + 'flex items-center justify-center rounded-full p-1.5', 181 + strokeWidth === index ? 'bg-accent-500 text-white' : '' 182 + ]} 183 + onclick={() => setStrokeWidth(index)} 184 + aria-label={`Stroke size ${size}`} 185 + > 186 + <div 187 + class={[ 188 + 'rounded-full bg-current', 189 + index === 0 ? 'h-1.5 w-1.5' : index === 1 ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5' 190 + ]} 191 + ></div> 192 + </button> 193 + {/each} 194 + </div> 195 + 196 + <button 197 + type="button" 198 + class="bg-base-100/80 dark:bg-base-800/80 rounded-full p-1.5" 199 + onclick={clearStrokes} 200 + aria-label="Clear drawing" 201 + > 202 + <svg 203 + xmlns="http://www.w3.org/2000/svg" 204 + class="h-4 w-4" 205 + viewBox="0 0 24 24" 206 + fill="none" 207 + stroke="currentColor" 208 + stroke-width="2" 209 + stroke-linecap="round" 210 + stroke-linejoin="round" 211 + > 212 + <polyline points="3 6 5 6 21 6"></polyline> 213 + <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" 214 + ></path> 215 + </svg> 216 + </button> 217 + {/if} 218 + 219 + <button 220 + type="button" 221 + class={[ 222 + 'rounded-full p-1.5', 223 + isLocked 224 + ? 'bg-accent-500 text-white' 225 + : 'bg-base-100/80 text-base-900 dark:bg-base-800/80 dark:text-base-50' 226 + ]} 227 + onclick={toggleLock} 228 + aria-label={isLocked ? 'Unlock card' : 'Lock card to draw'} 229 + > 230 + {#if isLocked} 231 + <svg 232 + xmlns="http://www.w3.org/2000/svg" 233 + class="h-4 w-4" 234 + viewBox="0 0 24 24" 235 + fill="none" 236 + stroke="currentColor" 237 + stroke-width="2" 238 + stroke-linecap="round" 239 + stroke-linejoin="round" 240 + > 241 + <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect> 242 + <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> 243 + </svg> 244 + {:else} 245 + <svg 246 + xmlns="http://www.w3.org/2000/svg" 247 + class="h-4 w-4" 248 + viewBox="0 0 24 24" 249 + fill="none" 250 + stroke="currentColor" 251 + stroke-width="2" 252 + stroke-linecap="round" 253 + stroke-linejoin="round" 254 + > 255 + <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect> 256 + <path d="M7 11V7a5 5 0 0 1 9.9-1"></path> 257 + </svg> 258 + {/if} 259 + </button> 260 + </div> 261 + </div>
+30
src/lib/cards/DrawCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import DrawCard from './DrawCard.svelte'; 3 + import EditingDrawCard from './EditingDrawCard.svelte'; 4 + 5 + export const DrawCardDefinition = { 6 + type: 'draw', 7 + name: 'Drawing', 8 + contentComponent: DrawCard, 9 + editingContentComponent: EditingDrawCard, 10 + defaultColor: 'base', 11 + allowSetColor: true, 12 + minW: 2, 13 + minH: 2, 14 + createNew: (item) => { 15 + item.w = 4; 16 + item.h = 4; 17 + item.mobileW = 4; 18 + item.mobileH = 4; 19 + item.cardData = { 20 + strokesJson: '[]', 21 + viewBox: '', 22 + strokeWidth: 1, 23 + locked: true 24 + }; 25 + }, 26 + 27 + keywords: ['paint', 'sketch', 'doodle', 'canvas', 'art'], 28 + groups: ['Visual'], 29 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /></svg>` 30 + } as CardDefinition & { type: 'draw' };
+8 -5
src/lib/cards/EmbedCard/index.ts
··· 14 14 card.mobileW = 8; 15 15 }, 16 16 17 - canChange: (item) => Boolean(item.cardData.href), 17 + // canChange: (item) => Boolean(item.cardData.href), 18 18 19 - change: (item) => { 20 - return item; 21 - }, 22 - name: 'Embed Card' 19 + // change: (item) => { 20 + // return item; 21 + // }, 22 + name: 'Embed', 23 + keywords: ['iframe', 'widget', 'html', 'website'], 24 + groups: ['Media'], 25 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>` 23 26 } as CardDefinition & { type: 'embed' };
+98
src/lib/cards/EventCard/CreateEventCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + const EVENT_COLLECTION = 'community.lexicon.calendar.event'; 6 + 7 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 8 + 9 + let isValidating = $state(false); 10 + let errorMessage = $state(''); 11 + let eventUrl = $state(''); 12 + 13 + function parseEventUrl(url: string): { did: string; rkey: string } | null { 14 + // Match smokesignal.events URLs: https://smokesignal.events/{did}/{rkey} 15 + const smokesignalMatch = url.match(/^https?:\/\/smokesignal\.events\/(did:[^/]+)\/([^/?#]+)/); 16 + if (smokesignalMatch) { 17 + return { did: smokesignalMatch[1], rkey: smokesignalMatch[2] }; 18 + } 19 + 20 + // Match AT URIs: at://{did}/community.lexicon.calendar.event/{rkey} 21 + const atUriMatch = url.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/?#]+)/); 22 + if (atUriMatch && atUriMatch[2] === EVENT_COLLECTION) { 23 + return { did: atUriMatch[1], rkey: atUriMatch[3] }; 24 + } 25 + 26 + return null; 27 + } 28 + 29 + async function validateAndCreate() { 30 + errorMessage = ''; 31 + isValidating = true; 32 + 33 + try { 34 + const parsed = parseEventUrl(eventUrl.trim()); 35 + 36 + if (!parsed) { 37 + throw new Error('Invalid URL format'); 38 + } 39 + 40 + // Validate the event exists by fetching it 41 + const response = await fetch( 42 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(parsed.did)}&record_key=${encodeURIComponent(parsed.rkey)}` 43 + ); 44 + 45 + if (!response.ok) { 46 + throw new Error('Event not found'); 47 + } 48 + 49 + // Store as AT URI 50 + item.cardData.uri = `at://${parsed.did}/${EVENT_COLLECTION}/${parsed.rkey}`; 51 + 52 + return true; 53 + } catch (err) { 54 + errorMessage = 55 + err instanceof Error && err.message === 'Event not found' 56 + ? "Couldn't find that event. Please check the URL and try again." 57 + : 'Invalid URL. Please enter a valid smokesignal.events URL or AT URI.'; 58 + return false; 59 + } finally { 60 + isValidating = false; 61 + } 62 + } 63 + </script> 64 + 65 + <Modal open={true} closeButton={false}> 66 + <form 67 + onsubmit={async () => { 68 + if (await validateAndCreate()) oncreate(); 69 + }} 70 + class="flex flex-col gap-2" 71 + > 72 + <Subheading>Enter a Smoke Signal event URL</Subheading> 73 + <Input 74 + bind:value={eventUrl} 75 + placeholder="https://smokesignal.events/did:.../..." 76 + class="mt-4" 77 + /> 78 + 79 + {#if errorMessage} 80 + <Alert type="error" title="Failed to create event card"><span>{errorMessage}</span></Alert> 81 + {/if} 82 + 83 + <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 84 + Paste a URL from <a 85 + href="https://smokesignal.events" 86 + class="text-accent-800 dark:text-accent-300" 87 + target="_blank">smokesignal.events</a 88 + > or an AT URI for a calendar event. 89 + </p> 90 + 91 + <div class="mt-4 flex justify-end gap-2"> 92 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 93 + <Button type="submit" disabled={isValidating || !eventUrl.trim()} 94 + >{isValidating ? 'Creating...' : 'Create'}</Button 95 + > 96 + </div> 97 + </form> 98 + </Modal>
+290
src/lib/cards/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 '.'; 8 + import { parseUri } from '$lib/atproto'; 9 + import { browser } from '$app/environment'; 10 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 11 + import type { Did } from '@atcute/lexicons'; 12 + 13 + let { item }: ContentComponentProps = $props(); 14 + 15 + let isMobile = getIsMobile(); 16 + let isLoaded = $state(false); 17 + let fetchedEventData = $state<EventData | undefined>(undefined); 18 + 19 + const data = getAdditionalUserData(); 20 + 21 + let eventData = $derived( 22 + fetchedEventData || 23 + ((data[item.cardType] as Record<string, EventData> | undefined)?.[item.id] as 24 + | EventData 25 + | undefined) 26 + ); 27 + 28 + let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); 29 + 30 + onMount(async () => { 31 + if (!eventData && item.cardData?.uri && parsedUri?.repo) { 32 + const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 33 + did: parsedUri.repo as Did, 34 + handle: '' 35 + })) as Record<string, EventData> | undefined; 36 + 37 + if (loadedData?.[item.id]) { 38 + fetchedEventData = loadedData[item.id]; 39 + if (!data[item.cardType]) { 40 + data[item.cardType] = {}; 41 + } 42 + (data[item.cardType] as Record<string, EventData>)[item.id] = fetchedEventData; 43 + } 44 + } 45 + isLoaded = true; 46 + }); 47 + 48 + function formatDate(dateStr: string): string { 49 + const date = new Date(dateStr); 50 + return date.toLocaleDateString('en-US', { 51 + weekday: 'short', 52 + month: 'short', 53 + day: 'numeric', 54 + year: 'numeric' 55 + }); 56 + } 57 + 58 + function formatTime(dateStr: string): string { 59 + const date = new Date(dateStr); 60 + return date.toLocaleTimeString('en-US', { 61 + hour: 'numeric', 62 + minute: '2-digit' 63 + }); 64 + } 65 + 66 + function getModeLabel(mode: string): string { 67 + if (mode.includes('virtual')) return 'Virtual'; 68 + if (mode.includes('hybrid')) return 'Hybrid'; 69 + if (mode.includes('inperson')) return 'In-Person'; 70 + return 'Event'; 71 + } 72 + 73 + function getModeColor(mode: string): string { 74 + if (mode.includes('virtual')) return 'blue'; 75 + if (mode.includes('hybrid')) return 'purple'; 76 + if (mode.includes('inperson')) return 'green'; 77 + return 'gray'; 78 + } 79 + 80 + function getLocationString( 81 + locations: 82 + | Array<{ address?: { locality?: string; region?: string; country?: string } }> 83 + | undefined 84 + ): string | undefined { 85 + if (!locations || locations.length === 0) return undefined; 86 + const loc = locations[0]?.address; 87 + if (!loc) return undefined; 88 + 89 + const parts = [loc.locality, loc.region, loc.country].filter(Boolean); 90 + return parts.length > 0 ? parts.join(', ') : undefined; 91 + } 92 + 93 + let eventUrl = $derived(() => { 94 + if (eventData?.url) return eventData.url; 95 + if (parsedUri) { 96 + return `https://smokesignal.events/${parsedUri.repo}/${parsedUri.rkey}`; 97 + } 98 + return '#'; 99 + }); 100 + 101 + let location = $derived(getLocationString(eventData?.locations)); 102 + 103 + let headerImage = $derived(() => { 104 + if (!eventData?.media || !parsedUri) return null; 105 + const header = eventData.media.find((m) => m.role === 'header'); 106 + if (!header?.content?.ref?.$link) return null; 107 + return { 108 + url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${header.content.ref.$link}@jpeg`, 109 + alt: header.alt || eventData.name 110 + }; 111 + }); 112 + 113 + let showImage = $derived( 114 + browser && headerImage() && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) 115 + ); 116 + </script> 117 + 118 + <div class="flex h-full flex-col justify-between overflow-hidden p-4"> 119 + {#if eventData} 120 + <div class="min-w-0 flex-1 overflow-hidden"> 121 + <div class="mb-2 flex items-center justify-between gap-2"> 122 + <div class="flex items-center gap-2"> 123 + <div 124 + class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 flex size-8 shrink-0 items-center justify-center rounded-xl border" 125 + > 126 + <svg 127 + xmlns="http://www.w3.org/2000/svg" 128 + fill="none" 129 + viewBox="0 0 24 24" 130 + stroke-width="1.5" 131 + stroke="currentColor" 132 + class="size-4" 133 + > 134 + <path 135 + stroke-linecap="round" 136 + stroke-linejoin="round" 137 + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 138 + /> 139 + </svg> 140 + </div> 141 + <Badge size="sm" color={getModeColor(eventData.mode)}> 142 + <span class="accent:text-base-900">{getModeLabel(eventData.mode)}</span> 143 + </Badge> 144 + </div> 145 + 146 + {#if isMobile() ? item.mobileW > 4 : item.w > 2} 147 + <Button href={eventUrl()} target="_blank" rel="noopener noreferrer" class="z-50" 148 + >View event</Button 149 + > 150 + {/if} 151 + </div> 152 + 153 + <h3 class="text-base-900 dark:text-base-50 mb-2 line-clamp-2 text-lg leading-tight font-bold"> 154 + {eventData.name} 155 + </h3> 156 + 157 + <div class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 text-sm"> 158 + <div class="flex items-center gap-1"> 159 + <svg 160 + xmlns="http://www.w3.org/2000/svg" 161 + fill="none" 162 + viewBox="0 0 24 24" 163 + stroke-width="1.5" 164 + stroke="currentColor" 165 + class="size-4 shrink-0" 166 + > 167 + <path 168 + stroke-linecap="round" 169 + stroke-linejoin="round" 170 + d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 171 + /> 172 + </svg> 173 + <span class="truncate"> 174 + {formatDate(eventData.startsAt)} at {formatTime(eventData.startsAt)} 175 + {#if eventData.endsAt} 176 + - {formatDate(eventData.endsAt)} 177 + {/if} 178 + </span> 179 + </div> 180 + </div> 181 + 182 + {#if location} 183 + <div 184 + class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 flex items-center gap-1 text-sm" 185 + > 186 + <svg 187 + xmlns="http://www.w3.org/2000/svg" 188 + fill="none" 189 + viewBox="0 0 24 24" 190 + stroke-width="1.5" 191 + stroke="currentColor" 192 + class="size-4 shrink-0" 193 + > 194 + <path 195 + stroke-linecap="round" 196 + stroke-linejoin="round" 197 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 198 + /> 199 + <path 200 + stroke-linecap="round" 201 + stroke-linejoin="round" 202 + d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 203 + /> 204 + </svg> 205 + <span class="truncate">{location}</span> 206 + </div> 207 + {/if} 208 + 209 + {#if eventData.description && ((isMobile() && item.mobileH >= 5) || (!isMobile() && item.h >= 3))} 210 + <p class="text-base-500 dark:text-base-400 accent:text-base-900 mb-3 line-clamp-3 text-sm"> 211 + {eventData.description} 212 + </p> 213 + {/if} 214 + 215 + {#if (eventData.countGoing !== undefined || eventData.countInterested !== undefined) && ((isMobile() && item.mobileH >= 4) || (!isMobile() && item.h >= 3))} 216 + <div 217 + class="text-base-600 dark:text-base-400 accent:text-base-800 flex flex-wrap gap-3 text-xs" 218 + > 219 + {#if eventData.countGoing !== undefined} 220 + <div class="flex items-center gap-1"> 221 + <svg 222 + xmlns="http://www.w3.org/2000/svg" 223 + fill="none" 224 + viewBox="0 0 24 24" 225 + stroke-width="1.5" 226 + stroke="currentColor" 227 + class="size-4" 228 + > 229 + <path 230 + stroke-linecap="round" 231 + stroke-linejoin="round" 232 + d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 233 + /> 234 + </svg> 235 + <span>{eventData.countGoing} going</span> 236 + </div> 237 + {/if} 238 + {#if eventData.countInterested !== undefined} 239 + <div class="flex items-center gap-1"> 240 + <svg 241 + xmlns="http://www.w3.org/2000/svg" 242 + fill="none" 243 + viewBox="0 0 24 24" 244 + stroke-width="1.5" 245 + stroke="currentColor" 246 + class="size-4" 247 + > 248 + <path 249 + stroke-linecap="round" 250 + stroke-linejoin="round" 251 + d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" 252 + /> 253 + </svg> 254 + <span>{eventData.countInterested} interested</span> 255 + </div> 256 + {/if} 257 + </div> 258 + {/if} 259 + </div> 260 + 261 + {#if showImage} 262 + {@const img = headerImage()} 263 + {#if img} 264 + <img src={img.url} alt={img.alt} class="mt-3 aspect-3/1 w-full rounded-xl object-cover" /> 265 + {/if} 266 + {/if} 267 + 268 + <a 269 + href={eventUrl()} 270 + class="absolute inset-0 h-full w-full" 271 + target="_blank" 272 + rel="noopener noreferrer" 273 + use:qrOverlay={{ 274 + context: { 275 + title: eventData?.name ?? '' 276 + } 277 + }} 278 + > 279 + <span class="sr-only">View event on smokesignal.events</span> 280 + </a> 281 + {:else if isLoaded} 282 + <div class="flex h-full w-full items-center justify-center"> 283 + <span class="text-base-500 dark:text-base-400">Event not found</span> 284 + </div> 285 + {:else} 286 + <div class="flex h-full w-full items-center justify-center"> 287 + <span class="text-base-500 dark:text-base-400">Loading event...</span> 288 + </div> 289 + {/if} 290 + </div>
+118
src/lib/cards/EventCard/index.ts
··· 1 + import { parseUri } from '$lib/atproto'; 2 + import type { CardDefinition } from '../types'; 3 + import CreateEventCardModal from './CreateEventCardModal.svelte'; 4 + import EventCard from './EventCard.svelte'; 5 + 6 + const EVENT_COLLECTION = 'community.lexicon.calendar.event'; 7 + 8 + export type EventData = { 9 + mode: string; 10 + name: string; 11 + status: string; 12 + startsAt: string; 13 + endsAt?: string; 14 + description?: string; 15 + locations?: Array<{ 16 + address?: { 17 + locality?: string; 18 + region?: string; 19 + country?: string; 20 + }; 21 + }>; 22 + media?: Array<{ 23 + alt?: string; 24 + role?: string; 25 + content?: { 26 + ref?: { 27 + $link: string; 28 + }; 29 + mimeType?: string; 30 + }; 31 + aspect_ratio?: { 32 + width: number; 33 + height: number; 34 + }; 35 + }>; 36 + countGoing?: number; 37 + countInterested?: number; 38 + url: string; 39 + }; 40 + 41 + export const EventCardDefinition = { 42 + type: 'event', 43 + contentComponent: EventCard, 44 + creationModalComponent: CreateEventCardModal, 45 + createNew: (card) => { 46 + card.w = 4; 47 + card.h = 4; 48 + card.mobileW = 8; 49 + card.mobileH = 6; 50 + }, 51 + 52 + loadData: async (items) => { 53 + const eventDataMap: Record<string, EventData> = {}; 54 + 55 + for (const item of items) { 56 + const uri = item.cardData?.uri; 57 + if (!uri) continue; 58 + 59 + const parsedUri = parseUri(uri); 60 + if (!parsedUri || !parsedUri.rkey || !parsedUri.repo) continue; 61 + 62 + try { 63 + const response = await fetch( 64 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(parsedUri.repo)}&record_key=${encodeURIComponent(parsedUri.rkey)}` 65 + ); 66 + 67 + if (response.ok) { 68 + const data = await response.json(); 69 + eventDataMap[item.id] = data as EventData; 70 + } 71 + } catch (error) { 72 + console.error('Failed to fetch event data:', error); 73 + } 74 + } 75 + 76 + return eventDataMap; 77 + }, 78 + 79 + onUrlHandler: (url, item) => { 80 + // Match smokesignal.events URLs: https://smokesignal.events/{did}/{rkey} 81 + const smokesignalMatch = url.match(/^https?:\/\/smokesignal\.events\/(did:[^/]+)\/([^/?#]+)/); 82 + if (smokesignalMatch) { 83 + const [, did, rkey] = smokesignalMatch; 84 + item.w = 4; 85 + item.h = 4; 86 + item.mobileW = 8; 87 + item.mobileH = 6; 88 + item.cardType = 'event'; 89 + item.cardData.uri = `at://${did}/${EVENT_COLLECTION}/${rkey}`; 90 + return item; 91 + } 92 + 93 + // Match AT URIs: at://{did}/community.lexicon.calendar.event/{rkey} 94 + const atUriMatch = url.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/?#]+)/); 95 + if (atUriMatch) { 96 + const [, did, collection, rkey] = atUriMatch; 97 + if (collection === EVENT_COLLECTION) { 98 + item.w = 4; 99 + item.h = 4; 100 + item.mobileW = 8; 101 + item.mobileH = 6; 102 + item.cardType = 'event'; 103 + item.cardData.uri = `at://${did}/${collection}/${rkey}`; 104 + return item; 105 + } 106 + } 107 + 108 + return null; 109 + }, 110 + 111 + urlHandlerPriority: 5, 112 + 113 + name: 'Event', 114 + 115 + keywords: ['calendar', 'meetup', 'schedule', 'date', 'rsvp'], 116 + groups: ['Social'], 117 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg>` 118 + } as CardDefinition & { type: 'event' };
+11 -1
src/lib/cards/FluidTextCard/EditingFluidTextCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Item } from '$lib/types'; 3 + import { onMount, tick } from 'svelte'; 3 4 import type { ContentComponentProps } from '../types'; 4 5 import FluidTextCard from './FluidTextCard.svelte'; 5 6 ··· 26 27 isEditing = false; 27 28 } 28 29 } 30 + 31 + let rerender = $state(0); 32 + onMount(() => { 33 + window.addEventListener('theme-changed', async () => { 34 + // Force re-render to update FluidTextCard colors 35 + await tick(); 36 + rerender = Math.random(); 37 + }); 38 + }); 29 39 </script> 30 40 31 41 <!-- svelte-ignore a11y_no_static_element_interactions --> ··· 36 46 : ''}" 37 47 onclick={handleClick} 38 48 > 39 - {#key item.color} 49 + {#key item.color + '-' + rerender.toString()} 40 50 <FluidTextCard {item} /> 41 51 {/key} 42 52
+8 -1
src/lib/cards/FluidTextCard/FluidTextCard.svelte
··· 187 187 // Redraw overlay when text settings change (only after initialization) 188 188 $effect(() => { 189 189 // Access all reactive values to track them 190 + // eslint-disable-next-line @typescript-eslint/no-unused-expressions 190 191 text; 192 + // eslint-disable-next-line @typescript-eslint/no-unused-expressions 191 193 fontSize; 192 194 // Only redraw if already initialized 193 195 if (isInitialized) { ··· 201 203 202 204 const computedColor = getHexOfCardColor(item); 203 205 const hue = colorToHue(computedColor) / 360; 204 - console.log(computedColor, hue); 205 206 206 207 // Wait for a frame to ensure dimensions are set 207 208 requestAnimationFrame(() => { ··· 1253 1254 1254 1255 switch (i % 6) { 1255 1256 case 0: 1257 + // eslint-disable-next-line @typescript-eslint/no-unused-expressions 1256 1258 ((r = v), (g = t), (b = p)); 1257 1259 break; 1258 1260 case 1: 1261 + // eslint-disable-next-line @typescript-eslint/no-unused-expressions 1259 1262 ((r = q), (g = v), (b = p)); 1260 1263 break; 1261 1264 case 2: 1265 + // eslint-disable-next-line @typescript-eslint/no-unused-expressions 1262 1266 ((r = p), (g = v), (b = t)); 1263 1267 break; 1264 1268 case 3: 1269 + // eslint-disable-next-line @typescript-eslint/no-unused-expressions 1265 1270 ((r = p), (g = q), (b = v)); 1266 1271 break; 1267 1272 case 4: 1273 + // eslint-disable-next-line @typescript-eslint/no-unused-expressions 1268 1274 ((r = t), (g = p), (b = v)); 1269 1275 break; 1270 1276 case 5: 1277 + // eslint-disable-next-line @typescript-eslint/no-unused-expressions 1271 1278 ((r = v), (g = p), (b = q)); 1272 1279 break; 1273 1280 }
+6 -2
src/lib/cards/FluidTextCard/index.ts
··· 20 20 }, 21 21 creationModalComponent: CreateFluidTextCardModal, 22 22 settingsComponent: FluidTextCardSettings, 23 - sidebarButtonText: 'Fluid Text', 24 23 defaultColor: 'transparent', 25 24 allowSetColor: true, 26 - minW: 2 25 + minW: 2, 26 + 27 + keywords: ['animated', 'big text', 'headline', 'display'], 28 + groups: ['Visual'], 29 + name: 'Fluid Text', 30 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></svg>` 27 31 } as CardDefinition & { type: 'fluid-text' };
+130
src/lib/cards/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'; 8 + import { Avatar } from '@foxui/core'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + const isMobile = getIsMobile(); 13 + const canEdit = getCanEdit(); 14 + const additionalData = getAdditionalUserData(); 15 + 16 + let dids: string[] = $derived(item.cardData.friends ?? []); 17 + 18 + let serverProfiles: FriendsProfile[] = $derived( 19 + (additionalData[item.cardType] as FriendsProfile[]) ?? [] 20 + ); 21 + 22 + let clientProfiles: FriendsProfile[] = $state([]); 23 + 24 + let profiles = $derived.by(() => { 25 + if (serverProfiles.length > 0) { 26 + return dids 27 + .map((did) => serverProfiles.find((p) => p.did === did)) 28 + .filter((p): p is FriendsProfile => !!p); 29 + } 30 + return dids 31 + .map((did) => clientProfiles.find((p) => p.did === did)) 32 + .filter((p): p is FriendsProfile => !!p); 33 + }); 34 + 35 + onMount(() => { 36 + if (serverProfiles.length === 0 && dids.length > 0) { 37 + loadProfiles(); 38 + } 39 + }); 40 + 41 + async function loadProfiles() { 42 + const results = await Promise.all( 43 + dids.map((did) => getBlentoOrBskyProfile({ did: did as Did }).catch(() => undefined)) 44 + ); 45 + clientProfiles = results.filter( 46 + (p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid' 47 + ); 48 + } 49 + 50 + // Reload when dids change in editing mode 51 + $effect(() => { 52 + if (canEdit() && dids.length > 0) { 53 + loadProfiles(); 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 + } 67 + 68 + function getLink(profile: FriendsProfile): string { 69 + if (profile.hasBlento && profile.handle && profile.handle !== 'handle.invalid') { 70 + return `/${profile.handle}`; 71 + } 72 + if (profile.handle && profile.handle !== 'handle.invalid') { 73 + return `https://bsky.app/profile/${profile.handle}`; 74 + } 75 + return `https://bsky.app/profile/${profile.did}`; 76 + } 77 + </script> 78 + 79 + <div class="flex h-full w-full items-center justify-center overflow-hidden px-2"> 80 + {#if dids.length === 0} 81 + {#if canEdit()} 82 + <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 83 + Add friends in settings 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 105 + aria-label="Remove friend" 106 + class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 transition-opacity group-hover:opacity-100" 107 + onclick={(e) => { 108 + e.preventDefault(); 109 + e.stopPropagation(); 110 + removeFriend(profile.did); 111 + }} 112 + > 113 + <svg 114 + xmlns="http://www.w3.org/2000/svg" 115 + fill="none" 116 + viewBox="0 0 24 24" 117 + stroke-width="2.5" 118 + stroke="currentColor" 119 + class="size-4" 120 + > 121 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 122 + </svg> 123 + </button> 124 + {/if} 125 + </div> 126 + {/each} 127 + </div> 128 + </div> 129 + {/if} 130 + </div>
+23
src/lib/cards/FriendsCard/FriendsCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import type { SettingsComponentProps } from '../types'; 4 + import type { AppBskyActorDefs } from '@atcute/bluesky'; 5 + import HandleInput from '$lib/atproto/UI/HandleInput.svelte'; 6 + 7 + let { item = $bindable<Item>() }: SettingsComponentProps = $props(); 8 + 9 + let handleValue = $state(''); 10 + let inputRef: HTMLInputElement | null = $state(null); 11 + 12 + function addFriend(actor: AppBskyActorDefs.ProfileViewBasic) { 13 + if (!item.cardData.friends) item.cardData.friends = []; 14 + if (item.cardData.friends.includes(actor.did)) return; 15 + item.cardData.friends = [...item.cardData.friends, actor.did]; 16 + requestAnimationFrame(() => { 17 + handleValue = ''; 18 + if (inputRef) inputRef.value = ''; 19 + }); 20 + } 21 + </script> 22 + 23 + <HandleInput bind:value={handleValue} onselected={addFriend} bind:ref={inputRef} />
+43
src/lib/cards/FriendsCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 4 + import FriendsCard from './FriendsCard.svelte'; 5 + import FriendsCardSettings from './FriendsCardSettings.svelte'; 6 + 7 + export type FriendsProfile = Awaited<ReturnType<typeof getBlentoOrBskyProfile>>; 8 + 9 + export const FriendsCardDefinition = { 10 + type: 'friends', 11 + contentComponent: FriendsCard, 12 + settingsComponent: FriendsCardSettings, 13 + createNew: (card) => { 14 + card.w = 4; 15 + card.h = 2; 16 + card.mobileW = 8; 17 + card.mobileH = 4; 18 + card.cardData.friends = []; 19 + }, 20 + loadData: async (items) => { 21 + const allDids = new Set<Did>(); 22 + for (const item of items) { 23 + for (const did of item.cardData.friends ?? []) { 24 + allDids.add(did as Did); 25 + } 26 + } 27 + if (allDids.size === 0) return []; 28 + 29 + const profiles = await Promise.all( 30 + Array.from(allDids).map((did) => getBlentoOrBskyProfile({ did }).catch(() => undefined)) 31 + ); 32 + return profiles.filter((p) => p && p.handle !== 'handle.invalid'); 33 + }, 34 + allowSetColor: true, 35 + defaultColor: 'base', 36 + minW: 2, 37 + minH: 2, 38 + name: 'Friends', 39 + groups: ['Social'], 40 + keywords: ['friends', 'avatars', 'people', 'community', 'blentos'], 41 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>`, 42 + canHaveLabel: true 43 + } as CardDefinition & { type: 'friends' };
+18 -21
src/lib/cards/GIFCard/GifCardSettings.svelte
··· 19 19 } 20 20 </script> 21 21 22 - <div class="flex flex-col gap-3"> 23 - <div> 24 - <Label class="mb-1 text-xs">Change GIF</Label> 25 - <Button variant="secondary" class="w-full justify-start" onclick={() => (isSearchOpen = true)}> 26 - <svg 27 - xmlns="http://www.w3.org/2000/svg" 28 - fill="none" 29 - viewBox="0 0 24 24" 30 - stroke-width="1.5" 31 - stroke="currentColor" 32 - class="mr-2 size-4" 33 - > 34 - <path 35 - stroke-linecap="round" 36 - stroke-linejoin="round" 37 - d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" 38 - /> 39 - </svg> 40 - Search GIPHY 41 - </Button> 42 - </div> 22 + <div class="flex flex-col gap-2"> 23 + <Button variant="secondary" class="w-full justify-start" onclick={() => (isSearchOpen = true)}> 24 + <svg 25 + xmlns="http://www.w3.org/2000/svg" 26 + fill="none" 27 + viewBox="0 0 24 24" 28 + stroke-width="1.5" 29 + stroke="currentColor" 30 + class="mr-2 size-4" 31 + > 32 + <path 33 + stroke-linecap="round" 34 + stroke-linejoin="round" 35 + d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" 36 + /> 37 + </svg> 38 + Change GIF 39 + </Button> 43 40 </div> 44 41 45 42 <GiphySearchModal
+6 -2
src/lib/cards/GIFCard/index.ts
··· 21 21 card.mobileH = 4; 22 22 }, 23 23 settingsComponent: GifCardSettings, 24 - sidebarButtonText: 'GIF', 25 24 defaultColor: 'transparent', 26 25 allowSetColor: false, 27 26 minW: 1, 28 27 minH: 1, 28 + canHaveLabel: true, 29 29 onUrlHandler: (url, item) => { 30 30 // Match Giphy page URLs: https://giphy.com/gifs/name-ID or https://giphy.com/gifs/ID 31 31 const pageMatch = url.match(/giphy\.com\/gifs\/(?:.*-)?([a-zA-Z0-9]+)(?:\?|$)/); ··· 44 44 return null; 45 45 }, 46 46 urlHandlerPriority: 5, 47 - name: 'GIF' 47 + name: 'GIF', 48 + 49 + keywords: ['animation', 'giphy', 'meme', 'tenor'], 50 + groups: ['Media'], 51 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 8.25v7.5m-6-3.75h3v3.75m-3-7.5h3M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /></svg>` 48 52 } as CardDefinition & { type: 'gif' };
+1 -1
src/lib/cards/GameCards/DinoGameCard/DinoGameCard.svelte
··· 525 525 }); 526 526 </script> 527 527 528 - <!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions --> 528 + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> 529 529 <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 530 530 <div 531 531 bind:this={container}
-21
src/lib/cards/GameCards/DinoGameCard/SidebarItemDinoGameCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <!-- Dino silhouette --> 15 - <path 16 - d="M18 4h-2v2h-2v2h-2V6h-2V4H8v2H6v2H4v2H2v8h2v2h2v-2h2v-2h2v2h2v-2h2v2h2v-2h2V8h-2V6h-2V4zm-8 8H8v-2h2v2zm4 0h-2v-2h2v2z" 17 - /> 18 - </svg> 19 - 20 - Dino Game</Button 21 - >
+9 -5
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 1 1 import type { CardDefinition, ContentComponentProps } from '$lib/cards/types'; 2 2 import type { Component } from 'svelte'; 3 3 import DinoGameCard from './DinoGameCard.svelte'; 4 - import SidebarItemDinoGameCard from './SidebarItemDinoGameCard.svelte'; 5 4 6 5 export const DinoGameCardDefinition = { 7 6 type: 'dino-game', 8 7 contentComponent: DinoGameCard as unknown as Component<ContentComponentProps>, 9 - sidebarComponent: SidebarItemDinoGameCard, 10 8 allowSetColor: true, 11 9 createNew: (card) => { 12 10 card.w = 4; 13 - card.h = 4; 11 + card.h = 2; 14 12 card.mobileW = 8; 15 - card.mobileH = 6; 13 + card.mobileH = 4; 16 14 card.cardData = {}; 17 - } 15 + }, 16 + canHaveLabel: true, 17 + 18 + keywords: ['chrome', 'dinosaur', 'runner', 'fun'], 19 + groups: ['Games'], 20 + name: 'Dino Game', 21 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.491 48.491 0 0 1-4.163-.3c-1.228-.158-2.33.895-2.33 2.134v0c0 1.26 1.09 2.22 2.34 2.14a48.089 48.089 0 0 1 3.27-.108c.43 0 .78.348.78.78v0c0 .22-.09.422-.234.577a8.398 8.398 0 0 0-2.07 4.238c-.19 1.14.513 2.163 1.578 2.428a2.07 2.07 0 0 0 2.478-1.41c.203-.636.37-1.294.524-1.947.128-.537.612-.898 1.16-.84 1.378.15 2.782.18 4.17.076 1.156-.087 2.03-1.09 1.883-2.24a8.52 8.52 0 0 0-1.568-3.7A2.01 2.01 0 0 1 18 8.053v0c0-1.064.82-1.98 1.88-2.08A48.678 48.678 0 0 0 24 5.328v0" /></svg>` 18 22 } as CardDefinition & { type: 'dino-game' };
-25
src/lib/cards/GameCards/TetrisCard/SidebarItemTetrisCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <!-- Tetris blocks --> 15 - <rect x="4" y="4" width="5" height="5" /> 16 - <rect x="9" y="4" width="5" height="5" /> 17 - <rect x="9" y="9" width="5" height="5" /> 18 - <rect x="14" y="9" width="5" height="5" /> 19 - <rect x="4" y="14" width="5" height="5" /> 20 - <rect x="9" y="14" width="5" height="5" /> 21 - <rect x="14" y="14" width="5" height="5" /> 22 - </svg> 23 - 24 - Tetris</Button 25 - >
+2 -1
src/lib/cards/GameCards/TetrisCard/TetrisCard.svelte
··· 227 227 } 228 228 } 229 229 230 + type OscillatorType = 'sine' | 'square' | 'sawtooth' | 'triangle'; 230 231 function playTone( 231 232 frequency: number, 232 233 duration: number, ··· 1003 1004 }); 1004 1005 </script> 1005 1006 1006 - <!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions --> 1007 1007 <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 1008 + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> 1008 1009 <div 1009 1010 bind:this={container} 1010 1011 class="relative h-full w-full overflow-hidden outline-none"
+10 -6
src/lib/cards/GameCards/TetrisCard/index.ts
··· 3 3 4 4 import type { CardDefinition, ContentComponentProps } from '../../types'; 5 5 import TetrisCard from './TetrisCard.svelte'; 6 - import SidebarItemTetrisCard from './SidebarItemTetrisCard.svelte'; 7 6 import type { Component } from 'svelte'; 8 7 9 8 export const TetrisCardDefinition = { 10 9 type: 'tetris', 11 10 contentComponent: TetrisCard as unknown as Component<ContentComponentProps>, 12 - sidebarComponent: SidebarItemTetrisCard, 13 11 allowSetColor: true, 14 - defaultColor: 'accent', 15 12 createNew: (card) => { 16 - card.w = 4; 17 - card.h = 6; 13 + card.w = 2; 14 + card.h = 4; 18 15 card.mobileW = 8; 19 16 card.mobileH = 12; 20 17 card.cardData = {}; 21 18 }, 22 - maxH: 10 19 + maxH: 10, 20 + canHaveLabel: true, 21 + 22 + keywords: ['blocks', 'puzzle', 'game', 'fun'], 23 + groups: ['Games'], 24 + 25 + name: 'Tetris', 26 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14 4h-4v4H6v4h4v4h4v-4h4V8h-4V4Z" /></svg>` 23 27 } as CardDefinition & { type: 'tetris' };
+76
src/lib/cards/GitHubContributorsCard/CreateGitHubContributorsCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + let inputValue = $state(''); 9 + </script> 10 + 11 + <Modal open={true} closeButton={false}> 12 + <form 13 + onsubmit={() => { 14 + let input = inputValue.trim(); 15 + if (!input) { 16 + errorMessage = 'Please enter a repository in owner/repo format or a GitHub URL'; 17 + return; 18 + } 19 + 20 + let owner: string | undefined; 21 + let repo: string | undefined; 22 + 23 + // Try parsing as URL first 24 + try { 25 + const parsed = new URL(input); 26 + if (/^(www\.)?github\.com$/.test(parsed.hostname)) { 27 + const segments = parsed.pathname.split('/').filter(Boolean); 28 + if (segments.length >= 2) { 29 + owner = segments[0]; 30 + repo = segments[1]; 31 + } 32 + } 33 + } catch { 34 + // Not a URL, try as owner/repo format 35 + const parts = input.split('/'); 36 + if (parts.length === 2) { 37 + owner = parts[0].trim(); 38 + repo = parts[1].trim(); 39 + } 40 + } 41 + 42 + if (!owner || !repo) { 43 + errorMessage = 'Please enter a valid owner/repo or GitHub repository URL'; 44 + return; 45 + } 46 + 47 + item.cardData.owner = owner; 48 + item.cardData.repo = repo; 49 + item.cardData.href = `https://github.com/${owner}/${repo}`; 50 + 51 + item.w = 4; 52 + item.mobileW = 8; 53 + item.h = 2; 54 + item.mobileH = 4; 55 + 56 + oncreate?.(); 57 + }} 58 + class="flex flex-col gap-2" 59 + > 60 + <Subheading>Enter a GitHub repository</Subheading> 61 + <Input 62 + bind:value={inputValue} 63 + placeholder="owner/repo or https://github.com/owner/repo" 64 + class="mt-4" 65 + /> 66 + 67 + {#if errorMessage} 68 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 69 + {/if} 70 + 71 + <div class="mt-4 flex justify-end gap-2"> 72 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 73 + <Button type="submit">Create</Button> 74 + </div> 75 + </form> 76 + </Modal>
+196
src/lib/cards/GitHubContributorsCard/GitHubContributorsCard.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 type { GitHubContributor, GitHubContributorsLoadedData } from '.'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + const isMobile = getIsMobile(); 10 + const canEdit = getCanEdit(); 11 + const additionalData = getAdditionalUserData(); 12 + 13 + let owner: string = $derived(item.cardData.owner ?? ''); 14 + let repo: string = $derived(item.cardData.repo ?? ''); 15 + let repoKey: string = $derived(owner && repo ? `${owner}/${repo}` : ''); 16 + let layout: 'grid' | 'cinema' = $derived(item.cardData.layout ?? 'grid'); 17 + let shape: 'square' | 'circle' = $derived(item.cardData.shape ?? 'square'); 18 + 19 + let serverContributors: GitHubContributor[] = $derived.by(() => { 20 + if (!repoKey) return []; 21 + const data = additionalData[item.cardType] as GitHubContributorsLoadedData | undefined; 22 + return data?.[repoKey] ?? []; 23 + }); 24 + 25 + let clientContributors: GitHubContributor[] = $state([]); 26 + 27 + let allContributors: GitHubContributor[] = $derived( 28 + serverContributors.length > 0 ? serverContributors : clientContributors 29 + ); 30 + 31 + let namedContributors: GitHubContributor[] = $derived( 32 + allContributors.filter((c) => !c.anonymous) 33 + ); 34 + 35 + onMount(() => { 36 + if (serverContributors.length === 0 && repoKey) { 37 + loadContributors(); 38 + } 39 + }); 40 + 41 + async function loadContributors() { 42 + if (!owner || !repo) return; 43 + try { 44 + const response = await fetch( 45 + `/api/github/contributors?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}` 46 + ); 47 + if (response.ok) { 48 + const data = await response.json(); 49 + clientContributors = data; 50 + } 51 + } catch (error) { 52 + console.error('Failed to fetch GitHub contributors:', error); 53 + } 54 + } 55 + 56 + let containerWidth = $state(0); 57 + let containerHeight = $state(0); 58 + 59 + let totalItems = $derived(namedContributors.length); 60 + 61 + const GAP = 6; 62 + const MIN_SIZE = 16; 63 + const MAX_SIZE = 120; 64 + 65 + function cinemaCapacity(size: number, availW: number, availH: number): number { 66 + const colsWide = Math.floor((availW + GAP) / (size + GAP)); 67 + if (colsWide < 1) return 0; 68 + const colsNarrow = Math.max(1, colsWide - 1); 69 + const maxRows = Math.floor((availH + GAP) / (size + GAP)); 70 + let capacity = 0; 71 + // Pattern: narrow, wide, narrow, wide... (row 0 is narrow) 72 + for (let r = 0; r < maxRows; r++) { 73 + capacity += r % 2 === 0 ? colsNarrow : colsWide; 74 + } 75 + return capacity; 76 + } 77 + 78 + function gridCapacity(size: number, availW: number, availH: number): number { 79 + const cols = Math.floor((availW + GAP) / (size + GAP)); 80 + const rows = Math.floor((availH + GAP) / (size + GAP)); 81 + return cols * rows; 82 + } 83 + 84 + let computedSize = $derived.by(() => { 85 + if (!containerWidth || !containerHeight || totalItems === 0) return 40; 86 + 87 + let lo = MIN_SIZE; 88 + let hi = MAX_SIZE; 89 + const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity; 90 + 91 + while (lo <= hi) { 92 + const mid = Math.floor((lo + hi) / 2); 93 + const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0); 94 + const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0); 95 + if (availW <= 0 || availH <= 0) { 96 + hi = mid - 1; 97 + continue; 98 + } 99 + if (capacityFn(mid, availW, availH) >= totalItems) { 100 + lo = mid + 1; 101 + } else { 102 + hi = mid - 1; 103 + } 104 + } 105 + 106 + return Math.max(MIN_SIZE, hi); 107 + }); 108 + 109 + let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0); 110 + 111 + let rows = $derived.by(() => { 112 + const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0); 113 + if (availW <= 0) return [] as GitHubContributor[][]; 114 + 115 + const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); 116 + const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide; 117 + 118 + // Calculate row sizes from bottom up, then reverse for incomplete row at top 119 + const rowSizes: number[] = []; 120 + let remaining = namedContributors.length; 121 + let rowNum = 0; 122 + while (remaining > 0) { 123 + const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide; 124 + rowSizes.push(Math.min(cols, remaining)); 125 + remaining -= cols; 126 + rowNum++; 127 + } 128 + rowSizes.reverse(); 129 + 130 + // Fill rows with contributors in order 131 + const result: GitHubContributor[][] = []; 132 + let idx = 0; 133 + for (const size of rowSizes) { 134 + result.push(namedContributors.slice(idx, idx + size)); 135 + idx += size; 136 + } 137 + return result; 138 + }); 139 + 140 + let textSize = $derived( 141 + computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' 142 + ); 143 + 144 + let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); 145 + </script> 146 + 147 + <div 148 + class="flex h-full w-full items-center justify-center overflow-hidden px-2" 149 + bind:clientWidth={containerWidth} 150 + bind:clientHeight={containerHeight} 151 + > 152 + {#if !owner || !repo} 153 + {#if canEdit()} 154 + <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 155 + Enter a repository 156 + </span> 157 + {/if} 158 + {:else if totalItems > 0} 159 + <div style="padding: {padding}px;"> 160 + <div class="flex flex-col items-center" style="gap: {GAP}px;"> 161 + {#each rows as row, rowIdx (rowIdx)} 162 + <div class="flex justify-center" style="gap: {GAP}px;"> 163 + {#each row as contributor (contributor.username)} 164 + <a 165 + href="https://github.com/{contributor.username}" 166 + target="_blank" 167 + rel="noopener noreferrer" 168 + class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 169 + > 170 + {#if contributor.avatarUrl} 171 + <img 172 + src={contributor.avatarUrl} 173 + alt={contributor.username} 174 + class="{shapeClass} object-cover" 175 + style="width: {computedSize}px; height: {computedSize}px;" 176 + /> 177 + {:else} 178 + <div 179 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}" 180 + style="width: {computedSize}px; height: {computedSize}px;" 181 + > 182 + <span 183 + class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium" 184 + > 185 + {contributor.username.charAt(0).toUpperCase()} 186 + </span> 187 + </div> 188 + {/if} 189 + </a> 190 + {/each} 191 + </div> 192 + {/each} 193 + </div> 194 + </div> 195 + {/if} 196 + </div>
+59
src/lib/cards/GitHubContributorsCard/GitHubContributorsCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { SettingsComponentProps } from '../types'; 3 + import { Label } from '@foxui/core'; 4 + 5 + let { item = $bindable() }: SettingsComponentProps = $props(); 6 + 7 + const layoutOptions = [ 8 + { value: 'grid', label: 'Grid' }, 9 + { value: 'cinema', label: 'Cinema' } 10 + ]; 11 + 12 + const shapeOptions = [ 13 + { value: 'square', label: 'Square' }, 14 + { value: 'circle', label: 'Circle' } 15 + ]; 16 + 17 + let layout = $derived(item.cardData.layout ?? 'grid'); 18 + let shape = $derived(item.cardData.shape ?? 'square'); 19 + </script> 20 + 21 + <div class="flex flex-col gap-4"> 22 + <div class="flex flex-col gap-2"> 23 + <Label>Layout</Label> 24 + <div class="flex gap-2"> 25 + {#each layoutOptions as opt (opt.value)} 26 + <button 27 + class={[ 28 + 'flex-1 rounded-xl border px-3 py-2 text-sm transition-colors', 29 + layout === opt.value 30 + ? 'bg-accent-500 border-accent-500 text-white' 31 + : 'bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 hover:border-accent-400' 32 + ]} 33 + onclick={() => (item.cardData.layout = opt.value)} 34 + > 35 + {opt.label} 36 + </button> 37 + {/each} 38 + </div> 39 + </div> 40 + 41 + <div class="flex flex-col gap-2"> 42 + <Label>Shape</Label> 43 + <div class="flex gap-2"> 44 + {#each shapeOptions as opt (opt.value)} 45 + <button 46 + class={[ 47 + 'flex-1 rounded-xl border px-3 py-2 text-sm transition-colors', 48 + shape === opt.value 49 + ? 'bg-accent-500 border-accent-500 text-white' 50 + : 'bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 hover:border-accent-400' 51 + ]} 52 + onclick={() => (item.cardData.shape = opt.value)} 53 + > 54 + {opt.label} 55 + </button> 56 + {/each} 57 + </div> 58 + </div> 59 + </div>
+72
src/lib/cards/GitHubContributorsCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import GitHubContributorsCard from './GitHubContributorsCard.svelte'; 3 + import CreateGitHubContributorsCardModal from './CreateGitHubContributorsCardModal.svelte'; 4 + import GitHubContributorsCardSettings from './GitHubContributorsCardSettings.svelte'; 5 + 6 + export type GitHubContributor = { 7 + username: string; 8 + avatarUrl: string | null; 9 + contributions: number; 10 + anonymous: boolean; 11 + }; 12 + 13 + export type GitHubContributorsLoadedData = Record<string, GitHubContributor[] | undefined>; 14 + 15 + export const GitHubContributorsCardDefinition = { 16 + type: 'githubContributors', 17 + contentComponent: GitHubContributorsCard, 18 + creationModalComponent: CreateGitHubContributorsCardModal, 19 + settingsComponent: GitHubContributorsCardSettings, 20 + createNew: (card) => { 21 + card.w = 4; 22 + card.h = 2; 23 + card.mobileW = 8; 24 + card.mobileH = 4; 25 + card.cardData.owner = ''; 26 + card.cardData.repo = ''; 27 + }, 28 + loadData: async (items) => { 29 + const contributorsData: GitHubContributorsLoadedData = {}; 30 + for (const item of items) { 31 + const { owner, repo } = item.cardData; 32 + if (!owner || !repo) continue; 33 + const key = `${owner}/${repo}`; 34 + if (contributorsData[key]) continue; 35 + try { 36 + const response = await fetch( 37 + `https://blento.app/api/github/contributors?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}` 38 + ); 39 + if (response.ok) { 40 + contributorsData[key] = await response.json(); 41 + } 42 + } catch (error) { 43 + console.error('Failed to fetch GitHub contributors:', error); 44 + } 45 + } 46 + return contributorsData; 47 + }, 48 + onUrlHandler: (url, item) => { 49 + const match = url.match(/github\.com\/([^/]+)\/([^/]+)/); 50 + if (!match) return null; 51 + 52 + item.cardData.owner = match[1]; 53 + item.cardData.repo = match[2]; 54 + 55 + item.w = 4; 56 + item.h = 2; 57 + item.mobileW = 8; 58 + item.mobileH = 4; 59 + 60 + return item; 61 + }, 62 + urlHandlerPriority: 1, 63 + allowSetColor: true, 64 + defaultColor: 'base', 65 + minW: 2, 66 + minH: 2, 67 + name: 'GitHub Contributors', 68 + groups: ['Social'], 69 + keywords: ['github', 'contributors', 'open source', 'repository'], 70 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>`, 71 + canHaveLabel: true 72 + } as CardDefinition & { type: 'githubContributors' };
+70
src/lib/cards/GitHubProfileCard/CreateGitHubProfileCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + </script> 9 + 10 + <Modal open={true} closeButton={false}> 11 + <form 12 + onsubmit={() => { 13 + let input = item.cardData.href?.trim(); 14 + if (!input) return; 15 + 16 + let username: string | undefined; 17 + 18 + // Try parsing as URL first 19 + try { 20 + const parsed = new URL(input); 21 + if (/^(www\.)?github\.com$/.test(parsed.hostname)) { 22 + const segments = parsed.pathname.split('/').filter(Boolean); 23 + if ( 24 + segments.length === 1 && 25 + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(segments[0]) 26 + ) { 27 + username = segments[0]; 28 + } 29 + } 30 + } catch { 31 + // Not a URL, try as plain username 32 + if (/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input)) { 33 + username = input; 34 + } 35 + } 36 + 37 + if (!username) { 38 + errorMessage = 'Please enter a valid GitHub username or profile URL'; 39 + return; 40 + } 41 + 42 + item.cardData.user = username; 43 + item.cardData.href = `https://github.com/${username}`; 44 + 45 + item.w = 6; 46 + item.mobileW = 8; 47 + item.h = 3; 48 + item.mobileH = 6; 49 + 50 + oncreate?.(); 51 + }} 52 + class="flex flex-col gap-2" 53 + > 54 + <Subheading>Enter a GitHub username or profile URL</Subheading> 55 + <Input 56 + bind:value={item.cardData.href} 57 + placeholder="username or https://github.com/username" 58 + class="mt-4" 59 + /> 60 + 61 + {#if errorMessage} 62 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 63 + {/if} 64 + 65 + <div class="mt-4 flex justify-end gap-2"> 66 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 67 + <Button type="submit">Create</Button> 68 + </div> 69 + </form> 70 + </Modal>
+14 -5
src/lib/cards/GitHubProfileCard/GitHubProfileCard.svelte
··· 7 7 import GithubContributionsGraph from './GithubContributionsGraph.svelte'; 8 8 import { Button } from '@foxui/core'; 9 9 import { browser } from '$app/environment'; 10 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 11 + 12 + let { item, isEditing }: ContentComponentProps = $props(); 10 13 11 - let { item }: ContentComponentProps = $props(); 14 + const githubUrl = $derived(`https://github.com/${item.cardData.user}`); 12 15 13 16 const data = getAdditionalUserData(); 14 17 ··· 18 21 ); 19 22 20 23 onMount(async () => { 21 - console.log(contributionsData); 22 24 if (!contributionsData && item.cardData?.user) { 23 25 try { 24 26 const response = await fetch(`/api/github?user=${encodeURIComponent(item.cardData.user)}`); ··· 75 77 </div> 76 78 </div> 77 79 78 - {#if item.cardData.href} 80 + {#if (item.cardData.href || item.cardData.user) && !isEditing} 79 81 <a 80 - href={item.cardData.href} 82 + href={item.cardData.href || githubUrl} 81 83 class="absolute inset-0 h-full w-full" 82 84 target="_blank" 83 85 rel="noopener noreferrer" 86 + use:qrOverlay={{ 87 + context: { 88 + title: item.cardData.user, 89 + icon: siGithub.svg, 90 + iconColor: siGithub.hex 91 + } 92 + }} 84 93 > 85 - <span class="sr-only"> Show on github </span> 94 + <span class="sr-only">Show on github</span> 86 95 </a> 87 96 {/if}
+7 -2
src/lib/cards/GitHubProfileCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 + import CreateGitHubProfileCardModal from './CreateGitHubProfileCardModal.svelte'; 2 3 import type GithubContributionsGraph from './GithubContributionsGraph.svelte'; 3 4 import GitHubProfileCard from './GitHubProfileCard.svelte'; 4 5 import type { GitHubContributionsData } from './types'; ··· 8 9 export const GithubProfileCardDefitition = { 9 10 type: 'githubProfile', 10 11 contentComponent: GitHubProfileCard, 12 + creationModalComponent: CreateGitHubProfileCardModal, 11 13 12 14 loadData: async (items) => { 13 15 const githubData: Record<string, GithubContributionsGraph> = {}; ··· 28 30 onUrlHandler: (url, item) => { 29 31 const username = getGitHubUsername(url); 30 32 31 - console.log(username); 32 33 if (!username) return; 33 34 34 35 item.cardData.href = url; ··· 50 51 51 52 return item; 52 53 }, 53 - name: 'Github Profile' 54 + name: 'Github Profile', 55 + 56 + keywords: ['developer', 'code', 'repos', 'contributions'], 57 + groups: ['Social'], 58 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>` 54 59 } as CardDefinition & { type: 'githubProfile' }; 55 60 56 61 function getGitHubUsername(url: string | undefined): string | undefined {
+166
src/lib/cards/GuestbookCard/CreateGuestbookCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { createPost } from '$lib/atproto/methods'; 5 + import { user } from '$lib/atproto/auth.svelte'; 6 + import { parseBlueskyPostUrl } from '../BlueskyPostCard/utils'; 7 + 8 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 9 + 10 + let mode = $state<'create' | 'existing'>('create'); 11 + 12 + const profileUrl = `https://blento.app/${user.profile?.handle ?? ''}`; 13 + let postText = $state(`Comment on this post to appear on my Blento! ${profileUrl}`); 14 + let postUrl = $state(''); 15 + let isPosting = $state(false); 16 + let errorMessage = $state(''); 17 + 18 + function buildFacets(text: string, url: string) { 19 + const encoder = new TextEncoder(); 20 + const encoded = encoder.encode(text); 21 + const urlBytes = encoder.encode(url); 22 + 23 + let byteStart = -1; 24 + for (let i = 0; i <= encoded.length - urlBytes.length; i++) { 25 + let match = true; 26 + for (let j = 0; j < urlBytes.length; j++) { 27 + if (encoded[i + j] !== urlBytes[j]) { 28 + match = false; 29 + break; 30 + } 31 + } 32 + if (match) { 33 + byteStart = i; 34 + break; 35 + } 36 + } 37 + 38 + if (byteStart === -1) return undefined; 39 + 40 + return [ 41 + { 42 + index: { byteStart, byteEnd: byteStart + urlBytes.length }, 43 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }] 44 + } 45 + ]; 46 + } 47 + 48 + async function handleCreateNew() { 49 + if (!postText.trim()) { 50 + errorMessage = 'Post text cannot be empty.'; 51 + return; 52 + } 53 + 54 + isPosting = true; 55 + errorMessage = ''; 56 + 57 + try { 58 + const facets = buildFacets(postText, profileUrl); 59 + const response = await createPost({ text: postText, facets }); 60 + 61 + if (!response.ok) { 62 + throw new Error('Failed to create post'); 63 + } 64 + 65 + item.cardData.uri = response.data.uri; 66 + 67 + const rkey = response.data.uri.split('/').pop(); 68 + item.cardData.href = `https://bsky.app/profile/${user.profile?.handle}/post/${rkey}`; 69 + 70 + oncreate(); 71 + } catch (err) { 72 + errorMessage = 73 + err instanceof Error ? err.message : 'Failed to create post. Please try again.'; 74 + } finally { 75 + isPosting = false; 76 + } 77 + } 78 + 79 + function handleExisting() { 80 + errorMessage = ''; 81 + const parsed = parseBlueskyPostUrl(postUrl.trim()); 82 + 83 + if (!parsed) { 84 + errorMessage = 85 + 'Invalid URL. Please enter a valid Bluesky post URL (e.g., https://bsky.app/profile/handle/post/...)'; 86 + return; 87 + } 88 + 89 + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; 90 + item.cardData.href = postUrl.trim(); 91 + 92 + oncreate(); 93 + } 94 + 95 + async function handleSubmit() { 96 + if (mode === 'create') { 97 + await handleCreateNew(); 98 + } else { 99 + handleExisting(); 100 + } 101 + } 102 + </script> 103 + 104 + <Modal open={true} closeButton={false}> 105 + <form 106 + onsubmit={(e) => { 107 + e.preventDefault(); 108 + handleSubmit(); 109 + }} 110 + class="flex flex-col gap-2" 111 + > 112 + <Subheading>Guestbook</Subheading> 113 + 114 + <div class="flex gap-2"> 115 + <Button 116 + size="sm" 117 + variant="ghost" 118 + class={mode === 'create' ? 'bg-base-200 dark:bg-base-700' : ''} 119 + onclick={() => (mode = 'create')} 120 + > 121 + Create new post 122 + </Button> 123 + <Button 124 + size="sm" 125 + variant="ghost" 126 + class={mode === 'existing' ? 'bg-base-200 dark:bg-base-700' : ''} 127 + onclick={() => (mode = 'existing')} 128 + > 129 + Use existing post 130 + </Button> 131 + </div> 132 + 133 + {#if mode === 'create'} 134 + <p class="text-base-500 dark:text-base-400 text-sm"> 135 + This will create a post on your Bluesky account. Replies to that post will appear on your 136 + guestbook card. 137 + </p> 138 + <textarea 139 + bind:value={postText} 140 + rows="4" 141 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-600 mt-2 w-full rounded-lg border p-3 text-sm focus:outline-none" 142 + ></textarea> 143 + {:else} 144 + <p class="text-base-500 dark:text-base-400 text-sm"> 145 + Paste a Bluesky post URL to use as your guestbook. Replies to that post will appear on your 146 + card. 147 + </p> 148 + <Input bind:value={postUrl} placeholder="https://bsky.app/profile/handle/post/..." /> 149 + {/if} 150 + 151 + {#if errorMessage} 152 + <Alert type="error" title="Error"><span>{errorMessage}</span></Alert> 153 + {/if} 154 + 155 + <div class="mt-4 flex justify-end gap-2"> 156 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 157 + {#if mode === 'create'} 158 + <Button type="submit" disabled={isPosting || !postText.trim()}> 159 + {isPosting ? 'Posting...' : 'Post to Bluesky & Create'} 160 + </Button> 161 + {:else} 162 + <Button type="submit" disabled={!postUrl.trim()}>Create</Button> 163 + {/if} 164 + </div> 165 + </form> 166 + </Modal>
+126
src/lib/cards/GuestbookCard/GuestbookCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 4 + import { CardDefinitionsByType } from '..'; 5 + import type { ContentComponentProps } from '../types'; 6 + import { Button } from '@foxui/core'; 7 + import { BlueskyPost } from '$lib/components/bluesky-post'; 8 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + const data = getAdditionalUserData(); 13 + const did = getDidContext(); 14 + const handle = getHandleContext(); 15 + 16 + type Reply = { 17 + $type: string; 18 + post: PostView; 19 + }; 20 + 21 + let isLoaded = $state(false); 22 + 23 + let cardUri = $derived(item.cardData.uri as string); 24 + 25 + // svelte-ignore state_referenced_locally 26 + let replies = $state<Reply[]>( 27 + ((data['guestbook'] as Record<string, Reply[]>)?.[item.cardData.uri as string] ?? []) as Reply[] 28 + ); 29 + 30 + onMount(async () => { 31 + if (!cardUri) { 32 + isLoaded = true; 33 + return; 34 + } 35 + 36 + try { 37 + const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 38 + did, 39 + handle 40 + }); 41 + const result = loaded as Record<string, Reply[]> | undefined; 42 + const freshReplies = result?.[cardUri] ?? []; 43 + 44 + if (freshReplies.length > 0) { 45 + replies = freshReplies; 46 + } 47 + 48 + if (!data['guestbook']) { 49 + data['guestbook'] = {}; 50 + } 51 + (data['guestbook'] as Record<string, Reply[]>)[cardUri] = replies; 52 + } catch (e) { 53 + console.error('Failed to load guestbook replies', e); 54 + } 55 + 56 + isLoaded = true; 57 + }); 58 + </script> 59 + 60 + <div class="flex h-full flex-col overflow-hidden p-4"> 61 + {#if item.cardData.href} 62 + <div class="mb-2 flex justify-end"> 63 + <a href={item.cardData.href} target="_blank" rel="noopener noreferrer"> 64 + <Button size="sm">Add a comment on Bluesky</Button> 65 + </a> 66 + </div> 67 + {/if} 68 + 69 + <div class="flex-1 overflow-y-auto"> 70 + {#if replies.length > 0} 71 + <div class="replies"> 72 + {#each replies as reply (reply.post.uri)} 73 + <div class="reply"> 74 + <BlueskyPost feedViewPost={reply.post} showAvatar compact showLogo={false} /> 75 + </div> 76 + {/each} 77 + </div> 78 + {:else if isLoaded} 79 + <div 80 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 81 + > 82 + No comments yet โ€” share your Bluesky post to get started! 83 + </div> 84 + {:else} 85 + <div 86 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 87 + > 88 + Loading comments... 89 + </div> 90 + {/if} 91 + </div> 92 + </div> 93 + 94 + <style> 95 + .reply { 96 + padding-bottom: 1rem; 97 + margin-bottom: 1rem; 98 + border-bottom: 1px solid oklch(0.5 0 0 / 0.1); 99 + } 100 + 101 + .reply:last-child { 102 + border-bottom: none; 103 + margin-bottom: 0; 104 + padding-bottom: 0; 105 + } 106 + 107 + .reply :global(img:not([class*='rounded-full'])) { 108 + max-height: 10rem; 109 + } 110 + 111 + .reply :global(article) { 112 + max-height: 10rem; 113 + } 114 + 115 + @container card (width >= 30rem) { 116 + .replies { 117 + columns: 2; 118 + column-gap: 1.5rem; 119 + column-rule: 1px solid oklch(0.5 0 0 / 0.15); 120 + } 121 + 122 + .reply { 123 + break-inside: avoid; 124 + } 125 + } 126 + </style>
+66
src/lib/cards/GuestbookCard/index.ts
··· 1 + import { getPostThread } from '$lib/atproto/methods'; 2 + import type { CardDefinition } from '../types'; 3 + import GuestbookCard from './GuestbookCard.svelte'; 4 + import CreateGuestbookCardModal from './CreateGuestbookCardModal.svelte'; 5 + 6 + export const GuestbookCardDefinition = { 7 + type: 'guestbook', 8 + contentComponent: GuestbookCard, 9 + creationModalComponent: CreateGuestbookCardModal, 10 + createNew: (card) => { 11 + card.w = 4; 12 + card.h = 6; 13 + card.mobileW = 8; 14 + card.mobileH = 12; 15 + card.cardData.label = 'Guestbook'; 16 + }, 17 + minW: 4, 18 + minH: 4, 19 + defaultColor: 'base', 20 + canHaveLabel: true, 21 + loadData: async (items) => { 22 + const uris = items 23 + .filter((item) => item.cardData?.uri) 24 + .map((item) => item.cardData.uri as string); 25 + 26 + if (uris.length === 0) return {}; 27 + 28 + const results: Record<string, unknown[]> = {}; 29 + 30 + await Promise.all( 31 + uris.map(async (uri) => { 32 + try { 33 + const thread = await getPostThread({ uri, depth: 1 }); 34 + if (thread && '$type' in thread && thread.$type === 'app.bsky.feed.defs#threadViewPost') { 35 + const typedThread = thread as { replies?: unknown[] }; 36 + results[uri] = (typedThread.replies ?? []) 37 + .filter( 38 + (r: unknown) => 39 + r != null && 40 + typeof r === 'object' && 41 + '$type' in r && 42 + (r as { $type: string }).$type === 'app.bsky.feed.defs#threadViewPost' 43 + ) 44 + .sort((a: unknown, b: unknown) => { 45 + const timeA = new Date( 46 + ((a as any).post?.record?.createdAt as string) ?? 0 47 + ).getTime(); 48 + const timeB = new Date( 49 + ((b as any).post?.record?.createdAt as string) ?? 0 50 + ).getTime(); 51 + return timeB - timeA; 52 + }); 53 + } 54 + } catch (e) { 55 + console.error('Failed to load guestbook thread for', uri, e); 56 + } 57 + }) 58 + ); 59 + 60 + return results; 61 + }, 62 + name: 'Guestbook', 63 + keywords: ['comments', 'visitors', 'message', 'sign'], 64 + groups: ['Social'], 65 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>` 66 + } as CardDefinition & { type: 'guestbook' };
+9 -16
src/lib/cards/ImageCard/ImageCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { getDidContext } from '$lib/website/context'; 3 - import { getImageBlobUrl } from '$lib/atproto'; 4 3 import type { ContentComponentProps } from '../types'; 4 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 5 + import { getImage } from '$lib/helper'; 5 6 6 - let { item = $bindable() }: ContentComponentProps = $props(); 7 + let { item = $bindable(), isEditing }: ContentComponentProps = $props(); 7 8 8 9 const did = getDidContext(); 9 - 10 - function getSrc() { 11 - if (item.cardData.objectUrl) return item.cardData.objectUrl; 12 - 13 - if (item.cardData.image && typeof item.cardData.image === 'object') { 14 - return getImageBlobUrl({ did, blob: item.cardData.image }); 15 - } 16 - return item.cardData.image; 17 - } 18 10 </script> 19 11 20 - {#key item.cardData.image || item.cardData.objectUrl} 12 + {#key getImage(item.cardData, did, 'image')} 21 13 <img 22 14 class={[ 23 15 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 24 - item.cardData.href ? 'group-hover:scale-102' : '' 16 + item.cardData.href ? 'group-hover/card:scale-101' : '' 25 17 ]} 26 - src={getSrc()} 18 + src={getImage(item.cardData, did, 'image')} 27 19 alt="" 28 20 /> 29 21 {/key} 30 - {#if item.cardData.href} 22 + {#if item.cardData.href && !isEditing} 31 23 <a 32 24 href={item.cardData.href} 33 - class="absolute inset-0 h-full w-full" 25 + class="absolute inset-0 z-50 h-full w-full" 34 26 target="_blank" 35 27 rel="noopener noreferrer" 28 + use:qrOverlay={{ context: { title: item.cardData.hrefText ?? 'Learn more' } }} 36 29 > 37 30 <span class="sr-only"> 38 31 {item.cardData.hrefText ?? 'Learn more'}
+40 -14
src/lib/cards/ImageCard/index.ts
··· 1 - import { uploadBlob } from '$lib/atproto'; 1 + import { checkAndUploadImage } from '$lib/helper'; 2 2 import type { CardDefinition } from '../types'; 3 3 import ImageCard from './ImageCard.svelte'; 4 4 import ImageCardSettings from './ImageCardSettings.svelte'; 5 + 6 + // Common image extensions 7 + const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|bmp|ico|avif|tiff?)(\?.*)?$/i; 5 8 6 9 export const ImageCardDefinition = { 7 10 type: 'image', ··· 15 18 }; 16 19 }, 17 20 upload: async (item) => { 18 - if (item.cardData.blob) { 19 - item.cardData.image = await uploadBlob({ blob: item.cardData.blob }); 20 - 21 - delete item.cardData.blob; 22 - } 23 - 24 - if (item.cardData.objectUrl) { 25 - URL.revokeObjectURL(item.cardData.objectUrl); 26 - 27 - delete item.cardData.objectUrl; 28 - } 29 - 21 + await checkAndUploadImage(item.cardData, 'image'); 30 22 return item; 31 23 }, 32 24 settingsComponent: ImageCardSettings, ··· 36 28 change: (item) => { 37 29 return item; 38 30 }, 39 - name: 'Image Card' 31 + 32 + onUrlHandler: (url, item) => { 33 + // Check if URL points to an image 34 + if (IMAGE_EXTENSIONS.test(url)) { 35 + item.cardType = 'image'; 36 + item.cardData.image = url; 37 + item.cardData.alt = ''; 38 + item.cardData.href = ''; 39 + return item; 40 + } 41 + return null; 42 + }, 43 + urlHandlerPriority: 3, 44 + 45 + name: 'Image', 46 + 47 + canHaveLabel: true, 48 + 49 + keywords: ['photo', 'picture', 'upload', 'png', 'jpg'], 50 + groups: ['Core'], 51 + 52 + icon: `<svg 53 + xmlns="http://www.w3.org/2000/svg" 54 + fill="none" 55 + viewBox="0 0 24 24" 56 + stroke-width="2" 57 + stroke="currentColor" 58 + class="size-4" 59 + > 60 + <path 61 + stroke-linecap="round" 62 + stroke-linejoin="round" 63 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 64 + /> 65 + </svg>` 40 66 } as CardDefinition & { type: 'image' };
+89
src/lib/cards/KickstarterCard/CreateKickstarterCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let embedCode = $state(''); 8 + let errorMessage = $state(''); 9 + 10 + function parseInput(code: string): { 11 + src: string | null; 12 + widgetType: 'card' | 'video'; 13 + } { 14 + const normalized = code.trim().replaceAll('&amp;', '&'); 15 + 16 + // Try iframe embed code first 17 + const srcMatch = normalized.match(/src="(https:\/\/www\.kickstarter\.com\/[^"]+)"/); 18 + if (srcMatch) { 19 + const src = srcMatch[1]; 20 + const widgetType = src.includes('/widget/video') ? 'video' : 'card'; 21 + return { src, widgetType }; 22 + } 23 + 24 + // Try plain project URL 25 + const urlMatch = normalized.match(/kickstarter\.com\/projects\/([^/]+\/[^/?#\s]+)/i); 26 + if (urlMatch) { 27 + return { 28 + src: `https://www.kickstarter.com/projects/${urlMatch[1]}/widget/card.html?v=2`, 29 + widgetType: 'card' 30 + }; 31 + } 32 + 33 + return { src: null, widgetType: 'card' }; 34 + } 35 + 36 + function validate(): boolean { 37 + errorMessage = ''; 38 + 39 + const { src, widgetType } = parseInput(embedCode); 40 + 41 + if (!src) { 42 + errorMessage = 'Could not find a Kickstarter URL in the input'; 43 + return false; 44 + } 45 + 46 + item.cardData.src = src; 47 + item.cardData.widgetType = widgetType; 48 + 49 + if (widgetType === 'video') { 50 + item.w = 4; 51 + item.h = 2; 52 + item.mobileW = 8; 53 + item.mobileH = 4; 54 + } else { 55 + item.w = 4; 56 + item.h = 4; 57 + item.mobileW = 8; 58 + item.mobileH = 8; 59 + } 60 + 61 + return true; 62 + } 63 + </script> 64 + 65 + <Modal open={true} closeButton={false}> 66 + <Subheading>Paste Kickstarter URL or Embed Code</Subheading> 67 + 68 + <textarea 69 + bind:value={embedCode} 70 + placeholder="https://www.kickstarter.com/projects/..." 71 + rows={5} 72 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 w-full rounded-xl border px-3 py-2 font-mono text-sm" 73 + ></textarea> 74 + 75 + {#if errorMessage} 76 + <Alert type="error" title="Invalid embed code"><span>{errorMessage}</span></Alert> 77 + {/if} 78 + 79 + <div class="mt-4 flex justify-end gap-2"> 80 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 81 + <Button 82 + onclick={() => { 83 + if (validate()) oncreate(); 84 + }} 85 + > 86 + Create 87 + </Button> 88 + </div> 89 + </Modal>
+25
src/lib/cards/KickstarterCard/KickstarterCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../types'; 3 + 4 + let { item, isEditing }: ContentComponentProps = $props(); 5 + 6 + let isVideo = $derived(item.cardData.widgetType === 'video'); 7 + let projectUrl = $derived( 8 + (item.cardData.src || '').replace(/\/widget\/(card|video)\.html.*$/, '') 9 + ); 10 + </script> 11 + 12 + <iframe 13 + src={item.cardData.src} 14 + title="Kickstarter widget" 15 + frameborder="0" 16 + scrolling="no" 17 + class={['absolute inset-0 h-full w-full', (!isVideo || isEditing) && 'pointer-events-none']} 18 + ></iframe> 19 + 20 + {#if !isVideo && !isEditing} 21 + <a href={projectUrl} target="_blank" rel="noopener noreferrer"> 22 + <div class="absolute inset-0 z-50"></div> 23 + <span class="sr-only">Open Kickstarter project</span> 24 + </a> 25 + {/if}
+46
src/lib/cards/KickstarterCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CreateKickstarterCardModal from './CreateKickstarterCardModal.svelte'; 3 + import KickstarterCard from './KickstarterCard.svelte'; 4 + 5 + const cardType = 'kickstarter'; 6 + 7 + export const KickstarterCardDefinition = { 8 + type: cardType, 9 + contentComponent: KickstarterCard, 10 + creationModalComponent: CreateKickstarterCardModal, 11 + createNew: (item) => { 12 + item.cardType = cardType; 13 + item.cardData = { widgetType: 'card' }; 14 + item.w = 4; 15 + item.h = 4; 16 + item.mobileW = 8; 17 + item.mobileH = 8; 18 + }, 19 + 20 + onUrlHandler: (url, item) => { 21 + const match = url.match(/kickstarter\.com\/projects\/([^/]+\/[^/?#]+)/i); 22 + if (!match) return null; 23 + 24 + item.cardData.src = `https://www.kickstarter.com/projects/${match[1]}/widget/card.html?v=2`; 25 + item.cardData.widgetType = 'card'; 26 + item.w = 4; 27 + item.h = 4; 28 + item.mobileW = 8; 29 + item.mobileH = 8; 30 + 31 + return item; 32 + }, 33 + 34 + defaultColor: 'transparent', 35 + allowSetColor: false, 36 + 37 + urlHandlerPriority: 10, 38 + 39 + name: 'Kickstarter', 40 + keywords: ['kickstarter', 'crowdfunding', 'campaign', 'funding'], 41 + groups: ['Social'], 42 + icon: `<svg class="size-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 43 + <path fill-rule="evenodd" clip-rule="evenodd" d="M20.9257 17.2442C20.9257 16.3321 20.6731 15.4527 20.1362 14.6709L18.1153 11.7719L20.1362 8.87291C20.6731 8.12373 20.9257 7.21169 20.9257 6.29964C20.9257 3.88924 18.9994 2.03257 16.7258 2.03257C15.3996 2.03257 14.0733 2.71661 13.2523 3.88924L12.2418 5.32245C11.8629 3.40064 10.2524 2 8.19984 2C5.83151 2 4 3.95438 4 6.36479V17.2768C4 19.6872 5.86309 21.6416 8.19984 21.6416C10.2208 21.6416 11.7997 20.3386 12.2102 18.4494L13.0944 19.7523C13.9154 20.9901 15.2733 21.6416 16.5995 21.6416C18.9994 21.6741 20.9257 19.6546 20.9257 17.2442Z" stroke="currentColor" stroke-width="2"/> 44 + </svg> 45 + ` 46 + } as CardDefinition & { type: typeof cardType };
+40
src/lib/cards/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { BlueskyPost } from '../../components/bluesky-post'; 5 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 6 + import { CardDefinitionsByType } from '..'; 7 + 8 + let { item }: { item: Item } = $props(); 9 + 10 + const data = getAdditionalUserData(); 11 + // svelte-ignore state_referenced_locally 12 + let feed = $state((data[item.cardType] as any)?.feed); 13 + 14 + let did = getDidContext(); 15 + let handle = getHandleContext(); 16 + 17 + onMount(async () => { 18 + if (!feed) { 19 + feed = ( 20 + (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 21 + did, 22 + handle 23 + })) as any 24 + ).feed; 25 + 26 + data[item.cardType] = feed; 27 + } 28 + }); 29 + </script> 30 + 31 + <div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4"> 32 + {#if feed?.[0]?.post} 33 + <div class={[item.cardData.label ? 'pt-8' : '']}> 34 + <BlueskyPost showLogo feedViewPost={feed?.[0].post}></BlueskyPost> 35 + </div> 36 + <div class="h-4 w-full"></div> 37 + {:else} 38 + Your latest bluesky post will appear here. 39 + {/if} 40 + </div>
+20
src/lib/cards/LatestBlueskyPostCard/SidebarItemLatestBlueskyPostCard.svelte
··· 1 + <script lang="ts"> 2 + import { Button } from '@foxui/core'; 3 + 4 + let { onclick }: { onclick: () => void } = $props(); 5 + </script> 6 + 7 + <Button {onclick} variant="ghost" class="w-full justify-start"> 8 + <svg 9 + xmlns="http://www.w3.org/2000/svg" 10 + version="1.1" 11 + class="text-accent-600 dark:text-accent-400 size-4" 12 + viewBox="0 0 600 530" 13 + > 14 + <path 15 + d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 16 + fill="currentColor" 17 + /> 18 + </svg> 19 + Latest Bluesky Post 20 + </Button>
+37
src/lib/cards/LatestBlueskyPostCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import LatestBlueskyPostCard from './LatestBlueskyPostCard.svelte'; 3 + import { getAuthorFeed } from '$lib/atproto/methods'; 4 + 5 + export const LatestBlueskyPostCardDefinition = { 6 + type: 'latestPost', 7 + contentComponent: LatestBlueskyPostCard, 8 + createNew: (card) => { 9 + card.cardType = 'latestPost'; 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 4; 13 + card.mobileH = 8; 14 + 15 + card.cardData.label = ''; 16 + }, 17 + loadData: async (items, { did }) => { 18 + const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); 19 + 20 + return JSON.parse(JSON.stringify(authorFeed)); 21 + }, 22 + minW: 4, 23 + 24 + name: 'Latest Bluesky Post', 25 + 26 + canHaveLabel: true, 27 + 28 + keywords: ['bsky', 'atproto', 'recent', 'feed'], 29 + groups: ['Social'], 30 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>`, 31 + 32 + migrate: (item) => { 33 + if (item.cardData.label === undefined) { 34 + item.cardData.label = 'My latest bluesky post'; 35 + } 36 + } 37 + } as CardDefinition & { type: 'latestPost' };
+44
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { validateLink } from '$lib/helper'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let isFetchingLocation = $state(false); 9 + 10 + let errorMessage = $state(''); 11 + </script> 12 + 13 + <Modal open={true} closeButton={false}> 14 + <form 15 + onsubmit={() => { 16 + if (!item.cardData.href.trim()) return; 17 + 18 + let link = validateLink(item.cardData.href); 19 + if (!link) { 20 + errorMessage = 'Invalid link'; 21 + return; 22 + } 23 + 24 + item.cardData.href = link; 25 + item.cardData.domain = new URL(link).hostname; 26 + item.cardData.hasFetched = false; 27 + 28 + oncreate?.(); 29 + }} 30 + class="flex flex-col gap-2" 31 + > 32 + <Subheading>Enter a link</Subheading> 33 + <Input bind:value={item.cardData.href} class="mt-4" /> 34 + 35 + {#if errorMessage} 36 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 37 + {/if} 38 + 39 + <div class="mt-4 flex justify-end gap-2"> 40 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 41 + <Button type="submit" disabled={isFetchingLocation}>Create</Button> 42 + </div> 43 + </form> 44 + </Modal>
+157 -9
src/lib/cards/LinkCard/EditingLinkCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment'; 3 - import { getIsMobile } from '$lib/website/context'; 3 + import { getImage, compressImage } from '$lib/helper'; 4 + import { getDidContext, getIsMobile } from '$lib/website/context'; 4 5 import type { ContentComponentProps } from '../types'; 5 - import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 6 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 6 7 7 8 let { item = $bindable() }: ContentComponentProps = $props(); 8 9 10 + let faviconInputRef: HTMLInputElement; 11 + let imageInputRef: HTMLInputElement; 12 + let isHoveringFavicon = $state(false); 13 + let isHoveringImage = $state(false); 14 + 15 + async function handleFaviconChange(event: Event) { 16 + const target = event.target as HTMLInputElement; 17 + const file = target.files?.[0]; 18 + if (!file) return; 19 + 20 + try { 21 + const compressedBlob = await compressImage(file, 128); 22 + const objectUrl = URL.createObjectURL(compressedBlob); 23 + 24 + item.cardData.favicon = { 25 + blob: compressedBlob, 26 + objectUrl 27 + } as any; 28 + 29 + faviconHasError = false; 30 + } catch (error) { 31 + console.error('Failed to process image:', error); 32 + } 33 + } 34 + 35 + async function handleImageChange(event: Event) { 36 + const target = event.target as HTMLInputElement; 37 + const file = target.files?.[0]; 38 + if (!file) return; 39 + 40 + try { 41 + const compressedBlob = await compressImage(file); 42 + const objectUrl = URL.createObjectURL(compressedBlob); 43 + 44 + item.cardData.image = { 45 + blob: compressedBlob, 46 + objectUrl 47 + } as any; 48 + } catch (error) { 49 + console.error('Failed to process image:', error); 50 + } 51 + } 52 + 9 53 let isMobile = getIsMobile(); 10 54 11 55 let faviconHasError = $state(false); ··· 50 94 isFetchingMetadata = false; 51 95 }); 52 96 }); 97 + 98 + let did = getDidContext(); 53 99 </script> 54 100 101 + <input 102 + type="file" 103 + accept="image/*" 104 + class="hidden" 105 + bind:this={faviconInputRef} 106 + onchange={handleFaviconChange} 107 + /> 108 + <input 109 + type="file" 110 + accept="image/*" 111 + class="hidden" 112 + bind:this={imageInputRef} 113 + onchange={handleImageChange} 114 + /> 115 + 55 116 <div class="relative flex h-full flex-col justify-between p-4"> 56 117 <div 57 118 class={[ ··· 61 122 ></div> 62 123 63 124 <div class={isFetchingMetadata ? 'pointer-events-none' : ''}> 64 - <div 65 - 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" 125 + <button 126 + type="button" 127 + class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 hover:ring-accent-500 relative mb-2 inline-flex size-8 cursor-pointer items-center justify-center rounded-xl border transition-all duration-200 hover:ring-2" 128 + onclick={() => faviconInputRef?.click()} 129 + onmouseenter={() => (isHoveringFavicon = true)} 130 + onmouseleave={() => (isHoveringFavicon = false)} 66 131 > 67 132 {#if hasFetched && item.cardData.favicon && !faviconHasError} 68 133 <img 69 134 class="size-6 rounded-lg object-cover" 70 135 onerror={() => (faviconHasError = true)} 71 - src={item.cardData.favicon} 136 + src={getImage(item.cardData, did, 'favicon')} 72 137 alt="" 73 138 /> 74 139 {:else} ··· 87 152 /> 88 153 </svg> 89 154 {/if} 90 - </div> 155 + <!-- Hover overlay --> 156 + <div 157 + class={[ 158 + 'absolute inset-0 flex items-center justify-center rounded-xl bg-black/50 transition-opacity duration-200', 159 + isHoveringFavicon ? 'opacity-100' : 'opacity-0' 160 + ]} 161 + > 162 + <svg 163 + xmlns="http://www.w3.org/2000/svg" 164 + fill="none" 165 + viewBox="0 0 24 24" 166 + stroke-width="2" 167 + stroke="currentColor" 168 + class="size-4 text-white" 169 + > 170 + <path 171 + stroke-linecap="round" 172 + stroke-linejoin="round" 173 + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z" 174 + /> 175 + </svg> 176 + </div> 177 + </button> 91 178 92 179 <div 93 180 class={[ ··· 101 188 <PlainTextEditor 102 189 class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold" 103 190 key="title" 104 - bind:item 191 + bind:contentDict={item.cardData} 105 192 placeholder="Title here" 106 193 /> 107 194 {:else} ··· 118 205 </div> 119 206 </div> 120 207 121 - {#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 122 - <img class=" mb-2 max-h-32 w-full rounded-xl object-cover" src={item.cardData.image} alt="" /> 208 + {#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4))} 209 + <button 210 + type="button" 211 + 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" 212 + onclick={() => imageInputRef?.click()} 213 + onmouseenter={() => (isHoveringImage = true)} 214 + onmouseleave={() => (isHoveringImage = false)} 215 + > 216 + {#if item.cardData.image} 217 + <img 218 + class="h-full w-full object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 219 + src={getImage(item.cardData, did)} 220 + alt="" 221 + /> 222 + {:else} 223 + <div class="bg-base-200 dark:bg-base-800 flex h-full w-full items-center justify-center"> 224 + <svg 225 + xmlns="http://www.w3.org/2000/svg" 226 + fill="none" 227 + viewBox="0 0 24 24" 228 + stroke-width="1.5" 229 + stroke="currentColor" 230 + class="text-base-400 dark:text-base-600 size-8" 231 + > 232 + <path 233 + stroke-linecap="round" 234 + stroke-linejoin="round" 235 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 236 + /> 237 + </svg> 238 + </div> 239 + {/if} 240 + <!-- Hover overlay --> 241 + <div 242 + class={[ 243 + 'absolute inset-0 flex items-center justify-center rounded-xl bg-black/50 transition-opacity duration-200', 244 + isHoveringImage ? 'opacity-100' : 'opacity-0' 245 + ]} 246 + > 247 + <div class="text-center text-sm text-white"> 248 + <svg 249 + xmlns="http://www.w3.org/2000/svg" 250 + fill="none" 251 + viewBox="0 0 24 24" 252 + stroke-width="1.5" 253 + stroke="currentColor" 254 + class="mx-auto mb-1 size-6" 255 + > 256 + <path 257 + stroke-linecap="round" 258 + stroke-linejoin="round" 259 + d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" 260 + /> 261 + <path 262 + stroke-linecap="round" 263 + stroke-linejoin="round" 264 + d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" 265 + /> 266 + </svg> 267 + <span class="font-medium">{item.cardData.image ? 'Change' : 'Add image'}</span> 268 + </div> 269 + </div> 270 + </button> 123 271 {/if} 124 272 </div>
+15 -6
src/lib/cards/LinkCard/LinkCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment'; 3 - import { getIsMobile } from '$lib/website/context'; 3 + import { getImage } from '$lib/helper'; 4 + import { getDidContext, getIsMobile } from '$lib/website/context'; 4 5 import type { ContentComponentProps } from '../types'; 6 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 5 7 6 - let { item }: ContentComponentProps = $props(); 8 + let { item, isEditing }: ContentComponentProps = $props(); 7 9 8 10 let isMobile = getIsMobile(); 9 11 10 12 let faviconHasError = $state(false); 13 + 14 + let did = getDidContext(); 11 15 </script> 12 16 13 17 <div class="flex h-full flex-col justify-between p-4"> ··· 19 23 <img 20 24 class="size-6 rounded-lg object-cover" 21 25 onerror={() => (faviconHasError = true)} 22 - src={item.cardData.favicon} 26 + src={getImage(item.cardData, did, 'favicon')} 23 27 alt="" 24 28 /> 25 29 {:else} ··· 57 61 58 62 {#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 59 63 <img 60 - class="mb-2 max-h-32 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 61 - src={item.cardData.image} 64 + class="mb-2 aspect-2/1 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 65 + src={getImage(item.cardData, did)} 62 66 alt="" 63 67 /> 64 68 {/if} 65 - {#if item.cardData.href} 69 + {#if item.cardData.href && !isEditing} 66 70 <a 67 71 href={item.cardData.href} 68 72 class="absolute inset-0 h-full w-full" 69 73 target="_blank" 70 74 rel="noopener noreferrer" 75 + use:qrOverlay={{ 76 + context: { 77 + title: item.cardData.title 78 + } 79 + }} 71 80 > 72 81 <span class="sr-only"> 73 82 {item.cardData.hrefText ?? 'Learn more'}
+29 -3
src/lib/cards/LinkCard/index.ts
··· 1 - import { validateLink } from '$lib/helper'; 1 + import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 2 import type { CardDefinition } from '../types'; 3 + import CreateLinkCardModal from './CreateLinkCardModal.svelte'; 3 4 import EditingLinkCard from './EditingLinkCard.svelte'; 4 5 import LinkCard from './LinkCard.svelte'; 5 6 import LinkCardSettings from './LinkCardSettings.svelte'; ··· 13 14 }, 14 15 settingsComponent: LinkCardSettings, 15 16 16 - name: 'Link Card', 17 + creationModalComponent: CreateLinkCardModal, 18 + 19 + name: 'Link', 17 20 canChange: (item) => Boolean(validateLink(item.cardData?.href)), 18 21 change: (item) => { 19 22 const href = validateLink(item.cardData?.href); ··· 31 34 item.cardData.hasFetched = false; 32 35 return item; 33 36 }, 34 - urlHandlerPriority: 0 37 + upload: async (item) => { 38 + await checkAndUploadImage(item.cardData, 'image'); 39 + await checkAndUploadImage(item.cardData, 'favicon'); 40 + return item; 41 + }, 42 + urlHandlerPriority: 0, 43 + 44 + keywords: ['url', 'website', 'href', 'webpage'], 45 + groups: ['Core'], 46 + 47 + icon: `<svg 48 + xmlns="http://www.w3.org/2000/svg" 49 + fill="none" 50 + viewBox="-2 -2 28 28" 51 + stroke-width="2" 52 + stroke="currentColor" 53 + class="size-4" 54 + > 55 + <path 56 + stroke-linecap="round" 57 + stroke-linejoin="round" 58 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 59 + /> 60 + </svg>` 35 61 } as CardDefinition & { type: 'link' };
-12
src/lib/cards/LivestreamCard/SidebarItemEmbedLivestreamCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - import Icon from './Icon.svelte'; 4 - 5 - let { onclick }: { onclick: () => void } = $props(); 6 - </script> 7 - 8 - <Button {onclick} variant="ghost" class="w-full justify-start"> 9 - <Icon class="size-4" /> 10 - 11 - Embed stream.place 12 - </Button>
-12
src/lib/cards/LivestreamCard/SidebarItemLivestreamCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - import Icon from './Icon.svelte'; 4 - 5 - let { onclick }: { onclick: () => void } = $props(); 6 - </script> 7 - 8 - <Button {onclick} variant="ghost" class="w-full justify-start"> 9 - <Icon class="size-4" /> 10 - 11 - Latest stream.place 12 - </Button>
+6 -6
src/lib/cards/LivestreamCard/index.ts
··· 1 - import { user, listRecords, getImageBlobUrl } from '$lib/atproto'; 1 + import { user, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; 2 2 import type { CardDefinition } from '../types'; 3 3 import LivestreamCard from './LivestreamCard.svelte'; 4 4 import LivestreamEmbedCard from './LivestreamEmbedCard.svelte'; 5 - import SidebarItemLivestreamCard from './SidebarItemLivestreamCard.svelte'; 6 5 7 6 export const LivestreamCardDefitition = { 8 7 type: 'latestLivestream', 9 8 contentComponent: LivestreamCard, 10 - sidebarComponent: SidebarItemLivestreamCard, 11 9 createNew: (card) => { 12 10 card.w = 4; 13 11 card.h = 4; ··· 36 34 createdAt: latest.value.createdAt, 37 35 title: latest.value?.title as string, 38 36 thumb: latest.value?.thumb?.ref?.$link 39 - ? getImageBlobUrl({ blob: latest.value.thumb, did }) 37 + ? getCDNImageBlobUrl({ blob: latest.value.thumb, did }) 40 38 : undefined, 41 39 href: latest.value?.canonicalUrl || latest.value.url, 42 40 online: undefined ··· 67 65 }, 68 66 69 67 onUrlHandler: (url, item) => { 70 - console.log(url, 'https://stream.place/' + user.profile?.handle); 71 68 if (url === 'https://stream.place/' + user.profile?.handle) { 72 69 item.w = 4; 73 70 item.h = 4; ··· 82 79 83 80 urlHandlerPriority: 5, 84 81 85 - name: 'stream.place Card' 82 + name: 'Latest Livestream (stream.place)', 83 + keywords: ['stream', 'live', 'broadcast', 'video'], 84 + groups: ['Media'], 85 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>` 86 86 } as CardDefinition & { type: 'latestLivestream' }; 87 87 88 88 export const LivestreamEmbedCardDefitition = {
+2 -4
src/lib/cards/MapCard/CreateMapCardModal.svelte
··· 20 20 if (response.ok) { 21 21 const data = await response.json(); 22 22 23 - console.log(data); 24 - 25 23 if (!data.lat || !data.lon) throw new Error('lat or lon not found'); 26 24 27 25 item.cardData.lat = data.lat; 28 26 item.cardData.lon = data.lon; 29 27 item.cardData.name = data.display_name?.split(',')[0] || search; 30 28 item.cardData.type = data.class || 'city'; 31 - item.cardData.zoom = Math.max(getZoomLevel(data.class), getZoomLevel(data.type)); 29 + item.cardData.zoom = Math.max(getZoomLevel(data.class), getZoomLevel(data.type)); 32 30 } else { 33 31 throw new Error('response not ok'); 34 32 } ··· 56 54 <Alert type="error" title="Failed to create map card"><span>{errorMessage}</span></Alert> 57 55 {/if} 58 56 59 - <p class="text-xs mt-2"> 57 + <p class="mt-2 text-xs"> 60 58 Geocoding by <a 61 59 href="https://nominatim.openstreetmap.org/" 62 60 class="text-accent-800 dark:text-accent-300"
+5 -12
src/lib/cards/MapCard/Map.svelte
··· 8 8 9 9 let { item = $bindable() }: { item: Item } = $props(); 10 10 11 - $inspect(item); 11 + // $inspect(item); 12 12 13 13 let mapContainer: HTMLElement | undefined = $state(); 14 14 let map: mapboxgl.Map | undefined = $state(); 15 15 16 - // Update light preset when changed in settings 17 - $effect(() => { 18 - const preset = item.cardData.lightPreset; 19 - if (map && preset) { 20 - map.setConfigProperty('basemap', 'lightPreset', preset); 21 - } 22 - }); 23 - 24 16 onMount(() => { 25 17 if (!mapContainer || !env.PUBLIC_MAPBOX_TOKEN) { 26 - console.log('no map container or no mapbox token'); 18 + console.error('no map container or no mapbox token'); 19 + return; 27 20 } 28 21 29 22 try { ··· 151 144 map.setCenter([lon, lat]); 152 145 } 153 146 }); 154 - resizeObserver.observe(mapContainer); 147 + if (mapContainer) resizeObserver.observe(mapContainer); 155 148 156 149 return () => { 157 150 resizeObserver.disconnect(); ··· 165 158 }); 166 159 </script> 167 160 168 - <div bind:this={mapContainer} class="absolute inset-0 isolate z-50 h-full w-full"></div> 161 + <div bind:this={mapContainer} class="absolute inset-0 isolate h-full w-full"></div>
+20 -2
src/lib/cards/MapCard/MapCard.svelte
··· 1 1 <script lang="ts"> 2 - import type { Item } from '$lib/types'; 2 + import type { ContentComponentProps } from '../types'; 3 3 import Map from './Map.svelte'; 4 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 4 5 5 - let { item = $bindable() }: { item: Item } = $props(); 6 + let { item = $bindable(), isEditing }: ContentComponentProps = $props(); 7 + 8 + const mapsUrl = $derived( 9 + 'https://maps.google.com/maps?q=' + 10 + encodeURIComponent(item.cardData.lat + ',' + item.cardData.lon) 11 + ); 6 12 </script> 7 13 8 14 <Map bind:item /> 15 + 16 + {#if item.cardData.linkToGoogleMaps && !isEditing} 17 + <a 18 + target="_blank" 19 + rel="noopener noreferrer" 20 + href={mapsUrl} 21 + use:qrOverlay={{ context: { title: 'Google Maps' } }} 22 + > 23 + <div class="absolute inset-0 z-100"></div> 24 + <span class="sr-only">open map</span> 25 + </a> 26 + {/if}
+24
src/lib/cards/MapCard/MapCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Checkbox, Label } from '@foxui/core'; 4 + 5 + let { item }: { item: Item; onclose: () => void } = $props(); 6 + </script> 7 + 8 + <div class="flex items-center space-x-2"> 9 + <Checkbox 10 + bind:checked={ 11 + () => Boolean(item.cardData.linkToGoogleMaps), (val) => (item.cardData.linkToGoogleMaps = val) 12 + } 13 + id="show-inline" 14 + aria-labelledby="show-inline-label" 15 + variant="secondary" 16 + /> 17 + <Label 18 + id="show-inline-label" 19 + for="show-inline" 20 + class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 21 + > 22 + Link to google maps 23 + </Label> 24 + </div>
-22
src/lib/cards/MapCard/SidebarItemMapCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <path 15 - fill-rule="evenodd" 16 - d="M8.161 2.58a1.875 1.875 0 0 1 1.678 0l4.993 2.498c.106.052.23.052.336 0l3.869-1.935A1.875 1.875 0 0 1 21.75 4.82v12.485c0 .71-.401 1.36-1.037 1.677l-4.875 2.437a1.875 1.875 0 0 1-1.676 0l-4.994-2.497a.375.375 0 0 0-.336 0l-3.868 1.935A1.875 1.875 0 0 1 2.25 19.18V6.695c0-.71.401-1.36 1.036-1.677l4.875-2.437ZM9 6a.75.75 0 0 1 .75.75V15a.75.75 0 0 1-1.5 0V6.75A.75.75 0 0 1 9 6Zm6.75 3a.75.75 0 0 0-1.5 0v8.25a.75.75 0 0 0 1.5 0V9Z" 17 - clip-rule="evenodd" 18 - /> 19 - </svg> 20 - 21 - Map with Location 22 - </Button>
+14 -4
src/lib/cards/MapCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import CreateMapCardModal from './CreateMapCardModal.svelte'; 3 3 import MapCard from './MapCard.svelte'; 4 - import SidebarItemMapCard from './SidebarItemMapCard.svelte'; 4 + import MapCardSettings from './MapCardSettings.svelte'; 5 5 6 6 export const MapCardDefinition = { 7 7 type: 'mapLocation', 8 8 contentComponent: MapCard, 9 - sidebarButtonText: 'map', 10 9 createNew: (item) => { 11 10 item.w = 4; 12 11 item.h = 4; ··· 14 13 item.mobileW = 8; 15 14 }, 16 15 17 - sidebarComponent: SidebarItemMapCard, 18 16 creationModalComponent: CreateMapCardModal, 19 - allowSetColor: false 17 + allowSetColor: false, 18 + canHaveLabel: true, 19 + settingsComponent: MapCardSettings, 20 + 21 + keywords: ['location', 'place', 'address', 'geo'], 22 + groups: ['Core'], 23 + 24 + name: 'Map', 25 + 26 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"> 27 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z" /> 28 + </svg> 29 + ` 20 30 } as CardDefinition & { type: 'mapLocation' }; 21 31 22 32 export function getZoomLevel(type: string | undefined): number {
+103
src/lib/cards/Model3DCard/CreateModel3DCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + let fileInput = $state<HTMLInputElement | undefined>(undefined); 9 + 10 + function handleFileSelect(event: Event) { 11 + const input = event.target as HTMLInputElement; 12 + const file = input.files?.[0]; 13 + 14 + if (!file) return; 15 + 16 + const extension = file.name.toLowerCase().split('.').pop(); 17 + if (!['gltf', 'glb', 'stl', 'fbx'].includes(extension || '')) { 18 + errorMessage = 'Please select a .gltf, .glb, .stl, or .fbx file'; 19 + return; 20 + } 21 + 22 + errorMessage = ''; 23 + item.cardData.modelFile = { 24 + blob: file, 25 + objectUrl: URL.createObjectURL(file), 26 + name: file.name, 27 + type: extension 28 + }; 29 + } 30 + 31 + function clearFile() { 32 + if (item.cardData.modelFile?.objectUrl) { 33 + URL.revokeObjectURL(item.cardData.modelFile.objectUrl); 34 + } 35 + item.cardData.modelFile = undefined; 36 + } 37 + 38 + function canCreate() { 39 + if (!item.cardData.modelFile) { 40 + errorMessage = 'Please upload a file'; 41 + return false; 42 + } 43 + return true; 44 + } 45 + </script> 46 + 47 + <Modal open={true} closeButton={false}> 48 + <Subheading>Add a 3D Model</Subheading> 49 + 50 + <div> 51 + <p class="text-base-600 dark:text-base-400 mb-2 text-sm"> 52 + Upload a 3D model file (.glb, .stl, .fbx, or .gltf) 53 + </p> 54 + {#if item.cardData.modelFile} 55 + <div 56 + class="bg-base-100 dark:bg-base-800 flex items-center justify-between rounded-lg border p-3" 57 + > 58 + <span class="text-sm">{item.cardData.modelFile.name}</span> 59 + <Button size="sm" variant="ghost" onclick={clearFile}>Remove</Button> 60 + </div> 61 + {:else} 62 + <input 63 + bind:this={fileInput} 64 + type="file" 65 + accept=".gltf,.glb,.stl,.fbx" 66 + onchange={handleFileSelect} 67 + class="hidden" 68 + /> 69 + <Button variant="secondary" onclick={() => fileInput?.click()} class="w-full"> 70 + <svg 71 + xmlns="http://www.w3.org/2000/svg" 72 + fill="none" 73 + viewBox="0 0 24 24" 74 + stroke-width="1.5" 75 + stroke="currentColor" 76 + class="mr-2 size-5" 77 + > 78 + <path 79 + stroke-linecap="round" 80 + stroke-linejoin="round" 81 + d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" 82 + /> 83 + </svg> 84 + Choose File 85 + </Button> 86 + {/if} 87 + </div> 88 + 89 + {#if errorMessage} 90 + <Alert type="error" title="Error"><span>{errorMessage}</span></Alert> 91 + {/if} 92 + 93 + <div class="mt-4 flex justify-end gap-2"> 94 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 95 + <Button 96 + onclick={() => { 97 + if (canCreate()) oncreate(); 98 + }} 99 + > 100 + Create 101 + </Button> 102 + </div> 103 + </Modal>
+74
src/lib/cards/Model3DCard/Model3DCard.svelte
··· 1 + <script lang="ts"> 2 + import { Canvas } from '@threlte/core'; 3 + import { CineonToneMapping } from 'three'; 4 + import type { ContentComponentProps } from '../types'; 5 + import Model3DScene from './Model3DScene.svelte'; 6 + import { getDidContext } from '$lib/website/context'; 7 + import { getBlobURL } from '$lib/atproto'; 8 + import type { Did } from '@atcute/lexicons'; 9 + import { onMount } from 'svelte'; 10 + 11 + let { item }: ContentComponentProps = $props(); 12 + 13 + let isHovering = $state(false); 14 + let objectUrl = $state<string | undefined>(undefined); 15 + let isLoading = $state(false); 16 + 17 + const did = getDidContext(); 18 + 19 + // Fetch blob from PDS and create object URL (like VideoCard does) 20 + onMount(async () => { 21 + if (item.cardData.modelBlob?.$type === 'blob') { 22 + isLoading = true; 23 + try { 24 + const pdsUrl = await getBlobURL({ did: did as Did, blob: item.cardData.modelBlob }); 25 + const response = await fetch(pdsUrl); 26 + if (!response.ok) throw new Error(response.statusText); 27 + const blob = await response.blob(); 28 + objectUrl = URL.createObjectURL(blob); 29 + } catch (e) { 30 + console.error('Failed to load 3D model:', e); 31 + } finally { 32 + isLoading = false; 33 + } 34 + } 35 + }); 36 + 37 + // Get the model URL from various sources 38 + let modelUrl = $derived.by(() => { 39 + // Local file (during editing before save) 40 + if (item.cardData.modelFile?.objectUrl) { 41 + return item.cardData.modelFile.objectUrl; 42 + } 43 + 44 + // Uploaded blob (after save) - use fetched object URL 45 + if (item.cardData.modelBlob?.$type === 'blob') { 46 + return objectUrl; 47 + } 48 + 49 + return undefined; 50 + }); 51 + 52 + let modelType = $derived(item.cardData.modelFile?.type || item.cardData.modelType || 'gltf') as 53 + | 'gltf' 54 + | 'stl' 55 + | 'fbx'; 56 + </script> 57 + 58 + <div 59 + class="absolute inset-0 h-full w-full" 60 + role="img" 61 + aria-label="3D model viewer" 62 + onpointerenter={() => (isHovering = true)} 63 + onpointerleave={() => (isHovering = false)} 64 + > 65 + {#if modelUrl} 66 + <Canvas toneMapping={CineonToneMapping}> 67 + <Model3DScene path={modelUrl} hover={isHovering} {modelType} /> 68 + </Canvas> 69 + {:else if isLoading} 70 + <div class="flex h-full items-center justify-center text-sm opacity-50">Loading model...</div> 71 + {:else} 72 + <div class="flex h-full items-center justify-center text-sm opacity-50">No model loaded</div> 73 + {/if} 74 + </div>
+161
src/lib/cards/Model3DCard/Model3DScene.svelte
··· 1 + <script lang="ts"> 2 + import { T, useTask, useThrelte } from '@threlte/core'; 3 + import { GLTF, OrbitControls } from '@threlte/extras'; 4 + import type { ThrelteGltf } from '@threlte/extras'; 5 + import { onMount } from 'svelte'; 6 + import { 7 + Box3, 8 + Group, 9 + Vector3, 10 + BufferGeometry, 11 + Mesh, 12 + MeshStandardMaterial, 13 + type Object3D 14 + } from 'three'; 15 + import { STLLoader } from 'three/addons/loaders/STLLoader.js'; 16 + import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'; 17 + 18 + let { 19 + path, 20 + hover = false, 21 + modelType = 'gltf' 22 + }: { 23 + path: string; 24 + hover?: boolean; 25 + modelType?: 'gltf' | 'stl' | 'fbx'; 26 + } = $props(); 27 + 28 + let rotation = $state(0); 29 + let group: Group | undefined = $state(); 30 + let stlMesh: Mesh | undefined = $state(); 31 + let stlLoaded = $state(false); 32 + let fbxGroup: Group | undefined = $state(); 33 + let fbxLoaded = $state(false); 34 + 35 + const { start, stop } = useTask((delta: number) => { 36 + rotation += delta * 0.5; 37 + }); 38 + 39 + $effect(() => { 40 + if (hover) { 41 + start(); 42 + } else { 43 + stop(); 44 + } 45 + }); 46 + 47 + const { renderer } = useThrelte(); 48 + 49 + onMount(() => { 50 + renderer.toneMappingExposure = 0.7; 51 + }); 52 + 53 + // Load STL file 54 + $effect(() => { 55 + if (modelType === 'stl' && path) { 56 + stlLoaded = false; 57 + const loader = new STLLoader(); 58 + loader.load( 59 + path, 60 + (geometry: BufferGeometry) => { 61 + // Center and scale the geometry 62 + geometry.computeBoundingBox(); 63 + const box = geometry.boundingBox; 64 + if (box) { 65 + const size = new Vector3(); 66 + box.getSize(size); 67 + const center = new Vector3(); 68 + box.getCenter(center); 69 + 70 + const maxSize = Math.max(size.x, size.y, size.z); 71 + const scale = 1.2 / maxSize; 72 + 73 + geometry.translate(-center.x, -center.y, -center.z); 74 + geometry.scale(scale, scale, scale); 75 + } 76 + 77 + // Create mesh with a nice material 78 + const material = new MeshStandardMaterial({ 79 + color: 0x808080, 80 + metalness: 0.3, 81 + roughness: 0.6 82 + }); 83 + 84 + stlMesh = new Mesh(geometry, material); 85 + stlLoaded = true; 86 + }, 87 + undefined, 88 + (error) => { 89 + console.error('Error loading STL:', error); 90 + } 91 + ); 92 + } 93 + }); 94 + 95 + // Load FBX file 96 + $effect(() => { 97 + if (modelType === 'fbx' && path) { 98 + fbxLoaded = false; 99 + const loader = new FBXLoader(); 100 + loader.load( 101 + path, 102 + (object: Group) => { 103 + // Center and scale the model 104 + const box = new Box3().setFromObject(object); 105 + const size = box.getSize(new Vector3()); 106 + const center = box.getCenter(new Vector3()); 107 + 108 + const maxSize = Math.max(size.x, size.y, size.z); 109 + const scale = 1.2 / maxSize; 110 + 111 + object.scale.set(scale, scale, scale); 112 + object.position.set(-center.x * scale, -center.y * scale, -center.z * scale); 113 + 114 + fbxGroup = object; 115 + fbxLoaded = true; 116 + }, 117 + undefined, 118 + (error) => { 119 + console.error('Error loading FBX:', error); 120 + } 121 + ); 122 + } 123 + }); 124 + 125 + function handleGltfLoad(gltf: ThrelteGltf) { 126 + if (!group) return; 127 + 128 + const box = new Box3().setFromObject(gltf.scene as Object3D); 129 + const size = box.getSize(new Vector3()); 130 + const center = box.getCenter(new Vector3()); 131 + 132 + let maxSize = Math.max(size.x, size.y, size.z); 133 + let scale = 1.2 / maxSize; 134 + 135 + group.scale.set(scale, scale, scale); 136 + group.position.set(-center.x * scale, -center.y * scale, -center.z * scale); 137 + } 138 + </script> 139 + 140 + <T.PerspectiveCamera makeDefault position={[0, 0, 2.5]} fov={50} near={0.1} far={100}> 141 + <OrbitControls enableZoom={false} enablePan={false} /> 142 + </T.PerspectiveCamera> 143 + 144 + <T.DirectionalLight args={[0xffffff, 2]} position={[-1, 1, 1]} /> 145 + <T.AmbientLight args={[0xffffff, 0.7]} /> 146 + 147 + <T.Group rotation={[0.3, rotation + 0.5, 0]}> 148 + {#if modelType === 'stl'} 149 + {#if stlLoaded && stlMesh} 150 + <T is={stlMesh} /> 151 + {/if} 152 + {:else if modelType === 'fbx'} 153 + {#if fbxLoaded && fbxGroup} 154 + <T is={fbxGroup} /> 155 + {/if} 156 + {:else} 157 + <T.Group bind:ref={group}> 158 + <GLTF url={path} onload={handleGltfLoad} /> 159 + </T.Group> 160 + {/if} 161 + </T.Group>
+60
src/lib/cards/Model3DCard/index.ts
··· 1 + import { uploadBlob } from '$lib/atproto'; 2 + import type { CardDefinition } from '../types'; 3 + import CreateModel3DCardModal from './CreateModel3DCardModal.svelte'; 4 + import Model3DCard from './Model3DCard.svelte'; 5 + 6 + export const Model3DCardDefinition = { 7 + type: 'model3d', 8 + contentComponent: Model3DCard, 9 + creationModalComponent: CreateModel3DCardModal, 10 + createNew: (card) => { 11 + card.w = 4; 12 + card.h = 4; 13 + card.mobileW = 4; 14 + card.mobileH = 4; 15 + card.cardData = { 16 + modelType: 'gltf' // 'gltf' | 'stl' 17 + }; 18 + }, 19 + 20 + upload: async (item) => { 21 + // Handle file upload 22 + if (item.cardData.modelFile?.blob) { 23 + let blob: Blob = item.cardData.modelFile.blob; 24 + const modelType = item.cardData.modelFile.type || 'glb'; 25 + 26 + // Ensure blob has a MIME type (STL/FBX files often have empty type) 27 + if (!blob.type) { 28 + const mimeTypes: Record<string, string> = { 29 + stl: 'model/stl', 30 + glb: 'model/gltf-binary', 31 + gltf: 'model/gltf+json', 32 + fbx: 'application/octet-stream' 33 + }; 34 + const mimeType = mimeTypes[modelType] || 'application/octet-stream'; 35 + blob = new Blob([blob], { type: mimeType }); 36 + } 37 + 38 + // Upload the blob to the PDS 39 + const uploadedBlob = await uploadBlob({ blob }); 40 + 41 + if (uploadedBlob) { 42 + item.cardData.modelBlob = uploadedBlob; 43 + item.cardData.modelType = modelType; 44 + } 45 + 46 + // Clean up the temporary file data 47 + if (item.cardData.modelFile.objectUrl) { 48 + URL.revokeObjectURL(item.cardData.modelFile.objectUrl); 49 + } 50 + delete item.cardData.modelFile; 51 + } 52 + 53 + return item; 54 + }, 55 + 56 + minW: 2, 57 + minH: 2, 58 + 59 + name: '3D Model Card' 60 + } as CardDefinition & { type: 'model3d' };
+11 -7
src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte
··· 8 8 getIsMobile 9 9 } from '$lib/website/context'; 10 10 import { CardDefinitionsByType } from '..'; 11 - import { getImageBlobUrl, parseUri } from '$lib/atproto'; 11 + import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; 12 12 13 13 import { ImageMasonry } from '@foxui/visual'; 14 14 ··· 33 33 let handle = getHandleContext(); 34 34 35 35 onMount(async () => { 36 - console.log(feed); 37 36 if (!feed) { 38 37 feed = ( 39 38 (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { ··· 42 41 })) as Record<string, PhotoItem[]> | undefined 43 42 )?.[item.cardData.galleryUri]; 44 43 45 - console.log(feed); 46 - 47 44 data[item.cardType] = feed; 48 45 } 49 46 }); 50 47 51 48 let images = $derived( 52 - feed 49 + (feed 53 50 ?.toSorted((a: PhotoItem, b: PhotoItem) => { 54 51 return (a.value.position ?? 0) - (b.value.position ?? 0); 55 52 }) 56 53 .map((i: PhotoItem) => { 57 - const { did: photoDid } = parseUri(i.uri); 54 + const item = parseUri(i.uri); 58 55 return { 59 - src: getImageBlobUrl({ did: photoDid, blob: i.value.photo }), 56 + src: getCDNImageBlobUrl({ did: item?.repo, blob: i.value.photo }), 60 57 name: '', 61 58 width: i.value.aspectRatio.width, 62 59 height: i.value.aspectRatio.height, 63 60 position: i.value.position ?? 0 64 61 }; 65 62 }) 63 + .filter((i) => i.src !== undefined) || []) as { 64 + src: string; 65 + name: string; 66 + width: number; 67 + height: number; 68 + position: number; 69 + }[] 66 70 ); 67 71 68 72 let isMobile = getIsMobile();
+11 -5
src/lib/cards/PhotoGalleryCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import { getRecord, listRecords, parseUri } from '$lib/atproto'; 3 3 import PhotoGalleryCard from './PhotoGalleryCard.svelte'; 4 + import type { Did } from '@atcute/lexicons'; 4 5 5 6 interface GalleryItem { 6 7 value: { ··· 33 34 for (const item of items) { 34 35 if (!item.cardData.galleryUri) continue; 35 36 36 - const { did, collection } = parseUri(item.cardData.galleryUri); 37 + const parsedUri = parseUri(item.cardData.galleryUri); 37 38 38 - if (collection === 'social.grain.gallery') { 39 + if (parsedUri?.collection === 'social.grain.gallery') { 39 40 const itemCollection = 'social.grain.gallery.item'; 40 41 41 42 if (!galleryItems[itemCollection]) { 42 43 galleryItems[itemCollection] = (await listRecords({ 43 - did, 44 + did: parsedUri.repo as Did, 44 45 collection: itemCollection 45 46 })) as unknown as GalleryItem[]; 46 47 } ··· 52 53 .filter((i) => i.value.gallery === item.cardData.galleryUri) 53 54 .map(async (i) => { 54 55 const itemData = parseUri(i.value.item); 55 - const record = await getRecord(itemData); 56 + if (!itemData) return null; 57 + const record = await getRecord({ 58 + did: itemData.repo as Did, 59 + collection: itemData.collection!, 60 + rkey: itemData.rkey 61 + }); 56 62 return { ...record, value: { ...record.value, ...i.value } }; 57 63 }); 58 64 ··· 62 68 63 69 return itemsData; 64 70 }, 71 + keywords: ['album', 'photos', 'slideshow', 'images', 'carousel'], 65 72 minW: 4 66 - //sidebarButtonText: 'Photo Gallery' 67 73 } as CardDefinition & { type: 'photoGallery' };
+49 -27
src/lib/cards/PopfeedReviews/PopfeedReviewsCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Item } from '$lib/types'; 3 3 import { onMount } from 'svelte'; 4 - import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 4 + import { 5 + getAdditionalUserData, 6 + getCanEdit, 7 + getDidContext, 8 + getHandleContext 9 + } from '$lib/website/context'; 5 10 import { CardDefinitionsByType } from '..'; 6 11 import Rating from './Rating.svelte'; 12 + import { Button } from '@foxui/core'; 7 13 8 14 let { item }: { item: Item } = $props(); 9 15 ··· 15 21 let handle = getHandleContext(); 16 22 17 23 onMount(async () => { 18 - console.log(feed); 19 24 if (!feed) { 20 25 feed = (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 21 26 did, 22 27 handle 23 28 })) as any; 24 29 25 - console.log(feed); 26 - 27 30 data[item.cardType] = feed; 28 31 } 29 32 }); 33 + 34 + let canEdit = getCanEdit(); 30 35 </script> 31 36 32 37 <div class="z-10 flex h-full gap-4 overflow-x-scroll p-4"> 33 - {#each feed ?? [] as review (review.uri)} 34 - {#if review.value.rating !== undefined && review.value.posterUrl} 35 - <a 36 - rel="noopener noreferrer" 37 - target="_blank" 38 - class="flex" 39 - href="https://popfeed.social/review/{review.uri}" 40 - > 41 - <div 42 - class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1" 38 + {#if feed && feed.length > 0} 39 + {#each feed as review (review.uri)} 40 + {#if review.value.rating !== undefined && review.value.posterUrl} 41 + <a 42 + rel="noopener noreferrer" 43 + target="_blank" 44 + class="flex h-full shrink-0" 45 + href="https://popfeed.social/review/{review.uri}" 43 46 > 44 - <img 45 - src={review.value.posterUrl} 46 - alt="" 47 - class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover" 48 - /> 49 - 50 47 <div 51 - class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent" 52 - ></div> 48 + class="relative flex aspect-2/3 h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1" 49 + > 50 + <img 51 + src={review.value.posterUrl} 52 + alt="" 53 + class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover" 54 + /> 55 + 56 + <div 57 + class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-linear-to-t via-transparent" 58 + ></div> 53 59 54 - <Rating class="z-10 text-lg" rating={review.value.rating} /> 55 - </div> 56 - </a> 57 - {/if} 58 - {/each} 60 + <Rating class="z-10 text-lg" rating={review.value.rating} /> 61 + </div> 62 + </a> 63 + {/if} 64 + {/each} 65 + {:else if feed} 66 + <div class="flex h-full w-full flex-col items-center justify-center gap-4 text-center text-sm"> 67 + No reviews yet. 68 + {#if canEdit()} 69 + <Button href="https://popfeed.social/" target="_blank" rel="noopener noreferrer"> 70 + Review something on Popfeed 71 + </Button> 72 + {/if} 73 + </div> 74 + {:else} 75 + <div 76 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full w-full items-center justify-center text-center text-sm" 77 + > 78 + Loading reviews... 79 + </div> 80 + {/if} 59 81 </div>
+6 -1
src/lib/cards/PopfeedReviews/index.ts
··· 17 17 return data; 18 18 }, 19 19 minH: 3, 20 - sidebarButtonText: 'Popfeed Reviews' 20 + canHaveLabel: true, 21 + 22 + keywords: ['movies', 'tv', 'film', 'reviews', 'ratings', 'popfeed'], 23 + groups: ['Media'], 24 + name: 'Movie and TV Reviews', 25 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>` 21 26 } as CardDefinition & { type: 'recentPopfeedReviews' };
+71
src/lib/cards/ProductHuntCard/CreateProductHuntCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let embedCode = $state(''); 8 + let errorMessage = $state(''); 9 + 10 + function parseEmbedCode(code: string): { 11 + imageSrc: string | null; 12 + linkHref: string | null; 13 + } { 14 + const normalized = code.replaceAll('&amp;', '&'); 15 + 16 + const srcMatch = normalized.match(/src="(https:\/\/api\.producthunt\.com\/[^"]+)"/); 17 + const imageSrc = srcMatch ? srcMatch[1] : null; 18 + 19 + const hrefMatch = normalized.match(/href="(https:\/\/www\.producthunt\.com\/[^"]+)"/); 20 + const linkHref = hrefMatch ? hrefMatch[1] : null; 21 + 22 + return { imageSrc, linkHref }; 23 + } 24 + 25 + function validate(): boolean { 26 + errorMessage = ''; 27 + 28 + const { imageSrc, linkHref } = parseEmbedCode(embedCode); 29 + 30 + if (!linkHref) { 31 + errorMessage = 'Could not find a Product Hunt link in the embed code'; 32 + return false; 33 + } 34 + 35 + if (!imageSrc) { 36 + errorMessage = 'Could not find a Product Hunt badge image in the embed code'; 37 + return false; 38 + } 39 + 40 + item.cardData.imageSrc = imageSrc; 41 + item.cardData.linkHref = linkHref; 42 + 43 + return true; 44 + } 45 + </script> 46 + 47 + <Modal open={true} closeButton={false}> 48 + <Subheading>Paste Product Hunt Embed Code</Subheading> 49 + 50 + <textarea 51 + bind:value={embedCode} 52 + placeholder="<a href=&quot;https://www.producthunt.com/posts/your-product?..." 53 + rows={5} 54 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 w-full rounded-xl border px-3 py-2 font-mono text-sm" 55 + ></textarea> 56 + 57 + {#if errorMessage} 58 + <Alert type="error" title="Invalid embed code"><span>{errorMessage}</span></Alert> 59 + {/if} 60 + 61 + <div class="mt-4 flex justify-end gap-2"> 62 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 63 + <Button 64 + onclick={() => { 65 + if (validate()) oncreate(); 66 + }} 67 + > 68 + Create 69 + </Button> 70 + </div> 71 + </Modal>
+37
src/lib/cards/ProductHuntCard/ProductHuntCard.svelte
··· 1 + <script lang="ts"> 2 + import { getCanEdit } from '$lib/website/context'; 3 + import type { ContentComponentProps } from '../types'; 4 + 5 + let { item }: ContentComponentProps = $props(); 6 + 7 + let isEditing = getCanEdit(); 8 + 9 + let linkHref = $derived(item.cardData.linkHref || ''); 10 + let lightImageSrc = $derived( 11 + (item.cardData.imageSrc || '').replace(/theme=(light|dark|neutral)/, 'theme=light') 12 + ); 13 + let darkImageSrc = $derived( 14 + (item.cardData.imageSrc || '').replace(/theme=(light|dark|neutral)/, 'theme=dark') 15 + ); 16 + </script> 17 + 18 + <a 19 + href={linkHref} 20 + target="_blank" 21 + rel="noopener noreferrer" 22 + class={[ 23 + 'flex h-full w-full items-center justify-center p-4', 24 + isEditing() && 'pointer-events-none' 25 + ]} 26 + > 27 + <img 28 + src={lightImageSrc} 29 + alt="Product Hunt badge" 30 + class="max-h-full max-w-full object-contain dark:hidden" 31 + /> 32 + <img 33 + src={darkImageSrc} 34 + alt="Product Hunt badge" 35 + class="hidden max-h-full max-w-full object-contain dark:block" 36 + /> 37 + </a>
+30
src/lib/cards/ProductHuntCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CreateProductHuntCardModal from './CreateProductHuntCardModal.svelte'; 3 + import ProductHuntCard from './ProductHuntCard.svelte'; 4 + 5 + const cardType = 'producthunt'; 6 + 7 + export const ProductHuntCardDefinition = { 8 + type: cardType, 9 + contentComponent: ProductHuntCard, 10 + creationModalComponent: CreateProductHuntCardModal, 11 + createNew: (item) => { 12 + item.cardType = cardType; 13 + item.cardData = {}; 14 + item.w = 4; 15 + item.h = 2; 16 + item.mobileW = 8; 17 + item.mobileH = 2; 18 + }, 19 + 20 + defaultColor: 'transparent', 21 + 22 + allowSetColor: false, 23 + 24 + minH: 1, 25 + 26 + name: 'Product Hunt', 27 + keywords: ['producthunt', 'product', 'launch', 'badge'], 28 + groups: ['Social'], 29 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M13.6 12h-3.2V8h3.2a2 2 0 1 1 0 4ZM12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.6 0 12 0Zm1.6 14.4h-3.2V18H8V6h5.6a4.4 4.4 0 0 1 0 8.8h0v-.4Z"/></svg>` 30 + } as CardDefinition & { type: typeof cardType };
+4 -3
src/lib/cards/SectionCard/EditingSectionCard.svelte
··· 2 2 import type { Item } from '$lib/types'; 3 3 import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.'; 4 4 import type { ContentComponentProps } from '../types'; 5 - import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 5 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 6 6 7 7 let { item = $bindable<Item>() }: ContentComponentProps = $props(); 8 8 </script> 9 9 10 10 <div 11 - class={["line-clamp-1 inline-flex h-full w-full rounded-md p-1 px-2 font-semibold", 11 + class={[ 12 + 'line-clamp-1 inline-flex h-full w-full rounded-md p-1 px-2 font-semibold', 12 13 textAlignClasses[item.cardData.textAlign as string], 13 14 verticalAlignClasses[item.cardData.verticalAlign ?? ('center' as string)], 14 15 textSizeClasses[(item.cardData.textSize ?? 1) as number] 15 16 ]} 16 17 > 17 - <PlainTextEditor bind:item key="text" class="line-clamp-1 w-full" /> 18 + <PlainTextEditor bind:contentDict={item.cardData} key="text" class="line-clamp-1 w-full" /> 18 19 </div>
+18 -3
src/lib/cards/SectionCard/index.ts
··· 24 24 }, 25 25 26 26 defaultColor: 'transparent', 27 + minW: COLUMNS, 27 28 maxH: 1, 28 29 canResize: false, 29 - settingsComponent: SectionCardSettings 30 - } as CardDefinition & { type: 'section' }; 30 + settingsComponent: SectionCardSettings, 31 31 32 + name: 'Heading', 33 + keywords: ['title', 'section', 'header', 'divider'], 34 + groups: ['Core'], 32 35 36 + icon: `<svg 37 + xmlns="http://www.w3.org/2000/svg" 38 + viewBox="0 0 24 24" 39 + fill="none" 40 + stroke="currentColor" 41 + stroke-width="2" 42 + stroke-linecap="round" 43 + stroke-linejoin="round" 44 + class="size-4" 45 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 46 + >` 47 + } as CardDefinition & { type: 'section' }; 33 48 34 49 export const textAlignClasses: Record<string, string> = { 35 50 left: '', ··· 43 58 bottom: 'items-end-safe' 44 59 }; 45 60 46 - export const textSizeClasses = ['text-lg', 'text-2xl', 'text-4xl', 'text-6xl']; 61 + export const textSizeClasses = ['text-lg', 'text-2xl', 'text-4xl', 'text-5xl'];
+11 -8
src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte
··· 2 2 import type { ContentComponentProps } from '$lib/cards/types'; 3 3 import { getAdditionalUserData } from '$lib/website/context'; 4 4 import type { AppBskyActorDefs } from '@atcute/bluesky'; 5 + import { Avatar } from '@foxui/core'; 5 6 6 7 let { item }: ContentComponentProps = $props(); 7 8 8 9 const data = getAdditionalUserData(); 9 10 // svelte-ignore state_referenced_locally 10 11 const profiles = data[item.cardType] as AppBskyActorDefs.ProfileViewDetailed[]; 12 + 13 + function getLink(profile: AppBskyActorDefs.ProfileViewDetailed): string { 14 + if (profile.handle && profile.handle !== 'handle.invalid') { 15 + return `/${profile.handle}`; 16 + } else { 17 + return `/${profile.did}`; 18 + } 19 + } 11 20 </script> 12 21 13 22 <div class="flex h-full flex-col"> 14 - <div class="px-4 py-2 text-2xl font-bold">Recently updated blentos</div> 15 23 <div class="flex max-w-full grow items-center gap-4 overflow-x-scroll overflow-y-hidden px-4"> 16 24 {#each profiles as profile (profile.did)} 17 25 <a 18 - href="/{profile.handle}" 26 + href={getLink(profile)} 19 27 class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 accent:bg-accent-200/30 accent:hover:bg-accent-200/50 flex h-52 w-44 min-w-44 flex-col items-center justify-center gap-2 rounded-xl p-2 transition-colors duration-150" 20 28 target="_blank" 21 29 > 22 - <img 23 - src={profile.avatar} 24 - class="bg-base-200 dark:bg-base-700 accent:bg-accent-300 aspect-square size-28 rounded-full" 25 - alt="" 26 - loading="lazy" 27 - /> 30 + <Avatar src={profile.avatar} class="size-28" alt="" /> 28 31 <div class="text-md line-clamp-1 max-w-full text-center font-bold"> 29 32 {profile.displayName || profile.handle} 30 33 </div>
+30 -14
src/lib/cards/SpecialCards/UpdatedBlentos/index.ts
··· 1 - import { getDetailedProfile } from '$lib/atproto'; 2 1 import type { CardDefinition } from '../../types'; 3 2 import UpdatedBlentosCard from './UpdatedBlentosCard.svelte'; 4 3 import type { Did } from '@atcute/lexicons'; 5 - import type { AppBskyActorDefs } from '@atcute/bluesky'; 4 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 5 + 6 + type ProfileWithBlentoFlag = Awaited<ReturnType<typeof getBlentoOrBskyProfile>>; 6 7 7 8 export const UpdatedBlentosCardDefitition = { 8 9 type: 'updatedBlentos', 9 10 contentComponent: UpdatedBlentosCard, 11 + keywords: ['feed', 'updates', 'recent', 'activity'], 10 12 loadData: async (items, { cache }) => { 11 13 try { 12 14 const response = await fetch( ··· 14 16 ); 15 17 const recentRecords = await response.json(); 16 18 const existingUsers = await cache?.get('updatedBlentos'); 17 - const existingUsersArray: AppBskyActorDefs.ProfileViewDetailed[] = existingUsers 19 + const existingUsersArray: ProfileWithBlentoFlag[] = existingUsers 18 20 ? JSON.parse(existingUsers) 19 21 : []; 20 22 21 - const existingUsersSet = new Set(existingUsersArray.map((v) => v.did)); 22 - 23 - const uniqueDids = new Set<Did>(); 24 - for (const record of recentRecords as { did: string }[]) { 25 - if (!existingUsersSet.has(record.did as Did)) uniqueDids.add(record.did as Did); 26 - } 23 + const uniqueDids = new Set<Did>(recentRecords.map((v: { did: string }) => v.did as Did)); 27 24 28 - const profiles: Promise<AppBskyActorDefs.ProfileViewDetailed | undefined>[] = []; 25 + const profiles: Promise<ProfileWithBlentoFlag | undefined>[] = []; 29 26 30 27 for (const did of Array.from(uniqueDids)) { 31 - const profile = getDetailedProfile({ did }); 32 - profiles.push(profile); 33 - if (profiles.length > 20) break; 28 + profiles.push(getBlentoOrBskyProfile({ did })); 34 29 } 35 30 36 - const result = [...(await Promise.all(profiles)), ...existingUsersArray]; 31 + for (let i = existingUsersArray.length - 1; i >= 0; i--) { 32 + // if handle is handle.invalid, remove from existing users and add to profiles to refresh 33 + if ( 34 + (existingUsersArray[i].handle === 'handle.invalid' || 35 + (!existingUsersArray[i].avatar && !existingUsersArray[i].hasBlento)) && 36 + !uniqueDids.has(existingUsersArray[i].did) 37 + ) { 38 + const removed = existingUsersArray.splice(i, 1)[0]; 39 + profiles.push(getBlentoOrBskyProfile({ did: removed.did })); 40 + // if in unique dids, remove from older existing users and keep the newer one 41 + // so updated profiles go first 42 + } else if (uniqueDids.has(existingUsersArray[i].did)) { 43 + existingUsersArray.splice(i, 1); 44 + } 45 + } 46 + 47 + let result = [...(await Promise.all(profiles)), ...existingUsersArray]; 48 + 49 + result = result.filter((v) => v && v.handle !== 'handle.invalid'); 37 50 38 51 if (cache) { 39 52 await cache?.put('updatedBlentos', JSON.stringify(result)); ··· 44 57 return []; 45 58 } 46 59 } 60 + // name: 'Updated Blentos', 61 + // groups: ['Social'], 62 + // icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 6c-1.602 0-3.155.474-4.434 1.357L18 16.791A8.959 8.959 0 0 0 21 12h-4.5Z" /></svg>` 47 63 } as CardDefinition & { type: 'updatedBlentos' };
+51
src/lib/cards/SpotifyCard/CreateSpotifyCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + 9 + function checkUrl() { 10 + errorMessage = ''; 11 + 12 + const pattern = /open\.spotify\.com\/(album|playlist)\/([a-zA-Z0-9]+)/; 13 + const match = item.cardData.href?.match(pattern); 14 + 15 + if (!match) { 16 + errorMessage = 'Please enter a valid Spotify album or playlist URL'; 17 + return false; 18 + } 19 + 20 + item.cardData.spotifyType = match[1]; 21 + item.cardData.spotifyId = match[2]; 22 + 23 + return true; 24 + } 25 + </script> 26 + 27 + <Modal open={true} closeButton={false}> 28 + <Subheading>Enter a Spotify album or playlist URL</Subheading> 29 + <Input 30 + bind:value={item.cardData.href} 31 + placeholder="https://open.spotify.com/album/..." 32 + onkeydown={(e) => { 33 + if (e.key === 'Enter' && checkUrl()) oncreate(); 34 + }} 35 + /> 36 + 37 + {#if errorMessage} 38 + <Alert type="error" title="Invalid URL"><span>{errorMessage}</span></Alert> 39 + {/if} 40 + 41 + <div class="mt-4 flex justify-end gap-2"> 42 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 43 + <Button 44 + onclick={() => { 45 + if (checkUrl()) oncreate(); 46 + }} 47 + > 48 + Create 49 + </Button> 50 + </div> 51 + </Modal>
+23
src/lib/cards/SpotifyCard/SpotifyCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../types'; 3 + 4 + let { item, isEditing }: ContentComponentProps = $props(); 5 + </script> 6 + 7 + {#if item.cardData?.spotifyType && item.cardData?.spotifyId} 8 + <div class="absolute inset-0 p-2"> 9 + <iframe 10 + class={['h-full w-full rounded-2xl', isEditing && 'pointer-events-none']} 11 + src="https://open.spotify.com/embed/{item.cardData.spotifyType}/{item.cardData 12 + .spotifyId}?utm_source=generator&theme=0" 13 + frameborder="0" 14 + allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" 15 + loading="lazy" 16 + title="Spotify {item.cardData.spotifyType}" 17 + ></iframe> 18 + </div> 19 + {:else} 20 + <div class="flex h-full items-center justify-center p-4 text-center opacity-50"> 21 + Missing Spotify data 22 + </div> 23 + {/if}
+68
src/lib/cards/SpotifyCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CreateSpotifyCardModal from './CreateSpotifyCardModal.svelte'; 3 + import SpotifyCard from './SpotifyCard.svelte'; 4 + 5 + const cardType = 'spotify-list-embed'; 6 + 7 + export const SpotifyCardDefinition = { 8 + type: cardType, 9 + contentComponent: SpotifyCard, 10 + creationModalComponent: CreateSpotifyCardModal, 11 + createNew: (item) => { 12 + item.cardType = cardType; 13 + item.cardData = {}; 14 + item.w = 4; 15 + item.mobileW = 8; 16 + item.h = 5; 17 + item.mobileH = 10; 18 + }, 19 + 20 + onUrlHandler: (url, item) => { 21 + const match = matchSpotifyUrl(url); 22 + if (!match) return null; 23 + 24 + item.cardData.spotifyType = match.type; 25 + item.cardData.spotifyId = match.id; 26 + item.cardData.href = url; 27 + 28 + item.w = 4; 29 + item.mobileW = 8; 30 + item.h = 5; 31 + item.mobileH = 10; 32 + 33 + return item; 34 + }, 35 + 36 + urlHandlerPriority: 2, 37 + 38 + name: 'Spotify Embed', 39 + canResize: true, 40 + minW: 4, 41 + minH: 5, 42 + 43 + keywords: ['music', 'song', 'playlist', 'album', 'podcast'], 44 + groups: ['Media'], 45 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" /></svg>` 46 + } as CardDefinition & { type: typeof cardType }; 47 + 48 + // Match Spotify album and playlist URLs 49 + // Examples: 50 + // https://open.spotify.com/album/1DFixLWuPkv3KT3TnV35m3 51 + // https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M 52 + function matchSpotifyUrl( 53 + url: string | undefined 54 + ): { type: 'album' | 'playlist'; id: string } | null { 55 + if (!url) return null; 56 + 57 + const pattern = /open\.spotify\.com\/(album|playlist)\/([a-zA-Z0-9]+)/; 58 + const match = url.match(pattern); 59 + 60 + if (match) { 61 + return { 62 + type: match[1] as 'album' | 'playlist', 63 + id: match[2] 64 + }; 65 + } 66 + 67 + return null; 68 + }
+44 -9
src/lib/cards/StandardSiteDocumentListCard/StandardSiteDocumentListCard.svelte
··· 1 1 <script lang="ts"> 2 - import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 2 + import { 3 + getAdditionalUserData, 4 + getCanEdit, 5 + getDidContext, 6 + getHandleContext 7 + } from '$lib/website/context'; 3 8 import { onMount } from 'svelte'; 4 9 import { CardDefinitionsByType } from '..'; 5 10 import type { ContentComponentProps } from '../types'; 6 11 import BlogEntry from './BlogEntry.svelte'; 12 + import { Button } from '@foxui/core'; 7 13 8 14 let { item }: ContentComponentProps = $props(); 9 15 ··· 13 19 14 20 let did = getDidContext(); 15 21 let handle = getHandleContext(); 22 + 23 + let canEdit = getCanEdit(); 16 24 17 25 onMount(async () => { 18 26 if (!feed) { ··· 27 35 </script> 28 36 29 37 <div class="flex h-full flex-col gap-10 overflow-y-scroll p-8"> 30 - {#each feed ?? [] as document (document.uri)} 31 - <BlogEntry 32 - title={document.value.title} 33 - description={document.value.description} 34 - date={document.value.publishedAt} 35 - href={document.value.href} 36 - /> 37 - {/each} 38 + {#if feed && feed.length > 0} 39 + {#each feed as document (document.uri)} 40 + <BlogEntry 41 + title={document.value.title} 42 + description={document.value.description} 43 + date={document.value.publishedAt} 44 + href={document.value.href} 45 + /> 46 + {/each} 47 + {:else if feed} 48 + <div class="z-50 flex h-full flex-col items-center justify-center gap-4 text-center text-sm"> 49 + <span class="text-lg font-semibold">No blog posts found.</span> 50 + 51 + {#if canEdit()} 52 + <span> 53 + Create some for example on <Button 54 + href="https://leaflet.pub" 55 + target="_blank" 56 + rel="noopener noreferrer" 57 + class="">Leaflet</Button 58 + > 59 + or 60 + <Button href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class="" 61 + >Pckt</Button 62 + > 63 + </span> 64 + {/if} 65 + </div> 66 + {:else} 67 + <div 68 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 69 + > 70 + Loading blog posts... 71 + </div> 72 + {/if} 38 73 </div>
+15 -3
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 22 22 if (!publications[site]) { 23 23 const siteParts = parseUri(site); 24 24 25 - const publicationRecord = await getRecord(siteParts); 25 + if (!siteParts) continue; 26 + 27 + const publicationRecord = await getRecord({ 28 + did: siteParts.repo as `did:${string}:${string}`, 29 + collection: siteParts.collection!, 30 + rkey: siteParts.rkey 31 + }); 32 + 33 + if (!publicationRecord.value) continue; 26 34 27 35 publications[site] = publicationRecord.value.url as string; 28 36 } ··· 33 41 } 34 42 } 35 43 36 - return records; 44 + return records.filter((r) => r.value?.href); 37 45 }, 38 46 39 - sidebarButtonText: 'site.standard.document list' 47 + name: 'Blog Posts', 48 + 49 + keywords: ['articles', 'writing', 'blog', 'posts', 'frontpage'], 50 + groups: ['Content'], 51 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>` 40 52 } as CardDefinition & { type: 'site.standard.document list' };
+8 -27
src/lib/cards/StatusphereCard/EditStatusphereCard.svelte
··· 5 5 import { CardDefinitionsByType } from '..'; 6 6 import { PopoverEmojiPicker } from '@foxui/social'; 7 7 import { emojiToNotoAnimatedWebp } from '.'; 8 - import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 9 - import { cn } from '@foxui/core'; 10 8 11 9 let { item }: { item: Item } = $props(); 12 10 ··· 28 26 } 29 27 }); 30 28 29 + // Use card-specific emoji if set, otherwise fall back to PDS data 30 + let emoji = $derived(item.cardData?.emoji ?? record?.value?.status); 31 + 31 32 let showPopover = $state(false); 32 33 </script> 33 34 34 35 <div class="flex h-full w-full items-center justify-center p-4"> 35 36 <PopoverEmojiPicker 36 37 bind:open={showPopover} 37 - onpicked={(emoji) => { 38 - record ??= { 39 - value: {} 40 - }; 41 - 42 - record.value.status = emoji.unicode; 43 - 38 + onpicked={(picked) => { 44 39 item.cardData.hasUpdate = true; 45 - item.cardData.emoji = emoji.unicode; 40 + item.cardData.emoji = picked.unicode; 46 41 47 42 showPopover = false; 48 43 }} 49 44 > 50 45 {#snippet child({ props })} 51 - {@const animated = emojiToNotoAnimatedWebp(record?.value?.status)} 46 + {@const animated = emojiToNotoAnimatedWebp(emoji)} 52 47 53 48 <button {...props} class="z-20 h-full max-h-40 w-full max-w-40"> 54 49 {#if animated} ··· 57 52 alt="" 58 53 class="hover:bg-base-500/10 h-full max-h-40 w-full max-w-40 rounded-2xl object-contain" 59 54 /> 60 - {:else if record?.value?.status} 55 + {:else if emoji} 61 56 <div class="text-9xl"> 62 - {record.value.status} 57 + {emoji} 63 58 </div> 64 59 {:else} 65 60 <div>Click here to set a status</div> ··· 67 62 </button> 68 63 {/snippet} 69 64 </PopoverEmojiPicker> 70 - 71 - <div 72 - class={cn( 73 - 'bg-base-200/30 dark:bg-base-900/30 absolute top-2 right-2 left-2 z-30 rounded-lg p-1 backdrop-blur-md', 74 - !item.cardData.title && 'hidden group-hover/card:block' 75 - )} 76 - > 77 - <PlainTextEditor 78 - class="text-base-900 dark:text-base-50 text-md line-clamp-1 font-bold" 79 - key="title" 80 - bind:item 81 - placeholder="I'm feeling..." 82 - /> 83 - </div> 84 65 </div>
+5 -11
src/lib/cards/StatusphereCard/StatusphereCard.svelte
··· 9 9 // svelte-ignore state_referenced_locally 10 10 let record = $state(data[item.cardType] as any); 11 11 12 - let animated = $derived(emojiToNotoAnimatedWebp(record?.value?.status)); 12 + // Use card-specific emoji if set, otherwise fall back to PDS data 13 + let emoji = $derived(item.cardData?.emoji ?? record?.value?.status); 14 + let animated = $derived(emojiToNotoAnimatedWebp(emoji)); 13 15 </script> 14 16 15 17 <div class="flex h-full w-full items-center justify-center p-4"> 16 18 {#if animated} 17 19 <img src={animated} alt="" class="h-full max-h-40 w-full object-contain" /> 18 - {:else if record?.value?.status} 20 + {:else if emoji} 19 21 <div class="text-9xl"> 20 - {record?.value?.status} 22 + {emoji} 21 23 </div> 22 24 {:else} 23 25 No status yet 24 - {/if} 25 - 26 - {#if item.cardData.title} 27 - <div 28 - class="text-base-900 dark:text-base-50 text-md bg-base-200/30 dark:bg-base-900/30 absolute top-2 right-2 left-2 z-30 line-clamp-1 rounded-lg p-1 font-bold backdrop-blur-md" 29 - > 30 - {item.cardData.title} 31 - </div> 32 26 {/if} 33 27 </div>
+15 -8
src/lib/cards/StatusphereCard/index.ts
··· 13 13 contentComponent: StatusphereCard, 14 14 editingContentComponent: EditStatusphereCard, 15 15 16 - createNew: (item) => { 17 - item.h = 3; 18 - item.mobileH = 5; 19 - }, 16 + createNew: (item) => {}, 20 17 21 18 loadData: async (items, { did }) => { 22 19 const data = await listRecords({ did, collection: 'xyz.statusphere.status', limit: 1 }); 23 20 24 21 return data[0]; 25 22 }, 26 - sidebarButtonText: 'Statusphere', 27 - 28 23 upload: async (item) => { 29 24 if (item.cardData.hasUpdate) { 30 25 await putRecord({ ··· 36 31 } 37 32 }); 38 33 delete item.cardData.hasUpdate; 39 - delete item.cardData.emoji; 34 + // Keep item.cardData.emoji so each card can have its own status 40 35 } 41 36 42 37 return item; 43 - } 38 + }, 39 + 40 + migrate: (item) => { 41 + if (item.cardData.title && !item.cardData.label) { 42 + item.cardData.label = item.cardData.title; 43 + } 44 + }, 45 + canHaveLabel: true, 46 + 47 + name: 'Emoji', 48 + keywords: ['status', 'mood', 'reaction', 'statusphere'], 49 + groups: ['Media'], 50 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" /></svg>` 44 51 } as CardDefinition & { type: 'statusphere' }; 45 52 46 53 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+22 -8
src/lib/cards/TealFMPlaysCard/TealFMPlaysCard.svelte
··· 85 85 {/snippet} 86 86 87 87 <div class="z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4"> 88 - {#each feed ?? [] as play (play.uri)} 89 - {#if play.value.originUrl} 90 - <a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full"> 88 + {#if feed && feed.length > 0} 89 + {#each feed as play (play.uri)} 90 + {#if play.value.originUrl} 91 + <a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full"> 92 + {@render musicItem(play)} 93 + </a> 94 + {:else} 91 95 {@render musicItem(play)} 92 - </a> 93 - {:else} 94 - {@render musicItem(play)} 95 - {/if} 96 - {/each} 96 + {/if} 97 + {/each} 98 + {:else if feed} 99 + <div 100 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 101 + > 102 + No recent plays found. 103 + </div> 104 + {:else} 105 + <div 106 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 107 + > 108 + Loading plays... 109 + </div> 110 + {/if} 97 111 </div>
+7 -1
src/lib/cards/TealFMPlaysCard/index.ts
··· 21 21 return data; 22 22 }, 23 23 minW: 4, 24 - sidebarButtonText: 'teal.fm Plays' 24 + canHaveLabel: true, 25 + 26 + keywords: ['music', 'scrobble', 'listening', 'songs'], 27 + name: 'Teal.fm Plays', 28 + 29 + groups: ['Media'], 30 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 25 31 } as CardDefinition & { type: 'recentTealFMPlays' };
+2 -2
src/lib/cards/TextCard/EditingTextCard.svelte
··· 3 3 import type { Editor } from '@tiptap/core'; 4 4 import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.'; 5 5 import type { ContentComponentProps } from '../types'; 6 - import MarkdownTextEditor from '../utils/MarkdownTextEditor.svelte'; 6 + import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 7 7 import { cn } from '@foxui/core'; 8 8 9 9 let { item = $bindable<Item>() }: ContentComponentProps = $props(); ··· 26 26 editor?.commands.focus('end'); 27 27 }} 28 28 > 29 - <MarkdownTextEditor bind:item bind:editor /> 29 + <MarkdownTextEditor bind:contentDict={item.cardData} key="text" bind:editor /> 30 30 </div>
+7 -2
src/lib/cards/TextCard/TextCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { marked } from 'marked'; 3 + import { sanitize } from '$lib/sanitize'; 3 4 import type { ContentComponentProps } from '../types'; 4 5 import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '.'; 5 6 import { cn } from '@foxui/core'; ··· 8 9 9 10 const renderer = new marked.Renderer(); 10 11 renderer.link = ({ href, title, text }) => 11 - `<a target="_blank" href="${href}" title="${title}">${text}</a>`; 12 + `<a target="_blank" href="${href}" title="${title ?? ''}">${text}</a>`; 12 13 </script> 13 14 14 15 <div ··· 19 20 textSizeClasses[(item.cardData.textSize ?? 0) as number] 20 21 )} 21 22 > 22 - <span>{@html marked.parse(item.cardData.text ?? '', { renderer })}</span> 23 + <span 24 + >{@html sanitize(marked.parse(item.cardData.text ?? '', { renderer }) as string, { 25 + ADD_ATTR: ['target'] 26 + })}</span 27 + > 23 28 </div>
+17 -1
src/lib/cards/TextCard/index.ts
··· 14 14 }; 15 15 }, 16 16 17 - settingsComponent: TextCardSettings 17 + settingsComponent: TextCardSettings, 18 + 19 + name: 'Text', 20 + 21 + keywords: ['paragraph', 'note', 'write', 'content', 'description', 'bio'], 22 + groups: ['Core'], 23 + 24 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" 25 + ><path 26 + fill="none" 27 + stroke="currentColor" 28 + stroke-linecap="round" 29 + stroke-linejoin="round" 30 + stroke-width="2" 31 + d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 32 + /></svg 33 + >` 18 34 } as CardDefinition & { type: 'text' }; 19 35 20 36 export const textAlignClasses: Record<string, string> = {
+262
src/lib/cards/TimerCard/TimerCard.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import type { TimerCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let cardData = $derived(item.cardData as TimerCardData); 10 + 11 + // For clock and event modes - current time 12 + let now = $state(new Date()); 13 + 14 + onMount(() => { 15 + const interval = setInterval(() => { 16 + now = new Date(); 17 + }, 1000); 18 + return () => clearInterval(interval); 19 + }); 20 + 21 + // Clock mode: get time parts for timezone 22 + let clockParts = $derived.by(() => { 23 + if (cardData.mode !== 'clock') return null; 24 + try { 25 + return new Intl.DateTimeFormat('en-US', { 26 + timeZone: cardData.timezone || 'UTC', 27 + hour: '2-digit', 28 + minute: '2-digit', 29 + second: '2-digit', 30 + hour12: false 31 + }).formatToParts(now); 32 + } catch { 33 + return null; 34 + } 35 + }); 36 + 37 + let clockHours = $derived( 38 + clockParts ? parseInt(clockParts.find((p) => p.type === 'hour')?.value || '0') : 0 39 + ); 40 + let clockMinutes = $derived( 41 + clockParts ? parseInt(clockParts.find((p) => p.type === 'minute')?.value || '0') : 0 42 + ); 43 + let clockSeconds = $derived( 44 + clockParts ? parseInt(clockParts.find((p) => p.type === 'second')?.value || '0') : 0 45 + ); 46 + 47 + // Event mode: countdown to target date 48 + let eventDiff = $derived.by(() => { 49 + if (cardData.mode !== 'event' || !cardData.targetDate) return null; 50 + const target = new Date(cardData.targetDate); 51 + return Math.max(0, target.getTime() - now.getTime()); 52 + }); 53 + 54 + let eventDays = $derived(eventDiff !== null ? Math.floor(eventDiff / (1000 * 60 * 60 * 24)) : 0); 55 + let eventHours = $derived( 56 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0 57 + ); 58 + let eventMinutes = $derived( 59 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0 60 + ); 61 + let eventSeconds = $derived( 62 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60)) / 1000) : 0 63 + ); 64 + 65 + // Check if event is in the past (elapsed mode) 66 + let isEventPast = $derived.by(() => { 67 + if (cardData.mode !== 'event' || !cardData.targetDate) return false; 68 + const target = new Date(cardData.targetDate); 69 + return now.getTime() > target.getTime(); 70 + }); 71 + 72 + // Elapsed time since past event 73 + let elapsedDiff = $derived.by(() => { 74 + if (!isEventPast || !cardData.targetDate) return null; 75 + const target = new Date(cardData.targetDate); 76 + return now.getTime() - target.getTime(); 77 + }); 78 + 79 + let elapsedYears = $derived( 80 + elapsedDiff !== null ? Math.floor(elapsedDiff / (1000 * 60 * 60 * 24 * 365)) : 0 81 + ); 82 + let elapsedDays = $derived( 83 + elapsedDiff !== null 84 + ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24)) 85 + : 0 86 + ); 87 + let elapsedHours = $derived( 88 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0 89 + ); 90 + let elapsedMinutes = $derived( 91 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0 92 + ); 93 + let elapsedSeconds = $derived( 94 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60)) / 1000) : 0 95 + ); 96 + 97 + // Get timezone display name 98 + let timezoneDisplay = $derived.by(() => { 99 + if (!cardData.timezone) return ''; 100 + try { 101 + const formatter = new Intl.DateTimeFormat('en-US', { 102 + timeZone: cardData.timezone, 103 + timeZoneName: 'short' 104 + }); 105 + const parts = formatter.formatToParts(now); 106 + return parts.find((p) => p.type === 'timeZoneName')?.value || cardData.timezone; 107 + } catch { 108 + return cardData.timezone; 109 + } 110 + }); 111 + </script> 112 + 113 + <div class="@container flex h-full w-full flex-col items-center justify-center p-4"> 114 + <!-- Clock Mode --> 115 + {#if cardData.mode === 'clock'} 116 + <NumberFlowGroup> 117 + <div 118 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-center text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 119 + style="font-variant-numeric: tabular-nums;" 120 + > 121 + <NumberFlow value={clockHours} format={{ minimumIntegerDigits: 2 }} /> 122 + <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 123 + <NumberFlow 124 + value={clockMinutes} 125 + format={{ minimumIntegerDigits: 2 }} 126 + digits={{ 1: { max: 5 } }} 127 + trend={1} 128 + /> 129 + <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 130 + <NumberFlow 131 + value={clockSeconds} 132 + format={{ minimumIntegerDigits: 2 }} 133 + digits={{ 1: { max: 5 } }} 134 + trend={1} 135 + /> 136 + </div> 137 + </NumberFlowGroup> 138 + {#if timezoneDisplay} 139 + <div class="text-base-500 dark:text-base-400 accent:text-base-600 mt-1 text-xs @sm:text-sm"> 140 + {timezoneDisplay} 141 + </div> 142 + {/if} 143 + 144 + <!-- Event Countdown Mode --> 145 + {:else if cardData.mode === 'event'} 146 + {#if isEventPast && elapsedDiff !== null} 147 + <!-- Elapsed time since past event --> 148 + <NumberFlowGroup> 149 + <div 150 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 151 + style="font-variant-numeric: tabular-nums;" 152 + > 153 + {#if elapsedYears > 0} 154 + <div class="flex flex-col items-center"> 155 + <NumberFlow 156 + value={elapsedYears} 157 + trend={1} 158 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 159 + /> 160 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 161 + >{elapsedYears === 1 ? 'year' : 'years'}</span 162 + > 163 + </div> 164 + {/if} 165 + {#if elapsedYears > 0 || elapsedDays > 0} 166 + <div class="flex flex-col items-center"> 167 + <NumberFlow 168 + value={elapsedDays} 169 + trend={1} 170 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 171 + /> 172 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 173 + >{elapsedDays === 1 ? 'day' : 'days'}</span 174 + > 175 + </div> 176 + {/if} 177 + <div class="flex flex-col items-center"> 178 + <NumberFlow 179 + value={elapsedHours} 180 + trend={1} 181 + format={{ minimumIntegerDigits: 2 }} 182 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 183 + /> 184 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span> 185 + </div> 186 + <div class="flex flex-col items-center"> 187 + <NumberFlow 188 + value={elapsedMinutes} 189 + trend={1} 190 + format={{ minimumIntegerDigits: 2 }} 191 + digits={{ 1: { max: 5 } }} 192 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 193 + /> 194 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span> 195 + </div> 196 + <div class="flex flex-col items-center"> 197 + <NumberFlow 198 + value={elapsedSeconds} 199 + trend={1} 200 + format={{ minimumIntegerDigits: 2 }} 201 + digits={{ 1: { max: 5 } }} 202 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 203 + /> 204 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span> 205 + </div> 206 + </div> 207 + </NumberFlowGroup> 208 + {:else if eventDiff !== null} 209 + <!-- Countdown to future event --> 210 + <NumberFlowGroup> 211 + <div 212 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 213 + style="font-variant-numeric: tabular-nums;" 214 + > 215 + {#if eventDays > 0} 216 + <div class="flex flex-col items-center"> 217 + <NumberFlow 218 + value={eventDays} 219 + trend={-1} 220 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 221 + /> 222 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 223 + >{eventDays === 1 ? 'day' : 'days'}</span 224 + > 225 + </div> 226 + {/if} 227 + <div class="flex flex-col items-center"> 228 + <NumberFlow 229 + value={eventHours} 230 + trend={-1} 231 + format={{ minimumIntegerDigits: 2 }} 232 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 233 + /> 234 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span> 235 + </div> 236 + <div class="flex flex-col items-center"> 237 + <NumberFlow 238 + value={eventMinutes} 239 + trend={-1} 240 + format={{ minimumIntegerDigits: 2 }} 241 + digits={{ 1: { max: 5 } }} 242 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 243 + /> 244 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span> 245 + </div> 246 + <div class="flex flex-col items-center"> 247 + <NumberFlow 248 + value={eventSeconds} 249 + trend={-1} 250 + format={{ minimumIntegerDigits: 2 }} 251 + digits={{ 1: { max: 5 } }} 252 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 253 + /> 254 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span> 255 + </div> 256 + </div> 257 + </NumberFlowGroup> 258 + {:else} 259 + <div class="text-base-500 text-sm">Set a target date in settings</div> 260 + {/if} 261 + {/if} 262 + </div>
+144
src/lib/cards/TimerCard/TimerCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Button, Input, Label } from '@foxui/core'; 4 + import type { TimerCardData, TimerMode } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: { item: Item; onclose: () => void } = $props(); 8 + 9 + let cardData = $derived(item.cardData as TimerCardData); 10 + 11 + const modeOptions = [ 12 + { value: 'clock', label: 'Clock', desc: 'Show current time' }, 13 + { value: 'event', label: 'Event', desc: 'Countdown to date' } 14 + ]; 15 + 16 + // All 24 timezones with representative cities 17 + const timezoneOptions = [ 18 + { value: 'Pacific/Midway', label: 'UTC-11 (Midway)' }, 19 + { value: 'Pacific/Honolulu', label: 'UTC-10 (Honolulu)' }, 20 + { value: 'America/Anchorage', label: 'UTC-9 (Anchorage)' }, 21 + { value: 'America/Los_Angeles', label: 'UTC-8 (Los Angeles)' }, 22 + { value: 'America/Denver', label: 'UTC-7 (Denver)' }, 23 + { value: 'America/Chicago', label: 'UTC-6 (Chicago)' }, 24 + { value: 'America/New_York', label: 'UTC-5 (New York)' }, 25 + { value: 'America/Halifax', label: 'UTC-4 (Halifax)' }, 26 + { value: 'America/Sao_Paulo', label: 'UTC-3 (Sรฃo Paulo)' }, 27 + { value: 'Atlantic/South_Georgia', label: 'UTC-2 (South Georgia)' }, 28 + { value: 'Atlantic/Azores', label: 'UTC-1 (Azores)' }, 29 + { value: 'UTC', label: 'UTC+0 (London)' }, 30 + { value: 'Europe/Paris', label: 'UTC+1 (Paris)' }, 31 + { value: 'Europe/Helsinki', label: 'UTC+2 (Helsinki)' }, 32 + { value: 'Europe/Moscow', label: 'UTC+3 (Moscow)' }, 33 + { value: 'Asia/Dubai', label: 'UTC+4 (Dubai)' }, 34 + { value: 'Asia/Karachi', label: 'UTC+5 (Karachi)' }, 35 + { value: 'Asia/Kolkata', label: 'UTC+5:30 (Mumbai)' }, 36 + { value: 'Asia/Dhaka', label: 'UTC+6 (Dhaka)' }, 37 + { value: 'Asia/Bangkok', label: 'UTC+7 (Bangkok)' }, 38 + { value: 'Asia/Shanghai', label: 'UTC+8 (Shanghai)' }, 39 + { value: 'Asia/Tokyo', label: 'UTC+9 (Tokyo)' }, 40 + { value: 'Australia/Sydney', label: 'UTC+10 (Sydney)' }, 41 + { value: 'Pacific/Noumea', label: 'UTC+11 (Noumea)' }, 42 + { value: 'Pacific/Auckland', label: 'UTC+12 (Auckland)' } 43 + ]; 44 + 45 + // Auto-detect timezone on mount if not set 46 + onMount(() => { 47 + if (!cardData.timezone) { 48 + try { 49 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 50 + } catch { 51 + item.cardData.timezone = 'UTC'; 52 + } 53 + } 54 + }); 55 + 56 + function useLocalTimezone() { 57 + try { 58 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 59 + } catch { 60 + item.cardData.timezone = 'UTC'; 61 + } 62 + } 63 + 64 + // Parse target date for inputs 65 + let targetDateValue = $derived.by(() => { 66 + if (!cardData.targetDate) return ''; 67 + return new Date(cardData.targetDate).toISOString().split('T')[0]; 68 + }); 69 + 70 + let targetTimeValue = $derived.by(() => { 71 + if (!cardData.targetDate) return '12:00'; 72 + return new Date(cardData.targetDate).toTimeString().slice(0, 5); 73 + }); 74 + 75 + function updateTargetDate(dateStr: string, timeStr: string) { 76 + if (!dateStr) return; 77 + item.cardData.targetDate = new Date(`${dateStr}T${timeStr}`).toISOString(); 78 + } 79 + </script> 80 + 81 + <div class="flex flex-col gap-4"> 82 + <!-- Mode Selection --> 83 + <div class="flex flex-col gap-2"> 84 + <Label>Mode</Label> 85 + <div class="grid grid-cols-2 gap-2"> 86 + {#each modeOptions as opt (opt.value)} 87 + <button 88 + type="button" 89 + onclick={() => (item.cardData.mode = opt.value as TimerMode)} 90 + class={[ 91 + 'rounded-xl border px-3 py-2 text-left transition-colors', 92 + cardData.mode === opt.value 93 + ? 'border-accent-500 bg-accent-500/10 text-accent-700 dark:text-accent-300' 94 + : 'border-base-300 dark:border-base-700 hover:bg-base-100 dark:hover:bg-base-800' 95 + ]} 96 + > 97 + <div class="text-sm font-medium">{opt.label}</div> 98 + <div class="text-base-500 text-xs">{opt.desc}</div> 99 + </button> 100 + {/each} 101 + </div> 102 + </div> 103 + 104 + <!-- Clock Settings --> 105 + {#if cardData.mode === 'clock'} 106 + <div class="flex flex-col gap-2"> 107 + <Label for="timezone">Timezone</Label> 108 + <div class="flex gap-2"> 109 + <select 110 + id="timezone" 111 + value={cardData.timezone || 'UTC'} 112 + onchange={(e) => (item.cardData.timezone = e.currentTarget.value)} 113 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 flex-1 rounded-xl border px-3 py-2" 114 + > 115 + {#each timezoneOptions as tz (tz.value)} 116 + <option value={tz.value}>{tz.label}</option> 117 + {/each} 118 + </select> 119 + <Button size="sm" variant="ghost" onclick={useLocalTimezone}>Local</Button> 120 + </div> 121 + </div> 122 + {/if} 123 + 124 + <!-- Event Settings --> 125 + {#if cardData.mode === 'event'} 126 + <div class="flex flex-col gap-2"> 127 + <Label>Target Date & Time</Label> 128 + <div class="flex gap-2"> 129 + <Input 130 + type="date" 131 + value={targetDateValue} 132 + onchange={(e) => updateTargetDate(e.currentTarget.value, targetTimeValue)} 133 + class="flex-1" 134 + /> 135 + <Input 136 + type="time" 137 + value={targetTimeValue} 138 + onchange={(e) => updateTargetDate(targetDateValue, e.currentTarget.value)} 139 + class="w-28" 140 + /> 141 + </div> 142 + </div> 143 + {/if} 144 + </div>
+50
src/lib/cards/TimerCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import TimerCard from './TimerCard.svelte'; 3 + import TimerCardSettings from './TimerCardSettings.svelte'; 4 + 5 + export type TimerMode = 'clock' | 'event'; 6 + 7 + export type TimerCardData = { 8 + mode: TimerMode; 9 + label?: string; 10 + // For clock mode 11 + timezone?: string; 12 + // For event mode: target date as ISO string 13 + targetDate?: string; 14 + }; 15 + 16 + export const TimerCardDefinition = { 17 + type: 'timer', 18 + contentComponent: TimerCard, 19 + settingsComponent: TimerCardSettings, 20 + 21 + createNew: (card) => { 22 + card.w = 4; 23 + card.h = 2; 24 + card.mobileW = 8; 25 + card.mobileH = 3; 26 + card.cardData = { 27 + mode: 'clock', 28 + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone 29 + } as TimerCardData; 30 + }, 31 + 32 + keywords: ['stopwatch', 'clock', 'time'], 33 + allowSetColor: true, 34 + minW: 4, 35 + canHaveLabel: true, 36 + 37 + migrate: (item) => { 38 + const data = item.cardData as TimerCardData; 39 + if (data.mode === 'event') { 40 + item.cardType = 'countdown'; 41 + item.cardData = { targetDate: data.targetDate }; 42 + } else { 43 + item.cardType = 'clock'; 44 + item.cardData = { timezone: data.timezone }; 45 + } 46 + if (data.label) { 47 + item.cardData.label = data.label; 48 + } 49 + } 50 + } as CardDefinition & { type: 'timer' };
+70
src/lib/cards/VCardCard/VCardCard.svelte
··· 1 + <script lang="ts"> 2 + import { Modal } from '@foxui/core'; 3 + import QRCodeDisplay from '$lib/components/qr/QRCodeDisplay.svelte'; 4 + import type { ContentComponentProps } from '../types'; 5 + import { parseVCardName, parseVCardOrg } from '.'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let showQR = $state(false); 10 + 11 + let displayName = $derived( 12 + item.cardData.displayName || parseVCardName(item.cardData.vcard || '') || 'Contact' 13 + ); 14 + let org = $derived(parseVCardOrg(item.cardData.vcard || '')); 15 + </script> 16 + 17 + <button 18 + class="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-3 p-3" 19 + onclick={() => (showQR = true)} 20 + ><div 21 + class="text-base-500 dark:text-base-400 accent:text-base-700 text-[12px] font-medium tracking-wide uppercase" 22 + > 23 + vCard 24 + </div> 25 + <!-- Identification Card icon (Heroicons) --> 26 + <svg 27 + xmlns="http://www.w3.org/2000/svg" 28 + fill="none" 29 + viewBox="0 0 24 24" 30 + stroke-width="1.5" 31 + stroke="currentColor" 32 + class="text-base-700 dark:text-base-300 accent:text-base-900 size-10" 33 + > 34 + <path 35 + stroke-linecap="round" 36 + stroke-linejoin="round" 37 + d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" 38 + /> 39 + </svg> 40 + 41 + <div class="text-center"> 42 + <div class="text-base-900 dark:text-base-100 accent:text-base-900 text-sm font-semibold"> 43 + {displayName} 44 + </div> 45 + {#if org} 46 + <div class="text-base-600 dark:text-base-400 accent:text-base-800 text-xs"> 47 + {org} 48 + </div> 49 + {/if} 50 + </div> 51 + </button> 52 + 53 + <Modal bind:open={showQR} closeButton={true} class="max-w-[90vw]! sm:max-w-sm! md:max-w-md!"> 54 + <div class="flex flex-col items-center justify-center gap-4 p-4"> 55 + <div class="text-base-900 dark:text-base-100 text-center text-2xl font-semibold"> 56 + {displayName} 57 + </div> 58 + 59 + <div class="flex items-center justify-center overflow-hidden rounded-2xl"> 60 + <QRCodeDisplay 61 + url={item.cardData.vcard || ''} 62 + class="size-[min(70vw,320px)] sm:size-72 md:size-80" 63 + /> 64 + </div> 65 + 66 + <p class="text-base-600 dark:text-base-400 text-center text-sm"> 67 + Scan to add contact to your phone 68 + </p> 69 + </div> 70 + </Modal>
+128
src/lib/cards/VCardCard/VCardCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Subheading } from '@foxui/core'; 3 + import type { SettingsComponentProps } from '../types'; 4 + import { parseVCard, generateVCard, parseVCardName, emptyVCardFields, type VCardFields } from '.'; 5 + 6 + let { item = $bindable(), onclose }: SettingsComponentProps = $props(); 7 + 8 + let mode: 'easy' | 'expert' = $state('easy'); 9 + let fields: VCardFields = $state( 10 + parseVCard(item.cardData.vcard || '') || { ...emptyVCardFields } 11 + ); 12 + 13 + function syncFromFields() { 14 + item.cardData.vcard = generateVCard(fields); 15 + item.cardData.displayName = parseVCardName(item.cardData.vcard); 16 + } 17 + 18 + function handleTextarea(e: Event) { 19 + const text = (e.target as HTMLTextAreaElement).value; 20 + item.cardData.vcard = text; 21 + item.cardData.displayName = parseVCardName(text); 22 + fields = parseVCard(text); 23 + } 24 + </script> 25 + 26 + <div class="flex w-72 flex-col gap-3 p-2"> 27 + <Subheading>Edit vCard</Subheading> 28 + 29 + <Alert type="info" title="Privacy"> 30 + <p class="text-xs">All data is public, be aware.</p> 31 + </Alert> 32 + 33 + <div class="flex items-center gap-2 text-xs"> 34 + <button 35 + class={[ 36 + 'rounded px-2 py-1', 37 + mode === 'easy' ? 'bg-accent-500 text-white' : 'bg-base-200 dark:bg-base-700' 38 + ]} 39 + onclick={() => (mode = 'easy')} 40 + > 41 + Easy 42 + </button> 43 + <button 44 + class={[ 45 + 'rounded px-2 py-1', 46 + mode === 'expert' ? 'bg-accent-500 text-white' : 'bg-base-200 dark:bg-base-700' 47 + ]} 48 + onclick={() => (mode = 'expert')} 49 + > 50 + Expert 51 + </button> 52 + <a 53 + href="https://wikipedia.org/wiki/VCard" 54 + target="_blank" 55 + class="text-accent-600 dark:text-accent-400 underline">Learn about the vCard format</a 56 + > 57 + </div> 58 + 59 + {#if mode === 'easy'} 60 + <div class="flex flex-col gap-1 text-xs"> 61 + <div class="grid grid-cols-2 gap-1"> 62 + <input 63 + bind:value={fields.firstName} 64 + oninput={syncFromFields} 65 + placeholder="First name" 66 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 67 + /> 68 + <input 69 + bind:value={fields.lastName} 70 + oninput={syncFromFields} 71 + placeholder="Last name" 72 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 73 + /> 74 + </div> 75 + <input 76 + bind:value={fields.org} 77 + oninput={syncFromFields} 78 + placeholder="Organization" 79 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 80 + /> 81 + <input 82 + bind:value={fields.title} 83 + oninput={syncFromFields} 84 + placeholder="Job title" 85 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 86 + /> 87 + <input 88 + bind:value={fields.email} 89 + oninput={syncFromFields} 90 + placeholder="Email" 91 + type="email" 92 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 93 + /> 94 + <input 95 + bind:value={fields.bday} 96 + oninput={syncFromFields} 97 + placeholder="Birthday" 98 + type="date" 99 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 100 + /> 101 + <input 102 + bind:value={fields.website} 103 + oninput={syncFromFields} 104 + placeholder="Website" 105 + type="url" 106 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 107 + /> 108 + <input 109 + bind:value={fields.address} 110 + oninput={syncFromFields} 111 + placeholder="Address" 112 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 113 + /> 114 + </div> 115 + {:else} 116 + <textarea 117 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 h-40 w-full resize-none rounded border p-2 font-mono text-xs focus:outline-none" 118 + value={item.cardData.vcard || ''} 119 + oninput={handleTextarea} 120 + placeholder="BEGIN:VCARD 121 + VERSION:4.0 122 + FN:John Doe 123 + END:VCARD" 124 + ></textarea> 125 + {/if} 126 + 127 + <Button onclick={onclose} size="sm">Done</Button> 128 + </div>
+128
src/lib/cards/VCardCard/index.ts
··· 1 + import { user } from '$lib/atproto/auth.svelte'; 2 + import type { CardDefinition } from '../types'; 3 + import VCardCard from './VCardCard.svelte'; 4 + import VCardCardSettings from './VCardCardSettings.svelte'; 5 + 6 + // vCard spec: https://wikipedia.org/wiki/VCard 7 + 8 + export type VCardFields = { 9 + firstName: string; 10 + lastName: string; 11 + org: string; 12 + title: string; 13 + email: string; 14 + bday: string; // YYYY-MM-DD for input, stored as YYYYMMDD 15 + website: string; 16 + address: string; 17 + note: string; 18 + }; 19 + 20 + export const emptyVCardFields: VCardFields = { 21 + firstName: '', 22 + lastName: '', 23 + org: '', 24 + title: '', 25 + email: '', 26 + bday: '', 27 + website: '', 28 + address: '', 29 + note: '' 30 + }; 31 + 32 + // Convert YYYY-MM-DD to YYYYMMDD for vCard 33 + export function formatBdayToVCard(date: string): string { 34 + return date.replace(/-/g, ''); 35 + } 36 + 37 + // Convert YYYYMMDD to YYYY-MM-DD for input 38 + export function formatBdayFromVCard(bday: string): string { 39 + if (bday.length === 8) { 40 + return `${bday.slice(0, 4)}-${bday.slice(4, 6)}-${bday.slice(6, 8)}`; 41 + } 42 + return bday; 43 + } 44 + 45 + // Generate vCard v4 string from fields 46 + export function generateVCard(f: VCardFields): string { 47 + const lines = ['BEGIN:VCARD', 'VERSION:4.0']; 48 + const fn = `${f.firstName} ${f.lastName}`.trim(); 49 + if (fn) lines.push(`FN:${fn}`); 50 + if (f.lastName || f.firstName) lines.push(`N:${f.lastName};${f.firstName};;;`); 51 + if (f.org) lines.push(`ORG:${f.org}`); 52 + if (f.title) lines.push(`TITLE:${f.title}`); 53 + if (f.email) lines.push(`EMAIL:${f.email}`); 54 + if (f.bday) lines.push(`BDAY:${formatBdayToVCard(f.bday)}`); 55 + if (f.website) lines.push(`URL:${f.website}`); 56 + if (f.address) lines.push(`ADR:;;${f.address};;;;`); 57 + if (f.note) lines.push(`NOTE:${f.note}`); 58 + lines.push('END:VCARD'); 59 + return lines.join('\n'); 60 + } 61 + 62 + // Parse vCard string to fields (supports v3 & v4) 63 + export function parseVCard(vcard: string): VCardFields { 64 + const get = (key: string) => { 65 + const m = vcard.match(new RegExp(`^${key}[;:](.*)$`, 'im')); 66 + return m?.[1]?.trim() || ''; 67 + }; 68 + 69 + const n = get('N').split(';'); 70 + let lastName = n[0] || ''; 71 + let firstName = n[1] || ''; 72 + 73 + if (!lastName && !firstName) { 74 + const fn = get('FN').split(' '); 75 + firstName = fn[0] || ''; 76 + lastName = fn.slice(1).join(' ') || ''; 77 + } 78 + 79 + const adr = get('ADR').split(';'); 80 + 81 + return { 82 + firstName, 83 + lastName, 84 + org: get('ORG').split(';')[0], 85 + title: get('TITLE'), 86 + email: get('EMAIL'), 87 + bday: formatBdayFromVCard(get('BDAY')), 88 + website: get('URL'), 89 + address: adr[2] || '', 90 + note: get('NOTE') 91 + }; 92 + } 93 + 94 + // Parse FN (formatted name) or N from vCard 95 + export function parseVCardName(vcard: string): string { 96 + const f = parseVCard(vcard); 97 + return `${f.firstName} ${f.lastName}`.trim(); 98 + } 99 + 100 + // Parse ORG from vCard 101 + export function parseVCardOrg(vcard: string): string { 102 + return parseVCard(vcard).org; 103 + } 104 + 105 + export const VCardCardDefinition = { 106 + type: 'vcard', 107 + contentComponent: VCardCard, 108 + settingsComponent: VCardCardSettings, 109 + 110 + createNew: (card) => { 111 + card.w = 2; 112 + card.h = 2; 113 + card.mobileW = 4; 114 + card.mobileH = 4; 115 + const displayName = user.profile?.displayName || user.profile?.handle || ''; 116 + card.cardData.vcard = generateVCard({ 117 + ...emptyVCardFields, 118 + lastName: displayName 119 + }); 120 + card.cardData.displayName = displayName; 121 + }, 122 + 123 + allowSetColor: true, 124 + name: 'vCard Card', 125 + keywords: ['contact', 'phone', 'email', 'address', 'business card'], 126 + groups: ['Social'], 127 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" /></svg>` 128 + } as CardDefinition & { type: 'vcard' };
-90
src/lib/cards/VideoCard/VideoCard.svelte
··· 1 - <script lang="ts"> 2 - import { getDidContext } from '$lib/website/context'; 3 - import { getBlobURL } from '$lib/atproto'; 4 - import { onMount } from 'svelte'; 5 - import type { ContentComponentProps } from '../types'; 6 - 7 - let { item = $bindable() }: ContentComponentProps = $props(); 8 - 9 - const did = getDidContext(); 10 - 11 - let element: HTMLVideoElement | undefined = $state(); 12 - 13 - onMount(async () => { 14 - const el = element; 15 - if (!el) return; 16 - 17 - el.muted = true; 18 - 19 - // If we already have an objectUrl (preview before upload), use it directly 20 - if (item.cardData.objectUrl) { 21 - el.src = item.cardData.objectUrl; 22 - el.play().catch((e) => { 23 - console.error('Video play error:', e); 24 - }); 25 - return; 26 - } 27 - 28 - // Fetch the video blob from the PDS 29 - if (item.cardData.video?.video && typeof item.cardData.video.video === 'object') { 30 - try { 31 - const blobUrl = await getBlobURL({ did, blob: item.cardData.video.video }); 32 - const res = await fetch(blobUrl); 33 - if (!res.ok) throw new Error(res.statusText); 34 - const blob = await res.blob(); 35 - const url = URL.createObjectURL(blob); 36 - el.src = url; 37 - el.play().catch((e) => { 38 - console.error('Video play error:', e); 39 - }); 40 - } catch (e) { 41 - console.error('Failed to load video:', e); 42 - } 43 - } 44 - }); 45 - </script> 46 - 47 - {#key item.cardData.video || item.cardData.objectUrl} 48 - <video 49 - bind:this={element} 50 - muted 51 - loop 52 - autoplay 53 - playsinline 54 - class={[ 55 - 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 56 - item.cardData.href ? 'group-hover:scale-102' : '' 57 - ]} 58 - ></video> 59 - {/key} 60 - {#if item.cardData.href} 61 - <a 62 - href={item.cardData.href} 63 - class="absolute inset-0 h-full w-full" 64 - target="_blank" 65 - rel="noopener noreferrer" 66 - > 67 - <span class="sr-only"> 68 - {item.cardData.hrefText ?? 'Learn more'} 69 - </span> 70 - 71 - <div 72 - 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" 73 - > 74 - <svg 75 - xmlns="http://www.w3.org/2000/svg" 76 - fill="none" 77 - viewBox="0 0 24 24" 78 - stroke-width="2.5" 79 - stroke="currentColor" 80 - class="size-4" 81 - > 82 - <path 83 - stroke-linecap="round" 84 - stroke-linejoin="round" 85 - d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 86 - /> 87 - </svg> 88 - </div> 89 - </a> 90 - {/if}
-54
src/lib/cards/VideoCard/VideoCardSettings.svelte
··· 1 - <script lang="ts"> 2 - import { validateLink } from '$lib/helper'; 3 - import type { Item } from '$lib/types'; 4 - import { Button, Input, toast } from '@foxui/core'; 5 - 6 - let { item, onclose }: { item: Item; onclose: () => void } = $props(); 7 - 8 - let linkValue = $derived( 9 - item.cardData.href?.replace('https://', '').replace('http://', '') ?? '' 10 - ); 11 - 12 - function updateLink() { 13 - if (!linkValue.trim()) { 14 - item.cardData.href = ''; 15 - item.cardData.domain = ''; 16 - } 17 - 18 - let link = validateLink(linkValue); 19 - if (!link) { 20 - toast.error('Invalid link'); 21 - return; 22 - } 23 - 24 - item.cardData.href = link; 25 - item.cardData.domain = new URL(link).hostname; 26 - 27 - onclose?.(); 28 - } 29 - </script> 30 - 31 - <Input 32 - spellcheck={false} 33 - type="url" 34 - bind:value={linkValue} 35 - onkeydown={(event) => { 36 - if (event.code === 'Enter') { 37 - updateLink(); 38 - event.preventDefault(); 39 - } 40 - }} 41 - placeholder="Enter link" 42 - /> 43 - <Button onclick={updateLink} size="icon" 44 - ><svg 45 - xmlns="http://www.w3.org/2000/svg" 46 - fill="none" 47 - viewBox="0 0 24 24" 48 - stroke-width="1.5" 49 - stroke="currentColor" 50 - class="size-6" 51 - > 52 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 53 - </svg> 54 - </Button>
-63
src/lib/cards/VideoCard/index.ts
··· 1 - import { uploadBlob } from '$lib/atproto'; 2 - import type { CardDefinition } from '../types'; 3 - import VideoCard from './VideoCard.svelte'; 4 - import VideoCardSettings from './VideoCardSettings.svelte'; 5 - 6 - async function getAspectRatio(videoBlob: Blob): Promise<{ width: number; height: number }> { 7 - return new Promise((resolve, reject) => { 8 - const video = document.createElement('video'); 9 - video.preload = 'metadata'; 10 - 11 - video.onloadedmetadata = () => { 12 - URL.revokeObjectURL(video.src); 13 - resolve({ 14 - width: video.videoWidth, 15 - height: video.videoHeight 16 - }); 17 - }; 18 - 19 - video.onerror = () => { 20 - URL.revokeObjectURL(video.src); 21 - reject(new Error('Failed to load video metadata')); 22 - }; 23 - 24 - video.src = URL.createObjectURL(videoBlob); 25 - }); 26 - } 27 - 28 - export const VideoCardDefinition = { 29 - type: 'video', 30 - contentComponent: VideoCard, 31 - createNew: (card) => { 32 - card.cardType = 'video'; 33 - card.cardData = { 34 - video: null, 35 - href: '' 36 - }; 37 - }, 38 - upload: async (item) => { 39 - if (item.cardData.blob) { 40 - const blob = item.cardData.blob; 41 - const aspectRatio = await getAspectRatio(blob); 42 - const uploadedBlob = await uploadBlob({ blob }); 43 - 44 - item.cardData.video = { 45 - $type: 'app.bsky.embed.video', 46 - video: uploadedBlob, 47 - aspectRatio 48 - }; 49 - 50 - delete item.cardData.blob; 51 - } 52 - 53 - if (item.cardData.objectUrl) { 54 - URL.revokeObjectURL(item.cardData.objectUrl); 55 - delete item.cardData.objectUrl; 56 - } 57 - 58 - return item; 59 - }, 60 - settingsComponent: VideoCardSettings, 61 - 62 - name: 'Video Card' 63 - } as CardDefinition & { type: 'video' };
+52
src/lib/cards/YoutubeVideoCard/CreateYoutubeCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { matcher } from './index'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let errorMessage = $state(''); 9 + </script> 10 + 11 + <Modal open={true} closeButton={false}> 12 + <form 13 + onsubmit={() => { 14 + const url = item.cardData.href?.trim(); 15 + if (!url) return; 16 + 17 + const id = matcher(url); 18 + if (!id) { 19 + errorMessage = 'Please enter a valid YouTube URL'; 20 + return; 21 + } 22 + 23 + item.cardData.youtubeId = id; 24 + item.cardData.poster = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; 25 + item.cardData.showInline = true; 26 + 27 + item.w = 4; 28 + item.mobileW = 8; 29 + item.h = 3; 30 + item.mobileH = 5; 31 + 32 + oncreate?.(); 33 + }} 34 + class="flex flex-col gap-2" 35 + > 36 + <Subheading>Enter a YouTube URL</Subheading> 37 + <Input 38 + bind:value={item.cardData.href} 39 + placeholder="https://youtube.com/watch?v=..." 40 + class="mt-4" 41 + /> 42 + 43 + {#if errorMessage} 44 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 45 + {/if} 46 + 47 + <div class="mt-4 flex justify-end gap-2"> 48 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 49 + <Button type="submit">Create</Button> 50 + </div> 51 + </form> 52 + </Modal>
+1 -1
src/lib/cards/YoutubeVideoCard/YoutubeCard.svelte
··· 1 1 <script lang="ts"> 2 - import { videoPlayer } from '../utils/YoutubeVideoPlayer.svelte'; 2 + import { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; 3 3 import type { ContentComponentProps } from '../types'; 4 4 5 5 let { item }: ContentComponentProps = $props();
+13 -1
src/lib/cards/YoutubeVideoCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 + import CreateYoutubeCardModal from './CreateYoutubeCardModal.svelte'; 2 3 import YoutubeCard from './YoutubeCard.svelte'; 3 4 import YoutubeCardSettings from './YoutubeCardSettings.svelte'; 4 5 ··· 6 7 type: 'youtubeVideo', 7 8 contentComponent: YoutubeCard, 8 9 settingsComponent: YoutubeCardSettings, 10 + creationModalComponent: CreateYoutubeCardModal, 9 11 createNew: (card) => { 10 12 card.cardType = 'youtubeVideo'; 11 13 card.cardData = {}; ··· 51 53 52 54 return item; 53 55 }, 54 - name: 'Youtube Video' 56 + name: 'Youtube Video', 57 + 58 + keywords: ['video', 'yt', 'stream', 'watch'], 59 + groups: ['Media'], 60 + 61 + icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-3" viewBox="0 0 256 180" 62 + ><path 63 + fill="currentColor" 64 + d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134" 65 + /><path fill="currentColor" class="invert" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg 66 + >` 55 67 } as CardDefinition & { type: 'youtubeVideo' }; 56 68 57 69 // Thanks to eleventy-plugin-youtube-embed
+5 -1
src/lib/cards/helper.ts
··· 5 5 return getComputedStyle(document.body).getPropertyValue(variable).trim(); 6 6 } 7 7 8 + export function getHexCSSVar(variable: string) { 9 + return convertCSSToHex(getCSSVar(variable)); 10 + } 11 + 8 12 /** 9 13 * Converts a CSS color string to a hue value in the 0-1 range 10 14 */ ··· 15 19 } 16 20 17 21 export function getHexOfCardColor(item: Item) { 18 - let color = 22 + const color = 19 23 !item.color || item.color === 'transparent' || item.color === 'base' ? 'accent' : item.color; 20 24 21 25 return convertCSSToHex(getCSSVar(`--color-${color}-500`));
+36 -4
src/lib/cards/index.ts
··· 3 3 import { BigSocialCardDefinition } from './BigSocialCard'; 4 4 import { BlueskyMediaCardDefinition } from './BlueskyMediaCard'; 5 5 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 6 + import { BlueskyFeedCardDefinition } from './BlueskyFeedCard'; 7 + import { LatestBlueskyPostCardDefinition } from './LatestBlueskyPostCard'; 6 8 import { DinoGameCardDefinition } from './GameCards/DinoGameCard'; 7 9 import { EmbedCardDefinition } from './EmbedCard'; 8 10 import { TetrisCardDefinition } from './GameCards/TetrisCard'; ··· 14 16 import { UpdatedBlentosCardDefitition } from './SpecialCards/UpdatedBlentos'; 15 17 import { TextCardDefinition } from './TextCard'; 16 18 import type { CardDefinition } from './types'; 17 - import { VideoCardDefinition } from './VideoCard'; 18 19 import { YoutubeCardDefinition } from './YoutubeVideoCard'; 19 20 import { BlueskyProfileCardDefinition } from './BlueskyProfileCard'; 20 21 import { GithubProfileCardDefitition } from './GitHubProfileCard'; ··· 25 26 import { PhotoGalleryCardDefinition } from './PhotoGalleryCard'; 26 27 import { StandardSiteDocumentListCardDefinition } from './StandardSiteDocumentListCard'; 27 28 import { StatusphereCardDefinition } from './StatusphereCard'; 29 + import { EventCardDefinition } from './EventCard'; 30 + import { VCardCardDefinition } from './VCardCard'; 31 + import { DrawCardDefinition } from './DrawCard'; 32 + import { TimerCardDefinition } from './TimerCard'; 33 + import { ClockCardDefinition } from './ClockCard'; 34 + import { CountdownCardDefinition } from './CountdownCard'; 35 + import { SpotifyCardDefinition } from './SpotifyCard'; 36 + import { AppleMusicCardDefinition } from './AppleMusicCard'; 37 + import { ButtonCardDefinition } from './ButtonCard'; 38 + import { GuestbookCardDefinition } from './GuestbookCard'; 39 + import { FriendsCardDefinition } from './FriendsCard'; 40 + import { GitHubContributorsCardDefinition } from './GitHubContributorsCard'; 41 + import { ProductHuntCardDefinition } from './ProductHuntCard'; 42 + import { KickstarterCardDefinition } from './KickstarterCard'; 43 + // import { Model3DCardDefinition } from './Model3DCard'; 28 44 29 45 export const AllCardDefinitions = [ 46 + GuestbookCardDefinition, 47 + ButtonCardDefinition, 30 48 ImageCardDefinition, 31 - VideoCardDefinition, 32 49 TextCardDefinition, 33 50 LinkCardDefinition, 34 51 BigSocialCardDefinition, 35 52 UpdatedBlentosCardDefitition, 36 53 YoutubeCardDefinition, 37 54 BlueskyPostCardDefinition, 55 + LatestBlueskyPostCardDefinition, 56 + BlueskyFeedCardDefinition, 38 57 LivestreamCardDefitition, 39 58 LivestreamEmbedCardDefitition, 40 - EmbedCardDefinition, 59 + // EmbedCardDefinition, 41 60 MapCardDefinition, 42 61 ATProtoCollectionsCardDefinition, 43 62 SectionCardDefinition, ··· 52 71 TealFMPlaysCardDefinition, 53 72 PhotoGalleryCardDefinition, 54 73 StandardSiteDocumentListCardDefinition, 55 - StatusphereCardDefinition 74 + StatusphereCardDefinition, 75 + EventCardDefinition, 76 + VCardCardDefinition, 77 + DrawCardDefinition, 78 + TimerCardDefinition, 79 + ClockCardDefinition, 80 + CountdownCardDefinition, 81 + SpotifyCardDefinition, 82 + AppleMusicCardDefinition, 83 + // Model3DCardDefinition 84 + FriendsCardDefinition, 85 + GitHubContributorsCardDefinition, 86 + ProductHuntCardDefinition, 87 + KickstarterCardDefinition 56 88 ] as const; 57 89 58 90 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+11 -8
src/lib/cards/types.ts
··· 13 13 onclose: () => void; 14 14 }; 15 15 16 - export type SidebarComponentProps = { 17 - onclick: () => void; 18 - }; 19 - 20 16 export type ContentComponentProps = { 21 17 item: Item; 18 + isEditing?: boolean; 22 19 }; 23 20 24 21 export type CardDefinition = { ··· 31 28 creationModalComponent?: Component<CreationModalComponentProps>; 32 29 33 30 upload?: (item: Item) => Promise<Item>; // optionally upload some other data needed for this card 34 - 35 - // one of those two has to be set for a card to appear in the sidebar 36 - sidebarComponent?: Component<SidebarComponentProps>; 37 - sidebarButtonText?: string; 38 31 39 32 // if this component exists, a settings button with a popover will be shown containing this component 40 33 settingsComponent?: Component<SettingsComponentProps>; ··· 69 62 change?: (item: Item) => Item; 70 63 71 64 name?: string; 65 + 66 + canHaveLabel?: boolean; 67 + 68 + migrate?: (item: Item) => void; 69 + 70 + groups?: string[]; 71 + 72 + keywords?: string[]; 73 + 74 + icon?: string; 72 75 };
-130
src/lib/cards/utils/MarkdownTextEditor.svelte
··· 1 - <script lang="ts"> 2 - import { onDestroy, onMount } from 'svelte'; 3 - import { Editor, type Content, type Extensions } from '@tiptap/core'; 4 - import StarterKit from '@tiptap/starter-kit'; 5 - import Image from '@tiptap/extension-image'; 6 - import Placeholder from '@tiptap/extension-placeholder'; 7 - import Link from '@tiptap/extension-link'; 8 - import { marked } from 'marked'; 9 - import { generateJSON } from '@tiptap/core'; 10 - import TurndownService from 'turndown'; 11 - import { RichTextLink } from './extensions/RichTextLink'; 12 - import type { Item } from '$lib/types'; 13 - 14 - let element: HTMLElement | undefined = $state(); 15 - 16 - let { 17 - editor = $bindable(), 18 - item = $bindable(), 19 - placeholder = '', 20 - defaultContent = '' 21 - }: { 22 - editor: Editor | null; 23 - item: Item; 24 - placeholder?: string; 25 - defaultContent?: string; 26 - } = $props(); 27 - 28 - const update = async () => { 29 - if (!editor) return {}; 30 - 31 - const html = editor.getHTML(); 32 - 33 - var turndownService = new TurndownService({ 34 - headingStyle: 'atx', 35 - bulletListMarker: '-' 36 - }); 37 - const markdown = turndownService.turndown(html); 38 - 39 - item.cardData.text = markdown; 40 - }; 41 - 42 - onMount(async () => { 43 - if (!element || editor) return; 44 - 45 - let json: Content = ''; 46 - 47 - try { 48 - let html = await marked.parse(item.cardData.text ?? (defaultContent as string)); 49 - 50 - // parse to json 51 - json = generateJSON(html, [ 52 - StarterKit.configure({ 53 - heading: false, 54 - bulletList: false, 55 - codeBlock: false 56 - }), 57 - Image.configure(), 58 - RichTextLink.configure({ 59 - openOnClick: false 60 - }) 61 - ]); 62 - } catch (error) { 63 - console.error(error); 64 - } 65 - 66 - let extensions: Extensions = [ 67 - StarterKit.configure({ 68 - heading: false, 69 - bulletList: false, 70 - codeBlock: false, 71 - dropcursor: false 72 - }), 73 - Image.configure(), 74 - Link.configure({ 75 - openOnClick: false 76 - }) 77 - ]; 78 - 79 - if (placeholder) { 80 - extensions.push( 81 - Placeholder.configure({ 82 - placeholder: placeholder 83 - }) 84 - ); 85 - } 86 - 87 - editor = new Editor({ 88 - element: element, 89 - extensions: extensions, 90 - onTransaction: () => { 91 - editor = editor; 92 - }, 93 - onUpdate: () => { 94 - update(); 95 - }, 96 - onDrop: () => { 97 - return false; 98 - }, 99 - content: json, 100 - 101 - editorProps: { 102 - attributes: { 103 - class: 'outline-none w-full' 104 - }, 105 - handleDOMEvents: { drop: () => false } 106 - } 107 - }); 108 - }); 109 - 110 - onDestroy(() => { 111 - if (editor) { 112 - editor.destroy(); 113 - } 114 - }); 115 - </script> 116 - 117 - <div class="w-full cursor-text" bind:this={element}></div> 118 - 119 - <style> 120 - :global(.tiptap p.is-editor-empty:first-child::before) { 121 - color: var(--color-base-800); 122 - content: attr(data-placeholder); 123 - float: left; 124 - height: 0; 125 - pointer-events: none; 126 - } 127 - :global(.dark .tiptap p.is-editor-empty:first-child::before) { 128 - color: var(--color-base-200); 129 - } 130 - </style>
-87
src/lib/cards/utils/PlainTextEditor.svelte
··· 1 - <script lang="ts"> 2 - import { onDestroy, onMount } from 'svelte'; 3 - import { Editor, type Extensions } from '@tiptap/core'; 4 - import Placeholder from '@tiptap/extension-placeholder'; 5 - import Paragraph from '@tiptap/extension-paragraph'; 6 - import Document from '@tiptap/extension-document'; 7 - import Text from '@tiptap/extension-text'; 8 - import type { Item } from '$lib/types'; 9 - 10 - let element: HTMLElement | undefined = $state(); 11 - let editor: Editor | null = $state(null); 12 - 13 - let { 14 - item = $bindable(), 15 - key, 16 - class: className, 17 - placeholder = '', 18 - defaultContent = '' 19 - }: { 20 - item: Item; 21 - key: string; 22 - class?: string; 23 - placeholder?: string; 24 - defaultContent?: string; 25 - } = $props(); 26 - 27 - const update = async () => { 28 - if (!editor) return; 29 - 30 - item.cardData[key] = editor.getText(); 31 - }; 32 - 33 - onMount(async () => { 34 - if (!element || editor) return; 35 - 36 - let extensions: Extensions = [Document.configure(), Paragraph.configure(), Text.configure()]; 37 - 38 - if (placeholder) { 39 - extensions.push( 40 - Placeholder.configure({ 41 - placeholder: placeholder 42 - }) 43 - ); 44 - } 45 - 46 - editor = new Editor({ 47 - element: element, 48 - extensions: extensions, 49 - onTransaction: () => { 50 - editor = editor; 51 - }, 52 - onUpdate: () => { 53 - update(); 54 - }, 55 - 56 - content: item.cardData[key] ?? defaultContent, 57 - 58 - editorProps: { 59 - attributes: { 60 - class: 'outline-none pointer-events-auto' 61 - } 62 - } 63 - }); 64 - }); 65 - 66 - onDestroy(() => { 67 - if (editor) { 68 - editor.destroy(); 69 - } 70 - }); 71 - </script> 72 - 73 - <span class={className} bind:this={element}></span> 74 - 75 - <style> 76 - :global(.tiptap p.is-editor-empty:first-child::before) { 77 - color: var(--color-base-800); 78 - content: attr(data-placeholder); 79 - opacity: 50%; 80 - float: left; 81 - height: 0; 82 - pointer-events: none; 83 - } 84 - :global(.dark .tiptap p.is-editor-empty:first-child::before) { 85 - color: var(--color-base-200); 86 - } 87 - </style>
-175
src/lib/cards/utils/YoutubeVideoPlayer.svelte
··· 1 - <script lang="ts" module> 2 - export const videoPlayer: { 3 - id: string | undefined; 4 - 5 - show: (id: string) => void; 6 - hide: () => void; 7 - } = $state({ 8 - id: undefined, 9 - 10 - show: (id: string) => { 11 - videoPlayer.id = id; 12 - }, 13 - 14 - hide: () => { 15 - videoPlayer.id = undefined; 16 - } 17 - }); 18 - </script> 19 - 20 - <script lang="ts"> 21 - import { cn } from '@foxui/core'; 22 - import { onDestroy, onMount } from 'svelte'; 23 - 24 - // Minimal Plyr interface for what we use 25 - interface PlyrInstance { 26 - source: { 27 - type: string; 28 - sources: { src: string; type: string }[]; 29 - }; 30 - on: (event: string, callback: () => void) => void; 31 - play: () => void; 32 - destroy: () => void; 33 - } 34 - 35 - interface PlyrConstructorType { 36 - new (selector: string, options: Record<string, unknown>): PlyrInstance; 37 - } 38 - 39 - const { class: className }: { class?: string } = $props(); 40 - 41 - let PlyrConstructor: PlyrConstructorType | undefined = $state(); 42 - 43 - let player: PlyrInstance | undefined = $state(); 44 - 45 - onMount(async () => { 46 - if (!PlyrConstructor) { 47 - const plyrModule = (await import('plyr')) as unknown as { default: PlyrConstructorType }; 48 - PlyrConstructor = plyrModule.default; 49 - } 50 - 51 - player = new PlyrConstructor('.js-player', { 52 - settings: ['captions', 'quality', 'loop', 'speed'], 53 - controls: [ 54 - 'play-large', 55 - 'play', 56 - 'progress', 57 - 'current-time', 58 - 'volume', 59 - 'settings', 60 - 'download', 61 - 'fullscreen' 62 - ] 63 - }); 64 - 65 - // set the video player to the id 66 - if (videoPlayer.id) { 67 - player.source = { 68 - type: 'video', 69 - sources: [ 70 - { 71 - src: videoPlayer.id, 72 - type: 'video/youtube' 73 - } 74 - ] 75 - }; 76 - } 77 - 78 - // when loaded play the video and go fullscreen 79 - player.on('ready', () => { 80 - player?.play(); 81 - //player.fullscreen.enter(); 82 - }); 83 - }); 84 - 85 - onDestroy(() => { 86 - player?.destroy(); 87 - }); 88 - 89 - let glow = 50; 90 - </script> 91 - 92 - <svelte:head> 93 - {#if videoPlayer.id} 94 - <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 95 - {/if} 96 - </svelte:head> 97 - 98 - <svelte:window 99 - onkeydown={(e) => { 100 - if (e.key === 'Escape') { 101 - videoPlayer.hide(); 102 - } 103 - }} 104 - /> 105 - 106 - {#key videoPlayer.id} 107 - {#if videoPlayer.id} 108 - <div class="fixed inset-0 z-100 flex h-screen w-screen items-center justify-center"> 109 - <button 110 - onclick={() => videoPlayer.hide()} 111 - class="absolute inset-0 bg-black/70 backdrop-blur-sm" 112 - > 113 - <span class="sr-only">Close</span> 114 - </button> 115 - 116 - <div 117 - class={cn( 118 - 'relative mx-4 aspect-video max-h-screen w-full overflow-hidden rounded-xl border border-black bg-white object-cover sm:mx-20 dark:border-white/10 dark:bg-white/5', 119 - className 120 - )} 121 - style="filter: url(#blur); width: 100%;" 122 - > 123 - <div class=""> 124 - <div 125 - id="player" 126 - class="h-full w-full overflow-hidden rounded-xl object-cover font-semibold text-black dark:text-white" 127 - > 128 - <div 129 - class="js-player plyr__video-embed" 130 - id="player" 131 - data-plyr-provider="youtube" 132 - data-plyr-embed-id={videoPlayer.id} 133 - ></div> 134 - </div> 135 - </div> 136 - </div> 137 - 138 - <button 139 - onclick={() => { 140 - videoPlayer.hide(); 141 - }} 142 - class="absolute top-2 right-2 z-20 rounded-full border border-white/10 bg-white/5 p-2 backdrop-blur-sm" 143 - > 144 - <svg 145 - xmlns="http://www.w3.org/2000/svg" 146 - viewBox="0 0 24 24" 147 - fill="currentColor" 148 - class="size-6" 149 - > 150 - <path 151 - fill-rule="evenodd" 152 - d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" 153 - clip-rule="evenodd" 154 - /> 155 - </svg> 156 - 157 - <span class="sr-only">Close</span> 158 - </button> 159 - </div> 160 - {/if} 161 - {/key} 162 - 163 - <svg width="0" height="0"> 164 - <filter id="blur" y="-50%" x="-50%" width="300%" height="300%"> 165 - <feGaussianBlur in="SourceGraphic" stdDeviation={glow} result="blurred" /> 166 - <feColorMatrix type="saturate" in="blurred" values="3" /> 167 - <feComposite in="SourceGraphic" operator="over" /> 168 - </filter> 169 - </svg> 170 - 171 - <style> 172 - * { 173 - --plyr-color-main: var(--color-accent-500); 174 - } 175 - </style>
-125
src/lib/cards/utils/extensions/RichTextLink.ts
··· 1 - import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core'; 2 - import { Link } from '@tiptap/extension-link'; 3 - 4 - import type { LinkOptions } from '@tiptap/extension-link'; 5 - 6 - /** 7 - * The input regex for Markdown links with title support, and multiple quotation marks (required 8 - * in case the `Typography` extension is being included). 9 - */ 10 - const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["โ€œ](.+)["โ€])?\)$/i; 11 - 12 - /** 13 - * The paste regex for Markdown links with title support, and multiple quotation marks (required 14 - * in case the `Typography` extension is being included). 15 - */ 16 - const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["โ€œ](.+)["โ€])?\)/gi; 17 - 18 - /** 19 - * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in 20 - * parentheses (e.g., `(https://doist.dev)`). 21 - * 22 - * @see https://github.com/ueberdosis/tiptap/discussions/1865 23 - */ 24 - function linkInputRule(config: Parameters<typeof markInputRule>[0]) { 25 - const defaultMarkInputRule = markInputRule(config); 26 - 27 - return new InputRule({ 28 - find: config.find, 29 - handler(props) { 30 - const { tr } = props.state; 31 - 32 - defaultMarkInputRule.handler(props); 33 - tr.setMeta('preventAutolink', true); 34 - } 35 - }); 36 - } 37 - 38 - /** 39 - * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in 40 - * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple 41 - * implementations found in a Tiptap discussion at GitHub. 42 - * 43 - * @see https://github.com/ueberdosis/tiptap/discussions/1865 44 - */ 45 - function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) { 46 - const defaultMarkPasteRule = markPasteRule(config); 47 - 48 - return new PasteRule({ 49 - find: config.find, 50 - handler(props) { 51 - const { tr } = props.state; 52 - 53 - defaultMarkPasteRule.handler(props); 54 - tr.setMeta('preventAutolink', true); 55 - } 56 - }); 57 - } 58 - 59 - /** 60 - * The options available to customize the `RichTextLink` extension. 61 - */ 62 - type RichTextLinkOptions = LinkOptions; 63 - 64 - /** 65 - * Custom extension that extends the built-in `Link` extension to add additional input/paste rules 66 - * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also 67 - * adds support for the `title` attribute. 68 - */ 69 - const RichTextLink = Link.extend<RichTextLinkOptions>({ 70 - inclusive: false, 71 - addOptions(): LinkOptions { 72 - return { 73 - ...this.parent?.(), 74 - openOnClick: 'whenNotEditable' 75 - } as LinkOptions; 76 - }, 77 - addAttributes() { 78 - return { 79 - ...this.parent?.(), 80 - title: { 81 - default: null 82 - } 83 - }; 84 - }, 85 - addInputRules() { 86 - return [ 87 - linkInputRule({ 88 - find: inputRegex, 89 - type: this.type, 90 - 91 - // We need to use `pop()` to remove the last capture groups from the match to 92 - // satisfy Tiptap's `markPasteRule` expectation of having the content as the last 93 - // capture group in the match (this makes the attribute order important) 94 - getAttributes(match) { 95 - return { 96 - title: match.pop()?.trim(), 97 - href: match.pop()?.trim() 98 - }; 99 - } 100 - }) 101 - ]; 102 - }, 103 - addPasteRules() { 104 - return [ 105 - linkPasteRule({ 106 - find: pasteRegex, 107 - type: this.type, 108 - 109 - // We need to use `pop()` to remove the last capture groups from the match to 110 - // satisfy Tiptap's `markInputRule` expectation of having the content as the last 111 - // capture group in the match (this makes the attribute order important) 112 - getAttributes(match) { 113 - return { 114 - title: match.pop()?.trim(), 115 - href: match.pop()?.trim() 116 - }; 117 - } 118 - }) 119 - ]; 120 - } 121 - }); 122 - 123 - export { RichTextLink }; 124 - 125 - export type { RichTextLinkOptions };
+138
src/lib/components/MarkdownTextEditor.svelte
··· 1 + <script lang="ts"> 2 + import { onDestroy, onMount } from 'svelte'; 3 + import { Editor, type Content, type Extensions } from '@tiptap/core'; 4 + import StarterKit from '@tiptap/starter-kit'; 5 + import Image from '@tiptap/extension-image'; 6 + import Placeholder from '@tiptap/extension-placeholder'; 7 + import Link from '@tiptap/extension-link'; 8 + import { marked } from 'marked'; 9 + import { generateJSON } from '@tiptap/core'; 10 + import TurndownService from 'turndown'; 11 + import { RichTextLink } from './extensions/RichTextLink'; 12 + import type { Item } from '$lib/types'; 13 + 14 + let element: HTMLElement | undefined = $state(); 15 + 16 + let { 17 + editor = $bindable(), 18 + contentDict = $bindable(), 19 + key = 'text', 20 + placeholder = '', 21 + defaultContent = '', 22 + class: className, 23 + onupdate 24 + }: { 25 + editor?: Editor | null; 26 + contentDict: Record<string, any>; 27 + key: string; 28 + placeholder?: string; 29 + defaultContent?: string; 30 + class?: string; 31 + onupdate?: (content: string) => void; 32 + } = $props(); 33 + 34 + const update = async () => { 35 + if (!editor) return {}; 36 + 37 + const html = editor.getHTML(); 38 + 39 + var turndownService = new TurndownService({ 40 + headingStyle: 'atx', 41 + bulletListMarker: '-' 42 + }); 43 + const markdown = turndownService.turndown(html); 44 + 45 + contentDict[key] = markdown; 46 + 47 + onupdate?.(markdown); 48 + }; 49 + 50 + onMount(async () => { 51 + if (!element || editor) return; 52 + 53 + let json: Content = ''; 54 + 55 + try { 56 + let html = await marked.parse(contentDict[key] ?? (defaultContent as string)); 57 + 58 + // parse to json 59 + json = generateJSON(html, [ 60 + StarterKit.configure({ 61 + heading: false, 62 + bulletList: false, 63 + codeBlock: false 64 + }), 65 + Image.configure(), 66 + RichTextLink.configure({ 67 + openOnClick: false 68 + }) 69 + ]); 70 + } catch (error) { 71 + console.error(error); 72 + } 73 + 74 + let extensions: Extensions = [ 75 + StarterKit.configure({ 76 + heading: false, 77 + bulletList: false, 78 + codeBlock: false, 79 + dropcursor: false 80 + }), 81 + Image.configure(), 82 + Link.configure({ 83 + openOnClick: false 84 + }) 85 + ]; 86 + 87 + if (placeholder) { 88 + extensions.push( 89 + Placeholder.configure({ 90 + placeholder: placeholder 91 + }) 92 + ); 93 + } 94 + 95 + editor = new Editor({ 96 + element: element, 97 + extensions: extensions, 98 + onTransaction: () => { 99 + editor = editor; 100 + }, 101 + onUpdate: () => { 102 + update(); 103 + }, 104 + onDrop: () => { 105 + return false; 106 + }, 107 + content: json, 108 + 109 + editorProps: { 110 + attributes: { 111 + class: 'outline-none w-full' 112 + }, 113 + handleDOMEvents: { drop: () => false } 114 + } 115 + }); 116 + }); 117 + 118 + onDestroy(() => { 119 + if (editor) { 120 + editor.destroy(); 121 + } 122 + }); 123 + </script> 124 + 125 + <div class={['w-full cursor-text', className]} bind:this={element}></div> 126 + 127 + <style> 128 + :global(.tiptap p.is-editor-empty:first-child::before) { 129 + color: var(--color-base-800); 130 + content: attr(data-placeholder); 131 + float: left; 132 + height: 0; 133 + pointer-events: none; 134 + } 135 + :global(.dark .tiptap p.is-editor-empty:first-child::before) { 136 + color: var(--color-base-200); 137 + } 138 + </style>
+97
src/lib/components/PlainTextEditor.svelte
··· 1 + <script lang="ts"> 2 + import { onDestroy, onMount } from 'svelte'; 3 + import { Editor, type Extensions } from '@tiptap/core'; 4 + import Placeholder from '@tiptap/extension-placeholder'; 5 + import Paragraph from '@tiptap/extension-paragraph'; 6 + import Document from '@tiptap/extension-document'; 7 + import Text from '@tiptap/extension-text'; 8 + import type { Item } from '$lib/types'; 9 + 10 + let element: HTMLElement | undefined = $state(); 11 + let editor: Editor | null = $state(null); 12 + 13 + let { 14 + contentDict = $bindable(), 15 + key, 16 + class: className, 17 + placeholder = '', 18 + defaultContent = '', 19 + onupdate 20 + }: { 21 + contentDict: Record<string, any>; 22 + key: string; 23 + class?: string; 24 + placeholder?: string; 25 + defaultContent?: string; 26 + onupdate?: (content: string) => void; 27 + } = $props(); 28 + 29 + const update = async () => { 30 + if (!editor) return; 31 + 32 + const text = editor.getText(); 33 + 34 + contentDict[key] = text; 35 + 36 + onupdate?.(text); 37 + }; 38 + 39 + onMount(async () => { 40 + if (!element || editor) return; 41 + 42 + let extensions: Extensions = [Document.configure(), Paragraph.configure(), Text.configure()]; 43 + 44 + if (placeholder) { 45 + extensions.push( 46 + Placeholder.configure({ 47 + placeholder: placeholder 48 + }) 49 + ); 50 + } 51 + 52 + editor = new Editor({ 53 + element: element, 54 + extensions: extensions, 55 + onTransaction: () => { 56 + editor = editor; 57 + }, 58 + onUpdate: () => { 59 + update(); 60 + }, 61 + 62 + content: contentDict[key] ?? defaultContent, 63 + 64 + editorProps: { 65 + attributes: { 66 + class: 'outline-none pointer-events-auto' 67 + }, 68 + handleKeyDown: (_view, event) => { 69 + // Prevent newlines by blocking Enter key 70 + if (event.key === 'Enter') { 71 + return true; 72 + } 73 + return false; 74 + } 75 + } 76 + }); 77 + }); 78 + 79 + onDestroy(() => { 80 + if (editor) { 81 + editor.destroy(); 82 + } 83 + }); 84 + </script> 85 + 86 + <span class={className} bind:this={element}></span> 87 + 88 + <style> 89 + :global(.tiptap p.is-editor-empty:first-child::before) { 90 + color: var(--color-base-500); 91 + content: attr(data-placeholder); 92 + opacity: 100%; 93 + float: left; 94 + height: 0; 95 + pointer-events: none; 96 + } 97 + </style>
+175
src/lib/components/YoutubeVideoPlayer.svelte
··· 1 + <script lang="ts" module> 2 + export const videoPlayer: { 3 + id: string | undefined; 4 + 5 + show: (id: string) => void; 6 + hide: () => void; 7 + } = $state({ 8 + id: undefined, 9 + 10 + show: (id: string) => { 11 + videoPlayer.id = id; 12 + }, 13 + 14 + hide: () => { 15 + videoPlayer.id = undefined; 16 + } 17 + }); 18 + </script> 19 + 20 + <script lang="ts"> 21 + import { cn } from '@foxui/core'; 22 + import { onDestroy, onMount } from 'svelte'; 23 + 24 + // Minimal Plyr interface for what we use 25 + interface PlyrInstance { 26 + source: { 27 + type: string; 28 + sources: { src: string; type: string }[]; 29 + }; 30 + on: (event: string, callback: () => void) => void; 31 + play: () => void; 32 + destroy: () => void; 33 + } 34 + 35 + interface PlyrConstructorType { 36 + new (selector: string, options: Record<string, unknown>): PlyrInstance; 37 + } 38 + 39 + const { class: className }: { class?: string } = $props(); 40 + 41 + let PlyrConstructor: PlyrConstructorType | undefined = $state(); 42 + 43 + let player: PlyrInstance | undefined = $state(); 44 + 45 + onMount(async () => { 46 + if (!PlyrConstructor) { 47 + const plyrModule = (await import('plyr')) as unknown as { default: PlyrConstructorType }; 48 + PlyrConstructor = plyrModule.default; 49 + } 50 + 51 + player = new PlyrConstructor('.js-player', { 52 + settings: ['captions', 'quality', 'loop', 'speed'], 53 + controls: [ 54 + 'play-large', 55 + 'play', 56 + 'progress', 57 + 'current-time', 58 + 'volume', 59 + 'settings', 60 + 'download', 61 + 'fullscreen' 62 + ] 63 + }); 64 + 65 + // set the video player to the id 66 + if (videoPlayer.id) { 67 + player.source = { 68 + type: 'video', 69 + sources: [ 70 + { 71 + src: videoPlayer.id, 72 + type: 'video/youtube' 73 + } 74 + ] 75 + }; 76 + } 77 + 78 + // when loaded play the video and go fullscreen 79 + player.on('ready', () => { 80 + player?.play(); 81 + //player.fullscreen.enter(); 82 + }); 83 + }); 84 + 85 + onDestroy(() => { 86 + player?.destroy(); 87 + }); 88 + 89 + let glow = 50; 90 + </script> 91 + 92 + <svelte:head> 93 + {#if videoPlayer.id} 94 + <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 95 + {/if} 96 + </svelte:head> 97 + 98 + <svelte:window 99 + onkeydown={(e) => { 100 + if (e.key === 'Escape') { 101 + videoPlayer.hide(); 102 + } 103 + }} 104 + /> 105 + 106 + {#key videoPlayer.id} 107 + {#if videoPlayer.id} 108 + <div class="fixed inset-0 z-100 flex h-screen w-screen items-center justify-center"> 109 + <button 110 + onclick={() => videoPlayer.hide()} 111 + class="absolute inset-0 bg-black/70 backdrop-blur-sm" 112 + > 113 + <span class="sr-only">Close</span> 114 + </button> 115 + 116 + <div 117 + class={cn( 118 + 'relative mx-4 aspect-video max-h-screen w-full overflow-hidden rounded-xl border border-black bg-white object-cover sm:mx-20 dark:border-white/10 dark:bg-white/5', 119 + className 120 + )} 121 + style="filter: url(#blur); width: 100%;" 122 + > 123 + <div class=""> 124 + <div 125 + id="player" 126 + class="h-full w-full overflow-hidden rounded-xl object-cover font-semibold text-black dark:text-white" 127 + > 128 + <div 129 + class="js-player plyr__video-embed" 130 + id="player" 131 + data-plyr-provider="youtube" 132 + data-plyr-embed-id={videoPlayer.id} 133 + ></div> 134 + </div> 135 + </div> 136 + </div> 137 + 138 + <button 139 + onclick={() => { 140 + videoPlayer.hide(); 141 + }} 142 + class="absolute top-2 right-2 z-20 rounded-full border border-white/10 bg-white/5 p-2 backdrop-blur-sm" 143 + > 144 + <svg 145 + xmlns="http://www.w3.org/2000/svg" 146 + viewBox="0 0 24 24" 147 + fill="currentColor" 148 + class="size-6" 149 + > 150 + <path 151 + fill-rule="evenodd" 152 + d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" 153 + clip-rule="evenodd" 154 + /> 155 + </svg> 156 + 157 + <span class="sr-only">Close</span> 158 + </button> 159 + </div> 160 + {/if} 161 + {/key} 162 + 163 + <svg width="0" height="0"> 164 + <filter id="blur" y="-50%" x="-50%" width="300%" height="300%"> 165 + <feGaussianBlur in="SourceGraphic" stdDeviation={glow} result="blurred" /> 166 + <feColorMatrix type="saturate" in="blurred" values="3" /> 167 + <feComposite in="SourceGraphic" operator="over" /> 168 + </filter> 169 + </svg> 170 + 171 + <style> 172 + * { 173 + --plyr-color-main: var(--color-accent-500); 174 + } 175 + </style>
+11 -1
src/lib/components/bluesky-post/BlueskyPost.svelte
··· 8 8 feedViewPost, 9 9 children, 10 10 showLogo = false, 11 + showAvatar = false, 12 + compact = false, 11 13 ...restProps 12 - }: { feedViewPost?: PostView; children?: Snippet; showLogo?: boolean } = $props(); 14 + }: { 15 + feedViewPost?: PostView; 16 + children?: Snippet; 17 + showLogo?: boolean; 18 + showAvatar?: boolean; 19 + compact?: boolean; 20 + } = $props(); 13 21 14 22 const postData = $derived(feedViewPost ? blueskyPostToPostData(feedViewPost) : undefined); 15 23 </script> ··· 37 45 likeHref={postData?.href} 38 46 showBookmark={false} 39 47 logo={showLogo ? logo : undefined} 48 + {showAvatar} 49 + {compact} 40 50 {...restProps} 41 51 > 42 52 {@render children?.()}
+162 -93
src/lib/components/bluesky-post/index.ts
··· 1 - import type { PostData, PostEmbed } from '../post'; 1 + import type { PostData, PostEmbed, QuotedPostData } from '../post'; 2 2 import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 3 3 import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 4 4 5 - function blueskyEmbedTypeToEmbedType(type: string) { 6 - switch (type) { 7 - case 'app.bsky.embed.external#view': 8 - case 'app.bsky.embed.external': 9 - return 'external'; 10 - case 'app.bsky.embed.images#view': 11 - case 'app.bsky.embed.images': 12 - return 'images'; 13 - case 'app.bsky.embed.video#view': 14 - case 'app.bsky.embed.video': 15 - return 'video'; 16 - default: 17 - return 'unknown'; 18 - } 19 - } 20 - 21 - export function blueskyPostToPostData( 22 - data: PostView, 23 - baseUrl: string = 'https://bsky.app' 24 - ): PostData { 25 - const post = data; 26 - // const reason = data.reason; 27 - // const reply = data.reply?.parent; 28 - // const replyId = reply?.uri?.split('/').pop(); 29 - 30 - const id = post.uri.split('/').pop(); 31 - 32 - return { 33 - id, 34 - href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 35 - // reposted: 36 - // reason && reason.$type === 'app.bsky.feed.defs#reasonRepost' 37 - // ? { 38 - // handle: reason.by.handle, 39 - // href: `${baseUrl}/profile/${reason.by.handle}` 40 - // } 41 - // : undefined, 42 - 43 - // replyTo: 44 - // reply && replyId 45 - // ? { 46 - // handle: reply.author.handle, 47 - // href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}` 48 - // } 49 - // : undefined, 50 - author: { 51 - displayName: post.author.displayName || '', 52 - handle: post.author.handle, 53 - avatar: post.author.avatar, 54 - href: `${baseUrl}/profile/${post.author.did}` 55 - }, 56 - replyCount: post.replyCount ?? 0, 57 - repostCount: post.repostCount ?? 0, 58 - likeCount: post.likeCount ?? 0, 59 - createdAt: post.record.createdAt as string, 60 - 61 - embed: post.embed 62 - ? ({ 63 - type: blueskyEmbedTypeToEmbedType(post.embed?.$type), 64 - // Cast to any to handle union type - properties are conditionally accessed 65 - images: (post.embed as any)?.images?.map((image: any) => ({ 66 - alt: image.alt, 67 - thumb: image.thumb, 68 - aspectRatio: image.aspectRatio, 69 - fullsize: image.fullsize 70 - })), 71 - external: (post.embed as any)?.external 72 - ? { 73 - href: (post.embed as any).external.uri, 74 - title: (post.embed as any).external.title, 75 - description: (post.embed as any).external.description, 76 - thumb: (post.embed as any).external.thumb 77 - } 78 - : undefined, 79 - video: (post.embed as any)?.playlist 80 - ? { 81 - playlist: (post.embed as any).playlist, 82 - thumb: (post.embed as any).thumbnail, 83 - alt: (post.embed as any).alt, 84 - aspectRatio: (post.embed as any).aspectRatio 85 - } 86 - : undefined 87 - } as PostEmbed) 88 - : undefined, 89 - 90 - htmlContent: blueskyPostToHTML(post, baseUrl) 91 - }; 5 + function escapeHtml(str: string): string { 6 + return str 7 + .replace(/&/g, '&amp;') 8 + .replace(/</g, '&lt;') 9 + .replace(/>/g, '&gt;') 10 + .replace(/"/g, '&quot;') 11 + .replace(/'/g, '&#39;'); 92 12 } 93 13 94 14 interface MentionFeature { ··· 110 30 111 31 const renderSegment = (segment: RichtextSegment, baseUrl: string) => { 112 32 const { text, features } = segment; 33 + const escaped = escapeHtml(text); 113 34 114 35 if (!features) { 115 - return `<span>${text}</span>`; 36 + return `<span>${escaped}</span>`; 116 37 } 117 38 118 39 // segments can have multiple features, use the first one ··· 124 45 125 46 switch (feature.$type) { 126 47 case 'app.bsky.richtext.facet#mention': 127 - return createLink(`${baseUrl}/profile/${feature.did}`, segment.text); 48 + return createLink(`${baseUrl}/profile/${feature.did}`, escaped); 128 49 case 'app.bsky.richtext.facet#link': 129 - return createLink(feature.uri, segment.text); 50 + return createLink(feature.uri, escaped); 130 51 case 'app.bsky.richtext.facet#tag': 131 - return createLink(`${baseUrl}/hashtag/${feature.tag}`, segment.text); 52 + return createLink(`${baseUrl}/hashtag/${feature.tag}`, escaped); 132 53 default: 133 - return `<span>${text}</span>`; 54 + return `<span>${escaped}</span>`; 134 55 } 135 56 }; 136 57 ··· 138 59 const segments = segmentize(text, facets); 139 60 return segments.map((v) => renderSegment(v, baseUrl)).join(''); 140 61 }; 62 + 63 + function blueskyEmbedTypeToEmbedType(type: string) { 64 + switch (type) { 65 + case 'app.bsky.embed.external#view': 66 + case 'app.bsky.embed.external': 67 + return 'external'; 68 + case 'app.bsky.embed.images#view': 69 + case 'app.bsky.embed.images': 70 + return 'images'; 71 + case 'app.bsky.embed.video#view': 72 + case 'app.bsky.embed.video': 73 + return 'video'; 74 + case 'app.bsky.embed.record#view': 75 + case 'app.bsky.embed.record': 76 + return 'record'; 77 + case 'app.bsky.embed.recordWithMedia#view': 78 + case 'app.bsky.embed.recordWithMedia': 79 + return 'recordWithMedia'; 80 + default: 81 + return 'unknown'; 82 + } 83 + } 84 + 85 + function extractQuotedPost(recordView: any, baseUrl: string): QuotedPostData | null { 86 + if (!recordView?.author) return null; 87 + 88 + const id = recordView.uri?.split('/').pop(); 89 + const author = recordView.author; 90 + const value = recordView.value as any; 91 + 92 + let htmlContent = ''; 93 + if (value?.text) { 94 + htmlContent = RichText({ text: value.text, facets: value.facets }, baseUrl).replace( 95 + /\n/g, 96 + '<br>' 97 + ); 98 + } 99 + 100 + // Convert nested media embeds (skip record embeds to avoid recursion) 101 + let embed: PostEmbed | undefined; 102 + const firstEmbed = recordView.embeds?.[0] as any; 103 + if (firstEmbed) { 104 + const embedType = blueskyEmbedTypeToEmbedType(firstEmbed.$type); 105 + if (embedType !== 'record' && embedType !== 'recordWithMedia' && embedType !== 'unknown') { 106 + embed = convertEmbed(firstEmbed, baseUrl); 107 + } 108 + } 109 + 110 + return { 111 + author: { 112 + displayName: author.displayName || '', 113 + handle: author.handle, 114 + avatar: author.avatar, 115 + href: `${baseUrl}/profile/${author.did}` 116 + }, 117 + href: `${baseUrl}/profile/${author.handle}/post/${id}`, 118 + htmlContent, 119 + createdAt: value?.createdAt, 120 + embed 121 + }; 122 + } 123 + 124 + function convertEmbed(embedView: any, baseUrl: string): PostEmbed { 125 + const type = blueskyEmbedTypeToEmbedType(embedView?.$type); 126 + 127 + switch (type) { 128 + case 'images': 129 + return { 130 + type: 'images', 131 + images: embedView.images?.map((image: any) => ({ 132 + alt: image.alt, 133 + thumb: image.thumb, 134 + aspectRatio: image.aspectRatio, 135 + fullsize: image.fullsize 136 + })) 137 + }; 138 + case 'external': 139 + return embedView.external 140 + ? { 141 + type: 'external', 142 + external: { 143 + href: embedView.external.uri, 144 + title: embedView.external.title, 145 + description: embedView.external.description, 146 + thumb: embedView.external.thumb 147 + } 148 + } 149 + : { type: 'unknown' }; 150 + case 'video': 151 + return embedView.playlist 152 + ? { 153 + type: 'video', 154 + video: { 155 + playlist: embedView.playlist, 156 + thumb: embedView.thumbnail, 157 + alt: embedView.alt, 158 + aspectRatio: embedView.aspectRatio 159 + } 160 + } 161 + : { type: 'unknown' }; 162 + case 'record': { 163 + const record = extractQuotedPost(embedView.record, baseUrl); 164 + return record ? { type: 'record', record } : { type: 'unknown' }; 165 + } 166 + case 'recordWithMedia': { 167 + const record = extractQuotedPost(embedView.record?.record, baseUrl); 168 + const media = embedView.media ? convertEmbed(embedView.media, baseUrl) : undefined; 169 + if (record) { 170 + return { 171 + type: 'recordWithMedia', 172 + record, 173 + media: media ?? { type: 'unknown' } 174 + }; 175 + } 176 + return media ?? { type: 'unknown' }; 177 + } 178 + default: 179 + return { type: 'unknown' }; 180 + } 181 + } 182 + 183 + export function blueskyPostToPostData( 184 + data: PostView, 185 + baseUrl: string = 'https://bsky.app' 186 + ): PostData { 187 + const post = data; 188 + const id = post.uri.split('/').pop(); 189 + 190 + return { 191 + id, 192 + href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 193 + author: { 194 + displayName: post.author.displayName || '', 195 + handle: post.author.handle, 196 + avatar: post.author.avatar, 197 + href: `${baseUrl}/profile/${post.author.did}` 198 + }, 199 + replyCount: post.replyCount ?? 0, 200 + repostCount: post.repostCount ?? 0, 201 + likeCount: post.likeCount ?? 0, 202 + createdAt: post.record.createdAt as string, 203 + 204 + embed: post.embed ? convertEmbed(post.embed, baseUrl) : undefined, 205 + 206 + htmlContent: blueskyPostToHTML(post, baseUrl), 207 + labels: post.labels ? post.labels.map((label) => label.val) : undefined 208 + }; 209 + } 141 210 142 211 export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') { 143 212 if (!post?.record) {
+192
src/lib/components/card-command/CardCommand.svelte
··· 1 + <script lang="ts"> 2 + import { AllCardDefinitions } from '$lib/cards'; 3 + import type { CardDefinition } from '$lib/cards/types'; 4 + import { Command, Dialog } from 'bits-ui'; 5 + import { isTyping } from '$lib/helper'; 6 + 7 + const CardDefGroups = [ 8 + 'Core', 9 + ...Array.from( 10 + new Set( 11 + AllCardDefinitions.map((cardDef) => cardDef.groups) 12 + .flat() 13 + .filter((g) => g) 14 + ) 15 + ) 16 + .sort() 17 + .filter((g) => g !== 'Core') 18 + ]; 19 + 20 + let { 21 + open = $bindable(false), 22 + onselect, 23 + onlink 24 + }: { 25 + open: boolean; 26 + onselect: (cardDef: CardDefinition) => void; 27 + onlink?: (url: string, cardDef: CardDefinition) => void; 28 + } = $props(); 29 + 30 + let searchValue = $state(''); 31 + 32 + let normalizedUrl = $derived.by(() => { 33 + if (!searchValue || searchValue.length < 8) return ''; 34 + try { 35 + const val = searchValue.trim(); 36 + const urlStr = val.startsWith('http') ? val : `https://${val}`; 37 + const url = new URL(urlStr); 38 + if (!url.hostname.includes('.')) return ''; 39 + return urlStr; 40 + } catch { 41 + return ''; 42 + } 43 + }); 44 + 45 + let urlMatchingCards = $derived.by(() => { 46 + if (!normalizedUrl) return []; 47 + return AllCardDefinitions.filter((d) => d.onUrlHandler) 48 + .filter((d) => { 49 + try { 50 + const testItem = { cardData: {} }; 51 + return d.onUrlHandler!(normalizedUrl, testItem as any); 52 + } catch { 53 + return false; 54 + } 55 + }) 56 + .toSorted((a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)); 57 + }); 58 + 59 + function selectUrl(cardDef: CardDefinition) { 60 + const url = normalizedUrl; 61 + open = false; 62 + searchValue = ''; 63 + onlink?.(url, cardDef); 64 + } 65 + 66 + function commandFilter(value: string, search: string, keywords?: string[]): number { 67 + if (value.startsWith('url:')) return 1; 68 + const s = search.toLowerCase(); 69 + for (const t of [value, ...(keywords ?? [])]) { 70 + if (t.toLowerCase().includes(s)) return 1; 71 + } 72 + return 0; 73 + } 74 + 75 + function handleKeydown(e: KeyboardEvent) { 76 + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 77 + e.preventDefault(); 78 + open = !open; 79 + } 80 + if (e.key === '+' && !isTyping()) { 81 + e.preventDefault(); 82 + open = true; 83 + } 84 + } 85 + </script> 86 + 87 + <svelte:document onkeydown={handleKeydown} /> 88 + 89 + <Dialog.Root bind:open> 90 + <Dialog.Portal> 91 + <Dialog.Overlay 92 + class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80" 93 + /> 94 + <Dialog.Content 95 + class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-36 left-[50%] z-50 w-full max-w-[94%] translate-x-[-50%] outline-hidden sm:max-w-lg md:w-full" 96 + > 97 + <Dialog.Title class="sr-only">Command Menu</Dialog.Title> 98 + <Dialog.Description class="sr-only"> 99 + This is the command menu. Use the arrow keys to navigate and press โŒ˜K to open the search 100 + bar. 101 + </Dialog.Description> 102 + <Command.Root 103 + filter={commandFilter} 104 + class="border-base-200 dark:border-base-800 mx-auto flex h-full w-full max-w-[90vw] flex-col overflow-hidden rounded-2xl border bg-white dark:bg-black" 105 + > 106 + <Command.Input 107 + class="focus-override placeholder:text-base-900/50 dark:placeholder:text-base-50/50 border-base-200 dark:border-base-800 bg-base-100 mx-1 mt-1 inline-flex truncate rounded-2xl rounded-tl-2xl px-4 text-sm transition-colors focus:ring-0 focus:outline-hidden dark:bg-black" 108 + placeholder="Search for a card or paste a link..." 109 + oninput={(e) => { 110 + searchValue = e.currentTarget.value; 111 + }} 112 + /> 113 + 114 + <Command.List 115 + class="focus:outline-accent-500/50 max-h-[50vh] overflow-x-hidden overflow-y-auto rounded-br-2xl rounded-bl-2xl bg-white px-2 pb-2 focus:border-0 dark:bg-black" 116 + > 117 + <Command.Viewport> 118 + <Command.Empty 119 + class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm" 120 + > 121 + No results found. 122 + </Command.Empty> 123 + 124 + {#if urlMatchingCards.length > 0} 125 + <Command.Group> 126 + <Command.GroupHeading 127 + class="text-base-600 dark:text-base-400 px-3 pt-3 pb-2 text-xs" 128 + > 129 + Add from link 130 + </Command.GroupHeading> 131 + <Command.GroupItems> 132 + {#each urlMatchingCards as cardDef (cardDef.type)} 133 + <Command.Item 134 + value="url:{cardDef.type}" 135 + onSelect={() => { 136 + selectUrl(cardDef); 137 + }} 138 + class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none" 139 + > 140 + {#if cardDef.icon} 141 + <div class="text-base-700 dark:text-base-300"> 142 + {@html cardDef.icon} 143 + </div> 144 + {/if} 145 + {cardDef.name} 146 + </Command.Item> 147 + {/each} 148 + </Command.GroupItems> 149 + </Command.Group> 150 + <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" /> 151 + {/if} 152 + 153 + {#each CardDefGroups as group, index (group)} 154 + {#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))} 155 + <Command.Group> 156 + <Command.GroupHeading 157 + class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs" 158 + > 159 + {group} 160 + </Command.GroupHeading> 161 + <Command.GroupItems> 162 + {#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef (cardDef.type)} 163 + <Command.Item 164 + onSelect={() => { 165 + open = false; 166 + searchValue = ''; 167 + onselect(cardDef); 168 + }} 169 + class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none" 170 + keywords={[group, cardDef.type, ...(cardDef.keywords || [])]} 171 + > 172 + {#if cardDef.icon} 173 + <div class="text-base-700 dark:text-base-300"> 174 + {@html cardDef.icon} 175 + </div> 176 + {/if} 177 + {cardDef.name} 178 + </Command.Item> 179 + {/each} 180 + </Command.GroupItems> 181 + </Command.Group> 182 + {#if index < CardDefGroups.length - 1} 183 + <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" /> 184 + {/if} 185 + {/if} 186 + {/each} 187 + </Command.Viewport> 188 + </Command.List> 189 + </Command.Root> 190 + </Dialog.Content> 191 + </Dialog.Portal> 192 + </Dialog.Root>
+125
src/lib/components/extensions/RichTextLink.ts
··· 1 + import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core'; 2 + import { Link } from '@tiptap/extension-link'; 3 + 4 + import type { LinkOptions } from '@tiptap/extension-link'; 5 + 6 + /** 7 + * The input regex for Markdown links with title support, and multiple quotation marks (required 8 + * in case the `Typography` extension is being included). 9 + */ 10 + const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["โ€œ](.+)["โ€])?\)$/i; 11 + 12 + /** 13 + * The paste regex for Markdown links with title support, and multiple quotation marks (required 14 + * in case the `Typography` extension is being included). 15 + */ 16 + const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["โ€œ](.+)["โ€])?\)/gi; 17 + 18 + /** 19 + * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in 20 + * parentheses (e.g., `(https://doist.dev)`). 21 + * 22 + * @see https://github.com/ueberdosis/tiptap/discussions/1865 23 + */ 24 + function linkInputRule(config: Parameters<typeof markInputRule>[0]) { 25 + const defaultMarkInputRule = markInputRule(config); 26 + 27 + return new InputRule({ 28 + find: config.find, 29 + handler(props) { 30 + const { tr } = props.state; 31 + 32 + defaultMarkInputRule.handler(props); 33 + tr.setMeta('preventAutolink', true); 34 + } 35 + }); 36 + } 37 + 38 + /** 39 + * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in 40 + * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple 41 + * implementations found in a Tiptap discussion at GitHub. 42 + * 43 + * @see https://github.com/ueberdosis/tiptap/discussions/1865 44 + */ 45 + function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) { 46 + const defaultMarkPasteRule = markPasteRule(config); 47 + 48 + return new PasteRule({ 49 + find: config.find, 50 + handler(props) { 51 + const { tr } = props.state; 52 + 53 + defaultMarkPasteRule.handler(props); 54 + tr.setMeta('preventAutolink', true); 55 + } 56 + }); 57 + } 58 + 59 + /** 60 + * The options available to customize the `RichTextLink` extension. 61 + */ 62 + type RichTextLinkOptions = LinkOptions; 63 + 64 + /** 65 + * Custom extension that extends the built-in `Link` extension to add additional input/paste rules 66 + * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also 67 + * adds support for the `title` attribute. 68 + */ 69 + const RichTextLink = Link.extend<RichTextLinkOptions>({ 70 + inclusive: false, 71 + addOptions(): LinkOptions { 72 + return { 73 + ...this.parent?.(), 74 + openOnClick: 'whenNotEditable' 75 + } as LinkOptions; 76 + }, 77 + addAttributes() { 78 + return { 79 + ...this.parent?.(), 80 + title: { 81 + default: null 82 + } 83 + }; 84 + }, 85 + addInputRules() { 86 + return [ 87 + linkInputRule({ 88 + find: inputRegex, 89 + type: this.type, 90 + 91 + // We need to use `pop()` to remove the last capture groups from the match to 92 + // satisfy Tiptap's `markPasteRule` expectation of having the content as the last 93 + // capture group in the match (this makes the attribute order important) 94 + getAttributes(match) { 95 + return { 96 + title: match.pop()?.trim(), 97 + href: match.pop()?.trim() 98 + }; 99 + } 100 + }) 101 + ]; 102 + }, 103 + addPasteRules() { 104 + return [ 105 + linkPasteRule({ 106 + find: pasteRegex, 107 + type: this.type, 108 + 109 + // We need to use `pop()` to remove the last capture groups from the match to 110 + // satisfy Tiptap's `markInputRule` expectation of having the content as the last 111 + // capture group in the match (this makes the attribute order important) 112 + getAttributes(match) { 113 + return { 114 + title: match.pop()?.trim(), 115 + href: match.pop()?.trim() 116 + }; 117 + } 118 + }) 119 + ]; 120 + } 121 + }); 122 + 123 + export { RichTextLink }; 124 + 125 + export type { RichTextLinkOptions };
+26 -8
src/lib/components/post/Post.svelte
··· 1 1 <script lang="ts"> 2 2 import Embed from './embeds/Embed.svelte'; 3 + import { sanitize } from '$lib/sanitize'; 3 4 import { cn, Prose } from '@foxui/core'; 4 5 import type { WithChildren, WithElementRef } from 'bits-ui'; 5 6 import type { HTMLAttributes } from 'svelte/elements'; ··· 8 9 import type { Snippet } from 'svelte'; 9 10 import { numberToHumanReadable } from '..'; 10 11 import { RelativeTime } from '@foxui/time'; 12 + import PostEmbed from './PostEmbed.svelte'; 11 13 12 14 let { 13 15 ref = $bindable(), ··· 34 36 35 37 children, 36 38 37 - logo 39 + logo, 40 + 41 + showAvatar = false, 42 + compact = false 38 43 }: WithElementRef<WithChildren<HTMLAttributes<HTMLDivElement>>> & { 39 44 data: PostData; 40 45 class?: string; ··· 59 64 customActions?: Snippet; 60 65 61 66 logo?: Snippet; 67 + 68 + showAvatar?: boolean; 69 + compact?: boolean; 62 70 } = $props(); 63 71 </script> 64 72 ··· 119 127 </div> 120 128 {/if} 121 129 <div class="flex gap-4"> 130 + {#if showAvatar && data.author.avatar} 131 + <a href={data.author.href} class="flex-shrink-0"> 132 + <img 133 + src={data.author.avatar} 134 + alt="" 135 + class={compact ? 'size-7 rounded-full object-cover' : 'size-10 rounded-full object-cover'} 136 + /> 137 + </a> 138 + {/if} 122 139 <div class="w-full"> 123 140 <div class="mb-1 flex items-start justify-between gap-2"> 124 141 <div class="flex items-start gap-4"> ··· 159 176 {/if} 160 177 161 178 <div 162 - class="text-base-600 dark:text-base-400 accent:text-accent-950 block text-sm no-underline" 179 + class={cn( 180 + 'text-base-600 dark:text-base-400 accent:text-accent-950 block no-underline', 181 + compact ? 'text-xs' : 'text-sm' 182 + )} 163 183 > 164 184 <RelativeTime date={new Date(data.createdAt)} locale="en" /> 165 185 </div> ··· 171 191 </div> 172 192 173 193 <Prose 174 - size="md" 194 + size={compact ? 'default' : 'md'} 175 195 class="accent:prose-a:text-accent-950 accent:text-base-900 accent:prose-p:text-base-900 accent:prose-a:underline" 176 196 > 177 197 {#if data.htmlContent} 178 - {@html data.htmlContent} 198 + {@html sanitize(data.htmlContent, { ADD_ATTR: ['target'] })} 179 199 {:else} 180 200 {@render children?.()} 181 201 {/if} 182 202 </Prose> 183 203 184 - {#if data.embed} 185 - <Embed embed={data.embed} /> 186 - {/if} 204 + <PostEmbed {data} /> 187 205 188 - {#if showReply || showRepost || showLike || showBookmark || customActions} 206 + {#if !compact && (showReply || showRepost || showLike || showBookmark || customActions)} 189 207 <div 190 208 class="text-base-500 dark:text-base-400 accent:text-base-900 mt-4 flex justify-between gap-2" 191 209 >
+23
src/lib/components/post/PostEmbed.svelte
··· 1 + <script lang="ts"> 2 + import { hasNSFWLabel, type PostData } from '.'; 3 + import Embed from './embeds/Embed.svelte'; 4 + 5 + let { 6 + data 7 + }: { 8 + data: PostData; 9 + } = $props(); 10 + 11 + let showNSFW = $state(false); 12 + </script> 13 + 14 + {#if hasNSFWLabel(data) && !showNSFW} 15 + <button 16 + onclick={() => (showNSFW = true)} 17 + class="border-base-500/20 bg-base-200/50 text-base-600 dark:border-base-400/20 dark:bg-base-800/50 dark:text-base-400 accent:border-accent-900 mt-4 flex h-18 w-full cursor-pointer items-center justify-center rounded-2xl border text-center text-sm" 18 + > 19 + NSFW content, click to show. 20 + </button> 21 + {:else if data.embed} 22 + <Embed embed={data.embed} /> 23 + {/if}
+14
src/lib/components/post/embeds/Embed.svelte
··· 3 3 import External from './External.svelte'; 4 4 import Images from './Images.svelte'; 5 5 import Video from './Video.svelte'; 6 + import QuotedPost from './QuotedPost.svelte'; 6 7 7 8 const { embed }: { embed: PostEmbed } = $props(); 8 9 </script> ··· 14 15 <External data={embed} /> 15 16 {:else if embed.type === 'video' && embed.video} 16 17 <Video data={embed} /> 18 + {:else if embed.type === 'record' && embed.record} 19 + <QuotedPost record={embed.record} /> 20 + {:else if embed.type === 'recordWithMedia' && embed.record} 21 + {#if embed.media} 22 + {#if embed.media.type === 'images'} 23 + <Images data={embed.media} /> 24 + {:else if embed.media.type === 'external' && embed.media.external} 25 + <External data={embed.media} /> 26 + {:else if embed.media.type === 'video' && embed.media.video} 27 + <Video data={embed.media} /> 28 + {/if} 29 + {/if} 30 + <QuotedPost record={embed.record} /> 17 31 {:else if embed.type === 'unknown'} 18 32 <div 19 33 class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm"
+47
src/lib/components/post/embeds/QuotedPost.svelte
··· 1 + <script lang="ts"> 2 + import type { QuotedPostData } from '..'; 3 + import { sanitize } from '$lib/sanitize'; 4 + import Images from './Images.svelte'; 5 + import External from './External.svelte'; 6 + import Video from './Video.svelte'; 7 + 8 + const { record }: { record: QuotedPostData } = $props(); 9 + </script> 10 + 11 + <div 12 + class="border-base-300 dark:border-base-600/30 accent:border-accent-300/20 accent:bg-accent-100/10 bg-base-950/5 dark:bg-base-950/20 overflow-hidden rounded-2xl border text-sm" 13 + > 14 + <div class="p-3"> 15 + <div class="flex items-center gap-2"> 16 + {#if record.author.avatar} 17 + <img src={record.author.avatar} alt="" class="size-5 rounded-full object-cover" /> 18 + {/if} 19 + <div class="flex items-baseline gap-1.5 overflow-hidden text-xs"> 20 + {#if record.author.displayName} 21 + <span class="text-base-900 dark:text-base-50 truncate font-semibold"> 22 + {record.author.displayName} 23 + </span> 24 + {/if} 25 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 truncate"> 26 + @{record.author.handle} 27 + </span> 28 + </div> 29 + </div> 30 + {#if record.htmlContent} 31 + <div class="text-base-800 dark:text-base-200 accent:text-base-900 mt-1.5 line-clamp-3"> 32 + {@html sanitize(record.htmlContent, { ADD_ATTR: ['target'] })} 33 + </div> 34 + {/if} 35 + </div> 36 + {#if record.embed} 37 + <div class="px-3 pb-3"> 38 + {#if record.embed.type === 'images'} 39 + <Images data={record.embed} /> 40 + {:else if record.embed.type === 'external' && record.embed.external} 41 + <External data={record.embed} /> 42 + {:else if record.embed.type === 'video' && record.embed.video} 43 + <Video data={record.embed} /> 44 + {/if} 45 + </div> 46 + {/if} 47 + </div>
+41 -1
src/lib/components/post/index.ts
··· 39 39 }; 40 40 }; 41 41 42 + export type QuotedPostData = { 43 + author: { 44 + displayName: string; 45 + handle: string; 46 + avatar?: string; 47 + href?: string; 48 + }; 49 + href?: string; 50 + htmlContent?: string; 51 + createdAt?: string; 52 + embed?: PostEmbed; 53 + }; 54 + 55 + export type PostEmbedRecord = { 56 + type: 'record'; 57 + record: QuotedPostData; 58 + }; 59 + 60 + export type PostEmbedRecordWithMedia = { 61 + type: 'recordWithMedia'; 62 + record: QuotedPostData; 63 + media: PostEmbed; 64 + }; 65 + 42 66 export type UnknownEmbed = { 43 67 type: 'unknown'; 44 68 } & Record<string, unknown>; 45 69 46 - export type PostEmbed = PostEmbedImage | PostEmbedExternal | PostEmbedVideo | UnknownEmbed; 70 + export type PostEmbed = 71 + | PostEmbedImage 72 + | PostEmbedExternal 73 + | PostEmbedVideo 74 + | PostEmbedRecord 75 + | PostEmbedRecordWithMedia 76 + | UnknownEmbed; 47 77 48 78 export type PostData = { 49 79 href?: string; ··· 70 100 htmlContent?: string; 71 101 72 102 replies?: PostData[]; 103 + 104 + labels?: string[]; 73 105 }; 106 + 107 + export const nsfwLabels = ['porn', 'sexual', 'graphic-media', 'nudity']; 108 + 109 + export function hasNSFWLabel(post: PostData): boolean { 110 + if (!post.labels) return false; 111 + 112 + return post.labels.some((label) => nsfwLabels.includes(label)); 113 + } 74 114 75 115 export { default as Post } from './Post.svelte';
+84
src/lib/components/qr/QRCodeDisplay.svelte
··· 1 + <script lang="ts"> 2 + import { getHexCSSVar } from '$lib/cards/helper'; 3 + import { onMount } from 'svelte'; 4 + 5 + let { 6 + url, 7 + icon, 8 + iconColor, 9 + class: className = '' 10 + }: { 11 + url: string; 12 + icon?: string; 13 + iconColor?: string; 14 + class?: string; 15 + } = $props(); 16 + 17 + let container: HTMLDivElement | undefined = $state(); 18 + 19 + // Convert SVG string to data URI for use as QR center image 20 + function svgToDataUri(svg: string, color: string): string { 21 + // Add fill color to SVG - insert fill attribute on the svg tag 22 + let coloredSvg = svg; 23 + if (!svg.includes('fill=')) { 24 + // No fill attribute, add it to the svg tag 25 + coloredSvg = svg.replace('<svg', `<svg fill="${color}"`); 26 + } else { 27 + // Replace existing fill attributes 28 + coloredSvg = svg.replace(/fill="[^"]*"/g, `fill="${color}"`); 29 + } 30 + const encoded = encodeURIComponent(coloredSvg); 31 + return `data:image/svg+xml,${encoded}`; 32 + } 33 + 34 + onMount(async () => { 35 + if (!container) return; 36 + 37 + // Use iconColor or accent color, ensure # prefix 38 + const rawColor = iconColor || getHexCSSVar('--color-accent-600'); 39 + const dotColor = rawColor.startsWith('#') ? rawColor : `#${rawColor}`; 40 + 41 + const module = await import('qr-code-styling'); 42 + const QRCodeStyling = module.default; 43 + 44 + // Get container size for responsive QR 45 + const rect = container.getBoundingClientRect(); 46 + const size = Math.min(rect.width, rect.height) || 280; 47 + 48 + const options: ConstructorParameters<typeof QRCodeStyling>[0] = { 49 + width: size, 50 + height: size, 51 + data: url, 52 + dotsOptions: { 53 + color: dotColor, 54 + type: 'rounded' 55 + }, 56 + backgroundOptions: { 57 + color: '#FFF' 58 + }, 59 + cornersSquareOptions: { 60 + type: 'extra-rounded', 61 + color: dotColor 62 + }, 63 + cornersDotOptions: { 64 + type: 'dot', 65 + color: dotColor 66 + }, 67 + margin: 10 68 + }; 69 + 70 + // Add icon as center image if provided (as SVG string) 71 + if (icon) { 72 + options.image = svgToDataUri(icon, dotColor); 73 + options.imageOptions = { 74 + margin: 10, 75 + imageSize: 0.5 76 + }; 77 + } 78 + 79 + const qrCode = new QRCodeStyling(options); 80 + qrCode.append(container); 81 + }); 82 + </script> 83 + 84 + <div bind:this={container} class="flex items-center justify-center {className}"></div>
+39
src/lib/components/qr/QRCodeModal.svelte
··· 1 + <script lang="ts"> 2 + import { Modal } from '@foxui/core'; 3 + import QRCodeDisplay from './QRCodeDisplay.svelte'; 4 + 5 + export type QRContext = { 6 + title?: string; 7 + icon?: string; 8 + iconColor?: string; 9 + }; 10 + 11 + let { 12 + open = $bindable(false), 13 + href, 14 + context = {} 15 + }: { 16 + open: boolean; 17 + href: string; 18 + context?: QRContext; 19 + } = $props(); 20 + </script> 21 + 22 + <Modal bind:open closeButton={true} class="max-w-[90vw]! sm:max-w-sm! md:max-w-md!"> 23 + <div class="flex flex-col items-center justify-center gap-4 p-4"> 24 + {#if context.title} 25 + <div class="text-base-900 dark:text-base-100 text-center text-3xl font-semibold"> 26 + {context.title} 27 + </div> 28 + {/if} 29 + 30 + <div class="flex items-center justify-center overflow-hidden rounded-2xl"> 31 + <QRCodeDisplay 32 + url={href} 33 + icon={context.icon} 34 + iconColor={context.iconColor} 35 + class="size-[min(70vw,320px)] sm:size-72 md:size-80" 36 + /> 37 + </div> 38 + </div> 39 + </Modal>
+25
src/lib/components/qr/QRModalProvider.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import QRCodeModal, { type QRContext } from './QRCodeModal.svelte'; 4 + import { registerQRModal, unregisterQRModal } from './qrOverlay.svelte'; 5 + 6 + let open = $state(false); 7 + let href = $state(''); 8 + let context = $state<QRContext>({}); 9 + 10 + function showModal(newHref: string, newContext: QRContext) { 11 + href = newHref; 12 + context = newContext; 13 + open = true; 14 + } 15 + 16 + onMount(() => { 17 + registerQRModal(showModal); 18 + }); 19 + 20 + onDestroy(() => { 21 + unregisterQRModal(); 22 + }); 23 + </script> 24 + 25 + <QRCodeModal bind:open {href} {context} />
+76
src/lib/components/qr/qrOverlay.svelte.ts
··· 1 + import type { QRContext } from './QRCodeModal.svelte'; 2 + 3 + // Global state for QR modal 4 + let openModal: ((href: string, context: QRContext) => void) | null = null; 5 + 6 + export function registerQRModal(fn: (href: string, context: QRContext) => void) { 7 + openModal = fn; 8 + } 9 + 10 + export function unregisterQRModal() { 11 + openModal = null; 12 + } 13 + 14 + export function qrOverlay( 15 + node: HTMLElement, 16 + params: { href?: string; context?: QRContext; disabled?: boolean } = {} 17 + ) { 18 + const LONG_PRESS_DURATION = 500; 19 + let longPressTimer: ReturnType<typeof setTimeout> | null = null; 20 + let isLongPress = false; 21 + 22 + function getHref() { 23 + return params.href || (node as HTMLAnchorElement).href || ''; 24 + } 25 + 26 + function startLongPress() { 27 + if (params.disabled) return; 28 + isLongPress = false; 29 + longPressTimer = setTimeout(() => { 30 + isLongPress = true; 31 + openModal?.(getHref(), params.context ?? {}); 32 + }, LONG_PRESS_DURATION); 33 + } 34 + 35 + function cancelLongPress() { 36 + if (longPressTimer) { 37 + clearTimeout(longPressTimer); 38 + longPressTimer = null; 39 + } 40 + } 41 + 42 + function handleClick(e: MouseEvent) { 43 + if (isLongPress) { 44 + e.preventDefault(); 45 + isLongPress = false; 46 + } 47 + } 48 + 49 + function handleContextMenu(e: MouseEvent) { 50 + if (params.disabled) return; 51 + e.preventDefault(); 52 + openModal?.(getHref(), params.context ?? {}); 53 + } 54 + 55 + node.addEventListener('pointerdown', startLongPress); 56 + node.addEventListener('pointerup', cancelLongPress); 57 + node.addEventListener('pointercancel', cancelLongPress); 58 + node.addEventListener('pointerleave', cancelLongPress); 59 + node.addEventListener('click', handleClick); 60 + node.addEventListener('contextmenu', handleContextMenu); 61 + 62 + return { 63 + update(newParams: { href?: string; context?: QRContext; disabled?: boolean }) { 64 + params = newParams; 65 + }, 66 + destroy() { 67 + node.removeEventListener('pointerdown', startLongPress); 68 + node.removeEventListener('pointerup', cancelLongPress); 69 + node.removeEventListener('pointercancel', cancelLongPress); 70 + node.removeEventListener('pointerleave', cancelLongPress); 71 + node.removeEventListener('click', handleClick); 72 + node.removeEventListener('contextmenu', handleContextMenu); 73 + cancelLongPress(); 74 + } 75 + }; 76 + }
+101
src/lib/components/select-theme/SelectTheme.svelte
··· 1 + <script lang="ts"> 2 + import { Paragraph } from '@foxui/core'; 3 + import { ColorSelect } from '@foxui/colors'; 4 + 5 + let accentColors = [ 6 + { class: 'text-red-500', label: 'red' }, 7 + { class: 'text-orange-500', label: 'orange' }, 8 + { class: 'text-amber-500', label: 'amber' }, 9 + { class: 'text-yellow-500', label: 'yellow' }, 10 + { class: 'text-lime-500', label: 'lime' }, 11 + { class: 'text-green-500', label: 'green' }, 12 + { class: 'text-emerald-500', label: 'emerald' }, 13 + { class: 'text-teal-500', label: 'teal' }, 14 + { class: 'text-cyan-500', label: 'cyan' }, 15 + { class: 'text-sky-500', label: 'sky' }, 16 + { class: 'text-blue-500', label: 'blue' }, 17 + { class: 'text-indigo-500', label: 'indigo' }, 18 + { class: 'text-violet-500', label: 'violet' }, 19 + { class: 'text-purple-500', label: 'purple' }, 20 + { class: 'text-fuchsia-500', label: 'fuchsia' }, 21 + { class: 'text-pink-500', label: 'pink' }, 22 + { class: 'text-rose-500', label: 'rose' } 23 + ]; 24 + 25 + let baseColors = [ 26 + { class: 'text-gray-500', label: 'gray' }, 27 + { class: 'text-stone-500', label: 'stone' }, 28 + { class: 'text-zinc-500', label: 'zinc' }, 29 + { class: 'text-neutral-500', label: 'neutral' }, 30 + { class: 'text-slate-500', label: 'slate' } 31 + ]; 32 + 33 + let { 34 + accentColor = $bindable('pink'), 35 + baseColor = $bindable('stone'), 36 + selectAccentColor = true, 37 + selectBaseColor = true, 38 + onchanged 39 + }: { 40 + accentColor?: string; 41 + baseColor?: string; 42 + selectAccentColor?: boolean; 43 + selectBaseColor?: boolean; 44 + onchanged?: (accentColor: string, baseColor: string) => void; 45 + } = $props(); 46 + 47 + let selectedAccentColor = $derived( 48 + accentColors.find((c) => c.label === accentColor) ?? accentColors[15] 49 + ); 50 + 51 + let selectedBaseColor = $derived(baseColors.find((c) => c.label === baseColor) ?? baseColors[1]); 52 + </script> 53 + 54 + {#if selectAccentColor} 55 + <Paragraph class="mb-2">Accent Color</Paragraph> 56 + <ColorSelect 57 + selected={selectedAccentColor} 58 + colors={accentColors} 59 + onselected={(color, previous) => { 60 + if (typeof previous === 'string' || typeof color === 'string') { 61 + return; 62 + } 63 + 64 + document.documentElement.classList.remove(previous.label.toLowerCase()); 65 + document.documentElement.classList.add(color.label.toLowerCase()); 66 + 67 + accentColor = color.label; 68 + 69 + window.dispatchEvent( 70 + new CustomEvent('theme-changed', { detail: { accentColor: color.label } }) 71 + ); 72 + 73 + onchanged?.(accentColor, baseColor); 74 + }} 75 + class="w-64" 76 + /> 77 + {/if} 78 + 79 + {#if selectBaseColor} 80 + <Paragraph class="mt-4 mb-2">Base Color</Paragraph> 81 + <ColorSelect 82 + selected={selectedBaseColor} 83 + colors={baseColors} 84 + onselected={(color, previous) => { 85 + if (typeof previous === 'string' || typeof color === 'string') { 86 + return; 87 + } 88 + 89 + document.documentElement.classList.remove(previous.label.toLowerCase()); 90 + document.documentElement.classList.add(color.label.toLowerCase()); 91 + 92 + baseColor = color.label; 93 + 94 + window.dispatchEvent( 95 + new CustomEvent('theme-changed', { detail: { baseColor: color.label } }) 96 + ); 97 + 98 + onchanged?.(accentColor, baseColor); 99 + }} 100 + /> 101 + {/if}
+43
src/lib/components/select-theme/SelectThemePopover.svelte
··· 1 + <script lang="ts"> 2 + import { buttonVariants, Popover, cn } from '@foxui/core'; 3 + import SelectTheme from './SelectTheme.svelte'; 4 + 5 + let { 6 + accentColor = $bindable('pink'), 7 + baseColor = $bindable('stone'), 8 + selectAccentColor = true, 9 + selectBaseColor = true, 10 + onchanged 11 + }: { 12 + accentColor?: string; 13 + baseColor?: string; 14 + selectAccentColor?: boolean; 15 + selectBaseColor?: boolean; 16 + onchanged?: (accentColor: string, baseColor: string) => void; 17 + } = $props(); 18 + </script> 19 + 20 + <Popover> 21 + {#snippet child({ props })} 22 + <button 23 + {...props} 24 + class={cn( 25 + buttonVariants({ variant: 'link', size: 'default' }), 26 + 'flex cursor-pointer items-center gap-0 -space-x-2 backdrop-blur-none' 27 + )} 28 + > 29 + {#if selectAccentColor} 30 + <div 31 + class=" from-accent-500 to-accent-600 border-accent-700 dark:border-accent-400 z-10 size-6 rounded-full border bg-linear-to-b" 32 + ></div> 33 + {/if} 34 + 35 + {#if selectBaseColor} 36 + <div 37 + class=" from-base-500 to-base-600 border-base-700 dark:border-base-400 size-6 rounded-full border bg-linear-to-b" 38 + ></div> 39 + {/if} 40 + </button> 41 + {/snippet} 42 + <SelectTheme bind:accentColor bind:baseColor {selectAccentColor} {selectBaseColor} {onchanged} /> 43 + </Popover>
+2
src/lib/components/select-theme/index.ts
··· 1 + export { default as SelectTheme } from './SelectTheme.svelte'; 2 + export { default as SelectThemePopover } from './SelectThemePopover.svelte';
+227 -35
src/lib/helper.ts
··· 1 1 import type { Item, WebsiteData } from './types'; 2 2 import { COLUMNS, margin, mobileMargin } from '$lib'; 3 3 import { CardDefinitionsByType } from './cards'; 4 - import { deleteRecord, putRecord } from '$lib/atproto'; 5 - import { toast } from '@foxui/core'; 4 + import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 6 5 import * as TID from '@atcute/tid'; 7 6 8 7 export function clamp(value: number, min: number, max: number): number { ··· 58 57 const pushDownCascade = (target: Item, blocker: Item) => { 59 58 // Keep x fixed always when pushing down 60 59 const fixedX = mobile ? target.mobileX : target.x; 60 + const prevY = mobile ? target.mobileY : target.y; 61 61 62 62 // We need target to move just below `blocker` 63 63 const desiredY = mobile ? blocker.mobileY + blocker.mobileH : blocker.y + blocker.h; 64 64 if (!mobile && target.y < desiredY) target.y = desiredY; 65 65 if (mobile && target.mobileY < desiredY) target.mobileY = desiredY; 66 + 67 + const newY = mobile ? target.mobileY : target.y; 68 + const targetH = mobile ? target.mobileH : target.h; 69 + 70 + // fall trough fix 71 + if (newY > prevY) { 72 + const prevBottom = prevY + targetH; 73 + const newBottom = newY + targetH; 74 + for (const it of items) { 75 + if (it === target || it === movedItem || it === blocker) continue; 76 + const itY = mobile ? it.mobileY : it.y; 77 + const itH = mobile ? it.mobileH : it.h; 78 + const itBottom = itY + itH; 79 + if (itBottom <= prevBottom || itY >= newBottom) continue; 80 + // horizontal overlap check 81 + const hOverlap = mobile 82 + ? target.mobileX < it.mobileX + it.mobileW && target.mobileX + target.mobileW > it.mobileX 83 + : target.x < it.x + it.w && target.x + target.w > it.x; 84 + if (hOverlap) { 85 + pushDownCascade(it, target); 86 + } 87 + } 88 + } 66 89 67 90 // Now resolve any collisions that creates by pushing those items down first 68 91 // Repeat until target is clean. ··· 241 264 ); 242 265 } 243 266 244 - export function setPositionOfNewItem(newItem: Item, items: Item[]) { 267 + export function setPositionOfNewItem( 268 + newItem: Item, 269 + items: Item[], 270 + viewportCenter?: { gridY: number; isMobile: boolean } 271 + ) { 272 + if (viewportCenter) { 273 + const { gridY, isMobile } = viewportCenter; 274 + 275 + if (isMobile) { 276 + // Place at viewport center Y 277 + newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); 278 + newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; 279 + 280 + // Try to find a free X at this Y 281 + let found = false; 282 + for ( 283 + newItem.mobileX = 0; 284 + newItem.mobileX <= COLUMNS - newItem.mobileW; 285 + newItem.mobileX += 2 286 + ) { 287 + if (!items.some((item) => overlaps(newItem, item, true))) { 288 + found = true; 289 + break; 290 + } 291 + } 292 + if (!found) { 293 + newItem.mobileX = 0; 294 + } 295 + 296 + // Desktop: derive from mobile 297 + newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); 298 + found = false; 299 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 300 + if (!items.some((item) => overlaps(newItem, item, false))) { 301 + found = true; 302 + break; 303 + } 304 + } 305 + if (!found) { 306 + newItem.x = 0; 307 + } 308 + } else { 309 + // Place at viewport center Y 310 + newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); 311 + 312 + // Try to find a free X at this Y 313 + let found = false; 314 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 315 + if (!items.some((item) => overlaps(newItem, item, false))) { 316 + found = true; 317 + break; 318 + } 319 + } 320 + if (!found) { 321 + newItem.x = 0; 322 + } 323 + 324 + // Mobile: derive from desktop 325 + newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); 326 + found = false; 327 + for ( 328 + newItem.mobileX = 0; 329 + newItem.mobileX <= COLUMNS - newItem.mobileW; 330 + newItem.mobileX += 2 331 + ) { 332 + if (!items.some((item) => overlaps(newItem, item, true))) { 333 + found = true; 334 + break; 335 + } 336 + } 337 + if (!found) { 338 + newItem.mobileX = 0; 339 + } 340 + } 341 + return; 342 + } 343 + 245 344 let foundPosition = false; 246 345 while (!foundPosition) { 247 346 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { ··· 268 367 } 269 368 } 270 369 370 + /** 371 + * Find a valid position for a new item in a single mode (desktop or mobile). 372 + * This modifies the item's position properties in-place. 373 + */ 374 + export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) { 375 + if (mobile) { 376 + let foundPosition = false; 377 + newItem.mobileY = 0; 378 + while (!foundPosition) { 379 + for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) { 380 + const collision = items.find((item) => overlaps(newItem, item, true)); 381 + if (!collision) { 382 + foundPosition = true; 383 + break; 384 + } 385 + } 386 + if (!foundPosition) newItem.mobileY! += 1; 387 + } 388 + } else { 389 + let foundPosition = false; 390 + newItem.y = 0; 391 + while (!foundPosition) { 392 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 393 + const collision = items.find((item) => overlaps(newItem, item, false)); 394 + if (!collision) { 395 + foundPosition = true; 396 + break; 397 + } 398 + } 399 + if (!foundPosition) newItem.y += 1; 400 + } 401 + } 402 + } 403 + 271 404 export async function refreshData(data: { updatedAt?: number; handle: string }) { 272 405 const TEN_MINUTES = 10 * 60 * 1000; 273 406 const now = Date.now(); ··· 285 418 } 286 419 287 420 export function getName(data: WebsiteData): string { 288 - return (data.publication?.name ?? data.profile.displayName) || data.handle; 421 + return data.publication?.name || data.profile.displayName || data.handle; 289 422 } 290 423 291 424 export function getDescription(data: WebsiteData): string { ··· 302 435 return data.page !== 'blento.self'; 303 436 } 304 437 438 + export function getProfilePosition(data: WebsiteData): 'side' | 'top' { 439 + return data?.publication?.preferences?.profilePosition ?? 'side'; 440 + } 441 + 305 442 export function isTyping() { 306 443 const active = document.activeElement; 307 444 ··· 323 460 new URL(link); 324 461 325 462 return link; 326 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 327 463 } catch (e) { 328 464 if (!tryAdding) return; 329 465 ··· 332 468 new URL(link); 333 469 334 470 return link; 335 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 336 471 } catch (e) { 337 472 return; 338 473 } 339 474 } 340 475 } 341 476 342 - export function compressImage(file: File, maxSize: number = 900 * 1024): Promise<Blob> { 477 + export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> { 343 478 return new Promise((resolve, reject) => { 344 479 const img = new Image(); 345 480 const reader = new FileReader(); ··· 355 490 reader.readAsDataURL(file); 356 491 357 492 img.onload = () => { 493 + const maxDimension = 2048; 494 + 495 + // If image is already small enough, return original 496 + if (file.size <= maxSize) { 497 + console.log('skipping compression+resizing, already small enough'); 498 + return resolve(file); 499 + } 500 + 358 501 let width = img.width; 359 502 let height = img.height; 360 - const maxDimension = 2048; 361 503 362 504 if (width > maxDimension || height > maxDimension) { 363 505 if (width > height) { ··· 377 519 if (!ctx) return reject(new Error('Failed to get canvas context.')); 378 520 ctx.drawImage(img, 0, 0, width, height); 379 521 380 - // Function to try compressing at a given quality 381 - let quality = 0.8; 522 + // Use WebP for both compression and transparency support 523 + let quality = 0.9; 524 + 382 525 function attemptCompression() { 383 526 canvas.toBlob( 384 527 (blob) => { 385 528 if (!blob) { 386 529 return reject(new Error('Compression failed.')); 387 530 } 388 - // If the blob is under our size limit, or quality is too low, resolve it 389 531 if (blob.size <= maxSize || quality < 0.3) { 390 - console.log('Compression successful. Blob size:', blob.size); 391 - console.log('Quality:', quality); 392 532 resolve(blob); 393 533 } else { 394 - // Otherwise, reduce the quality and try again 395 534 quality -= 0.1; 396 535 attemptCompression(); 397 536 } 398 537 }, 399 - 'image/jpeg', 538 + 'image/webp', 400 539 quality 401 540 ); 402 541 } ··· 428 567 item = await cardDef?.upload(item); 429 568 } 430 569 431 - item.page = data.page; 432 - item.version = 2; 570 + const parsedItem = JSON.parse(JSON.stringify(item)); 571 + 572 + parsedItem.page = data.page; 573 + parsedItem.version = 2; 433 574 434 575 promises.push( 435 576 putRecord({ 436 577 collection: 'app.blento.card', 437 - rkey: item.id, 438 - record: item 578 + rkey: parsedItem.id, 579 + record: parsedItem 439 580 }) 440 581 ); 441 582 } ··· 473 614 data.publication.url += '/' + data.page.replace('blento.', ''); 474 615 } 475 616 } 476 - promises.push( 477 - putRecord({ 478 - collection: 'site.standard.publication', 479 - rkey: data.page, 480 - record: data.publication 481 - }) 482 - ); 617 + if (data.page !== 'blento.self') { 618 + promises.push( 619 + putRecord({ 620 + collection: 'app.blento.page', 621 + rkey: data.page, 622 + record: data.publication 623 + }) 624 + ); 625 + } else { 626 + promises.push( 627 + putRecord({ 628 + collection: 'site.standard.publication', 629 + rkey: data.page, 630 + record: data.publication 631 + }) 632 + ); 633 + } 483 634 484 635 console.log('updating or adding publication', data.publication); 485 636 } 486 637 487 638 await Promise.all(promises); 488 - 489 - fetch('/' + data.handle + '/api/refresh').then(() => { 490 - console.log('data refreshed!'); 491 - }); 492 - console.log('refreshing data'); 493 - 494 - toast('Saved', { 495 - description: 'Your website has been saved!' 496 - }); 497 639 } 498 640 499 641 export function createEmptyCard(page: string) { ··· 538 680 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 539 681 } 540 682 } 683 + 684 + export async function checkAndUploadImage( 685 + objectWithImage: Record<string, any>, 686 + key: string = 'image' 687 + ) { 688 + if (!objectWithImage[key]) return; 689 + 690 + // Already uploaded as blob 691 + if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 692 + return; 693 + } 694 + 695 + if (typeof objectWithImage[key] === 'string') { 696 + // Download image from URL via proxy (to avoid CORS) and upload as blob 697 + try { 698 + const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(objectWithImage[key])}`; 699 + const response = await fetch(proxyUrl); 700 + if (!response.ok) { 701 + console.error('Failed to fetch image:', objectWithImage[key]); 702 + return; 703 + } 704 + const blob = await response.blob(); 705 + const compressedBlob = await compressImage(blob); 706 + objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 707 + } catch (error) { 708 + console.error('Failed to download and upload image:', error); 709 + } 710 + return; 711 + } 712 + 713 + if (objectWithImage[key]?.blob) { 714 + const compressedBlob = await compressImage(objectWithImage[key].blob); 715 + objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 716 + } 717 + } 718 + 719 + export function getImage( 720 + objectWithImage: Record<string, any> | undefined, 721 + did: string, 722 + key: string = 'image' 723 + ) { 724 + if (!objectWithImage?.[key]) return; 725 + 726 + if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 727 + 728 + if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 729 + return getCDNImageBlobUrl({ did, blob: objectWithImage[key] }); 730 + } 731 + return objectWithImage[key]; 732 + }
+30
src/lib/sanitize.ts
··· 1 + import { browser } from '$app/environment'; 2 + 3 + // Lightweight regex-based sanitizer for SSR in Cloudflare Workers 4 + // where DOMPurify is not available. Strips common XSS vectors. 5 + function regexSanitize(html: string): string { 6 + return html 7 + .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script\s*>/gi, '') 8 + .replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe\s*>/gi, '') 9 + .replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object\s*>/gi, '') 10 + .replace(/<embed\b[^>]*\/?>/gi, '') 11 + .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style\s*>/gi, '') 12 + .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '') 13 + .replace(/href\s*=\s*["']?\s*javascript\s*:/gi, 'href="') 14 + .replace(/src\s*=\s*["']?\s*javascript\s*:/gi, 'src="'); 15 + } 16 + 17 + let _purify: ((html: string, config?: { ADD_ATTR?: string[] }) => string) | null = null; 18 + 19 + if (browser) { 20 + import('dompurify').then((mod) => { 21 + _purify = (html, config) => mod.default.sanitize(html, config) as string; 22 + }); 23 + } 24 + 25 + export function sanitize(dirty: string, config?: { ADD_ATTR?: string[] }): string { 26 + if (_purify) { 27 + return _purify(dirty, config); 28 + } 29 + return regexSanitize(dirty); 30 + }
+26 -19
src/lib/types.ts
··· 18 18 19 19 color?: string; 20 20 21 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 21 cardData: any; 23 22 24 23 updatedAt?: string; ··· 34 33 handle: string; 35 34 36 35 cards: Item[]; 37 - publication: 38 - | { 39 - url?: string; 40 - name?: string; 41 - description?: string; 42 - icon?: Blob; 43 - preferences?: { 44 - /** 45 - * @deprecated 46 - * 47 - * use hideProfileSection instead 48 - */ 49 - hideProfile?: boolean; 36 + publication: { 37 + url?: string; 38 + name?: string; 39 + description?: string; 40 + icon?: Blob; 41 + preferences?: { 42 + /** 43 + * @deprecated 44 + * 45 + * use hideProfileSection instead 46 + */ 47 + hideProfile?: boolean; 48 + 49 + // use this instead 50 + hideProfileSection?: boolean; 51 + 52 + // 'side' (default on desktop) or 'top' (always top like mobile view) 53 + profilePosition?: 'side' | 'top'; 54 + 55 + // theme colors 56 + accentColor?: string; 57 + baseColor?: string; 50 58 51 - // use this instead 52 - hideProfileSection?: boolean; 53 - }; 54 - } 55 - | undefined; 59 + // layout mirroring: 0/undefined=never edited, 1=desktop only, 2=mobile only, 3=both 60 + editedOn?: number; 61 + }; 62 + }; 56 63 profile: AppBskyActorDefs.ProfileViewDetailed; 57 64 58 65 additionalData: Record<string, unknown>;
+16 -15
src/lib/website/Account.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from '$app/navigation'; 2 3 import { user, login, logout } from '$lib/atproto'; 4 + import { getHandleOrDid } from '$lib/atproto/methods'; 3 5 import type { WebsiteData } from '$lib/types'; 4 6 import type { ActorIdentifier } from '@atcute/lexicons'; 5 - import { Button, Popover } from '@foxui/core'; 7 + import { Avatar, Button, Popover } from '@foxui/core'; 6 8 7 9 let { 8 10 data ··· 18 20 <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900"> 19 21 {#snippet child({ props })} 20 22 <button {...props}> 21 - <img src={user.profile?.avatar} alt="" class="size-15 rounded-full" /> 23 + <Avatar src={user.profile?.avatar} alt="" class="size-15 rounded-full" /> 22 24 </button> 23 25 {/snippet} 24 26 25 - <Button variant="ghost" onclick={logout}>Logout</Button> 27 + <div class="flex flex-col"> 28 + {#if user.profile} 29 + <Button 30 + variant="ghost" 31 + onclick={() => { 32 + if (user.profile) goto('/' + getHandleOrDid(user.profile), {}); 33 + }}>Leave edit mode</Button 34 + > 35 + {/if} 36 + 37 + <Button variant="ghost" onclick={logout}>Logout</Button> 38 + </div> 26 39 </Popover> 27 - </div> 28 - {:else if !user.isInitializing} 29 - <div 30 - class="dark:bg-base-950 border-base-200 dark:border-base-900 fixed top-4 right-4 z-20 flex flex-col gap-4 rounded-2xl border bg-white p-4 shadow-lg" 31 - > 32 - <span class="text-sm font-semibold">Login to edit your page</span> 33 - 34 - <Button 35 - onclick={async () => { 36 - await login(data.handle as ActorIdentifier); 37 - }}>Login</Button 38 - > 39 40 </div> 40 41 {/if}
+6 -2
src/lib/website/Context.svelte
··· 8 8 9 9 let { 10 10 data, 11 - children 11 + children, 12 + isEditing 12 13 }: { 13 14 data: WebsiteData; 14 15 children: Snippet<[]>; 16 + isEditing?: boolean; 15 17 } = $props(); 16 18 17 19 // svelte-ignore state_referenced_locally 18 20 setAdditionalUserData(data.additionalData); 19 21 20 - setCanEdit(() => dev || (user.isLoggedIn && user.profile?.did === data.did)); 22 + setCanEdit( 23 + () => dev || (user.isLoggedIn && user.profile?.did === data.did && isEditing === true) 24 + ); 21 25 22 26 // svelte-ignore state_referenced_locally 23 27 setDidContext(data.did as Did);
+123
src/lib/website/Controls.svelte
··· 1 + <script lang="ts"> 2 + import { SelectThemePopover } from '$lib/components/select-theme'; 3 + import { getHideProfileSection, getProfilePosition } from '$lib/helper'; 4 + import type { WebsiteData } from '$lib/types'; 5 + import { Button } from '@foxui/core'; 6 + import { getIsMobile } from './context'; 7 + 8 + let { data = $bindable() }: { data: WebsiteData } = $props(); 9 + 10 + let accentColor = $derived(data.publication?.preferences?.accentColor ?? 'pink'); 11 + let baseColor = $derived(data.publication?.preferences?.baseColor ?? 'stone'); 12 + 13 + function updateTheme(newAccent: string, newBase: string) { 14 + data.publication.preferences ??= {}; 15 + data.publication.preferences.accentColor = newAccent; 16 + data.publication.preferences.baseColor = newBase; 17 + data = { ...data }; 18 + } 19 + 20 + let profilePosition = $derived(getProfilePosition(data)); 21 + 22 + function toggleProfilePosition() { 23 + data.publication.preferences ??= {}; 24 + data.publication.preferences.profilePosition = profilePosition === 'side' ? 'top' : 'side'; 25 + data = { ...data }; 26 + } 27 + 28 + let isMobile = getIsMobile(); 29 + </script> 30 + 31 + <div class={['fixed top-2 left-14 z-20 flex gap-2']}> 32 + <Button 33 + size="icon" 34 + onclick={() => { 35 + data.publication.preferences ??= {}; 36 + data.publication.preferences.hideProfileSection = 37 + !data.publication.preferences?.hideProfileSection; 38 + data = { ...data }; 39 + }} 40 + variant="ghost" 41 + > 42 + {#if !getHideProfileSection(data)} 43 + <svg 44 + xmlns="http://www.w3.org/2000/svg" 45 + fill="none" 46 + viewBox="0 0 24 24" 47 + stroke-width="1.5" 48 + stroke="currentColor" 49 + class="size-5!" 50 + > 51 + <path 52 + stroke-linecap="round" 53 + stroke-linejoin="round" 54 + d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" 55 + /> 56 + </svg> 57 + {:else} 58 + <svg 59 + xmlns="http://www.w3.org/2000/svg" 60 + fill="none" 61 + viewBox="0 0 24 24" 62 + stroke-width="1.5" 63 + stroke="currentColor" 64 + class="size-5!" 65 + > 66 + <path 67 + stroke-linecap="round" 68 + stroke-linejoin="round" 69 + d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" 70 + /> 71 + <path 72 + stroke-linecap="round" 73 + stroke-linejoin="round" 74 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 75 + /> 76 + </svg> 77 + {/if} 78 + </Button> 79 + 80 + <!-- Position toggle button (desktop only) --> 81 + {#if !isMobile() && !getHideProfileSection(data)} 82 + <Button size="icon" type="button" onclick={toggleProfilePosition} variant="ghost"> 83 + {#if profilePosition === 'side'} 84 + <svg 85 + xmlns="http://www.w3.org/2000/svg" 86 + fill="none" 87 + viewBox="0 0 24 24" 88 + stroke-width="1.5" 89 + stroke="currentColor" 90 + class="size-5!" 91 + > 92 + <path 93 + stroke-linecap="round" 94 + stroke-linejoin="round" 95 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 96 + /> 97 + </svg> 98 + {:else} 99 + <svg 100 + xmlns="http://www.w3.org/2000/svg" 101 + fill="none" 102 + viewBox="0 0 24 24" 103 + stroke-width="1.5" 104 + stroke="currentColor" 105 + class="size-5!" 106 + > 107 + <path 108 + stroke-linecap="round" 109 + stroke-linejoin="round" 110 + d="m19.5 4.5-15 15m0 0h11.25m-11.25 0V8.25" 111 + /> 112 + </svg> 113 + {/if} 114 + </Button> 115 + {/if} 116 + 117 + <!-- Theme selection --> 118 + <SelectThemePopover 119 + {accentColor} 120 + {baseColor} 121 + onchanged={(newAccent, newBase) => updateTheme(newAccent, newBase)} 122 + /> 123 + </div>
+298 -184
src/lib/website/EditBar.svelte
··· 1 1 <script lang="ts"> 2 2 import { dev } from '$app/environment'; 3 3 import { user } from '$lib/atproto'; 4 - import type { WebsiteData } from '$lib/types'; 5 - import { Button, Input, Modal, Navbar, Popover, Toggle } from '@foxui/core'; 4 + import { COLUMNS } from '$lib'; 5 + import type { Item, WebsiteData } from '$lib/types'; 6 + import { CardDefinitionsByType } from '$lib/cards'; 7 + import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core'; 8 + import { ColorSelect } from '@foxui/colors'; 6 9 7 10 let { 8 11 data, 9 - linkValue = $bindable(), 10 - newCard, 11 - addLink, 12 - showSettings = $bindable(), 13 12 14 13 showingMobileView = $bindable(), 15 14 isSaving = $bindable(), 15 + hasUnsavedChanges, 16 16 17 17 save, 18 18 19 19 handleImageInputChange, 20 - handleVideoInputChange 20 + handleVideoInputChange, 21 + 22 + newCard, 23 + addLink, 24 + linkValue = $bindable(''), 25 + 26 + showCardCommand, 27 + selectedCard = null, 28 + isMobile = false, 29 + isCoarse = false, 30 + ondeselect, 31 + ondelete, 32 + onsetsize 21 33 }: { 22 34 data: WebsiteData; 23 - linkValue: string; 24 - newCard: (type: string) => void; 25 - addLink: (url: string) => void; 26 - 27 - showSettings: boolean; 28 35 29 36 showingMobileView: boolean; 30 37 31 38 isSaving: boolean; 39 + hasUnsavedChanges: boolean; 32 40 33 41 save: () => Promise<void>; 34 42 35 43 handleImageInputChange: (evt: Event) => void; 36 44 handleVideoInputChange: (evt: Event) => void; 45 + 46 + newCard: (type?: string, cardData?: any) => void; 47 + addLink: (url: string) => void; 48 + linkValue: string; 49 + 50 + showCardCommand: () => void; 51 + selectedCard?: Item | null; 52 + isMobile?: boolean; 53 + isCoarse?: boolean; 54 + ondeselect?: () => void; 55 + ondelete?: () => void; 56 + onsetsize?: (w: number, h: number) => void; 37 57 } = $props(); 38 58 39 59 let linkPopoverOpen = $state(false); 40 - 41 60 let imageInputRef: HTMLInputElement | undefined = $state(); 42 61 let videoInputRef: HTMLInputElement | undefined = $state(); 43 62 44 - let shareModalOpen = $state(false); 63 + function getShareUrl() { 64 + const base = typeof window !== 'undefined' ? window.location.origin : ''; 65 + const pagePath = 66 + data.page && data.page !== 'blento.self' ? `/${data.page.replace('blento.', '')}` : ''; 67 + return `${base}/${data.handle}${pagePath}`; 68 + } 69 + 70 + async function copyShareLink() { 71 + const url = getShareUrl(); 72 + await navigator.clipboard.writeText(url); 73 + toast.success('Link copied to clipboard!'); 74 + } 75 + 76 + let colorsChoices = [ 77 + { class: 'text-base-500', label: 'base' }, 78 + { class: 'text-accent-500', label: 'accent' }, 79 + { class: 'text-base-300 dark:text-base-700', label: 'transparent' }, 80 + { class: 'text-red-500', label: 'red' }, 81 + { class: 'text-orange-500', label: 'orange' }, 82 + { class: 'text-amber-500', label: 'amber' }, 83 + { class: 'text-yellow-500', label: 'yellow' }, 84 + { class: 'text-lime-500', label: 'lime' }, 85 + { class: 'text-green-500', label: 'green' }, 86 + { class: 'text-emerald-500', label: 'emerald' }, 87 + { class: 'text-teal-500', label: 'teal' }, 88 + { class: 'text-cyan-500', label: 'cyan' }, 89 + { class: 'text-sky-500', label: 'sky' }, 90 + { class: 'text-blue-500', label: 'blue' }, 91 + { class: 'text-indigo-500', label: 'indigo' }, 92 + { class: 'text-violet-500', label: 'violet' }, 93 + { class: 'text-purple-500', label: 'purple' }, 94 + { class: 'text-fuchsia-500', label: 'fuchsia' }, 95 + { class: 'text-pink-500', label: 'pink' }, 96 + { class: 'text-rose-500', label: 'rose' } 97 + ]; 98 + 99 + let selectedColor = $derived( 100 + selectedCard 101 + ? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label) 102 + : undefined 103 + ); 104 + 105 + let cardDef = $derived( 106 + selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null 107 + ); 108 + 109 + let colorPopoverOpen = $state(false); 110 + let sizePopoverOpen = $state(false); 111 + let settingsPopoverOpen = $state(false); 112 + 113 + const minW = $derived(cardDef?.minW ?? 2); 114 + const minH = $derived(cardDef?.minH ?? 2); 115 + const maxW = $derived(cardDef?.maxW ?? COLUMNS); 116 + const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6)); 117 + 118 + function canSetSize(w: number, h: number) { 119 + if (!cardDef) return false; 120 + if (isMobile) { 121 + return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH; 122 + } 123 + return w >= minW && w <= maxW && h >= minH && h <= maxH; 124 + } 125 + 126 + const showMobileEditControls = $derived(isCoarse && selectedCard); 45 127 </script> 46 128 47 129 <input ··· 49 131 accept="image/*" 50 132 onchange={handleImageInputChange} 51 133 class="hidden" 134 + id="image-input" 52 135 multiple 53 136 bind:this={imageInputRef} 54 137 /> ··· 58 141 accept="video/*" 59 142 onchange={handleVideoInputChange} 60 143 class="hidden" 144 + id="video-input" 61 145 multiple 62 146 bind:this={videoInputRef} 63 147 /> 64 - 65 - <Modal bind:open={shareModalOpen}></Modal> 66 148 67 149 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 68 150 <Navbar 69 - class={[ 70 - '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 lg:inline-flex', 71 - !dev ? 'hidden' : '' 72 - ]} 151 + 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" 73 152 > 74 - <div class="flex items-center gap-2"> 75 - <Button 76 - size="iconLg" 77 - variant="ghost" 78 - class="backdrop-blur-none" 79 - onclick={() => { 80 - newCard('section'); 81 - }} 82 - > 83 - <svg 84 - xmlns="http://www.w3.org/2000/svg" 85 - viewBox="0 0 24 24" 86 - fill="none" 87 - stroke="currentColor" 88 - stroke-width="2" 89 - stroke-linecap="round" 90 - stroke-linejoin="round" 91 - ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 92 - > 93 - </Button> 153 + {#if showMobileEditControls} 154 + <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect --> 155 + <div class="flex items-center gap-1"> 156 + {#if cardDef?.allowSetColor !== false} 157 + <Popover bind:open={colorPopoverOpen}> 158 + {#snippet child({ props })} 159 + <button 160 + {...props} 161 + class={[ 162 + 'cursor-pointer rounded-xl p-2', 163 + !selectedCard?.color || 164 + selectedCard.color === 'base' || 165 + selectedCard.color === 'transparent' 166 + ? 'text-base-800 dark:text-base-200' 167 + : 'text-accent-500' 168 + ]} 169 + > 170 + <svg 171 + xmlns="http://www.w3.org/2000/svg" 172 + viewBox="0 0 24 24" 173 + fill="currentColor" 174 + class="size-5" 175 + > 176 + <path 177 + fill-rule="evenodd" 178 + d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z" 179 + clip-rule="evenodd" 180 + /> 181 + </svg> 182 + </button> 183 + {/snippet} 184 + <ColorSelect 185 + selected={selectedColor} 186 + colors={colorsChoices} 187 + onselected={(color, previous) => { 188 + if (typeof previous === 'string' || typeof color === 'string') { 189 + return; 190 + } 191 + if (selectedCard) { 192 + selectedCard.color = color.label; 193 + } 194 + }} 195 + class="w-64" 196 + /> 197 + </Popover> 198 + {/if} 94 199 95 - <Button 96 - size="iconLg" 97 - variant="ghost" 98 - class="backdrop-blur-none" 99 - onclick={() => { 100 - newCard('text'); 101 - }} 102 - > 103 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 104 - ><path 105 - fill="none" 106 - stroke="currentColor" 107 - stroke-linecap="round" 108 - stroke-linejoin="round" 109 - stroke-width="2" 110 - d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 111 - /></svg 112 - > 113 - </Button> 200 + <Popover bind:open={sizePopoverOpen}> 201 + {#snippet child({ props })} 202 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 203 + <svg 204 + xmlns="http://www.w3.org/2000/svg" 205 + fill="none" 206 + viewBox="0 0 24 24" 207 + stroke-width="1.5" 208 + stroke="currentColor" 209 + class="size-5" 210 + > 211 + <path 212 + stroke-linecap="round" 213 + stroke-linejoin="round" 214 + d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" 215 + /> 216 + </svg> 217 + </button> 218 + {/snippet} 219 + <div class="flex items-center gap-1"> 220 + {#if canSetSize(2, 2)} 221 + <button 222 + onclick={() => onsetsize?.(4, 4)} 223 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 224 + > 225 + <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 226 + <span class="sr-only">set size to 1x1</span> 227 + </button> 228 + {/if} 229 + {#if canSetSize(4, 2)} 230 + <button 231 + onclick={() => onsetsize?.(8, 4)} 232 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 233 + > 234 + <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 235 + <span class="sr-only">set size to 2x1</span> 236 + </button> 237 + {/if} 238 + {#if canSetSize(2, 4)} 239 + <button 240 + onclick={() => onsetsize?.(4, 8)} 241 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 242 + > 243 + <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 244 + <span class="sr-only">set size to 1x2</span> 245 + </button> 246 + {/if} 247 + {#if canSetSize(4, 4)} 248 + <button 249 + onclick={() => onsetsize?.(8, 8)} 250 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 251 + > 252 + <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 253 + <span class="sr-only">set size to 2x2</span> 254 + </button> 255 + {/if} 256 + </div> 257 + </Popover> 114 258 115 - <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 116 - {#snippet child({ props })} 117 - <Button 118 - size="iconLg" 119 - variant="ghost" 120 - class="backdrop-blur-none" 121 - onclick={() => { 122 - newCard('link'); 123 - }} 124 - {...props} 125 - > 126 - <svg 127 - xmlns="http://www.w3.org/2000/svg" 128 - fill="none" 129 - viewBox="-2 -2 28 28" 130 - stroke-width="2" 131 - stroke="currentColor" 132 - > 133 - <path 134 - stroke-linecap="round" 135 - stroke-linejoin="round" 136 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 137 - /> 138 - </svg> 139 - </Button> 140 - {/snippet} 141 - <Input 142 - spellcheck={false} 143 - type="url" 144 - bind:value={linkValue} 145 - onkeydown={(event) => { 146 - if (event.code === 'Enter') { 147 - addLink(linkValue); 148 - event.preventDefault(); 149 - } 150 - }} 151 - placeholder="Enter link" 152 - /> 153 - <Button onclick={() => addLink(linkValue)} size="icon" 154 - ><svg 259 + {#if cardDef?.settingsComponent && selectedCard} 260 + <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 261 + {#snippet child({ props })} 262 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 263 + <svg 264 + xmlns="http://www.w3.org/2000/svg" 265 + fill="none" 266 + viewBox="0 0 24 24" 267 + stroke-width="2" 268 + stroke="currentColor" 269 + class="size-5" 270 + > 271 + <path 272 + stroke-linecap="round" 273 + stroke-linejoin="round" 274 + 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" 275 + /> 276 + <path 277 + stroke-linecap="round" 278 + stroke-linejoin="round" 279 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 280 + /> 281 + </svg> 282 + </button> 283 + {/snippet} 284 + <cardDef.settingsComponent 285 + bind:item={selectedCard} 286 + onclose={() => { 287 + settingsPopoverOpen = false; 288 + }} 289 + /> 290 + </Popover> 291 + {/if} 292 + </div> 293 + <div class="flex items-center gap-1"> 294 + <Button 295 + size="iconLg" 296 + variant="ghost" 297 + class="text-rose-500 backdrop-blur-none" 298 + onclick={() => ondelete?.()} 299 + > 300 + <svg 155 301 xmlns="http://www.w3.org/2000/svg" 156 302 fill="none" 157 303 viewBox="0 0 24 24" 158 - stroke-width="2" 304 + stroke-width="1.5" 159 305 stroke="currentColor" 160 - class="size-6" 306 + class="size-5" 161 307 > 162 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 308 + <path 309 + stroke-linecap="round" 310 + stroke-linejoin="round" 311 + d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 312 + /> 163 313 </svg> 164 314 </Button> 165 - </Popover> 166 - 167 - <Button 168 - size="iconLg" 169 - variant="ghost" 170 - class="backdrop-blur-none" 171 - onclick={() => { 172 - imageInputRef?.click(); 173 - }} 174 - > 175 - <svg 176 - xmlns="http://www.w3.org/2000/svg" 177 - fill="none" 178 - viewBox="0 0 24 24" 179 - stroke-width="2" 180 - stroke="currentColor" 181 - > 182 - <path 183 - stroke-linecap="round" 184 - stroke-linejoin="round" 185 - d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 186 - /> 187 - </svg> 188 - </Button> 189 - 190 - {#if dev} 191 315 <Button 192 316 size="iconLg" 193 317 variant="ghost" 194 318 class="backdrop-blur-none" 195 - onclick={() => { 196 - videoInputRef?.click(); 197 - }} 319 + onclick={() => ondeselect?.()} 198 320 > 199 321 <svg 200 322 xmlns="http://www.w3.org/2000/svg" 201 323 fill="none" 202 324 viewBox="0 0 24 24" 325 + stroke-width="2" 326 + stroke="currentColor" 327 + class="size-5" 328 + > 329 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 330 + </svg> 331 + </Button> 332 + </div> 333 + {:else} 334 + <div class="flex items-center gap-2"> 335 + <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 336 + <svg 337 + xmlns="http://www.w3.org/2000/svg" 338 + fill="none" 339 + viewBox="0 0 24 24" 203 340 stroke-width="1.5" 204 341 stroke="currentColor" 205 342 > 206 - <path 207 - stroke-linecap="round" 208 - stroke-linejoin="round" 209 - d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" 210 - /> 343 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 211 344 </svg> 212 345 </Button> 213 - {/if} 214 - 215 - <Button size="iconLg" variant="ghost" class="backdrop-blur-none" popovertarget="mobile-menu"> 216 - <svg 217 - xmlns="http://www.w3.org/2000/svg" 218 - fill="none" 219 - viewBox="0 0 24 24" 220 - stroke-width="1.5" 221 - stroke="currentColor" 222 - > 223 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 224 - </svg> 225 - </Button> 226 - </div> 227 - <div class="flex items-center gap-2"> 228 - <Button 229 - size="iconLg" 230 - variant="ghost" 231 - class="backdrop-blur-none" 232 - onclick={() => { 233 - showSettings = true; 234 - }} 235 - > 236 - <svg 237 - xmlns="http://www.w3.org/2000/svg" 238 - fill="none" 239 - viewBox="0 0 24 24" 240 - stroke-width="1.5" 241 - stroke="currentColor" 242 - > 243 - <path 244 - stroke-linecap="round" 245 - stroke-linejoin="round" 246 - 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" 247 - /> 248 - <path 249 - stroke-linecap="round" 250 - stroke-linejoin="round" 251 - d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 252 - /> 253 - </svg> 254 - </Button> 346 + </div> 347 + {/if} 348 + <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 255 349 <Toggle 256 350 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 257 351 bind:pressed={showingMobileView} ··· 271 365 /> 272 366 </svg> 273 367 </Toggle> 274 - <Button 275 - disabled={isSaving} 276 - onclick={async () => { 277 - save(); 278 - }}>{isSaving ? 'Saving...' : 'Save'}</Button 279 - > 368 + {#if hasUnsavedChanges} 369 + <Button 370 + disabled={isSaving} 371 + onclick={async () => { 372 + save(); 373 + }}>{isSaving ? 'Saving...' : 'Save'}</Button 374 + > 375 + {:else} 376 + <Button onclick={copyShareLink}> 377 + <svg 378 + xmlns="http://www.w3.org/2000/svg" 379 + fill="none" 380 + viewBox="0 0 24 24" 381 + stroke-width="1.5" 382 + stroke="currentColor" 383 + class="size-5" 384 + > 385 + <path 386 + stroke-linecap="round" 387 + stroke-linejoin="round" 388 + d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" 389 + /> 390 + </svg> 391 + Share 392 + </Button> 393 + {/if} 280 394 </div> 281 395 </Navbar> 282 396 {/if}
+146
src/lib/website/EditableProfile.svelte
··· 1 + <script lang="ts"> 2 + import type { WebsiteData } from '$lib/types'; 3 + import { getImage, compressImage, getProfilePosition } from '$lib/helper'; 4 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 + import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 + import { Avatar, Button } from '@foxui/core'; 7 + import { getIsMobile } from './context'; 8 + import MadeWithBlento from './MadeWithBlento.svelte'; 9 + import { SelectThemePopover } from '$lib/components/select-theme'; 10 + 11 + let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } = 12 + $props(); 13 + 14 + let fileInput: HTMLInputElement; 15 + let isHoveringAvatar = $state(false); 16 + 17 + async function handleAvatarChange(event: Event) { 18 + const target = event.target as HTMLInputElement; 19 + const file = target.files?.[0]; 20 + if (!file) return; 21 + 22 + try { 23 + const compressedBlob = await compressImage(file); 24 + const objectUrl = URL.createObjectURL(compressedBlob); 25 + 26 + data.publication.icon = { 27 + blob: compressedBlob, 28 + objectUrl 29 + } as any; 30 + 31 + data = { ...data }; 32 + } catch (error) { 33 + console.error('Failed to process image:', error); 34 + } 35 + } 36 + 37 + function getAvatarUrl(): string | undefined { 38 + const customIcon = getImage(data.publication, data.did, 'icon'); 39 + if (customIcon) return customIcon; 40 + return data.profile.avatar; 41 + } 42 + 43 + function handleFileInputClick() { 44 + fileInput.click(); 45 + } 46 + 47 + let profilePosition = $derived(getProfilePosition(data)); 48 + </script> 49 + 50 + <div 51 + class={[ 52 + 'relative mx-auto flex max-w-lg flex-col justify-between px-8', 53 + profilePosition === 'side' 54 + ? '@5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12' 55 + : '@5xl/wrapper:max-w-4xl @5xl/wrapper:px-12' 56 + ]} 57 + > 58 + <div 59 + class={[ 60 + 'flex flex-col gap-4 pt-16 pb-4', 61 + profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 62 + ]} 63 + > 64 + <!-- Avatar with edit capability --> 65 + <button 66 + type="button" 67 + class={[ 68 + 'group relative size-32 shrink-0 cursor-pointer overflow-hidden rounded-full', 69 + profilePosition === 'side' && '@5xl/wrapper:size-44' 70 + ]} 71 + onmouseenter={() => (isHoveringAvatar = true)} 72 + onmouseleave={() => (isHoveringAvatar = false)} 73 + onclick={handleFileInputClick} 74 + > 75 + <Avatar 76 + src={getAvatarUrl()} 77 + class={[ 78 + 'border-base-400 dark:border-base-800 size-32 shrink-0 rounded-full border object-cover', 79 + profilePosition === 'side' && '@5xl/wrapper:size-44' 80 + ]} 81 + /> 82 + 83 + <!-- Hover overlay --> 84 + <div 85 + class={[ 86 + 'absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity duration-200', 87 + isHoveringAvatar ? 'opacity-100' : 'opacity-0' 88 + ]} 89 + > 90 + <div class="text-center text-sm text-white"> 91 + <svg 92 + xmlns="http://www.w3.org/2000/svg" 93 + fill="none" 94 + viewBox="0 0 24 24" 95 + stroke-width="1.5" 96 + stroke="currentColor" 97 + class="mx-auto mb-1 size-6" 98 + > 99 + <path 100 + stroke-linecap="round" 101 + stroke-linejoin="round" 102 + d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" 103 + /> 104 + <path 105 + stroke-linecap="round" 106 + stroke-linejoin="round" 107 + d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" 108 + /> 109 + </svg> 110 + <span class="font-medium">Click to change</span> 111 + </div> 112 + </div> 113 + </button> 114 + 115 + <input 116 + bind:this={fileInput} 117 + type="file" 118 + accept="image/*" 119 + class="hidden" 120 + onchange={handleAvatarChange} 121 + /> 122 + 123 + <!-- Editable Name --> 124 + {#if data.publication} 125 + <div class="text-4xl font-bold wrap-anywhere"> 126 + <PlainTextEditor bind:contentDict={data.publication} key="name" placeholder="Your name" /> 127 + </div> 128 + {/if} 129 + 130 + <!-- Editable Description --> 131 + <div class="scrollbar -mx-4 grow overflow-x-hidden overflow-y-scroll px-4"> 132 + {#if data.publication} 133 + <MarkdownTextEditor 134 + bind:contentDict={data.publication} 135 + key="description" 136 + placeholder="Something about me..." 137 + class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline" 138 + /> 139 + {/if} 140 + </div> 141 + 142 + {#if !hideBlento} 143 + <MadeWithBlento class="hidden {profilePosition === 'side' && '@5xl/wrapper:block'}" /> 144 + {/if} 145 + </div> 146 + </div>
+818 -89
src/lib/website/EditableWebsite.svelte
··· 1 1 <script lang="ts"> 2 - import { Button, toast, Toaster, Sidebar } from '@foxui/core'; 2 + import { Button, Modal, toast, Toaster, Sidebar } from '@foxui/core'; 3 3 import { COLUMNS, margin, mobileMargin } from '$lib'; 4 4 import { 5 + checkAndUploadImage, 5 6 clamp, 6 7 compactItems, 7 8 createEmptyCard, 9 + findValidPosition, 10 + fixAllCollisions, 8 11 fixCollisions, 9 12 getHideProfileSection, 13 + getProfilePosition, 10 14 getName, 11 15 isTyping, 12 16 savePage, 13 17 scrollToItem, 14 18 setPositionOfNewItem, 15 - validateLink 19 + validateLink, 20 + getImage 16 21 } from '../helper'; 17 - import Profile from './Profile.svelte'; 22 + import EditableProfile from './EditableProfile.svelte'; 18 23 import type { Item, WebsiteData } from '../types'; 19 24 import { innerWidth } from 'svelte/reactivity/window'; 20 25 import EditingCard from '../cards/Card/EditingCard.svelte'; 21 26 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 22 27 import { tick, type Component } from 'svelte'; 23 - import type { CreationModalComponentProps } from '../cards/types'; 28 + import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 24 29 import { dev } from '$app/environment'; 25 - import { setIsMobile } from './context'; 30 + import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 26 31 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 27 32 import Context from './Context.svelte'; 28 - import Settings from './Settings.svelte'; 29 33 import Head from './Head.svelte'; 30 - import { compressImage } from '../helper'; 31 34 import Account from './Account.svelte'; 35 + import { SelectThemePopover } from '$lib/components/select-theme'; 32 36 import EditBar from './EditBar.svelte'; 37 + import SaveModal from './SaveModal.svelte'; 38 + import FloatingEditButton from './FloatingEditButton.svelte'; 39 + import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; 40 + import * as TID from '@atcute/tid'; 41 + import { launchConfetti } from '@foxui/visual'; 42 + import Controls from './Controls.svelte'; 43 + import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 44 + import { shouldMirror, mirrorLayout } from './layout-mirror'; 45 + import { SvelteMap } from 'svelte/reactivity'; 33 46 34 47 let { 35 48 data ··· 37 50 data: WebsiteData; 38 51 } = $props(); 39 52 53 + // Check if floating login button will be visible (to hide MadeWithBlento) 54 + const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 55 + 56 + function updateTheme(newAccent: string, newBase: string) { 57 + data.publication.preferences ??= {}; 58 + data.publication.preferences.accentColor = newAccent; 59 + data.publication.preferences.baseColor = newBase; 60 + data = { ...data }; 61 + } 62 + 40 63 let imageDragOver = $state(false); 41 64 42 65 // svelte-ignore state_referenced_locally ··· 45 68 // svelte-ignore state_referenced_locally 46 69 let publication = $state(JSON.stringify(data.publication)); 47 70 71 + // Track saved state for comparison 72 + // svelte-ignore state_referenced_locally 73 + let savedItems = $state(JSON.stringify(data.cards)); 74 + // svelte-ignore state_referenced_locally 75 + let savedPublication = $state(JSON.stringify(data.publication)); 76 + 77 + let hasUnsavedChanges = $state(false); 78 + 79 + $effect(() => { 80 + if (!hasUnsavedChanges) { 81 + hasUnsavedChanges = 82 + JSON.stringify(items) !== savedItems || 83 + JSON.stringify(data.publication) !== savedPublication; 84 + } 85 + }); 86 + 87 + // Warn user before closing tab if there are unsaved changes 88 + $effect(() => { 89 + function handleBeforeUnload(e: BeforeUnloadEvent) { 90 + if (hasUnsavedChanges) { 91 + e.preventDefault(); 92 + return ''; 93 + } 94 + } 95 + 96 + window.addEventListener('beforeunload', handleBeforeUnload); 97 + return () => window.removeEventListener('beforeunload', handleBeforeUnload); 98 + }); 99 + 48 100 let container: HTMLDivElement | undefined = $state(); 49 101 50 102 let activeDragElement: { ··· 77 129 78 130 let showingMobileView = $state(false); 79 131 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 132 + let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024); 80 133 81 134 setIsMobile(() => isMobile); 82 135 136 + // svelte-ignore state_referenced_locally 137 + let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 138 + 139 + function onLayoutChanged() { 140 + // Set the bit for the current layout: desktop=1, mobile=2 141 + editedOn = editedOn | (isMobile ? 2 : 1); 142 + if (shouldMirror(editedOn)) { 143 + mirrorLayout(items, isMobile); 144 + } 145 + } 146 + 147 + const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 148 + setIsCoarse(() => isCoarse); 149 + 150 + let selectedCardId: string | null = $state(null); 151 + let selectedCard = $derived( 152 + selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 153 + ); 154 + 155 + setSelectedCardId(() => selectedCardId); 156 + setSelectCard((id: string | null) => { 157 + selectedCardId = id; 158 + }); 159 + 83 160 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 84 161 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 85 162 86 163 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 87 164 165 + function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { 166 + if (!container) return undefined; 167 + const rect = container.getBoundingClientRect(); 168 + const currentMargin = isMobile ? mobileMargin : margin; 169 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 170 + const viewportCenterY = window.innerHeight / 2; 171 + const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 172 + return { gridY, isMobile }; 173 + } 174 + 88 175 function newCard(type: string = 'link', cardData?: any) { 176 + selectedCardId = null; 177 + 89 178 // close sidebar if open 90 179 const popover = document.getElementById('mobile-menu'); 91 180 if (popover) { ··· 109 198 } 110 199 } 111 200 201 + function cleanupDialogArtifacts() { 202 + // bits-ui's body scroll lock and portal may not clean up fully when the 203 + // modal is unmounted instead of closed via the open prop. 204 + const restore = () => { 205 + document.body.style.removeProperty('overflow'); 206 + document.body.style.removeProperty('pointer-events'); 207 + document.body.style.removeProperty('padding-right'); 208 + document.body.style.removeProperty('margin-right'); 209 + // Remove any orphaned dialog overlay/content elements left by the portal 210 + for (const el of document.querySelectorAll('[data-dialog-overlay], [data-dialog-content]')) { 211 + el.remove(); 212 + } 213 + }; 214 + // Run immediately and again after bits-ui's 24ms scheduled cleanup 215 + restore(); 216 + setTimeout(restore, 50); 217 + } 218 + 112 219 async function saveNewItem() { 113 220 if (!newItem.item) return; 114 221 const item = newItem.item; 115 222 116 - setPositionOfNewItem(item, items); 223 + const viewportCenter = getViewportCenterGridY(); 224 + setPositionOfNewItem(item, items, viewportCenter); 117 225 118 226 items = [...items, item]; 119 227 228 + // Push overlapping items down, then compact to fill gaps 229 + fixCollisions(items, item, false, true); 230 + fixCollisions(items, item, true, true); 231 + compactItems(items, false); 232 + compactItems(items, true); 233 + 234 + onLayoutChanged(); 235 + 120 236 newItem = {}; 121 237 122 238 await tick(); 239 + cleanupDialogArtifacts(); 123 240 124 241 scrollToItem(item, isMobile, container); 125 242 } 126 243 127 244 let isSaving = $state(false); 245 + let showSaveModal = $state(false); 246 + let saveSuccess = $state(false); 128 247 129 248 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 130 249 131 250 async function save() { 132 251 isSaving = true; 252 + saveSuccess = false; 253 + showSaveModal = true; 133 254 134 255 try { 256 + // Upload profile icon if changed 257 + if (data.publication?.icon) { 258 + await checkAndUploadImage(data.publication, 'icon'); 259 + } 260 + 261 + // Persist layout editing state 262 + data.publication.preferences ??= {}; 263 + data.publication.preferences.editedOn = editedOn; 264 + 135 265 await savePage(data, items, publication); 136 266 137 267 publication = JSON.stringify(data.publication); 138 - } catch { 268 + 269 + // Update saved state 270 + savedItems = JSON.stringify(items); 271 + savedPublication = JSON.stringify(data.publication); 272 + 273 + saveSuccess = true; 274 + 275 + launchConfetti(); 276 + 277 + // Refresh cached data 278 + await fetch('/' + data.handle + '/api/refresh'); 279 + } catch (error) { 280 + console.error(error); 281 + showSaveModal = false; 139 282 toast.error('Error saving page!'); 140 283 } finally { 141 284 isSaving = false; 142 285 } 143 286 } 144 287 145 - const sidebarItems = AllCardDefinitions.filter( 146 - (cardDef) => cardDef.sidebarComponent || cardDef.sidebarButtonText 147 - ); 288 + function addAllCardTypes() { 289 + const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; 290 + const grouped = new SvelteMap<string, CardDefinition[]>(); 148 291 149 - let showSettings = $state(false); 292 + for (const def of AllCardDefinitions) { 293 + if (!def.name) continue; 294 + const group = def.groups?.[0] ?? 'Other'; 295 + if (!grouped.has(group)) grouped.set(group, []); 296 + grouped.get(group)!.push(def); 297 + } 150 298 151 - let debugPoint = $state({ x: 0, y: 0 }); 299 + // Sort groups by predefined order, unknowns at end 300 + const sortedGroups = [...grouped.keys()].sort((a, b) => { 301 + const ai = groupOrder.indexOf(a); 302 + const bi = groupOrder.indexOf(b); 303 + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); 304 + }); 152 305 153 - function getDragXY( 154 - e: DragEvent & { 155 - currentTarget: EventTarget & HTMLDivElement; 306 + // Sample data for cards that would otherwise render empty 307 + const sampleData: Record<string, Record<string, unknown>> = { 308 + text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' }, 309 + link: { 310 + href: 'https://bsky.app', 311 + title: 'Bluesky', 312 + domain: 'bsky.app', 313 + description: 'Social networking that gives you choice', 314 + hasFetched: true 315 + }, 316 + image: { 317 + image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600', 318 + alt: 'Mountain landscape' 319 + }, 320 + button: { text: 'Visit Bluesky', href: 'https://bsky.app' }, 321 + bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' }, 322 + blueskyPost: { 323 + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y', 324 + href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y' 325 + }, 326 + blueskyProfile: { 327 + handle: 'bsky.app', 328 + displayName: 'Bluesky', 329 + avatar: 330 + 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg' 331 + }, 332 + blueskyMedia: {}, 333 + latestPost: {}, 334 + youtubeVideo: { 335 + youtubeId: 'dQw4w9WgXcQ', 336 + poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 337 + href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 338 + showInline: true 339 + }, 340 + 'spotify-list-embed': { 341 + spotifyType: 'album', 342 + spotifyId: '4aawyAB9vmqN3uQ7FjRGTy', 343 + href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy' 344 + }, 345 + latestLivestream: {}, 346 + livestreamEmbed: { 347 + href: 'https://stream.place/', 348 + embed: 'https://stream.place/embed/' 349 + }, 350 + mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' }, 351 + gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' }, 352 + event: { 353 + uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q' 354 + }, 355 + guestbook: { label: 'Guestbook' }, 356 + githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' }, 357 + photoGallery: { 358 + galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w' 359 + }, 360 + atprotocollections: {}, 361 + publicationList: {}, 362 + recentPopfeedReviews: {}, 363 + recentTealFMPlays: {}, 364 + statusphere: { emoji: 'โœจ' }, 365 + vcard: {}, 366 + 'fluid-text': { text: 'Hello World' }, 367 + draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true }, 368 + clock: {}, 369 + countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() }, 370 + timer: {}, 371 + 'dino-game': {}, 372 + tetris: {}, 373 + updatedBlentos: {} 374 + }; 375 + 376 + // Labels for cards that support canHaveLabel 377 + const sampleLabels: Record<string, string> = { 378 + image: 'Mountain Landscape', 379 + mapLocation: 'Eiffel Tower', 380 + gif: 'Cat Typing', 381 + bigsocial: 'Bluesky', 382 + guestbook: 'Guestbook', 383 + statusphere: 'My Status', 384 + recentPopfeedReviews: 'My Reviews', 385 + recentTealFMPlays: 'Recently Played', 386 + clock: 'Local Time', 387 + countdown: 'Launch Day', 388 + timer: 'Timer', 389 + 'dino-game': 'Dino Game', 390 + tetris: 'Tetris', 391 + blueskyMedia: 'Bluesky Media' 392 + }; 393 + 394 + const newItems: Item[] = []; 395 + let cursorY = 0; 396 + let mobileCursorY = 0; 397 + 398 + for (const group of sortedGroups) { 399 + const defs = grouped.get(group)!; 400 + 401 + // Add a section heading for the group 402 + const heading = createEmptyCard(data.page); 403 + heading.cardType = 'section'; 404 + heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 }; 405 + heading.w = COLUMNS; 406 + heading.h = 1; 407 + heading.x = 0; 408 + heading.y = cursorY; 409 + heading.mobileW = COLUMNS; 410 + heading.mobileH = 2; 411 + heading.mobileX = 0; 412 + heading.mobileY = mobileCursorY; 413 + newItems.push(heading); 414 + cursorY += 1; 415 + mobileCursorY += 2; 416 + 417 + // Place cards in rows 418 + let rowX = 0; 419 + let rowMaxH = 0; 420 + let mobileRowX = 0; 421 + let mobileRowMaxH = 0; 422 + 423 + for (const def of defs) { 424 + if (def.type === 'section' || def.type === 'embed') continue; 425 + 426 + const item = createEmptyCard(data.page); 427 + item.cardType = def.type; 428 + item.cardData = {}; 429 + def.createNew?.(item); 430 + 431 + // Merge in sample data (without overwriting createNew defaults) 432 + const extra = sampleData[def.type]; 433 + if (extra) { 434 + item.cardData = { ...item.cardData, ...extra }; 435 + } 436 + 437 + // Set item-level color for cards that need it 438 + if (def.type === 'button') { 439 + item.color = 'transparent'; 440 + } 441 + 442 + // Add label if card supports it 443 + const label = sampleLabels[def.type]; 444 + if (label && def.canHaveLabel) { 445 + item.cardData.label = label; 446 + } 447 + 448 + // Desktop layout 449 + if (rowX + item.w > COLUMNS) { 450 + cursorY += rowMaxH; 451 + rowX = 0; 452 + rowMaxH = 0; 453 + } 454 + item.x = rowX; 455 + item.y = cursorY; 456 + rowX += item.w; 457 + rowMaxH = Math.max(rowMaxH, item.h); 458 + 459 + // Mobile layout 460 + if (mobileRowX + item.mobileW > COLUMNS) { 461 + mobileCursorY += mobileRowMaxH; 462 + mobileRowX = 0; 463 + mobileRowMaxH = 0; 464 + } 465 + item.mobileX = mobileRowX; 466 + item.mobileY = mobileCursorY; 467 + mobileRowX += item.mobileW; 468 + mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH); 469 + 470 + newItems.push(item); 471 + } 472 + 473 + // Move cursor past last row 474 + cursorY += rowMaxH; 475 + mobileCursorY += mobileRowMaxH; 156 476 } 477 + 478 + items = newItems; 479 + onLayoutChanged(); 480 + } 481 + 482 + let copyInput = $state(''); 483 + let isCopying = $state(false); 484 + 485 + async function copyPageFrom() { 486 + const input = copyInput.trim(); 487 + if (!input) return; 488 + 489 + isCopying = true; 490 + try { 491 + // Parse "handle" or "handle/page" 492 + const parts = input.split('/'); 493 + const handle = parts[0]; 494 + const pageName = parts[1] || 'self'; 495 + 496 + const did = await resolveHandle({ handle: handle as `${string}.${string}` }); 497 + if (!did) throw new Error('Could not resolve handle'); 498 + 499 + const records = await listRecords({ did, collection: 'app.blento.card' }); 500 + const targetPage = 'blento.' + pageName; 501 + 502 + const copiedCards: Item[] = records 503 + .map((r) => ({ ...r.value }) as Item) 504 + .filter((card) => { 505 + // v0/v1 cards without page field belong to blento.self 506 + if (!card.page) return targetPage === 'blento.self'; 507 + return card.page === targetPage; 508 + }) 509 + .map((card) => { 510 + // Apply v0โ†’v1 migration (coords were halved in old format) 511 + if (!card.version) { 512 + card.x *= 2; 513 + card.y *= 2; 514 + card.h *= 2; 515 + card.w *= 2; 516 + card.mobileX *= 2; 517 + card.mobileY *= 2; 518 + card.mobileH *= 2; 519 + card.mobileW *= 2; 520 + card.version = 1; 521 + } 522 + 523 + // Convert blob refs to CDN URLs using source DID 524 + if (card.cardData) { 525 + for (const key of Object.keys(card.cardData)) { 526 + const val = card.cardData[key]; 527 + if (val && typeof val === 'object' && val.$type === 'blob') { 528 + const url = getCDNImageBlobUrl({ did, blob: val }); 529 + if (url) card.cardData[key] = url; 530 + } 531 + } 532 + } 533 + 534 + // Regenerate ID and assign to current page 535 + card.id = TID.now(); 536 + card.page = data.page; 537 + return card; 538 + }); 539 + 540 + if (copiedCards.length === 0) { 541 + toast.error('No cards found on that page'); 542 + return; 543 + } 544 + 545 + fixAllCollisions(copiedCards); 546 + fixAllCollisions(copiedCards, true); 547 + compactItems(copiedCards); 548 + compactItems(copiedCards, true); 549 + 550 + items = copiedCards; 551 + onLayoutChanged(); 552 + toast.success(`Copied ${copiedCards.length} cards from ${handle}`); 553 + } catch (e) { 554 + console.error('Failed to copy page:', e); 555 + toast.error('Failed to copy page'); 556 + } finally { 557 + isCopying = false; 558 + } 559 + } 560 + 561 + let debugPoint = $state({ x: 0, y: 0 }); 562 + 563 + function getGridPosition( 564 + clientX: number, 565 + clientY: number 157 566 ): 158 567 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 159 568 | undefined { 160 569 if (!container || !activeDragElement.item) return; 161 570 162 571 // x, y represent the top-left corner of the dragged card 163 - const x = e.clientX + activeDragElement.mouseDeltaX; 164 - const y = e.clientY + activeDragElement.mouseDeltaY; 572 + const x = clientX + activeDragElement.mouseDeltaX; 573 + const y = clientY + activeDragElement.mouseDeltaY; 165 574 166 575 const rect = container.getBoundingClientRect(); 167 576 const currentMargin = isMobile ? mobileMargin : margin; ··· 283 692 return { x: gridX, y: gridY, swapWithId, placement }; 284 693 } 285 694 695 + function getDragXY( 696 + e: DragEvent & { 697 + currentTarget: EventTarget & HTMLDivElement; 698 + } 699 + ) { 700 + return getGridPosition(e.clientX, e.clientY); 701 + } 702 + 703 + // Touch drag system (instant drag on selected card) 704 + let touchDragActive = $state(false); 705 + 706 + function touchStart(e: TouchEvent) { 707 + if (!selectedCardId || !container) return; 708 + const touch = e.touches[0]; 709 + if (!touch) return; 710 + 711 + // Check if the touch is on the selected card element 712 + const target = (e.target as HTMLElement)?.closest?.('.card'); 713 + if (!target || target.id !== selectedCardId) return; 714 + 715 + const item = items.find((i) => i.id === selectedCardId); 716 + if (!item || item.cardData?.locked) return; 717 + 718 + // Start dragging immediately 719 + touchDragActive = true; 720 + 721 + const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 722 + if (!cardEl) return; 723 + 724 + activeDragElement.element = cardEl; 725 + activeDragElement.w = item.w; 726 + activeDragElement.h = item.h; 727 + activeDragElement.item = item; 728 + 729 + // Store original positions of all items 730 + activeDragElement.originalPositions = new Map(); 731 + for (const it of items) { 732 + activeDragElement.originalPositions.set(it.id, { 733 + x: it.x, 734 + y: it.y, 735 + mobileX: it.mobileX, 736 + mobileY: it.mobileY 737 + }); 738 + } 739 + 740 + const rect = cardEl.getBoundingClientRect(); 741 + activeDragElement.mouseDeltaX = rect.left - touch.clientX; 742 + activeDragElement.mouseDeltaY = rect.top - touch.clientY; 743 + } 744 + 745 + function touchMove(e: TouchEvent) { 746 + if (!touchDragActive) return; 747 + 748 + const touch = e.touches[0]; 749 + if (!touch) return; 750 + 751 + e.preventDefault(); 752 + 753 + const result = getGridPosition(touch.clientX, touch.clientY); 754 + if (!result || !activeDragElement.item) return; 755 + 756 + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 757 + 758 + // Reset all items to original positions first 759 + for (const it of items) { 760 + const origPos = activeDragElement.originalPositions.get(it.id); 761 + if (origPos && it !== activeDragElement.item) { 762 + if (isMobile) { 763 + it.mobileX = origPos.mobileX; 764 + it.mobileY = origPos.mobileY; 765 + } else { 766 + it.x = origPos.x; 767 + it.y = origPos.y; 768 + } 769 + } 770 + } 771 + 772 + // Update dragged item position 773 + if (isMobile) { 774 + activeDragElement.item.mobileX = result.x; 775 + activeDragElement.item.mobileY = result.y; 776 + } else { 777 + activeDragElement.item.x = result.x; 778 + activeDragElement.item.y = result.y; 779 + } 780 + 781 + // Handle horizontal swap 782 + if (result.swapWithId && draggedOrigPos) { 783 + const swapTarget = items.find((it) => it.id === result.swapWithId); 784 + if (swapTarget) { 785 + if (isMobile) { 786 + swapTarget.mobileX = draggedOrigPos.mobileX; 787 + swapTarget.mobileY = draggedOrigPos.mobileY; 788 + } else { 789 + swapTarget.x = draggedOrigPos.x; 790 + swapTarget.y = draggedOrigPos.y; 791 + } 792 + } 793 + } 794 + 795 + fixCollisions(items, activeDragElement.item, isMobile); 796 + 797 + // Auto-scroll near edges 798 + const scrollZone = 100; 799 + const scrollSpeed = 10; 800 + const viewportHeight = window.innerHeight; 801 + 802 + if (touch.clientY < scrollZone) { 803 + const intensity = 1 - touch.clientY / scrollZone; 804 + window.scrollBy(0, -scrollSpeed * intensity); 805 + } else if (touch.clientY > viewportHeight - scrollZone) { 806 + const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 807 + window.scrollBy(0, scrollSpeed * intensity); 808 + } 809 + } 810 + 811 + function touchEnd() { 812 + if (touchDragActive && activeDragElement.item) { 813 + // Finalize position 814 + fixCollisions(items, activeDragElement.item, isMobile); 815 + onLayoutChanged(); 816 + 817 + activeDragElement.x = -1; 818 + activeDragElement.y = -1; 819 + activeDragElement.element = null; 820 + activeDragElement.item = null; 821 + activeDragElement.lastTargetId = null; 822 + activeDragElement.lastPlacement = null; 823 + } 824 + 825 + touchDragActive = false; 826 + } 827 + 828 + // Only register non-passive touchmove when actively dragging 829 + $effect(() => { 830 + const el = container; 831 + if (!touchDragActive || !el) return; 832 + 833 + el.addEventListener('touchmove', touchMove, { passive: false }); 834 + return () => { 835 + el.removeEventListener('touchmove', touchMove); 836 + }; 837 + }); 838 + 286 839 let linkValue = $state(''); 287 840 288 - function addLink(url: string) { 841 + function addLink(url: string, specificCardDef?: CardDefinition) { 289 842 let link = validateLink(url); 290 843 if (!link) { 291 844 toast.error('invalid link'); ··· 293 846 } 294 847 let item = createEmptyCard(data.page); 295 848 849 + if (specificCardDef?.onUrlHandler?.(link, item)) { 850 + item.cardType = specificCardDef.type; 851 + newItem.item = item; 852 + saveNewItem(); 853 + toast(specificCardDef.name + ' added!'); 854 + return; 855 + } 856 + 296 857 for (const cardDef of AllCardDefinitions.toSorted( 297 858 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 298 859 )) { ··· 305 866 break; 306 867 } 307 868 } 869 + } 870 + 871 + function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 872 + return new Promise((resolve) => { 873 + const img = new Image(); 874 + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 875 + img.onerror = () => resolve({ width: 1, height: 1 }); 876 + img.src = src; 877 + }); 878 + } 308 879 309 - if (linkValue === url) { 310 - linkValue = ''; 880 + function getBestGridSize( 881 + imageWidth: number, 882 + imageHeight: number, 883 + candidates: [number, number][] 884 + ): [number, number] { 885 + const imageRatio = imageWidth / imageHeight; 886 + let best: [number, number] = candidates[0]; 887 + let bestDiff = Infinity; 888 + 889 + for (const candidate of candidates) { 890 + const gridRatio = candidate[0] / candidate[1]; 891 + const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 892 + if (diff < bestDiff) { 893 + bestDiff = diff; 894 + best = candidate; 895 + } 311 896 } 897 + 898 + return best; 312 899 } 313 900 901 + const desktopSizeCandidates: [number, number][] = [ 902 + [2, 2], 903 + [2, 4], 904 + [4, 2], 905 + [4, 4], 906 + [4, 6], 907 + [6, 4] 908 + ]; 909 + const mobileSizeCandidates: [number, number][] = [ 910 + [4, 4], 911 + [4, 6], 912 + [4, 8], 913 + [6, 4], 914 + [8, 4], 915 + [8, 6] 916 + ]; 917 + 314 918 async function processImageFile(file: File, gridX?: number, gridY?: number) { 315 919 const isGif = file.type === 'image/gif'; 316 920 317 921 // Don't compress GIFs to preserve animation 318 - const processedFile = isGif ? file : await compressImage(file); 319 - const objectUrl = URL.createObjectURL(processedFile); 922 + const objectUrl = URL.createObjectURL(file); 320 923 321 924 let item = createEmptyCard(data.page); 322 925 323 926 item.cardType = isGif ? 'gif' : 'image'; 324 927 item.cardData = { 325 - blob: processedFile, 326 - objectUrl 928 + image: { blob: file, objectUrl } 327 929 }; 328 930 329 - // If grid position is provided 931 + // Size card based on image aspect ratio 932 + const { width, height } = await getImageDimensions(objectUrl); 933 + const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 934 + const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 935 + item.w = dw; 936 + item.h = dh; 937 + item.mobileW = mw; 938 + item.mobileH = mh; 939 + 940 + // If grid position is provided (image dropped on grid) 330 941 if (gridX !== undefined && gridY !== undefined) { 331 942 if (isMobile) { 332 943 item.mobileX = gridX; 333 944 item.mobileY = gridY; 945 + // Derive desktop Y from mobile 946 + item.x = Math.floor((COLUMNS - item.w) / 2); 947 + item.x = Math.floor(item.x / 2) * 2; 948 + item.y = Math.max(0, Math.round(gridY / 2)); 334 949 } else { 335 950 item.x = gridX; 336 951 item.y = gridY; 952 + // Derive mobile Y from desktop 953 + item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 954 + item.mobileX = Math.floor(item.mobileX / 2) * 2; 955 + item.mobileY = Math.max(0, Math.round(gridY * 2)); 337 956 } 338 957 339 958 items = [...items, item]; 340 959 fixCollisions(items, item, isMobile); 960 + fixCollisions(items, item, !isMobile); 341 961 } else { 342 - setPositionOfNewItem(item, items); 962 + const viewportCenter = getViewportCenterGridY(); 963 + setPositionOfNewItem(item, items, viewportCenter); 343 964 items = [...items, item]; 965 + fixCollisions(items, item, false, true); 966 + fixCollisions(items, item, true, true); 967 + compactItems(items, false); 968 + compactItems(items, true); 344 969 } 345 970 971 + onLayoutChanged(); 972 + 346 973 await tick(); 347 974 348 975 scrollToItem(item, isMobile, container); ··· 417 1044 } 418 1045 } 419 1046 420 - for (const file of imageFiles) { 421 - await processImageFile(file, gridX, gridY); 422 - 423 - // Move to next cell position 424 - const cardW = isMobile ? 4 : 2; 425 - gridX += cardW; 426 - if (gridX + cardW > COLUMNS) { 427 - gridX = 0; 428 - gridY += isMobile ? 4 : 2; 1047 + for (let i = 0; i < imageFiles.length; i++) { 1048 + // First image gets the drop position, rest use normal placement 1049 + if (i === 0) { 1050 + await processImageFile(imageFiles[i], gridX, gridY); 1051 + } else { 1052 + await processImageFile(imageFiles[i]); 429 1053 } 430 1054 } 431 1055 } ··· 473 1097 objectUrl 474 1098 }; 475 1099 476 - setPositionOfNewItem(item, items); 1100 + const viewportCenter = getViewportCenterGridY(); 1101 + setPositionOfNewItem(item, items, viewportCenter); 477 1102 items = [...items, item]; 1103 + fixCollisions(items, item, false, true); 1104 + fixCollisions(items, item, true, true); 1105 + compactItems(items, false); 1106 + compactItems(items, true); 1107 + 1108 + onLayoutChanged(); 478 1109 479 1110 await tick(); 480 1111 ··· 494 1125 // Reset the input so the same file can be selected again 495 1126 target.value = ''; 496 1127 } 1128 + 1129 + let showCardCommand = $state(false); 497 1130 </script> 498 1131 499 1132 <svelte:body ··· 515 1148 /> 516 1149 517 1150 <Head 518 - favicon={data.profile.avatar ?? null} 1151 + favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 519 1152 title={getName(data)} 520 1153 image={'/' + data.handle + '/og.png'} 1154 + accentColor={data.publication?.preferences?.accentColor} 1155 + baseColor={data.publication?.preferences?.baseColor} 521 1156 /> 522 1157 523 - <Settings bind:open={showSettings} bind:data /> 1158 + <Account {data} /> 524 1159 525 - <Account {data} /> 1160 + <Context {data} isEditing={true}> 1161 + <CardCommand 1162 + bind:open={showCardCommand} 1163 + onselect={(cardDef: CardDefinition) => { 1164 + if (cardDef.type === 'image') { 1165 + const input = document.getElementById('image-input') as HTMLInputElement; 1166 + if (input) { 1167 + input.click(); 1168 + return; 1169 + } 1170 + } else if (cardDef.type === 'video') { 1171 + const input = document.getElementById('video-input') as HTMLInputElement; 1172 + if (input) { 1173 + input.click(); 1174 + return; 1175 + } 1176 + } else { 1177 + newCard(cardDef.type); 1178 + } 1179 + }} 1180 + onlink={(url, cardDef) => { 1181 + addLink(url, cardDef); 1182 + }} 1183 + /> 526 1184 527 - <Context {data}> 528 - {#if !dev} 529 - <div 530 - class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 531 - > 532 - Editing on mobile is not supported yet. Please use a desktop browser. 533 - </div> 534 - {/if} 1185 + <Controls bind:data /> 535 1186 536 1187 {#if showingMobileView} 537 1188 <div ··· 545 1196 saveNewItem(); 546 1197 }} 547 1198 bind:item={newItem.item} 548 - oncancel={() => { 1199 + oncancel={async () => { 549 1200 newItem = {}; 1201 + await tick(); 1202 + cleanupDialogArtifacts(); 550 1203 }} 551 1204 /> 552 1205 {/if} 553 1206 1207 + <SaveModal 1208 + bind:open={showSaveModal} 1209 + success={saveSuccess} 1210 + handle={data.handle} 1211 + page={data.page} 1212 + /> 1213 + 1214 + <Modal open={showMobileWarning} closeButton={false}> 1215 + <div class="flex flex-col items-center gap-4 text-center"> 1216 + <svg 1217 + xmlns="http://www.w3.org/2000/svg" 1218 + fill="none" 1219 + viewBox="0 0 24 24" 1220 + stroke-width="1.5" 1221 + stroke="currentColor" 1222 + class="text-accent-500 size-10" 1223 + > 1224 + <path 1225 + stroke-linecap="round" 1226 + stroke-linejoin="round" 1227 + d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3" 1228 + /> 1229 + </svg> 1230 + <p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p> 1231 + <p class="text-base-500 dark:text-base-400 text-sm"> 1232 + Mobile editing is currently experimental. For the best experience, use a desktop browser. 1233 + </p> 1234 + <Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button> 1235 + </div> 1236 + </Modal> 1237 + 554 1238 <div 555 1239 class={[ 556 1240 '@container/wrapper relative w-full', 557 1241 showingMobileView 558 - ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-[375px]' 1242 + ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 559 1243 : '' 560 1244 ]} 561 1245 > 562 1246 {#if !getHideProfileSection(data)} 563 - <Profile {data} /> 1247 + <EditableProfile bind:data hideBlento={showLoginOnEditPage} /> 564 1248 {/if} 565 1249 566 1250 <div 567 1251 class={[ 568 - 'mx-auto max-w-lg', 569 - !getHideProfileSection(data) 1252 + 'pointer-events-none relative mx-auto max-w-lg', 1253 + !getHideProfileSection(data) && getProfilePosition(data) === 'side' 570 1254 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 571 1255 : '@5xl/wrapper:max-w-4xl' 572 1256 ]} 573 1257 > 574 - <div></div> 575 - <!-- svelte-ignore a11y_no_static_element_interactions --> 1258 + <div class="pointer-events-none"></div> 1259 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 576 1260 <div 577 1261 bind:this={container} 1262 + onclick={(e) => { 1263 + // Deselect when tapping empty grid space 1264 + if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 1265 + selectedCardId = null; 1266 + } 1267 + }} 1268 + ontouchstart={touchStart} 1269 + ontouchend={touchEnd} 578 1270 ondragover={(e) => { 579 1271 e.preventDefault(); 580 1272 ··· 649 1341 }} 650 1342 ondragend={async (e) => { 651 1343 e.preventDefault(); 652 - const cell = getDragXY(e); 653 - if (!cell) return; 654 - 655 - if (activeDragElement.item) { 656 - if (isMobile) { 657 - activeDragElement.item.mobileX = cell.x; 658 - activeDragElement.item.mobileY = cell.y; 659 - } else { 660 - activeDragElement.item.x = cell.x; 661 - activeDragElement.item.y = cell.y; 662 - } 663 - 664 - // Fix collisions and compact items after drag ends 665 - fixCollisions(items, activeDragElement.item, isMobile); 666 - } 1344 + // safari fix 667 1345 activeDragElement.x = -1; 668 1346 activeDragElement.y = -1; 669 1347 activeDragElement.element = null; ··· 673 1351 return true; 674 1352 }} 675 1353 class={[ 676 - '@container/grid relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 1354 + '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 677 1355 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 678 1356 ]} 679 1357 > ··· 683 1361 bind:item={items[i]} 684 1362 ondelete={() => { 685 1363 items = items.filter((it) => it !== item); 686 - compactItems(items, isMobile); 1364 + compactItems(items, false); 1365 + compactItems(items, true); 1366 + onLayoutChanged(); 687 1367 }} 688 1368 onsetsize={(newW: number, newH: number) => { 689 1369 if (isMobile) { ··· 695 1375 } 696 1376 697 1377 fixCollisions(items, item, isMobile); 1378 + onLayoutChanged(); 698 1379 }} 699 - ondragstart={(e) => { 1380 + ondragstart={(e: DragEvent) => { 700 1381 const target = e.currentTarget as HTMLDivElement; 701 1382 activeDragElement.element = target; 702 1383 activeDragElement.w = item.w; 703 1384 activeDragElement.h = item.h; 704 1385 activeDragElement.item = item; 1386 + // fix for div shadow during drag and drop 1387 + const transparent = document.createElement('div'); 1388 + transparent.style.position = 'fixed'; 1389 + transparent.style.top = '-1000px'; 1390 + transparent.style.width = '1px'; 1391 + transparent.style.height = '1px'; 1392 + document.body.appendChild(transparent); 1393 + e.dataTransfer?.setDragImage(transparent, 0, 0); 1394 + requestAnimationFrame(() => transparent.remove()); 705 1395 706 1396 // Store original positions of all items 707 1397 activeDragElement.originalPositions = new Map(); ··· 729 1419 </div> 730 1420 </div> 731 1421 732 - <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 733 - <div class="flex flex-col gap-2"> 734 - {#each sidebarItems as cardDef (cardDef.type)} 735 - {#if cardDef.sidebarComponent} 736 - <cardDef.sidebarComponent onclick={() => newCard(cardDef.type)} /> 737 - {:else if cardDef.sidebarButtonText} 738 - <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 739 - >{cardDef.sidebarButtonText}</Button 740 - > 741 - {/if} 742 - {/each} 743 - </div> 744 - </Sidebar> 745 - 746 1422 <EditBar 747 1423 {data} 748 1424 bind:linkValue 749 1425 bind:isSaving 750 1426 bind:showingMobileView 751 - bind:showSettings 1427 + {hasUnsavedChanges} 752 1428 {newCard} 753 1429 {addLink} 754 1430 {save} 755 1431 {handleImageInputChange} 756 1432 {handleVideoInputChange} 1433 + showCardCommand={() => { 1434 + showCardCommand = true; 1435 + }} 1436 + {selectedCard} 1437 + {isMobile} 1438 + {isCoarse} 1439 + ondeselect={() => { 1440 + selectedCardId = null; 1441 + }} 1442 + ondelete={() => { 1443 + if (selectedCard) { 1444 + items = items.filter((it) => it.id !== selectedCardId); 1445 + compactItems(items, false); 1446 + compactItems(items, true); 1447 + onLayoutChanged(); 1448 + selectedCardId = null; 1449 + } 1450 + }} 1451 + onsetsize={(w: number, h: number) => { 1452 + if (selectedCard) { 1453 + if (isMobile) { 1454 + selectedCard.mobileW = w; 1455 + selectedCard.mobileH = h; 1456 + } else { 1457 + selectedCard.w = w; 1458 + selectedCard.h = h; 1459 + } 1460 + fixCollisions(items, selectedCard, isMobile); 1461 + onLayoutChanged(); 1462 + } 1463 + }} 757 1464 /> 758 1465 759 1466 <Toaster /> 1467 + 1468 + <FloatingEditButton {data} /> 1469 + 1470 + {#if dev} 1471 + <div 1472 + class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs" 1473 + > 1474 + <span>editedOn: {editedOn}</span> 1475 + <button class="underline" onclick={addAllCardTypes}>+ all cards</button> 1476 + <input 1477 + bind:value={copyInput} 1478 + placeholder="handle/page" 1479 + class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5" 1480 + onkeydown={(e) => { 1481 + if (e.key === 'Enter') copyPageFrom(); 1482 + }} 1483 + /> 1484 + <button class="underline" onclick={copyPageFrom} disabled={isCopying}> 1485 + {isCopying ? 'copying...' : 'copy'} 1486 + </button> 1487 + </div> 1488 + {/if} 760 1489 </Context>
+106
src/lib/website/EmptyState.svelte
··· 1 + <script lang="ts"> 2 + import BaseCard from '$lib/cards/BaseCard/BaseCard.svelte'; 3 + import Card from '$lib/cards/Card/Card.svelte'; 4 + import type { Item, WebsiteData } from '$lib/types'; 5 + import { text } from '@sveltejs/kit'; 6 + 7 + let { data }: { data: WebsiteData } = $props(); 8 + 9 + let cards = $derived.by((): Item[] => { 10 + const items: Item[] = []; 11 + 12 + // Name + "No blento yet" card 13 + items.push({ 14 + id: 'empty-main', 15 + x: 0, 16 + y: 0, 17 + w: 6, 18 + h: 2, 19 + mobileX: 0, 20 + mobileY: 0, 21 + mobileW: 8, 22 + mobileH: 3, 23 + cardType: 'text', 24 + color: 'cyan', 25 + cardData: { 26 + text: `## No blento yet!`, 27 + textAlign: 'center', 28 + verticalAlign: 'center' 29 + } 30 + }); 31 + 32 + // Bluesky social icon 33 + items.push({ 34 + id: 'empty-bluesky', 35 + x: 6, 36 + y: 0, 37 + w: 2, 38 + h: 2, 39 + mobileX: 0, 40 + mobileY: 3, 41 + mobileW: 3, 42 + mobileH: 3, 43 + cardType: 'bigsocial', 44 + cardData: { 45 + platform: 'bluesky', 46 + href: `https://bsky.app/profile/${data.handle}`, 47 + color: '0285FF' 48 + } 49 + }); 50 + 51 + items.push({ 52 + id: 'empty-instruction', 53 + x: 0, 54 + y: 3, 55 + w: 8, 56 + h: 1, 57 + mobileX: 0, 58 + mobileY: 6, 59 + mobileW: 8, 60 + mobileH: 2, 61 + cardType: 'text', 62 + color: 'transparent', 63 + cardData: { 64 + text: `Is this your account? Login to start creating your blento!`, 65 + textAlign: 'center', 66 + verticalAlign: 'bottom' 67 + } 68 + }); 69 + 70 + items.push({ 71 + id: 'empty-login-button', 72 + x: 0, 73 + y: 4, 74 + w: 8, 75 + h: 1, 76 + mobileX: 0, 77 + mobileY: 8, 78 + mobileW: 8, 79 + mobileH: 2, 80 + cardType: 'button', 81 + color: 'transparent', 82 + cardData: { 83 + href: '#login', 84 + text: `Login` 85 + } 86 + }); 87 + 88 + return items; 89 + }); 90 + 91 + let maxHeight = $derived(cards.reduce((max, item) => Math.max(max, item.y + item.h), 0)); 92 + 93 + let maxMobileHeight = $derived( 94 + cards.reduce((max, item) => Math.max(max, item.mobileY + item.mobileH), 0) 95 + ); 96 + </script> 97 + 98 + {#each cards as item (item.id)} 99 + <BaseCard {item}> 100 + <Card {item} /> 101 + </BaseCard> 102 + {/each} 103 + 104 + <!-- Spacer for grid height --> 105 + <div class="hidden @[42rem]/grid:block" style="height: {(maxHeight / 8) * 100}cqw;"></div> 106 + <div class="@[42rem]/grid:hidden" style="height: {(maxMobileHeight / 4) * 100}cqw;"></div>
+95
src/lib/website/FloatingEditButton.svelte
··· 1 + <script lang="ts"> 2 + import { user, login } from '$lib/atproto'; 3 + import { Button } from '@foxui/core'; 4 + import { BlueskyLogin } from '@foxui/social'; 5 + import { env } from '$env/dynamic/public'; 6 + import type { WebsiteData } from '$lib/types'; 7 + import { page } from '$app/state'; 8 + import type { ActorIdentifier } from '@atcute/lexicons'; 9 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 10 + import { getHandleOrDid } from '$lib/atproto/methods'; 11 + 12 + let { data }: { data: WebsiteData } = $props(); 13 + 14 + const isOwnPage = $derived(user.isLoggedIn && user.profile?.did === data.did); 15 + const isBlento = $derived(!env.PUBLIC_IS_SELFHOSTED && data.handle === 'blento.app'); 16 + const isEditPage = $derived(page.url.pathname.endsWith('/edit')); 17 + const showLoginOnBlento = $derived( 18 + isBlento && !user.isInitializing && !user.isLoggedIn && user.profile?.handle !== data.handle 19 + ); 20 + const showLoginOnEditPage = $derived(isEditPage && !user.isInitializing && !user.isLoggedIn); 21 + const showEditBlentoButton = $derived( 22 + isBlento && user.isLoggedIn && user.profile?.handle !== data.handle 23 + ); 24 + 25 + function getUserIdentifier(): ActorIdentifier { 26 + const id = user.profile ? getHandleOrDid(user.profile) : (data.did as ActorIdentifier); 27 + return id; 28 + } 29 + </script> 30 + 31 + {#if isOwnPage && !isEditPage} 32 + <div class="fixed bottom-6 left-6 z-49 hidden lg:block"> 33 + <Button size="lg" href="{page.url}/edit"> 34 + <svg 35 + xmlns="http://www.w3.org/2000/svg" 36 + fill="none" 37 + viewBox="0 0 24 24" 38 + stroke-width="1.5" 39 + stroke="currentColor" 40 + class="size-5" 41 + > 42 + <path 43 + stroke-linecap="round" 44 + stroke-linejoin="round" 45 + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" 46 + /> 47 + </svg> 48 + Edit Website 49 + </Button> 50 + </div> 51 + {:else if showLoginOnEditPage} 52 + <div class="fixed bottom-6 left-6 z-49"> 53 + <Button size="lg" onclick={() => login(getUserIdentifier())}> 54 + <svg 55 + xmlns="http://www.w3.org/2000/svg" 56 + fill="none" 57 + viewBox="0 0 24 24" 58 + stroke-width="1.5" 59 + stroke="currentColor" 60 + class="size-5" 61 + > 62 + <path 63 + stroke-linecap="round" 64 + stroke-linejoin="round" 65 + d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" 66 + /> 67 + </svg> 68 + Login 69 + </Button> 70 + </div> 71 + {:else if showLoginOnBlento} 72 + <div class="fixed bottom-6 left-6 z-49"> 73 + <Button size="lg" onclick={() => loginModalState.show()}>Login</Button> 74 + </div> 75 + {:else if showEditBlentoButton} 76 + <div class="fixed bottom-6 left-6 z-49"> 77 + <Button size="lg" href="/{env.PUBLIC_IS_SELFHOSTED ? '' : getUserIdentifier()}/edit"> 78 + <svg 79 + xmlns="http://www.w3.org/2000/svg" 80 + fill="none" 81 + viewBox="0 0 24 24" 82 + stroke-width="1.5" 83 + stroke="currentColor" 84 + class="size-5" 85 + > 86 + <path 87 + stroke-linecap="round" 88 + stroke-linejoin="round" 89 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 90 + /> 91 + </svg> 92 + Edit Your Blento 93 + </Button> 94 + </div> 95 + {/if}
+15 -3
src/lib/website/Head.svelte
··· 1 1 <script lang="ts"> 2 + import ThemeScript from './ThemeScript.svelte'; 3 + 2 4 let { 3 5 favicon, 4 6 title, 5 7 image, 6 - description 7 - }: { favicon: string | null; title: string | null; image?: string; description?: string } = 8 - $props(); 8 + description, 9 + accentColor, 10 + baseColor 11 + }: { 12 + favicon: string | null; 13 + title: string | null; 14 + image?: string; 15 + description?: string; 16 + accentColor?: string; 17 + baseColor?: string; 18 + } = $props(); 9 19 </script> 20 + 21 + <ThemeScript {accentColor} {baseColor} /> 10 22 11 23 <svelte:head> 12 24 {#if favicon}
+12
src/lib/website/MadeWithBlento.svelte
··· 1 + <script lang="ts"> 2 + let { class: className = '' }: { class?: string } = $props(); 3 + </script> 4 + 5 + <div class={['text-xs font-light', className]}> 6 + made with <a 7 + href="https://blento.app" 8 + target="_blank" 9 + class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 10 + >blento</a 11 + > 12 + </div>
+47 -88
src/lib/website/Profile.svelte
··· 1 1 <script lang="ts"> 2 2 import { marked } from 'marked'; 3 - import { user, login } from '$lib/atproto'; 4 - import { Button } from '@foxui/core'; 5 - import { BlueskyLogin } from '@foxui/social'; 6 - import { env } from '$env/dynamic/public'; 3 + import { sanitize } from '$lib/sanitize'; 7 4 import type { WebsiteData } from '$lib/types'; 8 - import { getDescription, getName } from '$lib/helper'; 5 + import { getDescription, getImage, getName, getProfilePosition } from '$lib/helper'; 9 6 import { page } from '$app/state'; 10 - import type { ActorIdentifier } from '@atcute/lexicons'; 7 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 8 + import MadeWithBlento from './MadeWithBlento.svelte'; 9 + import { Avatar } from '@foxui/core'; 11 10 12 11 let { 13 12 data, 14 - showEditButton = false 13 + hideBlento = false 15 14 }: { 16 15 data: WebsiteData; 17 - showEditButton?: boolean; 16 + hideBlento?: boolean; 18 17 } = $props(); 19 18 20 19 const renderer = new marked.Renderer(); 21 20 renderer.link = ({ href, title, text }) => 22 - `<a target="_blank" href="${href}" title="${title}">${text}</a>`; 21 + `<a target="_blank" href="${href}" title="${title ?? ''}">${text}</a>`; 22 + 23 + const profileUrl = $derived(`${page.url.origin}/${data.handle}`); 24 + const profilePosition = $derived(getProfilePosition(data)); 23 25 </script> 24 26 25 27 <!-- lg:fixed lg:h-screen lg:w-1/4 lg:max-w-none lg:px-12 lg:pt-24 xl:w-1/3 --> 26 28 <div 27 - class="mx-auto flex max-w-lg flex-col justify-between px-8 @5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12" 29 + class={[ 30 + 'mx-auto flex max-w-lg flex-col justify-between px-8', 31 + profilePosition === 'side' 32 + ? '@5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12' 33 + : '@5xl/wrapper:max-w-4xl @5xl/wrapper:px-12' 34 + ]} 28 35 > 29 - <div class="flex flex-col gap-4 pt-16 pb-8 @5xl/wrapper:h-screen @5xl/wrapper:pt-24"> 30 - {#if data.profile.avatar} 31 - <img 32 - class="border-base-400 dark:border-base-800 size-32 rounded-full border @5xl/wrapper:size-44" 33 - src={data.profile.avatar} 34 - alt="" 36 + <div 37 + class={[ 38 + 'flex flex-col gap-4 pt-16 pb-4', 39 + profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 40 + ]} 41 + > 42 + <a 43 + href={profileUrl} 44 + class="w-fit" 45 + use:qrOverlay={{ 46 + context: { 47 + title: getName(data) + "'s blento" 48 + } 49 + }} 50 + > 51 + <Avatar 52 + src={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 53 + class={[ 54 + 'border-base-400 dark:border-base-800 size-32 shrink-0 rounded-full border object-cover', 55 + profilePosition === 'side' && '@5xl/wrapper:size-44' 56 + ]} 35 57 /> 36 - {:else} 37 - <div class="bg-base-300 dark:bg-base-700 size-32 rounded-full @5xl/wrapper:size-44"></div> 38 - {/if} 58 + </a> 39 59 40 60 <div class="text-4xl font-bold wrap-anywhere"> 41 61 {getName(data)} ··· 45 65 <div 46 66 class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline" 47 67 > 48 - {@html marked.parse(getDescription(data), { 49 - renderer 50 - })} 68 + {@html sanitize( 69 + marked.parse(getDescription(data), { 70 + renderer 71 + }) as string, 72 + { ADD_ATTR: ['target'] } 73 + )} 51 74 </div> 52 75 </div> 53 76 54 - {#if showEditButton && user.isLoggedIn && user.profile?.did === data.did} 55 - <div> 56 - <Button href="{page.url}/edit" class="mt-2"> 57 - <svg 58 - xmlns="http://www.w3.org/2000/svg" 59 - fill="none" 60 - viewBox="0 0 24 24" 61 - stroke-width="1.5" 62 - stroke="currentColor" 63 - > 64 - <path 65 - stroke-linecap="round" 66 - stroke-linejoin="round" 67 - d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" 68 - /> 69 - </svg> 70 - 71 - Edit Your Website</Button 72 - > 73 - </div> 74 - {:else} 75 - <div class="h-10.5 w-1 @5xl/wrapper:hidden"></div> 77 + {#if !hideBlento} 78 + <MadeWithBlento class="hidden {profilePosition === 'side' && '@5xl/wrapper:block'}" /> 76 79 {/if} 77 - 78 - {#if !env.PUBLIC_IS_SELFHOSTED && data.handle === 'blento.app' && user.profile?.handle !== data.handle} 79 - {#if !user.isInitializing && !user.isLoggedIn} 80 - <div> 81 - <div class="my-4 text-sm"> 82 - To create your own blento, sign in with your bluesky account 83 - </div> 84 - <BlueskyLogin 85 - login={async (handle) => { 86 - await login(handle as ActorIdentifier); 87 - return true; 88 - }} 89 - /> 90 - </div> 91 - {:else if user.isLoggedIn} 92 - <div> 93 - <Button href="/{env.PUBLIC_IS_SELFHOSTED ? '' : user.profile?.handle}/edit" class="mt-2"> 94 - <svg 95 - xmlns="http://www.w3.org/2000/svg" 96 - fill="none" 97 - viewBox="0 0 24 24" 98 - stroke-width="1.5" 99 - stroke="currentColor" 100 - > 101 - <path 102 - stroke-linecap="round" 103 - stroke-linejoin="round" 104 - d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 105 - /> 106 - </svg> 107 - 108 - Edit Your Blento</Button 109 - > 110 - </div> 111 - {/if} 112 - {/if} 113 - <div class="hidden text-xs font-light @5xl/wrapper:block"> 114 - made with <a 115 - href="https://blento.app" 116 - target="_blank" 117 - class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 118 - >blento</a 119 - > 120 - </div> 121 80 </div> 122 81 </div>
+88
src/lib/website/SaveModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Modal, toast } from '@foxui/core'; 3 + 4 + let { 5 + open = $bindable(), 6 + success, 7 + handle, 8 + page 9 + }: { 10 + open: boolean; 11 + success: boolean; 12 + handle: string; 13 + page: string; 14 + } = $props(); 15 + 16 + function getShareUrl() { 17 + const base = typeof window !== 'undefined' ? window.location.origin : ''; 18 + const pagePath = page && page !== 'blento.self' ? `/${page.replace('blento.', '')}` : ''; 19 + return `${base}/${handle}${pagePath}`; 20 + } 21 + 22 + async function copyShareLink() { 23 + const url = getShareUrl(); 24 + await navigator.clipboard.writeText(url); 25 + toast.success('Link copied to clipboard!'); 26 + } 27 + </script> 28 + 29 + <Modal {open} closeButton={false}> 30 + <div class="flex flex-col items-center gap-4"> 31 + {#if !success} 32 + <div class="flex items-center gap-4"> 33 + <svg 34 + class="text-accent-500 size-8 animate-spin" 35 + xmlns="http://www.w3.org/2000/svg" 36 + fill="none" 37 + viewBox="0 0 24 24" 38 + > 39 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 40 + ></circle> 41 + <path 42 + class="opacity-75" 43 + fill="currentColor" 44 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 45 + ></path> 46 + </svg> 47 + <p class="text-base-700 dark:text-base-300 text-3xl font-bold">Saving...</p> 48 + </div> 49 + {:else} 50 + <div class="flex items-center gap-4"> 51 + <svg 52 + xmlns="http://www.w3.org/2000/svg" 53 + viewBox="0 0 24 24" 54 + fill="currentColor" 55 + class="text-accent-500 size-8" 56 + > 57 + <path 58 + fill-rule="evenodd" 59 + d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" 60 + clip-rule="evenodd" 61 + /> 62 + </svg> 63 + 64 + <p class="text-base-700 dark:text-base-300 text-3xl font-bold">Website Saved</p> 65 + </div> 66 + <div class="mt-8 flex w-full flex-col gap-2"> 67 + <Button size="lg" onclick={copyShareLink}> 68 + <svg 69 + xmlns="http://www.w3.org/2000/svg" 70 + fill="none" 71 + viewBox="0 0 24 24" 72 + stroke-width="1.5" 73 + stroke="currentColor" 74 + class="size-5" 75 + > 76 + <path 77 + stroke-linecap="round" 78 + stroke-linejoin="round" 79 + d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" 80 + /> 81 + </svg> 82 + Share link 83 + </Button> 84 + <Button variant="ghost" onclick={() => (open = false)}>Close</Button> 85 + </div> 86 + {/if} 87 + </div> 88 + </Modal>
-74
src/lib/website/Settings.svelte
··· 1 - <script lang="ts"> 2 - import { getDescription, getHideProfileSection, getName } from '$lib/helper'; 3 - import type { WebsiteData } from '$lib/types'; 4 - import { Button, Checkbox, Heading, Input, Label, Modal, Textarea } from '@foxui/core'; 5 - 6 - export type Settings = { 7 - title: string; 8 - }; 9 - 10 - let { open = $bindable(), data = $bindable() }: { open: boolean; data: WebsiteData } = $props(); 11 - 12 - let name = $state(getName(data)); 13 - 14 - $effect(() => { 15 - if (!open && name && name !== getName(data)) { 16 - data.publication ??= {}; 17 - data.publication.name = name; 18 - 19 - data = { ...data }; 20 - } 21 - }); 22 - </script> 23 - 24 - <Modal bind:open class="dark:bg-base-900"> 25 - <Heading>Settings</Heading> 26 - <Label>Name</Label> 27 - <Input bind:value={name} /> 28 - <Label class="mt-4">Description</Label> 29 - <Textarea 30 - rows={5} 31 - bind:value={ 32 - () => { 33 - return getDescription(data); 34 - }, 35 - (value) => { 36 - data.publication ??= {}; 37 - data.publication.description = value; 38 - 39 - data = { ...data }; 40 - } 41 - } 42 - /> 43 - 44 - <div class="flex items-center space-x-2"> 45 - <Checkbox 46 - bind:checked={ 47 - () => { 48 - return getHideProfileSection(data); 49 - }, 50 - (value) => { 51 - data.publication ??= {}; 52 - data.publication.preferences ??= {}; 53 - data.publication.preferences.hideProfileSection = value; 54 - 55 - data = { ...data }; 56 - } 57 - } 58 - id="hide-profile" 59 - aria-labelledby="hide-profile-label" 60 - variant="secondary" 61 - /> 62 - <Label 63 - id="hide-profile-label" 64 - for="hide-profile" 65 - class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 66 - > 67 - Hide Profile Section 68 - </Label> 69 - </div> 70 - 71 - <div class="flex w-full justify-end space-x-2"> 72 - <Button onclick={() => (open = false)}>Close</Button> 73 - </div> 74 - </Modal>
+20
src/lib/website/ThemeScript.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + accentColor = 'pink', 4 + baseColor = 'stone' 5 + }: { 6 + accentColor?: string; 7 + baseColor?: string; 8 + } = $props(); 9 + 10 + const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c'); 11 + 12 + let script = $derived( 13 + `<script>(function(){document.documentElement.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})();<` + 14 + '/script>' 15 + ); 16 + </script> 17 + 18 + <svelte:head> 19 + {@html script} 20 + </svelte:head>
+46 -18
src/lib/website/Website.svelte
··· 1 1 <script lang="ts"> 2 2 import Card from '../cards/Card/Card.svelte'; 3 3 import Profile from './Profile.svelte'; 4 - import { getDescription, getHideProfileSection, getName, sortItems } from '../helper'; 4 + import { 5 + getDescription, 6 + getHideProfileSection, 7 + getProfilePosition, 8 + getName, 9 + sortItems, 10 + getImage 11 + } from '../helper'; 5 12 import { innerWidth } from 'svelte/reactivity/window'; 6 13 import { setDidContext, setHandleContext, setIsMobile } from './context'; 7 14 import BaseCard from '../cards/BaseCard/BaseCard.svelte'; 8 15 import type { WebsiteData } from '$lib/types'; 9 16 import Context from './Context.svelte'; 17 + import MadeWithBlento from './MadeWithBlento.svelte'; 10 18 import Head from './Head.svelte'; 11 19 import type { Did, Handle } from '@atcute/lexicons'; 20 + import QRModalProvider from '$lib/components/qr/QRModalProvider.svelte'; 21 + import EmptyState from './EmptyState.svelte'; 22 + import FloatingEditButton from './FloatingEditButton.svelte'; 23 + import { user } from '$lib/atproto'; 24 + import { env } from '$env/dynamic/public'; 25 + import { page } from '$app/state'; 12 26 13 27 let { data }: { data: WebsiteData } = $props(); 14 28 29 + // Check if floating edit button will be visible (to hide MadeWithBlento) 30 + const isOwnPage = $derived(user.isLoggedIn && user.profile?.did === data.did); 31 + const isBlento = $derived(!env.PUBLIC_IS_SELFHOSTED && data.handle === 'blento.app'); 32 + const isEditPage = $derived(page.url.pathname.endsWith('/edit')); 33 + const showLoginOnEditPage = $derived(isEditPage && !user.isInitializing && !user.isLoggedIn); 34 + const showFloatingButton = $derived( 35 + (isOwnPage && !isEditPage) || 36 + showLoginOnEditPage || 37 + (isBlento && !user.isInitializing && !user.isLoggedIn) || 38 + (isBlento && user.isLoggedIn && user.profile?.handle !== data.handle) 39 + ); 40 + 15 41 let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 16 42 setIsMobile(() => isMobile); 17 43 ··· 31 57 </script> 32 58 33 59 <Head 34 - favicon={data.profile.avatar ?? null} 60 + favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 35 61 title={getName(data)} 36 62 image={'/' + data.handle + '/og.png'} 37 63 description={getDescription(data)} 64 + accentColor={data.publication?.preferences?.accentColor} 65 + baseColor={data.publication?.preferences?.baseColor} 38 66 /> 39 67 40 68 <Context {data}> 69 + <QRModalProvider /> 41 70 <div class="@container/wrapper relative w-full"> 42 71 {#if !getHideProfileSection(data)} 43 - <Profile {data} showEditButton={true} /> 72 + <Profile {data} hideBlento={showFloatingButton} /> 44 73 {/if} 45 74 46 75 <div 47 76 class={[ 48 77 'mx-auto max-w-lg', 49 - !getHideProfileSection(data) 78 + !getHideProfileSection(data) && getProfilePosition(data) === 'side' 50 79 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 51 80 : '@5xl/wrapper:max-w-4xl' 52 81 ]} 53 82 > 54 83 <div></div> 55 84 <div bind:this={container} class="@container/grid relative col-span-3 px-2 py-8 lg:px-8"> 56 - {#each data.cards.toSorted(sortItems) as item (item.id)} 57 - <BaseCard {item}> 58 - <Card {item} /> 59 - </BaseCard> 60 - {/each} 61 - <div style="height: {(maxHeight / 8) * 100}cqw;"></div> 85 + {#if data.cards.length === 0 && data.page === 'blento.self'} 86 + <EmptyState {data} /> 87 + {:else} 88 + {#each data.cards.toSorted(sortItems) as item (item.id)} 89 + <BaseCard {item}> 90 + <Card {item} /> 91 + </BaseCard> 92 + {/each} 93 + <div style="height: {(maxHeight / 8) * 100}cqw;"></div> 94 + {/if} 62 95 </div> 63 96 </div> 64 97 65 - <div class="mx-auto block pb-8 text-center text-xs font-light @5xl/wrapper:hidden"> 66 - made with <a 67 - href="https://blento.app" 68 - target="_blank" 69 - class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 70 - >blento</a 71 - > 72 - </div> 98 + <MadeWithBlento class="mx-auto block pb-8 text-center @5xl/wrapper:hidden" /> 73 99 </div> 100 + 101 + <FloatingEditButton {data} /> 74 102 </Context>
+3
src/lib/website/context.ts
··· 7 7 export const [getCanEdit, setCanEdit] = createContext<() => boolean>(); 8 8 export const [getAdditionalUserData, setAdditionalUserData] = 9 9 createContext<Record<string, unknown>>(); 10 + export const [getIsCoarse, setIsCoarse] = createContext<() => boolean>(); 11 + export const [getSelectedCardId, setSelectedCardId] = createContext<() => string | null>(); 12 + export const [getSelectCard, setSelectCard] = createContext<(id: string | null) => void>();
+72
src/lib/website/layout-mirror.ts
··· 1 + import { COLUMNS } from '$lib'; 2 + import { CardDefinitionsByType } from '$lib/cards'; 3 + import { clamp, findValidPosition, fixAllCollisions } from '$lib/helper'; 4 + import type { Item } from '$lib/types'; 5 + 6 + /** 7 + * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). 8 + * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both 9 + */ 10 + export function shouldMirror(editedOn: number | undefined): boolean { 11 + return (editedOn ?? 0) !== 3; 12 + } 13 + 14 + /** Snap a value to the nearest even integer (min 2). */ 15 + function snapEven(v: number): number { 16 + return Math.max(2, Math.round(v / 2) * 2); 17 + } 18 + 19 + /** 20 + * Compute the other layout's size for a single item, preserving aspect ratio. 21 + * Clamps to the card definition's minW/maxW/minH/maxH if defined. 22 + * Mutates the item in-place. 23 + */ 24 + export function mirrorItemSize(item: Item, fromMobile: boolean): void { 25 + const def = CardDefinitionsByType[item.cardType]; 26 + 27 + if (fromMobile) { 28 + // Mobile โ†’ Desktop: halve both dimensions, then clamp to card def constraints 29 + // (constraints are in desktop units) 30 + item.w = clamp(snapEven(item.mobileW / 2), def?.minW ?? 2, def?.maxW ?? COLUMNS); 31 + item.h = clamp(Math.round(item.mobileH / 2), def?.minH ?? 1, def?.maxH ?? Infinity); 32 + } else { 33 + // Desktop โ†’ Mobile: double both dimensions 34 + // (don't apply card def constraints โ€” they're in desktop units) 35 + item.mobileW = Math.min(item.w * 2, COLUMNS); 36 + item.mobileH = Math.max(item.h * 2, 2); 37 + } 38 + } 39 + 40 + /** 41 + * Mirror the full layout from one view to the other. 42 + * Copies sizes proportionally and maps positions, then resolves collisions. 43 + * Mutates items in-place. 44 + */ 45 + export function mirrorLayout(items: Item[], fromMobile: boolean): void { 46 + // Mirror sizes first 47 + for (const item of items) { 48 + mirrorItemSize(item, fromMobile); 49 + } 50 + 51 + if (fromMobile) { 52 + // Mobile โ†’ Desktop: reflow items to use the full grid width. 53 + // Sort by mobile position so items are placed in reading order. 54 + const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 55 + 56 + // Place each item into the first available spot on the desktop grid 57 + const placed: Item[] = []; 58 + for (const item of sorted) { 59 + item.x = 0; 60 + item.y = 0; 61 + findValidPosition(item, placed, false); 62 + placed.push(item); 63 + } 64 + } else { 65 + // Desktop โ†’ Mobile: proportional positions 66 + for (const item of items) { 67 + item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 68 + item.mobileY = Math.max(0, Math.round(item.y * 2)); 69 + } 70 + fixAllCollisions(items, true); 71 + } 72 + }
+51 -17
src/lib/website/load.ts
··· 1 - import { getDetailedProfile, listRecords, resolveHandle, parseUri } from '$lib/atproto'; 1 + import { getDetailedProfile, listRecords, resolveHandle, parseUri, getRecord } from '$lib/atproto'; 2 2 import { CardDefinitionsByType } from '$lib/cards'; 3 3 import type { Item, UserCache, WebsiteData } from '$lib/types'; 4 4 import { compactItems, fixAllCollisions } from '$lib/helper'; 5 5 import { error } from '@sveltejs/kit'; 6 - import type { Handle } from '@atcute/lexicons'; 6 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 7 + 8 + import { isDid, isHandle } from '@atcute/lexicons/syntax'; 7 9 8 10 const CURRENT_CACHE_VERSION = 1; 9 11 ··· 31 33 result.page = 'blento.' + page; 32 34 33 35 result.publication = (result.publications as Awaited<ReturnType<typeof listRecords>>).find( 34 - (v) => parseUri(v.uri).rkey === result.page 36 + (v) => parseUri(v.uri)?.rkey === result.page 35 37 )?.value; 38 + result.publication ??= { 39 + name: result.profile?.displayName || result.profile?.handle, 40 + description: result.profile?.description 41 + }; 36 42 37 43 delete result['publications']; 38 44 ··· 44 50 } 45 51 46 52 export async function loadData( 47 - handle: Handle, 53 + handle: ActorIdentifier, 48 54 cache: UserCache | undefined, 49 55 forceUpdate: boolean = false, 50 56 page: string = 'self' 51 57 ): Promise<WebsiteData> { 52 58 if (!handle) throw error(404); 59 + if (handle === 'favicon.ico') throw error(404); 53 60 54 61 if (!forceUpdate) { 55 62 const cachedResult = await getCache(handle, page, cache); ··· 57 64 if (cachedResult) return cachedResult; 58 65 } 59 66 60 - if (handle === 'favicon.ico') throw error(404); 61 - 62 - console.log('resolving', handle); 63 - const did = await resolveHandle({ handle }); 67 + let did: Did | undefined = undefined; 68 + if (isHandle(handle)) { 69 + did = await resolveHandle({ handle }); 70 + } else if (isDid(handle)) { 71 + did = handle; 72 + } else { 73 + throw error(404); 74 + } 64 75 65 76 const cards = await listRecords({ did, collection: 'app.blento.card' }).catch(() => { 66 77 console.error('error getting records for collection app.blento.card'); 67 78 return [] as Awaited<ReturnType<typeof listRecords>>; 68 79 }); 69 80 70 - const publications = await listRecords({ did, collection: 'site.standard.publication' }).catch( 71 - () => { 72 - console.error('error getting records for collection site.standard.publication'); 73 - return [] as Awaited<ReturnType<typeof listRecords>>; 74 - } 75 - ); 81 + const mainPublication = await getRecord({ 82 + did, 83 + collection: 'site.standard.publication', 84 + rkey: 'blento.self' 85 + }).catch(() => { 86 + console.error('error getting record for collection site.standard.publication'); 87 + return undefined; 88 + }); 89 + 90 + const pages = await listRecords({ did, collection: 'app.blento.page' }).catch(() => { 91 + console.error('error getting records for collection app.blento.page'); 92 + return [] as Awaited<ReturnType<typeof listRecords>>; 93 + }); 76 94 77 95 const profile = await getDetailedProfile({ did }); 78 96 ··· 116 134 cards: (cards.map((v) => { 117 135 return { ...v.value }; 118 136 }) ?? []) as Item[], 119 - publications: publications, 137 + publications: [mainPublication, ...pages].filter((v) => v), 120 138 additionalData, 121 139 profile, 122 140 updatedAt: Date.now(), ··· 127 145 await cache?.put?.(handle, stringifiedResult); 128 146 129 147 const parsedResult = JSON.parse(stringifiedResult); 148 + 130 149 parsedResult.publication = ( 131 150 parsedResult.publications as Awaited<ReturnType<typeof listRecords>> 132 - ).find((v) => parseUri(v.uri).rkey === parsedResult.page)?.value; 151 + ).find((v) => parseUri(v.uri)?.rkey === parsedResult.page)?.value; 152 + parsedResult.publication ??= { 153 + name: profile?.displayName || profile?.handle, 154 + description: profile?.description 155 + }; 133 156 134 157 delete parsedResult['publications']; 135 158 ··· 163 186 return data; 164 187 } 165 188 189 + function migrateCards(data: WebsiteData): WebsiteData { 190 + for (const card of data.cards) { 191 + const cardDef = CardDefinitionsByType[card.cardType]; 192 + 193 + if (!cardDef?.migrate) continue; 194 + 195 + cardDef.migrate(card); 196 + } 197 + return data; 198 + } 199 + 166 200 function checkData(data: WebsiteData): WebsiteData { 167 201 data = migrateData(data); 168 202 ··· 182 216 } 183 217 184 218 function migrateData(data: WebsiteData): WebsiteData { 185 - return migrateFromV1ToV2(migrateFromV0ToV1(data)); 219 + return migrateCards(migrateFromV1ToV2(migrateFromV0ToV1(data))); 186 220 }
+1 -1
src/params/handle.ts
··· 1 1 import type { ParamMatcher } from '@sveltejs/kit'; 2 2 3 3 export const match = ((param: string) => { 4 - return param.includes('.'); 4 + return param.includes('.') || param.startsWith('did:'); 5 5 }) satisfies ParamMatcher;
+25 -3
src/routes/(auth)/oauth/callback/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from '$app/navigation'; 3 3 import { user } from '$lib/atproto'; 4 + import { getHandleOrDid } from '$lib/atproto/methods'; 5 + 6 + let showError = $state(false); 7 + 8 + let startedErrorTimer = $state(); 4 9 5 10 $effect(() => { 6 - console.log('hello', user); 7 11 if (user.profile) { 8 - goto('/' + user.profile.handle + '/edit', {}); 12 + goto('/' + getHandleOrDid(user.profile) + '/edit', {}); 13 + } 14 + 15 + if (!user.isInitializing && !startedErrorTimer) { 16 + startedErrorTimer = true; 17 + 18 + setTimeout(() => { 19 + showError = true; 20 + }, 3000); 9 21 } 10 22 }); 11 23 </script> 12 24 13 - <div class="flex min-h-screen w-full items-center justify-center text-3xl">Loading...</div> 25 + {#if user.isInitializing || !showError} 26 + <div class="flex min-h-screen w-full items-center justify-center text-3xl">Loading...</div> 27 + {:else if showError} 28 + <div class="flex min-h-screen w-full items-center justify-center text-3xl"> 29 + <span class="max-w-xl text-center font-medium" 30 + >There was an error signing you in, please go back to the 31 + <a class="text-accent-600 dark:text-accent-400" href="/">homepage</a> 32 + and try again. 33 + </span> 34 + </div> 35 + {/if}
+87
src/routes/+error.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/stores'; 3 + import MadeWithBlento from '$lib/website/MadeWithBlento.svelte'; 4 + </script> 5 + 6 + <div 7 + class="bg-base-100 dark:bg-base-950 text-base-900 dark:text-base-50 min-h-screen px-4 py-8 lg:px-8" 8 + > 9 + <div class="@container/grid mx-auto max-w-4xl"> 10 + <!-- Bento Grid --> 11 + <div class="grid grid-cols-4 gap-3 lg:grid-cols-8 lg:gap-4"> 12 + <!-- Error Code - Large prominent card --> 13 + <div 14 + class="col-span-4 row-span-2 flex flex-col items-center justify-center rounded-3xl bg-gradient-to-br from-pink-500 to-rose-500 p-8 text-white lg:col-span-4" 15 + > 16 + <span class="text-8xl font-black lg:text-9xl">{$page.status}</span> 17 + <span class="mt-2 text-xl font-medium opacity-90">Error</span> 18 + </div> 19 + 20 + <!-- Oops card --> 21 + <div 22 + class="col-span-2 flex items-center justify-center rounded-3xl bg-amber-500 p-6 text-white" 23 + > 24 + <span class="text-2xl font-bold lg:text-3xl">Oops!</span> 25 + </div> 26 + 27 + <!-- Decorative emoji card --> 28 + <div 29 + class="col-span-2 flex items-center justify-center rounded-3xl bg-violet-500 p-6 text-4xl lg:text-5xl" 30 + > 31 + <span class="animate-bounce">:(</span> 32 + </div> 33 + 34 + <!-- Message card --> 35 + <div 36 + class="bg-base-200/50 dark:bg-base-800/50 text-base-700 dark:text-base-300 col-span-4 flex items-center justify-center rounded-3xl p-6 text-center" 37 + > 38 + <p class="text-lg font-medium"> 39 + {$page.error?.message || 40 + "Something went wrong. We couldn't find what you're looking for."} 41 + </p> 42 + </div> 43 + 44 + <!-- Decorative pattern card --> 45 + <div 46 + class="col-span-2 flex items-center justify-center overflow-hidden rounded-3xl bg-cyan-500 p-4" 47 + > 48 + <div class="grid grid-cols-3 gap-2"> 49 + {#each Array(9) as _, i (i)} 50 + <div class="h-3 w-3 rounded-full bg-white/40 lg:h-4 lg:w-4"></div> 51 + {/each} 52 + </div> 53 + </div> 54 + 55 + <!-- Home link card --> 56 + <a 57 + href="/" 58 + class="col-span-2 flex items-center justify-center rounded-3xl bg-emerald-500 p-6 text-white transition-transform hover:scale-[1.02] active:scale-[0.98]" 59 + > 60 + <span class="text-lg font-bold lg:text-xl">Go Home</span> 61 + </a> 62 + 63 + <!-- Decorative stripes card --> 64 + <div class="col-span-2 overflow-hidden rounded-3xl bg-blue-500 p-4"> 65 + <div class="flex h-full w-full flex-col justify-center gap-2"> 66 + <div class="h-2 w-full rounded-full bg-white/30"></div> 67 + <div class="h-2 w-3/4 rounded-full bg-white/40"></div> 68 + <div class="h-2 w-1/2 rounded-full bg-white/50"></div> 69 + </div> 70 + </div> 71 + 72 + <!-- Decorative circles card --> 73 + <div 74 + class="col-span-2 flex items-center justify-center rounded-3xl bg-orange-500 p-4 lg:col-span-2" 75 + > 76 + <div class="relative h-16 w-16 lg:h-20 lg:w-20"> 77 + <div class="absolute inset-0 rounded-full border-4 border-white/40"></div> 78 + <div class="absolute inset-2 rounded-full border-4 border-white/50"></div> 79 + <div class="absolute inset-4 rounded-full border-4 border-white/60"></div> 80 + </div> 81 + </div> 82 + </div> 83 + </div> 84 + 85 + <!-- Footer --> 86 + <MadeWithBlento class="text-base-500 mt-12 text-center" /> 87 + </div>
+19 -2
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 4 - import { ThemeToggle } from '@foxui/core'; 4 + import { ThemeToggle, Toaster, toast } from '@foxui/core'; 5 5 import { onMount } from 'svelte'; 6 6 import { initClient } from '$lib/atproto'; 7 - import YoutubeVideoPlayer, { videoPlayer } from '$lib/cards/utils/YoutubeVideoPlayer.svelte'; 7 + import YoutubeVideoPlayer, { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; 8 + import { page } from '$app/state'; 9 + import { goto } from '$app/navigation'; 10 + import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 8 11 9 12 let { children } = $props(); 13 + 14 + const errorMessages: Record<string, (params: URLSearchParams) => string> = { 15 + handle_not_found: (p) => `Handle ${p.get('handle') ?? ''} not found!` 16 + }; 10 17 11 18 onMount(() => { 12 19 initClient(); 20 + 21 + const error = page.url.searchParams.get('error'); 22 + if (error) { 23 + const msg = errorMessages[error]?.(page.url.searchParams) ?? error; 24 + toast.error(msg); 25 + goto(page.url.pathname, { replaceState: true }); 26 + } 13 27 }); 14 28 </script> 15 29 16 30 {@render children()} 17 31 18 32 <ThemeToggle class="fixed top-2 left-2 z-10" /> 33 + <Toaster /> 19 34 20 35 {#if videoPlayer.id} 21 36 <YoutubeVideoPlayer /> 22 37 {/if} 38 + 39 + <LoginModal />
-2
src/routes/[handle=handle]/[[page]]/+layout.server.ts
··· 9 9 10 10 const cache = platform?.env?.USER_DATA_CACHE as unknown; 11 11 12 - console.log(params.page); 13 - 14 12 return await loadData(params.handle as Handle, cache as UserCache, false, params.page); 15 13 }
+11 -2
src/routes/[handle=handle]/og.png/+server.ts
··· 3 3 import type { Handle } from '@atcute/lexicons'; 4 4 import { ImageResponse } from '@ethercorps/sveltekit-og'; 5 5 6 + function escapeHtml(str: string): string { 7 + return str 8 + .replace(/&/g, '&amp;') 9 + .replace(/</g, '&lt;') 10 + .replace(/>/g, '&gt;') 11 + .replace(/"/g, '&quot;') 12 + .replace(/'/g, '&#39;'); 13 + } 14 + 6 15 export async function GET({ params, platform }) { 7 16 const handle = params.handle; 8 17 ··· 15 24 const htmlString = ` 16 25 <div class="flex flex-col p-8 w-full h-full bg-neutral-900"> 17 26 <div class="flex items-center mb-8 mt-16"> 18 - <img src="${image}" width="128" height="128" class="rounded-full" /> 27 + <img src="${escapeHtml(image ?? '')}" width="128" height="128" class="rounded-full" /> 19 28 20 - <h1 class="text-neutral-50 text-7xl ml-4">${handle}</h1> 29 + <h1 class="text-neutral-50 text-7xl ml-4">${escapeHtml(handle)}</h1> 21 30 </div> 22 31 23 32 <p class="mt-8 text-4xl text-neutral-300">Check out my blento</p>
+1 -3
src/routes/api/github/+server.ts
··· 26 26 27 27 try { 28 28 const response = await fetch(GithubAPIURL + user); 29 - console.log('hello', user); 30 29 31 30 if (!response.ok) { 32 - console.log('error', response.statusText); 31 + console.error('error', response.statusText); 33 32 return json( 34 33 { error: 'Failed to fetch GitHub data ' + response.statusText }, 35 34 { status: response.status } ··· 39 38 const data = await response.json(); 40 39 41 40 if (!data?.user) { 42 - console.log('user not found', response.statusText); 43 41 return json({ error: 'User not found' }, { status: 404 }); 44 42 } 45 43
+53
src/routes/api/github/contributors/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + 4 + const GithubContributorsAPIURL = 5 + 'https://edge-function-github-contribution.vercel.app/api/github-contributors'; 6 + 7 + export const GET: RequestHandler = async ({ url, platform }) => { 8 + const owner = url.searchParams.get('owner'); 9 + const repo = url.searchParams.get('repo'); 10 + 11 + if (!owner || !repo) { 12 + return json({ error: 'Missing owner or repo parameter' }, { status: 400 }); 13 + } 14 + 15 + const cacheKey = `#github-contributors:${owner}/${repo}`; 16 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey); 17 + 18 + if (cachedData) { 19 + const parsedCache = JSON.parse(cachedData); 20 + 21 + const TWELVE_HOURS = 12 * 60 * 60 * 1000; 22 + const now = Date.now(); 23 + 24 + if (now - (parsedCache.updatedAt || 0) < TWELVE_HOURS) { 25 + return json(parsedCache.data); 26 + } 27 + } 28 + 29 + try { 30 + const response = await fetch( 31 + `${GithubContributorsAPIURL}?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}` 32 + ); 33 + 34 + if (!response.ok) { 35 + return json( 36 + { error: 'Failed to fetch GitHub contributors ' + response.statusText }, 37 + { status: response.status } 38 + ); 39 + } 40 + 41 + const data = await response.json(); 42 + 43 + await platform?.env?.USER_DATA_CACHE?.put( 44 + cacheKey, 45 + JSON.stringify({ data, updatedAt: Date.now() }) 46 + ); 47 + 48 + return json(data); 49 + } catch (error) { 50 + console.error('Error fetching GitHub contributors:', error); 51 + return json({ error: 'Failed to fetch GitHub contributors' }, { status: 500 }); 52 + } 53 + };
+44
src/routes/api/image-proxy/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + 3 + export async function GET({ url }) { 4 + const imageUrl = url.searchParams.get('url'); 5 + if (!imageUrl) { 6 + throw error(400, 'No URL provided'); 7 + } 8 + 9 + try { 10 + new URL(imageUrl); 11 + } catch { 12 + throw error(400, 'Invalid URL'); 13 + } 14 + 15 + try { 16 + const response = await fetch(imageUrl); 17 + 18 + if (!response.ok) { 19 + throw error(response.status, 'Failed to fetch image'); 20 + } 21 + 22 + const contentType = response.headers.get('content-type'); 23 + 24 + // Only allow image content types 25 + if (!contentType?.startsWith('image/')) { 26 + throw error(400, 'URL does not point to an image'); 27 + } 28 + 29 + const blob = await response.blob(); 30 + 31 + return new Response(blob, { 32 + headers: { 33 + 'Content-Type': contentType, 34 + 'Cache-Control': 'public, max-age=86400' 35 + } 36 + }); 37 + } catch (err) { 38 + if (err && typeof err === 'object' && 'status' in err) { 39 + throw err; 40 + } 41 + console.error('Error proxying image:', err); 42 + throw error(500, 'Failed to proxy image'); 43 + } 44 + }
-2
src/routes/api/update/+server.ts
··· 18 18 for (const handle of existingUsersHandle) { 19 19 if (!handle) continue; 20 20 21 - console.log('updating', handle); 22 21 try { 23 22 const cached = await getCache(handle, 'self', cache as UserCache); 24 23 if (!cached) await loadData(handle, cache as UserCache, true); ··· 26 25 console.error(error); 27 26 return json('error'); 28 27 } 29 - console.log('updated', handle); 30 28 } 31 29 32 30 return json('ok');
static/favicon.ico

This is a binary file and will not be displayed.

+1
wrangler.jsonc
··· 44 44 "id": "d6ff203259de48538d332b0a5df258a7" 45 45 } 46 46 ] 47 + 47 48 /** 48 49 * Service Bindings (communicate between multiple Workers) 49 50 * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings