your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+3393 -1245
+1
.github/FUNDING.yml
···
··· 1 + github: flo-bit
+2
.gitignore
··· 21 # Vite 22 vite.config.js.timestamp-* 23 vite.config.ts.timestamp-*
··· 21 # Vite 22 vite.config.js.timestamp-* 23 vite.config.ts.timestamp-* 24 + 25 + references
+1 -1
README.md
··· 16 17 Should blento.app shut down at some point, someone else can also spin up a new version that shows all blentos (note: it's MIT licensed so you _could_ do that now too and offer a competing service, but please don't (except for self-hosting your own profile ofc), legal != nice). 18 19 - One other note: for most independence I encourage everyone to get their own domain see [how to setup a custom domain](./docs/CustomDomain.md), selfhosting on the other hand while currently possible is not encourage *yet* before this project reaches a more stable state (if you do want to selfhost though, see [here](./docs/Selfhosting.md)). 20 21 ## Making Custom cards 22
··· 16 17 Should blento.app shut down at some point, someone else can also spin up a new version that shows all blentos (note: it's MIT licensed so you _could_ do that now too and offer a competing service, but please don't (except for self-hosting your own profile ofc), legal != nice). 18 19 + One other note: for most independence I encourage everyone to get their own domain see [how to setup a custom domain](./docs/CustomDomain.md), selfhosting on the other hand while currently possible is not encourage _yet_ before this project reaches a more stable state (if you do want to selfhost though, see [here](./docs/Selfhosting.md)). 20 21 ## Making Custom cards 22
+1 -1
docs/CustomCards.md
··· 9 - in light/dark mode 10 - with and without colored cards 11 - in edit mode and not in edit mode 12 - - on mobile and desktop (note that there's two different mobile "modes", one dependent on screen size and one enabled when pointer: coarse)
··· 9 - in light/dark mode 10 - with and without colored cards 11 - in edit mode and not in edit mode 12 + - on mobile and desktop (note that there's two different mobile "modes", one dependent on screen size and one enabled when pointer: coarse)
+1 -2
docs/Selfhosting.md
··· 1 # Selfhosting with cloudflare workers 2 3 - I currently advise everyone to *not* selfhost, as long as this project is still in the beginning stages with lots of changes every day, isntead I'd suggest setting up a custom domain (see [here](./CustomDomain.md)) and only later once this project is more stable to selfhost, thanks to the magic of atproto, you'll just have to change where your domain points to and setup the selfhosted version and your content will magically transfer over. 4 5 If you do still want to give selfhosting a try now, some features/cards may break on your selfhosted version, in that case sync with the upstream repo. 6 - 7 8 1. fork this repo 9 2. create a cloudflare worker application and connect it to your fork
··· 1 # Selfhosting with cloudflare workers 2 3 + I currently advise everyone to _not_ selfhost, as long as this project is still in the beginning stages with lots of changes every day, isntead I'd suggest setting up a custom domain (see [here](./CustomDomain.md)) and only later once this project is more stable to selfhost, thanks to the magic of atproto, you'll just have to change where your domain points to and setup the selfhosted version and your content will magically transfer over. 4 5 If you do still want to give selfhosting a try now, some features/cards may break on your selfhosted version, in that case sync with the upstream repo. 6 7 1. fork this repo 8 2. create a cloudflare worker application and connect it to your fork
+2 -1
package.json
··· 5 "type": "module", 6 "scripts": { 7 "dev": "vite dev", 8 - "build": "vite build", 9 "preview": "pnpm run build && wrangler dev", 10 "prepare": "svelte-kit sync || echo ''", 11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", ··· 81 "perfect-freehand": "^1.2.2", 82 "plyr": "^3.8.4", 83 "qr-code-styling": "^1.8.6", 84 "simple-icons": "^16.6.0", 85 "svelte-sonner": "^1.0.7", 86 "tailwind-merge": "^3.4.0",
··· 5 "type": "module", 6 "scripts": { 7 "dev": "vite dev", 8 + "build": "NODE_OPTIONS='--max-old-space-size=4096' vite build", 9 "preview": "pnpm run build && wrangler dev", 10 "prepare": "svelte-kit sync || echo ''", 11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", ··· 81 "perfect-freehand": "^1.2.2", 82 "plyr": "^3.8.4", 83 "qr-code-styling": "^1.8.6", 84 + "react-grid-layout": "^2.2.2", 85 "simple-icons": "^16.6.0", 86 "svelte-sonner": "^1.0.7", 87 "tailwind-merge": "^3.4.0",
+110
pnpm-lock.yaml
··· 134 qr-code-styling: 135 specifier: ^1.8.6 136 version: 1.9.2 137 simple-icons: 138 specifier: ^16.6.0 139 version: 16.6.0 ··· 1967 fast-deep-equal@3.1.3: 1968 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 1969 1970 fast-json-stable-stringify@2.1.0: 1971 resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} 1972 ··· 2103 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 2104 hasBin: true 2105 2106 js-yaml@4.1.1: 2107 resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} 2108 hasBin: true ··· 2239 lodash.merge@4.6.2: 2240 resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} 2241 2242 lz-string@1.5.0: 2243 resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} 2244 hasBin: true ··· 2340 number-flow@0.5.9: 2341 resolution: {integrity: sha512-o3102c/4qRd6eV4n+rw6B/UP8+FosbhIxj4uA6GsjhryrGZRVtCtKIKEeBiOwUV52cUGJneeu0treELcV7U/lw==} 2342 2343 obug@2.1.1: 2344 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 2345 ··· 2526 engines: {node: '>=14'} 2527 hasBin: true 2528 2529 prosemirror-changeset@2.3.1: 2530 resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} 2531 ··· 2608 rangetouch@2.0.1: 2609 resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} 2610 2611 readdirp@4.1.2: 2612 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 2613 engines: {node: '>= 14.18.0'} ··· 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'} 2622 2623 resolve-from@4.0.0: 2624 resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} ··· 2676 resolution: {integrity: sha512-abovcqmwl97WKioxpkfuMeZmndB1TuDFY/R+FymrZyiGP+pMYomvgSzVPnbNMWHHESOPosVHGL352oFbdAnJcA==} 2677 engines: {node: '>=16'} 2678 2679 semver@7.7.3: 2680 resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} 2681 engines: {node: '>=10'} ··· 4731 4732 fast-deep-equal@3.1.3: {} 4733 4734 fast-json-stable-stringify@2.1.0: {} 4735 4736 fast-levenshtein@2.0.6: {} ··· 4836 iso-datestring-validator@2.2.2: {} 4837 4838 jiti@2.6.1: {} 4839 4840 js-yaml@4.1.1: 4841 dependencies: ··· 4942 4943 lodash.merge@4.6.2: {} 4944 4945 lz-string@1.5.0: {} 4946 4947 maath@0.10.8(@types/three@0.176.0)(three@0.176.0): ··· 5066 number-flow@0.5.9: 5067 dependencies: 5068 esm-env: 1.2.2 5069 5070 obug@2.1.1: {} 5071 ··· 5200 5201 prettier@3.8.1: {} 5202 5203 prosemirror-changeset@2.3.1: 5204 dependencies: 5205 prosemirror-transform: 1.11.0 ··· 5319 5320 rangetouch@2.0.1: {} 5321 5322 readdirp@4.1.2: {} 5323 5324 regexparam@3.0.0: {} 5325 5326 require-from-string@2.0.2: {} 5327 5328 resolve-from@4.0.0: {} 5329 ··· 5412 parse-css-color: 0.2.1 5413 postcss-value-parser: 4.2.0 5414 yoga-wasm-web: 0.3.3 5415 5416 semver@7.7.3: {} 5417
··· 134 qr-code-styling: 135 specifier: ^1.8.6 136 version: 1.9.2 137 + react-grid-layout: 138 + specifier: ^2.2.2 139 + version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 140 simple-icons: 141 specifier: ^16.6.0 142 version: 16.6.0 ··· 1970 fast-deep-equal@3.1.3: 1971 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 1972 1973 + fast-equals@4.0.3: 1974 + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==, tarball: https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz} 1975 + 1976 fast-json-stable-stringify@2.1.0: 1977 resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} 1978 ··· 2109 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 2110 hasBin: true 2111 2112 + js-tokens@4.0.0: 2113 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} 2114 + 2115 js-yaml@4.1.1: 2116 resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} 2117 hasBin: true ··· 2248 lodash.merge@4.6.2: 2249 resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} 2250 2251 + loose-envify@1.4.0: 2252 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, tarball: https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz} 2253 + hasBin: true 2254 + 2255 lz-string@1.5.0: 2256 resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} 2257 hasBin: true ··· 2353 number-flow@0.5.9: 2354 resolution: {integrity: sha512-o3102c/4qRd6eV4n+rw6B/UP8+FosbhIxj4uA6GsjhryrGZRVtCtKIKEeBiOwUV52cUGJneeu0treELcV7U/lw==} 2355 2356 + object-assign@4.1.1: 2357 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz} 2358 + engines: {node: '>=0.10.0'} 2359 + 2360 obug@2.1.1: 2361 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 2362 ··· 2543 engines: {node: '>=14'} 2544 hasBin: true 2545 2546 + prop-types@15.8.1: 2547 + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz} 2548 + 2549 prosemirror-changeset@2.3.1: 2550 resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} 2551 ··· 2628 rangetouch@2.0.1: 2629 resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} 2630 2631 + react-dom@19.2.4: 2632 + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz} 2633 + peerDependencies: 2634 + react: ^19.2.4 2635 + 2636 + react-draggable@4.5.0: 2637 + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==, tarball: https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz} 2638 + peerDependencies: 2639 + react: '>= 16.3.0' 2640 + react-dom: '>= 16.3.0' 2641 + 2642 + react-grid-layout@2.2.2: 2643 + resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==, tarball: https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz} 2644 + peerDependencies: 2645 + react: '>= 16.3.0' 2646 + react-dom: '>= 16.3.0' 2647 + 2648 + react-is@16.13.1: 2649 + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, tarball: https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz} 2650 + 2651 + react-resizable@3.1.3: 2652 + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==, tarball: https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz} 2653 + peerDependencies: 2654 + react: '>= 16.3' 2655 + react-dom: '>= 16.3' 2656 + 2657 + react@19.2.4: 2658 + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==, tarball: https://registry.npmjs.org/react/-/react-19.2.4.tgz} 2659 + engines: {node: '>=0.10.0'} 2660 + 2661 readdirp@4.1.2: 2662 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 2663 engines: {node: '>= 14.18.0'} ··· 2669 require-from-string@2.0.2: 2670 resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 2671 engines: {node: '>=0.10.0'} 2672 + 2673 + resize-observer-polyfill@1.5.1: 2674 + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==, tarball: https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz} 2675 2676 resolve-from@4.0.0: 2677 resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} ··· 2729 resolution: {integrity: sha512-abovcqmwl97WKioxpkfuMeZmndB1TuDFY/R+FymrZyiGP+pMYomvgSzVPnbNMWHHESOPosVHGL352oFbdAnJcA==} 2730 engines: {node: '>=16'} 2731 2732 + scheduler@0.27.0: 2733 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz} 2734 + 2735 semver@7.7.3: 2736 resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} 2737 engines: {node: '>=10'} ··· 4787 4788 fast-deep-equal@3.1.3: {} 4789 4790 + fast-equals@4.0.3: {} 4791 + 4792 fast-json-stable-stringify@2.1.0: {} 4793 4794 fast-levenshtein@2.0.6: {} ··· 4894 iso-datestring-validator@2.2.2: {} 4895 4896 jiti@2.6.1: {} 4897 + 4898 + js-tokens@4.0.0: {} 4899 4900 js-yaml@4.1.1: 4901 dependencies: ··· 5002 5003 lodash.merge@4.6.2: {} 5004 5005 + loose-envify@1.4.0: 5006 + dependencies: 5007 + js-tokens: 4.0.0 5008 + 5009 lz-string@1.5.0: {} 5010 5011 maath@0.10.8(@types/three@0.176.0)(three@0.176.0): ··· 5130 number-flow@0.5.9: 5131 dependencies: 5132 esm-env: 1.2.2 5133 + 5134 + object-assign@4.1.1: {} 5135 5136 obug@2.1.1: {} 5137 ··· 5266 5267 prettier@3.8.1: {} 5268 5269 + prop-types@15.8.1: 5270 + dependencies: 5271 + loose-envify: 1.4.0 5272 + object-assign: 4.1.1 5273 + react-is: 16.13.1 5274 + 5275 prosemirror-changeset@2.3.1: 5276 dependencies: 5277 prosemirror-transform: 1.11.0 ··· 5391 5392 rangetouch@2.0.1: {} 5393 5394 + react-dom@19.2.4(react@19.2.4): 5395 + dependencies: 5396 + react: 19.2.4 5397 + scheduler: 0.27.0 5398 + 5399 + react-draggable@4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 5400 + dependencies: 5401 + clsx: 2.1.1 5402 + prop-types: 15.8.1 5403 + react: 19.2.4 5404 + react-dom: 19.2.4(react@19.2.4) 5405 + 5406 + react-grid-layout@2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 5407 + dependencies: 5408 + clsx: 2.1.1 5409 + fast-equals: 4.0.3 5410 + prop-types: 15.8.1 5411 + react: 19.2.4 5412 + react-dom: 19.2.4(react@19.2.4) 5413 + react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 5414 + react-resizable: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 5415 + resize-observer-polyfill: 1.5.1 5416 + 5417 + react-is@16.13.1: {} 5418 + 5419 + react-resizable@3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 5420 + dependencies: 5421 + prop-types: 15.8.1 5422 + react: 19.2.4 5423 + react-dom: 19.2.4(react@19.2.4) 5424 + react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 5425 + 5426 + react@19.2.4: {} 5427 + 5428 readdirp@4.1.2: {} 5429 5430 regexparam@3.0.0: {} 5431 5432 require-from-string@2.0.2: {} 5433 + 5434 + resize-observer-polyfill@1.5.1: {} 5435 5436 resolve-from@4.0.0: {} 5437 ··· 5520 parse-css-color: 0.2.1 5521 postcss-value-parser: 4.2.0 5522 yoga-wasm-web: 0.3.3 5523 + 5524 + scheduler@0.27.0: {} 5525 5526 semver@7.7.3: {} 5527
+1 -11
src/lib/cards/_base/BaseCard/BaseCard.svelte
··· 5 import type { Snippet } from 'svelte'; 6 import type { HTMLAttributes } from 'svelte/elements'; 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(); 18 19 const colors = { 20 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 49 id={item.id} 50 data-flip-id={item.id} 51 bind:this={ref} 52 - draggable={isEditing && !locked && !isCoarse?.()} 53 class={[ 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', 55 color ? (colors[color] ?? colors.accent) : colors.base,
··· 5 import type { Snippet } from 'svelte'; 6 import type { HTMLAttributes } from 'svelte/elements'; 7 import { getColor } from '../..'; 8 9 const colors = { 10 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 39 id={item.id} 40 data-flip-id={item.id} 41 bind:this={ref} 42 + draggable={false} 43 class={[ 44 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2', 45 color ? (colors[color] ?? colors.accent) : colors.base,
+4 -7
src/lib/cards/_base/BaseCard/BaseEditingCard.svelte
··· 15 getSelectCard 16 } from '$lib/website/context'; 17 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 18 - import { fixAllCollisions, fixCollisions } from '$lib/helper'; 19 20 let colorsChoices = [ 21 { class: 'text-base-500', label: 'base' }, ··· 194 ]} 195 {...rest} 196 > 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}
··· 15 getSelectCard 16 } from '$lib/website/context'; 17 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 18 19 let colorsChoices = [ 20 { class: 'text-base-500', label: 'base' }, ··· 193 ]} 194 {...rest} 195 > 196 + {#if isCoarse?.() && !isSelected} 197 <!-- svelte-ignore a11y_click_events_have_key_events --> 198 <div 199 role="button" 200 tabindex="-1" 201 + class="absolute inset-0 z-20 cursor-pointer" 202 onclick={(e) => { 203 + e.stopPropagation(); 204 + selectCard?.(item.id); 205 }} 206 ></div> 207 {/if}
+13 -1
src/lib/cards/index.ts
··· 40 import { GitHubContributorsCardDefinition } from './social/GitHubContributorsCard'; 41 import { ProductHuntCardDefinition } from './social/ProductHuntCard'; 42 import { KickstarterCardDefinition } from './social/KickstarterCard'; 43 // import { Model3DCardDefinition } from './visual/Model3DCard'; 44 45 export const AllCardDefinitions = [ ··· 84 FriendsCardDefinition, 85 GitHubContributorsCardDefinition, 86 ProductHuntCardDefinition, 87 - KickstarterCardDefinition 88 ] as const; 89 90 export const CardDefinitionsByType = AllCardDefinitions.reduce(
··· 40 import { GitHubContributorsCardDefinition } from './social/GitHubContributorsCard'; 41 import { ProductHuntCardDefinition } from './social/ProductHuntCard'; 42 import { KickstarterCardDefinition } from './social/KickstarterCard'; 43 + import { NpmxLikesCardDefinition } from './social/NpmxLikesCard'; 44 + import { NpmxLikesLeaderboardCardDefinition } from './social/NpmxLikesLeaderboardCard'; 45 + import { LastFMRecentTracksCardDefinition } from './media/LastFMCard/LastFMRecentTracksCard'; 46 + import { LastFMTopTracksCardDefinition } from './media/LastFMCard/LastFMTopTracksCard'; 47 + import { LastFMTopAlbumsCardDefinition } from './media/LastFMCard/LastFMTopAlbumsCard'; 48 + import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard'; 49 // import { Model3DCardDefinition } from './visual/Model3DCard'; 50 51 export const AllCardDefinitions = [ ··· 90 FriendsCardDefinition, 91 GitHubContributorsCardDefinition, 92 ProductHuntCardDefinition, 93 + KickstarterCardDefinition, 94 + NpmxLikesCardDefinition, 95 + NpmxLikesLeaderboardCardDefinition, 96 + LastFMRecentTracksCardDefinition, 97 + LastFMTopTracksCardDefinition, 98 + LastFMTopAlbumsCardDefinition, 99 + LastFMProfileCardDefinition 100 ] as const; 101 102 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+60
src/lib/cards/media/LastFMCard/CreateLastFMCardModal.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 { 19 + const parsed = new URL(input); 20 + if (/^(www\.)?last\.fm$/.test(parsed.hostname)) { 21 + const segments = parsed.pathname.split('/').filter(Boolean); 22 + if (segments.length >= 2 && segments[0] === 'user') { 23 + username = segments[1]; 24 + } 25 + } 26 + } catch { 27 + if (/^[a-zA-Z0-9_-]{2,15}$/.test(input)) { 28 + username = input; 29 + } 30 + } 31 + 32 + if (!username) { 33 + errorMessage = 'Please enter a valid Last.fm username or profile URL'; 34 + return; 35 + } 36 + 37 + item.cardData.lastfmUsername = username; 38 + item.cardData.href = `https://www.last.fm/user/${username}`; 39 + 40 + oncreate?.(); 41 + }} 42 + class="flex flex-col gap-2" 43 + > 44 + <Subheading>Enter a Last.fm username or profile URL</Subheading> 45 + <Input 46 + bind:value={item.cardData.href} 47 + placeholder="username or https://www.last.fm/user/username" 48 + class="mt-4" 49 + /> 50 + 51 + {#if errorMessage} 52 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 53 + {/if} 54 + 55 + <div class="mt-4 flex justify-end gap-2"> 56 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 57 + <Button type="submit">Create</Button> 58 + </div> 59 + </form> 60 + </Modal>
+50
src/lib/cards/media/LastFMCard/LastFMAlbumArt.svelte
···
··· 1 + <script lang="ts"> 2 + let { 3 + images, 4 + alt = '', 5 + size = 'medium' 6 + }: { images?: { '#text': string; size: string }[]; alt?: string; size?: string } = $props(); 7 + 8 + let isLoading = $state(true); 9 + let hasError = $state(false); 10 + 11 + const imageUrl = $derived.by(() => { 12 + if (!images || images.length === 0) return ''; 13 + const preferred = ['extralarge', 'large', 'medium', 'small']; 14 + for (const pref of preferred) { 15 + const img = images.find((i) => i.size === pref); 16 + if (img?.['#text']) return img['#text']; 17 + } 18 + return images[images.length - 1]?.['#text'] || ''; 19 + }); 20 + </script> 21 + 22 + {#if !imageUrl || hasError} 23 + <div 24 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-700/50 flex h-full w-full items-center justify-center rounded-lg" 25 + > 26 + <svg 27 + class="text-base-500 dark:text-base-400 accent:text-accent-200 h-5 w-5" 28 + fill="currentColor" 29 + viewBox="0 0 20 20" 30 + > 31 + <path 32 + d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" 33 + /> 34 + </svg> 35 + </div> 36 + {:else} 37 + {#if isLoading} 38 + <div class="bg-base-200 dark:bg-base-800 h-full w-full animate-pulse rounded-lg"></div> 39 + {/if} 40 + <img 41 + src={imageUrl} 42 + {alt} 43 + class="h-full w-full rounded-lg object-cover {isLoading && 'hidden'}" 44 + onload={() => (isLoading = false)} 45 + onerror={() => { 46 + isLoading = false; 47 + hasError = true; 48 + }} 49 + /> 50 + {/if}
+36
src/lib/cards/media/LastFMCard/LastFMPeriodSettings.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 periodOptions = [ 8 + { value: '7day', label: '7 Days' }, 9 + { value: '1month', label: '1 Month' }, 10 + { value: '3month', label: '3 Months' }, 11 + { value: '6month', label: '6 Months' }, 12 + { value: '12month', label: '12 Months' }, 13 + { value: 'overall', label: 'All Time' } 14 + ]; 15 + 16 + let period = $derived(item.cardData.period ?? '7day'); 17 + </script> 18 + 19 + <div class="flex flex-col gap-2"> 20 + <Label>Time Period</Label> 21 + <div class="flex flex-wrap gap-2"> 22 + {#each periodOptions as opt (opt.value)} 23 + <button 24 + class={[ 25 + 'rounded-xl border px-3 py-2 text-sm transition-colors', 26 + period === opt.value 27 + ? 'bg-accent-500 border-accent-500 text-white' 28 + : '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' 29 + ]} 30 + onclick={() => (item.cardData.period = opt.value)} 31 + > 32 + {opt.label} 33 + </button> 34 + {/each} 35 + </div> 36 + </div>
+113
src/lib/cards/media/LastFMCard/LastFMProfileCard/LastFMProfileCard.svelte
···
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { siLastdotfm } from 'simple-icons'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 + import type { ContentComponentProps } from '../../../types'; 6 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 7 + 8 + interface UserInfo { 9 + name: string; 10 + realname: string; 11 + url: string; 12 + image: { '#text': string; size: string }[]; 13 + playcount: string; 14 + registered: { unixtime: string }; 15 + } 16 + 17 + let { item, isEditing }: ContentComponentProps = $props(); 18 + 19 + const data = getAdditionalUserData(); 20 + const cacheKey = $derived(`lastfmProfile:${item.cardData.lastfmUsername}`); 21 + 22 + // svelte-ignore state_referenced_locally 23 + let userInfo = $state(data[cacheKey] as UserInfo | undefined); 24 + 25 + onMount(async () => { 26 + if (userInfo) return; 27 + if (!item.cardData.lastfmUsername) return; 28 + 29 + try { 30 + const response = await fetch( 31 + `/api/lastfm?method=user.getInfo&user=${encodeURIComponent(item.cardData.lastfmUsername)}` 32 + ); 33 + if (response.ok) { 34 + const result = await response.json(); 35 + userInfo = result?.user; 36 + data[cacheKey] = userInfo; 37 + } 38 + } catch (error) { 39 + console.error('Failed to fetch Last.fm profile:', error); 40 + } 41 + }); 42 + 43 + const profileUrl = $derived(`https://www.last.fm/user/${item.cardData.lastfmUsername}`); 44 + 45 + const avatarUrl = $derived.by(() => { 46 + if (!userInfo?.image) return ''; 47 + const preferred = ['extralarge', 'large', 'medium']; 48 + for (const pref of preferred) { 49 + const img = userInfo.image.find((i) => i.size === pref); 50 + if (img?.['#text']) return img['#text']; 51 + } 52 + return ''; 53 + }); 54 + 55 + const memberSince = $derived.by(() => { 56 + if (!userInfo?.registered?.unixtime) return ''; 57 + const date = new Date(parseInt(userInfo.registered.unixtime) * 1000); 58 + return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); 59 + }); 60 + </script> 61 + 62 + <div class="h-full overflow-hidden p-4"> 63 + <div class="flex h-full flex-col justify-between"> 64 + <div class="flex items-center gap-3"> 65 + <div 66 + class="fill-base-950 accent:fill-white size-6 shrink-0 dark:fill-white [&_svg]:size-full" 67 + > 68 + {@html siLastdotfm.svg} 69 + </div> 70 + <span class="truncate text-2xl font-bold"> 71 + {item.cardData.lastfmUsername} 72 + </span> 73 + </div> 74 + 75 + {#if userInfo} 76 + <div class="flex items-center gap-4"> 77 + {#if avatarUrl} 78 + <img src={avatarUrl} alt={userInfo.name} class="size-12 rounded-full object-cover" /> 79 + {/if} 80 + <div class="min-w-0 flex-1"> 81 + <div class="text-lg font-semibold"> 82 + {parseInt(userInfo.playcount).toLocaleString()} scrobbles 83 + </div> 84 + {#if memberSince} 85 + <div class="text-sm opacity-60"> 86 + Since {memberSince} 87 + </div> 88 + {/if} 89 + </div> 90 + </div> 91 + {:else} 92 + <div class="text-sm opacity-60">Loading profile...</div> 93 + {/if} 94 + </div> 95 + </div> 96 + 97 + {#if !isEditing} 98 + <a 99 + href={profileUrl} 100 + class="absolute inset-0 h-full w-full" 101 + target="_blank" 102 + rel="noopener noreferrer" 103 + use:qrOverlay={{ 104 + context: { 105 + title: item.cardData.lastfmUsername, 106 + icon: siLastdotfm.svg, 107 + iconColor: '#' + siLastdotfm.hex 108 + } 109 + }} 110 + > 111 + <span class="sr-only">View on Last.fm</span> 112 + </a> 113 + {/if}
+40
src/lib/cards/media/LastFMCard/LastFMProfileCard/index.ts
···
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMProfileCard from './LastFMProfileCard.svelte'; 4 + 5 + export const LastFMProfileCardDefinition = { 6 + type: 'lastfmProfile', 7 + contentComponent: LastFMProfileCard, 8 + creationModalComponent: CreateLastFMCardModal, 9 + createNew: (card) => { 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 2; 13 + card.mobileH = 3; 14 + }, 15 + loadData: async (items) => { 16 + const allData: Record<string, unknown> = {}; 17 + for (const item of items) { 18 + const username = item.cardData.lastfmUsername; 19 + if (!username) continue; 20 + try { 21 + const response = await fetch( 22 + `https://blento.app/api/lastfm?method=user.getInfo&user=${encodeURIComponent(username)}` 23 + ); 24 + if (!response.ok) continue; 25 + const text = await response.text(); 26 + const result = JSON.parse(text); 27 + allData[`lastfmProfile:${username}`] = result?.user; 28 + } catch (error) { 29 + console.error('Failed to fetch Last.fm profile:', error); 30 + } 31 + } 32 + return allData; 33 + }, 34 + minW: 2, 35 + minH: 2, 36 + name: 'Last.fm Profile', 37 + keywords: ['music', 'scrobble', 'profile', 'lastfm', 'last.fm'], 38 + groups: ['Media'], 39 + 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>` 40 + } as CardDefinition & { type: 'lastfmProfile' };
+103
src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/LastFMRecentTracksCard.svelte
···
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getAdditionalUserData } from '$lib/website/context'; 4 + import type { ContentComponentProps } from '../../../types'; 5 + import LastFMAlbumArt from '../LastFMAlbumArt.svelte'; 6 + import { RelativeTime } from '@foxui/time'; 7 + 8 + interface Track { 9 + name: string; 10 + artist: { '#text': string }; 11 + album: { '#text': string }; 12 + image: { '#text': string; size: string }[]; 13 + url: string; 14 + date?: { uts: string }; 15 + '@attr'?: { nowplaying: string }; 16 + } 17 + 18 + let { item }: ContentComponentProps = $props(); 19 + 20 + const data = getAdditionalUserData(); 21 + const cacheKey = $derived(`lastfmRecentTracks:${item.cardData.lastfmUsername}`); 22 + 23 + // svelte-ignore state_referenced_locally 24 + let tracks = $state(data[cacheKey] as Track[] | undefined); 25 + let error = $state(false); 26 + 27 + onMount(async () => { 28 + if (tracks) return; 29 + if (!item.cardData.lastfmUsername) return; 30 + 31 + try { 32 + const response = await fetch( 33 + `/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(item.cardData.lastfmUsername)}&limit=50` 34 + ); 35 + if (response.ok) { 36 + const result = await response.json(); 37 + tracks = result?.recenttracks?.track ?? []; 38 + data[cacheKey] = tracks; 39 + } else { 40 + error = true; 41 + } 42 + } catch { 43 + error = true; 44 + } 45 + }); 46 + </script> 47 + 48 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 49 + {#if tracks && tracks.length > 0} 50 + {#each tracks as track, i (track.url + i)} 51 + <a 52 + href={track.url} 53 + target="_blank" 54 + rel="noopener noreferrer" 55 + class="flex w-full items-center gap-3" 56 + > 57 + <div class="size-10 shrink-0"> 58 + <LastFMAlbumArt images={track.image} alt={track.album?.['#text']} /> 59 + </div> 60 + <div class="min-w-0 flex-1"> 61 + <div class="inline-flex w-full max-w-full justify-between gap-2"> 62 + <div 63 + class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate font-semibold" 64 + > 65 + {track.name} 66 + </div> 67 + {#if track['@attr']?.nowplaying === 'true'} 68 + <div class="flex shrink-0 items-center gap-1 text-xs text-green-500"> 69 + <span class="inline-block size-2 animate-pulse rounded-full bg-green-500"></span> 70 + Now 71 + </div> 72 + {:else if track.date?.uts} 73 + <div class="shrink-0 text-xs"> 74 + <RelativeTime date={new Date(parseInt(track.date.uts) * 1000)} locale="en-US" /> ago 75 + </div> 76 + {/if} 77 + </div> 78 + <div class="my-1 min-w-0 truncate text-xs whitespace-nowrap"> 79 + {track.artist?.['#text']} 80 + </div> 81 + </div> 82 + </a> 83 + {/each} 84 + {:else if error} 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 + Failed to load tracks. 89 + </div> 90 + {:else if tracks} 91 + <div 92 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 93 + > 94 + No recent tracks found. 95 + </div> 96 + {:else} 97 + <div 98 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 99 + > 100 + Loading tracks... 101 + </div> 102 + {/if} 103 + </div>
+70
src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/index.ts
···
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMRecentTracksCard from './LastFMRecentTracksCard.svelte'; 4 + 5 + export const LastFMRecentTracksCardDefinition = { 6 + type: 'lastfmRecentTracks', 7 + contentComponent: LastFMRecentTracksCard, 8 + creationModalComponent: CreateLastFMCardModal, 9 + createNew: (card) => { 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 3; 13 + card.mobileH = 6; 14 + }, 15 + loadData: async (items) => { 16 + const allData: Record<string, unknown> = {}; 17 + for (const item of items) { 18 + const username = item.cardData.lastfmUsername; 19 + if (!username) continue; 20 + try { 21 + const response = await fetch( 22 + `https://blento.app/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(username)}&limit=50` 23 + ); 24 + if (!response.ok) continue; 25 + const text = await response.text(); 26 + const result = JSON.parse(text); 27 + allData[`lastfmRecentTracks:${username}`] = result?.recenttracks?.track ?? []; 28 + } catch (error) { 29 + console.error('Failed to fetch Last.fm recent tracks:', error); 30 + } 31 + } 32 + return allData; 33 + }, 34 + onUrlHandler: (url, item) => { 35 + const username = getLastFMUsername(url); 36 + if (!username) return null; 37 + 38 + item.cardData.lastfmUsername = username; 39 + item.cardData.href = `https://www.last.fm/user/${username}`; 40 + item.w = 4; 41 + item.mobileW = 8; 42 + item.h = 3; 43 + item.mobileH = 6; 44 + item.cardType = 'lastfmRecentTracks'; 45 + return item; 46 + }, 47 + urlHandlerPriority: 5, 48 + minW: 3, 49 + minH: 2, 50 + canHaveLabel: true, 51 + name: 'Last.fm Recent Tracks', 52 + keywords: ['music', 'scrobble', 'listening', 'songs', 'lastfm', 'last.fm'], 53 + groups: ['Media'], 54 + 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>` 55 + } as CardDefinition & { type: 'lastfmRecentTracks' }; 56 + 57 + function getLastFMUsername(url: string | undefined): string | undefined { 58 + if (!url) return; 59 + try { 60 + const parsed = new URL(url); 61 + if (!/^(www\.)?last\.fm$/.test(parsed.hostname)) return undefined; 62 + const segments = parsed.pathname.split('/').filter(Boolean); 63 + if (segments.length >= 2 && segments[0] === 'user') { 64 + return segments[1]; 65 + } 66 + return undefined; 67 + } catch { 68 + return undefined; 69 + } 70 + }
+103
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCard.svelte
···
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { ContentComponentProps } from '../../../types'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 + import ImageGrid from '$lib/components/ImageGrid.svelte'; 6 + 7 + interface Album { 8 + name: string; 9 + playcount: string; 10 + url: string; 11 + artist: { name: string; url: string }; 12 + image: { '#text': string; size: string }[]; 13 + } 14 + 15 + let { item }: ContentComponentProps = $props(); 16 + 17 + const data = getAdditionalUserData(); 18 + 19 + let period = $derived(item.cardData.period ?? '7day'); 20 + let layout: 'grid' | 'cinema' = $derived(item.cardData.layout ?? 'grid'); 21 + const cacheKey = $derived(`lastfmTopAlbums:${item.cardData.lastfmUsername}:${period}`); 22 + 23 + // svelte-ignore state_referenced_locally 24 + let albums = $state(data[cacheKey] as Album[] | undefined); 25 + let loading = $state(false); 26 + let error = $state(false); 27 + 28 + async function fetchAlbums() { 29 + if (!item.cardData.lastfmUsername) return; 30 + loading = true; 31 + 32 + try { 33 + const response = await fetch( 34 + `/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(item.cardData.lastfmUsername)}&period=${period}&limit=50` 35 + ); 36 + if (response.ok) { 37 + const result = await response.json(); 38 + albums = result?.topalbums?.album ?? []; 39 + data[cacheKey] = albums; 40 + } else { 41 + error = true; 42 + } 43 + } catch { 44 + error = true; 45 + } finally { 46 + loading = false; 47 + } 48 + } 49 + 50 + onMount(() => { 51 + if (!albums) fetchAlbums(); 52 + }); 53 + 54 + $effect(() => { 55 + const _period = period; 56 + const cached = data[cacheKey] as Album[] | undefined; 57 + if (cached) { 58 + albums = cached; 59 + } else { 60 + fetchAlbums(); 61 + } 62 + }); 63 + 64 + function getImageUrl(album: Album): string | null { 65 + if (!album.image || album.image.length === 0) return null; 66 + const preferred = ['extralarge', 'large', 'medium', 'small']; 67 + for (const pref of preferred) { 68 + const img = album.image.find((i) => i.size === pref); 69 + if (img?.['#text']) return img['#text']; 70 + } 71 + return album.image[album.image.length - 1]?.['#text'] || null; 72 + } 73 + 74 + let gridItems = $derived( 75 + (albums ?? []).map((album) => ({ 76 + imageUrl: getImageUrl(album), 77 + link: album.url, 78 + label: `${album.name} - ${album.artist.name}` 79 + })) 80 + ); 81 + </script> 82 + 83 + {#if error} 84 + <div class="flex h-full w-full items-center justify-center"> 85 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 86 + Failed to load albums. 87 + </span> 88 + </div> 89 + {:else if albums && gridItems.length > 0} 90 + <ImageGrid items={gridItems} {layout} tooltip /> 91 + {:else if loading || !albums} 92 + <div class="flex h-full w-full items-center justify-center"> 93 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 94 + Loading albums... 95 + </span> 96 + </div> 97 + {:else} 98 + <div class="flex h-full w-full items-center justify-center"> 99 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 100 + No top albums found. 101 + </span> 102 + </div> 103 + {/if}
+63
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCardSettings.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 periodOptions = [ 8 + { value: '7day', label: '7 Days' }, 9 + { value: '1month', label: '1 Month' }, 10 + { value: '3month', label: '3 Months' }, 11 + { value: '6month', label: '6 Months' }, 12 + { value: '12month', label: '12 Months' }, 13 + { value: 'overall', label: 'All Time' } 14 + ]; 15 + 16 + const layoutOptions = [ 17 + { value: 'grid', label: 'Grid' }, 18 + { value: 'cinema', label: 'Cinema' } 19 + ]; 20 + 21 + let period = $derived(item.cardData.period ?? '7day'); 22 + let layout = $derived(item.cardData.layout ?? 'grid'); 23 + </script> 24 + 25 + <div class="flex flex-col gap-4"> 26 + <div class="flex flex-col gap-2"> 27 + <Label>Time Period</Label> 28 + <div class="flex flex-wrap gap-2"> 29 + {#each periodOptions as opt (opt.value)} 30 + <button 31 + class={[ 32 + 'rounded-xl border px-3 py-2 text-sm transition-colors', 33 + period === opt.value 34 + ? 'bg-accent-500 border-accent-500 text-white' 35 + : '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' 36 + ]} 37 + onclick={() => (item.cardData.period = opt.value)} 38 + > 39 + {opt.label} 40 + </button> 41 + {/each} 42 + </div> 43 + </div> 44 + 45 + <div class="flex flex-col gap-2"> 46 + <Label>Layout</Label> 47 + <div class="flex gap-2"> 48 + {#each layoutOptions as opt (opt.value)} 49 + <button 50 + class={[ 51 + 'flex-1 rounded-xl border px-3 py-2 text-sm transition-colors', 52 + layout === opt.value 53 + ? 'bg-accent-500 border-accent-500 text-white' 54 + : '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' 55 + ]} 56 + onclick={() => (item.cardData.layout = opt.value)} 57 + > 58 + {opt.label} 59 + </button> 60 + {/each} 61 + </div> 62 + </div> 63 + </div>
+47
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/index.ts
···
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMTopAlbumsCard from './LastFMTopAlbumsCard.svelte'; 4 + import LastFMTopAlbumsCardSettings from './LastFMTopAlbumsCardSettings.svelte'; 5 + 6 + export const LastFMTopAlbumsCardDefinition = { 7 + type: 'lastfmTopAlbums', 8 + contentComponent: LastFMTopAlbumsCard, 9 + creationModalComponent: CreateLastFMCardModal, 10 + settingsComponent: LastFMTopAlbumsCardSettings, 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.h = 3; 14 + card.mobileW = 8; 15 + card.mobileH = 4; 16 + card.cardData.period = '7day'; 17 + }, 18 + loadData: async (items) => { 19 + const allData: Record<string, unknown> = {}; 20 + for (const item of items) { 21 + const username = item.cardData.lastfmUsername; 22 + const period = item.cardData.period ?? '7day'; 23 + if (!username) continue; 24 + try { 25 + const response = await fetch( 26 + `https://blento.app/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(username)}&period=${period}&limit=50` 27 + ); 28 + if (!response.ok) continue; 29 + const text = await response.text(); 30 + const result = JSON.parse(text); 31 + allData[`lastfmTopAlbums:${username}:${period}`] = result?.topalbums?.album ?? []; 32 + } catch (error) { 33 + console.error('Failed to fetch Last.fm top albums:', error); 34 + } 35 + } 36 + return allData; 37 + }, 38 + allowSetColor: true, 39 + defaultColor: 'base', 40 + minW: 2, 41 + minH: 2, 42 + canHaveLabel: true, 43 + name: 'Last.fm Top Albums', 44 + keywords: ['music', 'scrobble', 'albums', 'lastfm', 'last.fm', 'top'], 45 + groups: ['Media'], 46 + 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>` 47 + } as CardDefinition & { type: 'lastfmTopAlbums' };
+117
src/lib/cards/media/LastFMCard/LastFMTopTracksCard/LastFMTopTracksCard.svelte
···
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getAdditionalUserData } from '$lib/website/context'; 4 + import type { ContentComponentProps } from '../../../types'; 5 + import LastFMAlbumArt from '../LastFMAlbumArt.svelte'; 6 + 7 + interface Track { 8 + name: string; 9 + playcount: string; 10 + artist: { name: string; url: string }; 11 + image: { '#text': string; size: string }[]; 12 + url: string; 13 + } 14 + 15 + let { item }: ContentComponentProps = $props(); 16 + 17 + const data = getAdditionalUserData(); 18 + 19 + let period = $derived(item.cardData.period ?? '7day'); 20 + const cacheKey = $derived(`lastfmTopTracks:${item.cardData.lastfmUsername}:${period}`); 21 + 22 + // svelte-ignore state_referenced_locally 23 + let tracks = $state(data[cacheKey] as Track[] | undefined); 24 + let error = $state(false); 25 + let loading = $state(false); 26 + 27 + async function fetchTracks() { 28 + if (!item.cardData.lastfmUsername) return; 29 + loading = true; 30 + 31 + try { 32 + const response = await fetch( 33 + `/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(item.cardData.lastfmUsername)}&period=${period}&limit=50` 34 + ); 35 + if (response.ok) { 36 + const result = await response.json(); 37 + tracks = result?.toptracks?.track ?? []; 38 + data[cacheKey] = tracks; 39 + } else { 40 + error = true; 41 + } 42 + } catch { 43 + error = true; 44 + } finally { 45 + loading = false; 46 + } 47 + } 48 + 49 + onMount(() => { 50 + if (!tracks) fetchTracks(); 51 + }); 52 + 53 + $effect(() => { 54 + const _period = period; 55 + const cached = data[cacheKey] as Track[] | undefined; 56 + if (cached) { 57 + tracks = cached; 58 + } else { 59 + fetchTracks(); 60 + } 61 + }); 62 + </script> 63 + 64 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 65 + {#if tracks && tracks.length > 0} 66 + {#each tracks as track, i (track.url)} 67 + <a 68 + href={track.url} 69 + target="_blank" 70 + rel="noopener noreferrer" 71 + class="flex w-full items-center gap-3" 72 + > 73 + <div 74 + class="text-base-400 dark:text-base-500 accent:text-white/40 w-5 shrink-0 text-right text-xs font-bold" 75 + > 76 + {i + 1} 77 + </div> 78 + <div class="size-10 shrink-0"> 79 + <LastFMAlbumArt images={track.image} alt={track.name} /> 80 + </div> 81 + <div class="min-w-0 flex-1"> 82 + <div class="inline-flex w-full max-w-full justify-between gap-2"> 83 + <div 84 + class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate font-semibold" 85 + > 86 + {track.name} 87 + </div> 88 + <div class="shrink-0 text-xs"> 89 + {parseInt(track.playcount).toLocaleString()} plays 90 + </div> 91 + </div> 92 + <div class="my-1 min-w-0 truncate text-xs whitespace-nowrap"> 93 + {track.artist?.name} 94 + </div> 95 + </div> 96 + </a> 97 + {/each} 98 + {:else if error} 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 + Failed to load tracks. 103 + </div> 104 + {:else if tracks || loading} 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 + {tracks?.length === 0 ? 'No top tracks found.' : 'Loading tracks...'} 109 + </div> 110 + {:else} 111 + <div 112 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 113 + > 114 + Loading tracks... 115 + </div> 116 + {/if} 117 + </div>
+45
src/lib/cards/media/LastFMCard/LastFMTopTracksCard/index.ts
···
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMPeriodSettings from '../LastFMPeriodSettings.svelte'; 4 + import LastFMTopTracksCard from './LastFMTopTracksCard.svelte'; 5 + 6 + export const LastFMTopTracksCardDefinition = { 7 + type: 'lastfmTopTracks', 8 + contentComponent: LastFMTopTracksCard, 9 + creationModalComponent: CreateLastFMCardModal, 10 + settingsComponent: LastFMPeriodSettings, 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.mobileW = 8; 14 + card.h = 3; 15 + card.mobileH = 6; 16 + card.cardData.period = '7day'; 17 + }, 18 + loadData: async (items) => { 19 + const allData: Record<string, unknown> = {}; 20 + for (const item of items) { 21 + const username = item.cardData.lastfmUsername; 22 + const period = item.cardData.period ?? '7day'; 23 + if (!username) continue; 24 + try { 25 + const response = await fetch( 26 + `https://blento.app/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(username)}&period=${period}&limit=50` 27 + ); 28 + if (!response.ok) continue; 29 + const text = await response.text(); 30 + const result = JSON.parse(text); 31 + allData[`lastfmTopTracks:${username}:${period}`] = result?.toptracks?.track ?? []; 32 + } catch (error) { 33 + console.error('Failed to fetch Last.fm top tracks:', error); 34 + } 35 + } 36 + return allData; 37 + }, 38 + minW: 3, 39 + minH: 2, 40 + canHaveLabel: true, 41 + name: 'Last.fm Top Tracks', 42 + keywords: ['music', 'scrobble', 'songs', 'lastfm', 'last.fm', 'top'], 43 + groups: ['Media'], 44 + 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>` 45 + } as CardDefinition & { type: 'lastfmTopTracks' };
+9 -1
src/lib/cards/social/BigSocialCard/BigSocialCard.svelte
··· 7 8 const platform = $derived(item.cardData.platform as string); 9 const platformData = $derived(platformsData[platform]); 10 </script> 11 12 <div 13 class="flex h-full w-full items-center justify-center p-10" 14 - style={`background-color: #${item.cardData.color}`} 15 > 16 <div 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"
··· 7 8 const platform = $derived(item.cardData.platform as string); 9 const platformData = $derived(platformsData[platform]); 10 + 11 + // Color logic: 12 + // - base/transparent/undefined: background = brand color, icon = white 13 + // - other: background = that color (from BaseCard), icon = white 14 + const useBrandBackground = $derived( 15 + !item.color || item.color === 'base' || item.color === 'transparent' 16 + ); 17 + const brandColor = $derived(`#${item.cardData.color}`); 18 </script> 19 20 <div 21 class="flex h-full w-full items-center justify-center p-10" 22 + style={useBrandBackground ? `background-color: ${brandColor}` : ''} 23 > 24 <div 25 class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white"
+24 -3
src/lib/cards/social/BigSocialCard/index.ts
··· 36 return item; 37 }, 38 name: 'Social Icon', 39 - allowSetColor: false, 40 - defaultColor: 'transparent', 41 minW: 2, 42 minH: 2, 43 onUrlHandler: (url, item) => { ··· 167 168 tangled: /(?:tangled\.org)/i, 169 170 - mail: /(?:mailto:)/i 171 }; 172 173 export const platformsData: Record<string, SimpleIcon> = { ··· 277 </g> 278 <defs> 279 <clipPath id="clip0_0_3"> 280 <rect width="24" height="24" fill="white"/> 281 </clipPath> 282 </defs>
··· 36 return item; 37 }, 38 name: 'Social Icon', 39 + allowSetColor: true, 40 + defaultColor: 'base', 41 minW: 2, 42 minH: 2, 43 onUrlHandler: (url, item) => { ··· 167 168 tangled: /(?:tangled\.org)/i, 169 170 + mail: /(?:mailto:)/i, 171 + 172 + npmx: /(?:npmx\.dev)/i 173 }; 174 175 export const platformsData: Record<string, SimpleIcon> = { ··· 279 </g> 280 <defs> 281 <clipPath id="clip0_0_3"> 282 + <rect width="24" height="24" fill="white"/> 283 + </clipPath> 284 + </defs> 285 + </svg>` 286 + }, 287 + 288 + npmx: { 289 + slug: 'npmx', 290 + path: '', 291 + title: 'npmx', 292 + source: '', 293 + hex: '0A0A0A', 294 + svg: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 295 + <g clip-path="url(#clip0_4_2)"> 296 + <path d="M6.12765 16.4049H2V20.5326H6.12765V16.4049Z" fill="#525252"/> 297 + <path d="M10.9049 23.8485L19.6885 -1H22L13.2164 23.8485H10.9049Z" fill="#FAFAFA"/> 298 + </g> 299 + <defs> 300 + <clipPath id="clip0_4_2"> 301 <rect width="24" height="24" fill="white"/> 302 </clipPath> 303 </defs>
+14 -135
src/lib/cards/social/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 ··· 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>
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import type { ContentComponentProps } from '../../types'; 4 + import { getAdditionalUserData, getCanEdit } from '$lib/website/context'; 5 import type { GitHubContributor, GitHubContributorsLoadedData } from '.'; 6 + import ImageGrid from '$lib/components/ImageGrid.svelte'; 7 8 let { item }: ContentComponentProps = $props(); 9 10 const canEdit = getCanEdit(); 11 const additionalData = getAdditionalUserData(); 12 ··· 53 } 54 } 55 56 + let gridItems = $derived( 57 + namedContributors.map((c) => ({ 58 + imageUrl: c.avatarUrl, 59 + link: `https://github.com/${c.username}`, 60 + label: c.username 61 + })) 62 ); 63 </script> 64 65 + {#if !owner || !repo} 66 + {#if canEdit()} 67 + <div class="flex h-full w-full items-center justify-center"> 68 <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 69 Enter a repository 70 </span> 71 </div> 72 {/if} 73 + {:else} 74 + <ImageGrid items={gridItems} {layout} {shape} tooltip /> 75 + {/if}
+103
src/lib/cards/social/NpmxLikesCard/NpmxLikesCard.svelte
···
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 + import { NpmxLikesCardDefinition } from '.'; 6 + import { RelativeTime } from '@foxui/time'; 7 + 8 + interface NpmxLike { 9 + uri: string; 10 + value: { 11 + subjectRef: string; 12 + createdAt: string; 13 + }; 14 + } 15 + 16 + let { item }: { item: Item } = $props(); 17 + 18 + const data = getAdditionalUserData(); 19 + // svelte-ignore state_referenced_locally 20 + let feed = $state(data[item.cardType] as NpmxLike[] | undefined); 21 + 22 + let did = getDidContext(); 23 + let handle = getHandleContext(); 24 + 25 + onMount(async () => { 26 + if (feed) return; 27 + 28 + feed = (await NpmxLikesCardDefinition.loadData?.([], { 29 + did, 30 + handle 31 + })) as NpmxLike[] | undefined; 32 + 33 + data[item.cardType] = feed; 34 + }); 35 + 36 + function getPackageName(like: NpmxLike): string { 37 + return like.value.subjectRef.split('/package/')[1] ?? like.value.subjectRef; 38 + } 39 + </script> 40 + 41 + {#snippet likeItem(like: NpmxLike)} 42 + <div class="flex w-full items-center gap-3"> 43 + <div 44 + class="text-accent-500 accent:text-accent-950 flex size-8 shrink-0 items-center justify-center" 45 + > 46 + <svg 47 + xmlns="http://www.w3.org/2000/svg" 48 + fill="none" 49 + viewBox="0 0 24 24" 50 + stroke-width="1.5" 51 + stroke="currentColor" 52 + class="size-5" 53 + > 54 + <path 55 + stroke-linecap="round" 56 + stroke-linejoin="round" 57 + d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" 58 + /> 59 + </svg> 60 + </div> 61 + <div class="min-w-0 flex-1"> 62 + <div class="inline-flex w-full max-w-full items-baseline justify-between gap-2"> 63 + <div 64 + class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate text-sm font-semibold" 65 + > 66 + {getPackageName(like)} 67 + </div> 68 + {#if like.value.createdAt} 69 + <div class="text-base-500 dark:text-base-400 accent:text-white/60 shrink-0 text-xs"> 70 + <RelativeTime date={new Date(like.value.createdAt)} locale="en-US" /> ago 71 + </div> 72 + {/if} 73 + </div> 74 + </div> 75 + </div> 76 + {/snippet} 77 + 78 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 79 + {#if feed && feed.length > 0} 80 + {#each feed as like (like.uri)} 81 + <a 82 + href="https://npmx.dev/package/{getPackageName(like)}" 83 + target="_blank" 84 + rel="noopener noreferrer" 85 + class="w-full" 86 + > 87 + {@render likeItem(like)} 88 + </a> 89 + {/each} 90 + {:else if feed} 91 + <div 92 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 93 + > 94 + No liked packages found. 95 + </div> 96 + {:else} 97 + <div 98 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 99 + > 100 + Loading likes... 101 + </div> 102 + {/if} 103 + </div>
+31
src/lib/cards/social/NpmxLikesCard/index.ts
···
··· 1 + import type { CardDefinition } from '../../types'; 2 + import { listRecords } from '$lib/atproto'; 3 + import NpmxLikesCard from './NpmxLikesCard.svelte'; 4 + 5 + export const NpmxLikesCardDefinition = { 6 + type: 'npmxLikes', 7 + contentComponent: NpmxLikesCard, 8 + createNew: (card) => { 9 + card.w = 4; 10 + card.mobileW = 8; 11 + card.h = 3; 12 + card.mobileH = 6; 13 + }, 14 + loadData: async (items, { did }) => { 15 + const data = await listRecords({ 16 + did, 17 + collection: 'dev.npmx.feed.like', 18 + limit: 99 19 + }); 20 + 21 + return data; 22 + }, 23 + minW: 4, 24 + canHaveLabel: true, 25 + 26 + keywords: ['npm', 'package', 'npmx', 'likes'], 27 + name: 'npmx Likes', 28 + 29 + groups: ['Social'], 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="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /></svg>` 31 + } as CardDefinition & { type: 'npmxLikes' };
+116
src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte
···
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 + import { NpmxLikesLeaderboardCardDefinition } from '.'; 6 + 7 + interface LeaderboardEntry { 8 + subjectRef: string; 9 + totalLikes: number; 10 + } 11 + 12 + interface LeaderboardData { 13 + totalLikes: number; 14 + totalUniqueLikers: number; 15 + leaderBoard: LeaderboardEntry[]; 16 + } 17 + 18 + let { item }: { item: Item } = $props(); 19 + 20 + const data = getAdditionalUserData(); 21 + // svelte-ignore state_referenced_locally 22 + let leaderboard = $state(data[item.cardType] as LeaderboardData | undefined); 23 + 24 + let did = getDidContext(); 25 + let handle = getHandleContext(); 26 + 27 + onMount(async () => { 28 + if (leaderboard) return; 29 + 30 + leaderboard = (await NpmxLikesLeaderboardCardDefinition.loadData?.([], { 31 + did, 32 + handle 33 + })) as LeaderboardData | undefined; 34 + 35 + data[item.cardType] = leaderboard; 36 + }); 37 + 38 + function getPackageName(entry: LeaderboardEntry): string { 39 + return entry.subjectRef.split('/package/')[1] ?? entry.subjectRef; 40 + } 41 + </script> 42 + 43 + {#snippet leaderboardRow(entry: LeaderboardEntry, index: number)} 44 + <div 45 + class="hover:bg-base-100 dark:hover:bg-base-800 accent:hover:bg-white/10 flex w-full items-center gap-3 rounded-lg px-2 py-1.5 transition-colors" 46 + > 47 + <div 48 + class="text-base-600 dark:text-base-400 accent:text-white/60 w-6 shrink-0 text-right text-xs font-medium" 49 + > 50 + #{index + 1} 51 + </div> 52 + <div class="min-w-0 flex-1"> 53 + <div class="inline-flex w-full max-w-full items-center justify-between gap-2"> 54 + <div 55 + class="text-accent-500 accent:text-accent-50 dark:text-accent-400 min-w-0 flex-1 shrink truncate text-sm font-semibold" 56 + > 57 + {getPackageName(entry)} 58 + </div> 59 + <div 60 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex shrink-0 items-center gap-1 text-xs" 61 + > 62 + <svg 63 + xmlns="http://www.w3.org/2000/svg" 64 + viewBox="0 0 24 24" 65 + fill="currentColor" 66 + class="accent:text-accent-200 text-accent-400 size-3.5" 67 + > 68 + <path 69 + d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" 70 + /> 71 + </svg> 72 + {entry.totalLikes} 73 + </div> 74 + </div> 75 + </div> 76 + </div> 77 + {/snippet} 78 + 79 + <div class="z-10 flex h-full w-full flex-col overflow-hidden"> 80 + {#if leaderboard && leaderboard.leaderBoard.length > 0} 81 + <div class="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-4 pb-10"> 82 + {#each leaderboard.leaderBoard as entry, index (entry.subjectRef)} 83 + <a 84 + href="https://npmx.dev/package/{getPackageName(entry)}" 85 + target="_blank" 86 + rel="noopener noreferrer" 87 + class="w-full" 88 + > 89 + {@render leaderboardRow(entry, index)} 90 + </a> 91 + {/each} 92 + </div> 93 + <div 94 + class="from-base-200 dark:from-base-950 accent:from-accent-500 pointer-events-none absolute inset-x-0 bottom-0 z-10 h-12 bg-linear-to-t from-40% to-transparent" 95 + ></div> 96 + <div 97 + class="text-base-500 dark:text-base-400 accent:text-white/60 bg-base-200 dark:bg-base-950/50 accent:bg-accent-500/20 relative z-10 flex shrink-0 items-center justify-center gap-3 px-4 pb-3 text-xs" 98 + > 99 + <span>{leaderboard.totalLikes} likes</span> 100 + <span class="text-base-300 dark:text-base-600 accent:text-white/20">&middot;</span> 101 + <span>{leaderboard.totalUniqueLikers} unique likers</span> 102 + </div> 103 + {:else if leaderboard} 104 + <div 105 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center p-4 text-center text-sm" 106 + > 107 + No leaderboard data. 108 + </div> 109 + {:else} 110 + <div 111 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center p-4 text-center text-sm" 112 + > 113 + Loading leaderboard... 114 + </div> 115 + {/if} 116 + </div>
+26
src/lib/cards/social/NpmxLikesLeaderboardCard/index.ts
···
··· 1 + import type { CardDefinition } from '../../types'; 2 + import NpmxLikesLeaderboardCard from './NpmxLikesLeaderboardCard.svelte'; 3 + 4 + export const NpmxLikesLeaderboardCardDefinition = { 5 + type: 'npmxLikesLeaderboard', 6 + contentComponent: NpmxLikesLeaderboardCard, 7 + createNew: (card) => { 8 + card.w = 4; 9 + card.mobileW = 8; 10 + card.h = 4; 11 + card.mobileH = 6; 12 + }, 13 + loadData: async () => { 14 + const res = await fetch('https://blento.app/api/npmx-leaderboard'); 15 + const data = await res.json(); 16 + return data; 17 + }, 18 + minW: 3, 19 + canHaveLabel: true, 20 + 21 + keywords: ['npm', 'package', 'npmx', 'likes', 'leaderboard', 'ranking'], 22 + name: 'npmx Likes Leaderboard', 23 + 24 + //groups: ['Social'], 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="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-4.5A3.375 3.375 0 0 0 13.125 10.875h-2.25A3.375 3.375 0 0 0 7.5 14.25v4.5m6-6V6.375a3.375 3.375 0 0 0-3-3.353A3.375 3.375 0 0 0 7.5 6.375v1.5" /></svg>` 26 + } as CardDefinition & { type: 'npmxLikesLeaderboard' };
+4 -2
src/lib/cards/special/UpdatedBlentos/index.ts
··· 46 47 let result = [...(await Promise.all(profiles)), ...existingUsersArray]; 48 49 - result = result.filter((v) => v && v.handle !== 'handle.invalid'); 50 51 if (cache) { 52 await cache?.put('updatedBlentos', JSON.stringify(result)); 53 } 54 - return JSON.parse(JSON.stringify(result)); 55 } catch (error) { 56 console.error('error fetching updated blentos', error); 57 return [];
··· 46 47 let result = [...(await Promise.all(profiles)), ...existingUsersArray]; 48 49 + result = result.filter( 50 + (v) => v && v.handle !== 'handle.invalid' && !v.handle.endsWith('.pds.rip') 51 + ); 52 53 if (cache) { 54 await cache?.put('updatedBlentos', JSON.stringify(result)); 55 } 56 + return JSON.parse(JSON.stringify(result.slice(0, 20))); 57 } catch (error) { 58 console.error('error fetching updated blentos', error); 59 return [];
+86 -10
src/lib/cards/visual/FluidTextCard/FluidTextCard.svelte
··· 1 <script lang="ts"> 2 - import { colorToHue, getCSSVar, getHexOfCardColor } from '../../helper'; 3 import type { ContentComponentProps } from '../../types'; 4 import { onMount, onDestroy, tick } from 'svelte'; 5 let { item }: ContentComponentProps = $props(); ··· 7 let container: HTMLDivElement; 8 let fluidCanvas: HTMLCanvasElement; 9 let maskCanvas: HTMLCanvasElement; 10 let animationId: number; 11 let splatIntervalId: ReturnType<typeof setInterval>; 12 let maskDrawRaf = 0; 13 let maskReady = false; 14 let isInitialized = $state(false); 15 let resizeObserver: ResizeObserver | null = null; 16 17 // Pure hash function for shader keyword caching 18 function hashCode(s: string) { ··· 122 if (width === 0 || height === 0) return; 123 124 const dpr = window.devicePixelRatio || 1; 125 126 maskCanvas.width = width * dpr; 127 maskCanvas.height = height * dpr; ··· 132 ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); 133 ctx.scale(dpr, dpr); 134 135 - //const color = getCSSVar('--color-base-900'); 136 - 137 - ctx.fillStyle = 'black'; 138 ctx.fillRect(0, 0, width, height); 139 140 // Font size as percentage of container width 141 const textFontSize = Math.round(width * fontSize); 142 ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; 143 144 - ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; 145 - ctx.lineWidth = 2; 146 ctx.textAlign = 'center'; 147 148 const metrics = ctx.measureText(text); ··· 157 ctx.textBaseline = 'middle'; 158 } 159 160 - ctx.strokeText(text, width / 2, textY); 161 ctx.globalCompositeOperation = 'destination-out'; 162 ctx.fillText(text, width / 2, textY); 163 ctx.globalCompositeOperation = 'source-over'; ··· 214 if (isInitialized) scheduleMaskDraw(); 215 }); 216 } 217 }); 218 219 onDestroy(() => { ··· 221 if (splatIntervalId) clearInterval(splatIntervalId); 222 if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf); 223 if (resizeObserver) resizeObserver.disconnect(); 224 }); 225 226 function initFluidSimulation(startHue: number, endHue: number) { ··· 246 COLOR_UPDATE_SPEED: 10, 247 PAUSED: false, 248 BACK_COLOR: { r: 0, g: 0, b: 0 }, 249 - TRANSPARENT: false, 250 BLOOM: false, 251 BLOOM_ITERATIONS: 8, 252 BLOOM_RESOLUTION: 256, ··· 1701 } 1702 </script> 1703 1704 - <div bind:this={container} class="relative h-full w-full overflow-hidden bg-black"> 1705 - <canvas bind:this={fluidCanvas} class="absolute h-full w-full"></canvas> 1706 <canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas> 1707 </div>
··· 1 <script lang="ts"> 2 + import { colorToHue, getHexCSSVar, getHexOfCardColor } from '../../helper'; 3 import type { ContentComponentProps } from '../../types'; 4 import { onMount, onDestroy, tick } from 'svelte'; 5 let { item }: ContentComponentProps = $props(); ··· 7 let container: HTMLDivElement; 8 let fluidCanvas: HTMLCanvasElement; 9 let maskCanvas: HTMLCanvasElement; 10 + let shadowCanvas: HTMLCanvasElement; 11 let animationId: number; 12 let splatIntervalId: ReturnType<typeof setInterval>; 13 let maskDrawRaf = 0; 14 let maskReady = false; 15 let isInitialized = $state(false); 16 let resizeObserver: ResizeObserver | null = null; 17 + let themeObserver: MutationObserver | null = null; 18 19 // Pure hash function for shader keyword caching 20 function hashCode(s: string) { ··· 124 if (width === 0 || height === 0) return; 125 126 const dpr = window.devicePixelRatio || 1; 127 + const isDark = document.documentElement.classList.contains('dark'); 128 + 129 + // Draw shadow behind fluid (light mode only, transparent only) 130 + if (shadowCanvas && item.color === 'transparent') { 131 + shadowCanvas.width = width * dpr; 132 + shadowCanvas.height = height * dpr; 133 + const shadowCtx = shadowCanvas.getContext('2d')!; 134 + shadowCtx.setTransform(1, 0, 0, 1, 0, 0); 135 + shadowCtx.clearRect(0, 0, shadowCanvas.width, shadowCanvas.height); 136 + shadowCtx.scale(dpr, dpr); 137 + 138 + const textFontSize = Math.round(width * fontSize); 139 + shadowCtx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; 140 + shadowCtx.textAlign = 'center'; 141 + 142 + const metrics = shadowCtx.measureText(text); 143 + let textY = height / 2; 144 + if ( 145 + metrics.actualBoundingBoxAscent !== undefined && 146 + metrics.actualBoundingBoxDescent !== undefined 147 + ) { 148 + shadowCtx.textBaseline = 'alphabetic'; 149 + textY = (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2; 150 + } else { 151 + shadowCtx.textBaseline = 'middle'; 152 + } 153 + 154 + // Draw darkened text shape behind fluid 155 + shadowCtx.fillStyle = getHexCSSVar(isDark ? '--color-base-950' : '--color-base-200'); 156 + shadowCtx.fillText(text, width / 2, textY); 157 + } else if (shadowCanvas) { 158 + // Clear shadow canvas when not transparent 159 + shadowCanvas.width = 1; 160 + shadowCanvas.height = 1; 161 + } 162 163 maskCanvas.width = width * dpr; 164 maskCanvas.height = height * dpr; ··· 169 ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); 170 ctx.scale(dpr, dpr); 171 172 + const bgColor = 173 + item.color === 'transparent' 174 + ? getHexCSSVar(isDark ? '--color-base-900' : '--color-base-50') 175 + : 'black'; 176 + ctx.fillStyle = bgColor; 177 ctx.fillRect(0, 0, width, height); 178 179 // Font size as percentage of container width 180 const textFontSize = Math.round(width * fontSize); 181 ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; 182 183 + ctx.lineWidth = 3; 184 ctx.textAlign = 'center'; 185 186 const metrics = ctx.measureText(text); ··· 195 ctx.textBaseline = 'middle'; 196 } 197 198 + if (item.color === 'transparent') { 199 + // Partially cut out the stroke area so fluid shows through 200 + ctx.globalCompositeOperation = 'destination-out'; 201 + ctx.globalAlpha = 0.7; 202 + ctx.strokeStyle = 'white'; 203 + ctx.strokeText(text, width / 2, textY); 204 + ctx.globalAlpha = 1; 205 + ctx.globalCompositeOperation = 'source-over'; 206 + 207 + // Add overlay: brighten in dark mode, darken in light mode 208 + ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'; 209 + ctx.strokeText(text, width / 2, textY); 210 + } else { 211 + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; 212 + ctx.strokeText(text, width / 2, textY); 213 + } 214 + 215 ctx.globalCompositeOperation = 'destination-out'; 216 ctx.fillText(text, width / 2, textY); 217 ctx.globalCompositeOperation = 'source-over'; ··· 268 if (isInitialized) scheduleMaskDraw(); 269 }); 270 } 271 + 272 + // Watch for dark mode changes to redraw mask with correct background 273 + if (item.color === 'transparent') { 274 + themeObserver = new MutationObserver(() => { 275 + if (isInitialized) scheduleMaskDraw(); 276 + }); 277 + themeObserver.observe(document.documentElement, { 278 + attributes: true, 279 + attributeFilter: ['class'] 280 + }); 281 + } 282 }); 283 284 onDestroy(() => { ··· 286 if (splatIntervalId) clearInterval(splatIntervalId); 287 if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf); 288 if (resizeObserver) resizeObserver.disconnect(); 289 + if (themeObserver) themeObserver.disconnect(); 290 }); 291 292 function initFluidSimulation(startHue: number, endHue: number) { ··· 312 COLOR_UPDATE_SPEED: 10, 313 PAUSED: false, 314 BACK_COLOR: { r: 0, g: 0, b: 0 }, 315 + TRANSPARENT: item.color === 'transparent', 316 BLOOM: false, 317 BLOOM_ITERATIONS: 8, 318 BLOOM_RESOLUTION: 256, ··· 1767 } 1768 </script> 1769 1770 + <div 1771 + bind:this={container} 1772 + class="relative h-full w-full overflow-hidden rounded-[inherit] {item.color === 'transparent' 1773 + ? 'bg-base-50 dark:bg-base-900' 1774 + : 'bg-black'}" 1775 + > 1776 + <canvas 1777 + bind:this={shadowCanvas} 1778 + class="absolute inset-0.5 h-[calc(100%-4px)] w-[calc(100%-4px)] rounded-[inherit]" 1779 + ></canvas> 1780 + <canvas bind:this={fluidCanvas} class="absolute inset-0.5 h-[calc(100%-4px)] w-[calc(100%-4px)]" 1781 + ></canvas> 1782 <canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas> 1783 </div>
+180
src/lib/components/ImageGrid.svelte
···
··· 1 + <script lang="ts"> 2 + import { Tooltip } from 'bits-ui'; 3 + 4 + export type ImageGridItem = { 5 + imageUrl: string | null; 6 + link: string; 7 + label: string; 8 + }; 9 + 10 + let { 11 + items, 12 + layout = 'grid', 13 + shape = 'square', 14 + tooltip = false 15 + }: { 16 + items: ImageGridItem[]; 17 + layout?: 'grid' | 'cinema'; 18 + shape?: 'square' | 'circle'; 19 + tooltip?: boolean; 20 + } = $props(); 21 + 22 + let containerWidth = $state(0); 23 + let containerHeight = $state(0); 24 + 25 + let totalItems = $derived(items.length); 26 + 27 + const GAP = 6; 28 + const MIN_SIZE = 16; 29 + const MAX_SIZE = 120; 30 + 31 + function cinemaCapacity(size: number, availW: number, availH: number): number { 32 + const colsWide = Math.floor((availW + GAP) / (size + GAP)); 33 + if (colsWide < 1) return 0; 34 + const colsNarrow = Math.max(1, colsWide - 1); 35 + const maxRows = Math.floor((availH + GAP) / (size + GAP)); 36 + let capacity = 0; 37 + for (let r = 0; r < maxRows; r++) { 38 + capacity += r % 2 === 0 ? colsNarrow : colsWide; 39 + } 40 + return capacity; 41 + } 42 + 43 + function gridCapacity(size: number, availW: number, availH: number): number { 44 + const cols = Math.floor((availW + GAP) / (size + GAP)); 45 + const rows = Math.floor((availH + GAP) / (size + GAP)); 46 + return cols * rows; 47 + } 48 + 49 + let computedSize = $derived.by(() => { 50 + if (!containerWidth || !containerHeight || totalItems === 0) return 40; 51 + 52 + let lo = MIN_SIZE; 53 + let hi = MAX_SIZE; 54 + const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity; 55 + 56 + while (lo <= hi) { 57 + const mid = Math.floor((lo + hi) / 2); 58 + const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0); 59 + const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0); 60 + if (availW <= 0 || availH <= 0) { 61 + hi = mid - 1; 62 + continue; 63 + } 64 + if (capacityFn(mid, availW, availH) >= totalItems) { 65 + lo = mid + 1; 66 + } else { 67 + hi = mid - 1; 68 + } 69 + } 70 + 71 + return Math.max(MIN_SIZE, hi); 72 + }); 73 + 74 + let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0); 75 + 76 + let rows = $derived.by(() => { 77 + const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0); 78 + if (availW <= 0) return [] as ImageGridItem[][]; 79 + 80 + const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); 81 + const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide; 82 + 83 + const rowSizes: number[] = []; 84 + let remaining = items.length; 85 + let rowNum = 0; 86 + while (remaining > 0) { 87 + const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide; 88 + rowSizes.push(Math.min(cols, remaining)); 89 + remaining -= cols; 90 + rowNum++; 91 + } 92 + rowSizes.reverse(); 93 + 94 + const result: ImageGridItem[][] = []; 95 + let idx = 0; 96 + for (const size of rowSizes) { 97 + result.push(items.slice(idx, idx + size)); 98 + idx += size; 99 + } 100 + return result; 101 + }); 102 + 103 + let textSize = $derived( 104 + computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' 105 + ); 106 + 107 + let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); 108 + </script> 109 + 110 + {#snippet gridItem(item: ImageGridItem)} 111 + {#if item.imageUrl} 112 + <img 113 + src={item.imageUrl} 114 + alt={item.label} 115 + class="{shapeClass} object-cover" 116 + style="width: {computedSize}px; height: {computedSize}px;" 117 + /> 118 + {:else} 119 + <div 120 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}" 121 + style="width: {computedSize}px; height: {computedSize}px;" 122 + > 123 + <span class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium"> 124 + {item.label.charAt(0).toUpperCase()} 125 + </span> 126 + </div> 127 + {/if} 128 + {/snippet} 129 + 130 + <div 131 + class="flex h-full w-full items-center justify-center overflow-hidden px-2" 132 + bind:clientWidth={containerWidth} 133 + bind:clientHeight={containerHeight} 134 + > 135 + {#if totalItems > 0} 136 + <div style="padding: {padding}px;"> 137 + <div class="flex flex-col items-center" style="gap: {GAP}px;"> 138 + {#each rows as row, rowIdx (rowIdx)} 139 + <div class="flex justify-center" style="gap: {GAP}px;"> 140 + {#each row as item (item.link)} 141 + {#if tooltip} 142 + <Tooltip.Root> 143 + <Tooltip.Trigger> 144 + <a 145 + href={item.link} 146 + target="_blank" 147 + rel="noopener noreferrer" 148 + class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 149 + > 150 + {@render gridItem(item)} 151 + </a> 152 + </Tooltip.Trigger> 153 + <Tooltip.Portal> 154 + <Tooltip.Content 155 + side="top" 156 + sideOffset={4} 157 + class="bg-base-900 dark:bg-base-800 text-base-100 z-50 rounded-lg px-3 py-1.5 text-xs font-medium shadow-md" 158 + > 159 + {item.label} 160 + </Tooltip.Content> 161 + </Tooltip.Portal> 162 + </Tooltip.Root> 163 + {:else} 164 + <a 165 + href={item.link} 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 + title={item.label} 170 + > 171 + {@render gridItem(item)} 172 + </a> 173 + {/if} 174 + {/each} 175 + </div> 176 + {/each} 177 + </div> 178 + </div> 179 + {/if} 180 + </div>
+28 -5
src/lib/components/qr/qrOverlay.svelte.ts
··· 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; ··· 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); ··· 71 node.removeEventListener('click', handleClick); 72 node.removeEventListener('contextmenu', handleContextMenu); 73 cancelLongPress(); 74 } 75 }; 76 }
··· 18 const LONG_PRESS_DURATION = 500; 19 let longPressTimer: ReturnType<typeof setTimeout> | null = null; 20 let isLongPress = false; 21 + let touchActive = false; 22 + 23 + // Prevent iOS link preview on long-press 24 + const originalCallout = node.style.getPropertyValue('-webkit-touch-callout'); 25 + node.style.setProperty('-webkit-touch-callout', 'none'); 26 27 function getHref() { 28 return params.href || (node as HTMLAnchorElement).href || ''; 29 } 30 31 + function startLongPress(e: PointerEvent) { 32 if (params.disabled) return; 33 + // Only start long press for primary button (touch/left-click), not right-click 34 + if (e.button !== 0) return; 35 + touchActive = e.pointerType === 'touch'; 36 isLongPress = false; 37 longPressTimer = setTimeout(() => { 38 isLongPress = true; ··· 45 clearTimeout(longPressTimer); 46 longPressTimer = null; 47 } 48 + touchActive = false; 49 } 50 51 function handleClick(e: MouseEvent) { 52 if (isLongPress) { 53 e.preventDefault(); 54 isLongPress = false; 55 + return; 56 + } 57 + 58 + // Shift-click opens QR modal 59 + if (e.shiftKey && !params.disabled) { 60 + e.preventDefault(); 61 + openModal?.(getHref(), params.context ?? {}); 62 } 63 } 64 65 + function handleContextMenu(e: Event) { 66 + // Prevent context menu during touch to avoid iOS preview 67 + if (touchActive || isLongPress) { 68 + e.preventDefault(); 69 + } 70 } 71 72 node.addEventListener('pointerdown', startLongPress); ··· 88 node.removeEventListener('click', handleClick); 89 node.removeEventListener('contextmenu', handleContextMenu); 90 cancelLongPress(); 91 + // Restore original style 92 + if (originalCallout) { 93 + node.style.setProperty('-webkit-touch-callout', originalCallout); 94 + } else { 95 + node.style.removeProperty('-webkit-touch-callout'); 96 + } 97 } 98 }; 99 }
+9 -353
src/lib/helper.ts
··· 3 import { CardDefinitionsByType } from './cards'; 4 import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5 import * as TID from '@atcute/tid'; 6 - 7 export function clamp(value: number, min: number, max: number): number { 8 return Math.min(Math.max(value, min), max); 9 } ··· 28 'bg-rose-500' 29 ]; 30 31 - export const overlaps = (a: Item, b: Item, mobile: boolean = false) => { 32 - if (a === b) return false; 33 - if (mobile) { 34 - return ( 35 - a.mobileX < b.mobileX + b.mobileW && 36 - a.mobileX + a.mobileW > b.mobileX && 37 - a.mobileY < b.mobileY + b.mobileH && 38 - a.mobileY + a.mobileH > b.mobileY 39 - ); 40 - } 41 - return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; 42 - }; 43 - 44 - export function fixCollisions( 45 - items: Item[], 46 - movedItem: Item, 47 - mobile: boolean = false, 48 - skipCompact: boolean = false 49 - ) { 50 - const clampX = (item: Item) => { 51 - if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 52 - else item.x = clamp(item.x, 0, COLUMNS - item.w); 53 - }; 54 - 55 - // Push `target` down until it no longer overlaps with any item (including movedItem), 56 - // while keeping target.x fixed. Any item we collide with gets pushed down first (cascade). 57 - const pushDownCascade = (target: Item, blocker: Item) => { 58 - // Keep x fixed always when pushing down 59 - const fixedX = mobile ? target.mobileX : target.x; 60 - const prevY = mobile ? target.mobileY : target.y; 61 - 62 - // We need target to move just below `blocker` 63 - const desiredY = mobile ? blocker.mobileY + blocker.mobileH : blocker.y + blocker.h; 64 - if (!mobile && target.y < desiredY) target.y = desiredY; 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 - } 89 - 90 - // Now resolve any collisions that creates by pushing those items down first 91 - // Repeat until target is clean. 92 - while (true) { 93 - const hit = items.find((it) => it !== target && overlaps(target, it, mobile)); 94 - if (!hit) break; 95 - 96 - // push the hit item down first (cascade), keeping its x fixed 97 - pushDownCascade(hit, target); 98 - 99 - // after moving the hit item, target.x must remain fixed 100 - if (mobile) target.mobileX = fixedX; 101 - else target.x = fixedX; 102 - } 103 - }; 104 - 105 - // Ensure moved item is in bounds 106 - clampX(movedItem); 107 - 108 - // Find all items colliding with movedItem, and push them down in a stable order: 109 - // top-to-bottom so you get the nice chain reaction (0,0 -> 0,1 -> 0,2). 110 - const colliders = items 111 - .filter((it) => it !== movedItem && overlaps(movedItem, it, mobile)) 112 - .toSorted((a, b) => 113 - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 114 - ); 115 - 116 - for (const it of colliders) { 117 - // keep x clamped, but do NOT change x during push (we rely on fixed x) 118 - clampX(it); 119 - 120 - // push it down just below movedItem; cascade handles the rest 121 - pushDownCascade(it, movedItem); 122 - 123 - // enforce "x stays the same" during pushing (clamp already applied) 124 - if (mobile) it.mobileX = clamp(it.mobileX, 0, COLUMNS - it.mobileW); 125 - else it.x = clamp(it.x, 0, COLUMNS - it.w); 126 - } 127 - 128 - if (!skipCompact) { 129 - compactItems(items, mobile); 130 - } 131 - } 132 - 133 - // Fix all collisions between items (not just one moved item) 134 - // Items higher on the page have priority and stay in place 135 - export function fixAllCollisions(items: Item[], mobile: boolean = false) { 136 - // Sort by Y position (top-to-bottom, then left-to-right) 137 - // Items at the top have priority and won't be moved 138 - const sortedItems = items.toSorted((a, b) => 139 - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 140 - ); 141 - 142 - // Process each item and push it down if it overlaps with any item above it 143 - for (let i = 0; i < sortedItems.length; i++) { 144 - const item = sortedItems[i]; 145 - 146 - // Clamp X to valid range 147 - if (mobile) { 148 - item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 149 - } else { 150 - item.x = clamp(item.x, 0, COLUMNS - item.w); 151 - } 152 - 153 - // Check for collisions with all items that come before (higher priority) 154 - let hasCollision = true; 155 - while (hasCollision) { 156 - hasCollision = false; 157 - for (let j = 0; j < i; j++) { 158 - const other = sortedItems[j]; 159 - if (overlaps(item, other, mobile)) { 160 - // Push item down below the colliding item 161 - if (mobile) { 162 - item.mobileY = other.mobileY + other.mobileH; 163 - } else { 164 - item.y = other.y + other.h; 165 - } 166 - hasCollision = true; 167 - break; // Restart collision check from the beginning 168 - } 169 - } 170 - } 171 - } 172 - 173 - compactItems(items, mobile); 174 - } 175 - 176 - // Move all items up as far as possible without collisions 177 - export function compactItems(items: Item[], mobile: boolean = false) { 178 - // Sort by Y position (top-to-bottom) so upper items settle first. 179 - const sortedItems = items.toSorted((a, b) => 180 - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 181 - ); 182 - 183 - for (const item of sortedItems) { 184 - // Try moving item up row by row until we hit y=0 or a collision 185 - while (true) { 186 - const currentY = mobile ? item.mobileY : item.y; 187 - if (currentY <= 0) break; 188 - 189 - // Temporarily move up by 1 190 - if (mobile) item.mobileY -= 1; 191 - else item.y -= 1; 192 - 193 - // Check for collision with any other item 194 - const hasCollision = items.some((other) => other !== item && overlaps(item, other, mobile)); 195 - 196 - if (hasCollision) { 197 - // Revert the move 198 - if (mobile) item.mobileY += 1; 199 - else item.y += 1; 200 - break; 201 - } 202 - // No collision, keep the new position and try moving up again 203 - } 204 - } 205 - } 206 - 207 - // Simulate where an item would end up after fixCollisions + compaction 208 - export function simulateFinalPosition( 209 - items: Item[], 210 - movedItem: Item, 211 - newX: number, 212 - newY: number, 213 - mobile: boolean = false 214 - ): { x: number; y: number } { 215 - // Deep clone positions for simulation 216 - const clonedItems: Item[] = items.map((item) => ({ 217 - ...item, 218 - x: item.x, 219 - y: item.y, 220 - mobileX: item.mobileX, 221 - mobileY: item.mobileY 222 - })); 223 - 224 - const clonedMovedItem = clonedItems.find((item) => item.id === movedItem.id); 225 - if (!clonedMovedItem) return { x: newX, y: newY }; 226 - 227 - // Set the new position 228 - if (mobile) { 229 - clonedMovedItem.mobileX = newX; 230 - clonedMovedItem.mobileY = newY; 231 - } else { 232 - clonedMovedItem.x = newX; 233 - clonedMovedItem.y = newY; 234 - } 235 - 236 - // Run fixCollisions on the cloned data 237 - fixCollisions(clonedItems, clonedMovedItem, mobile); 238 - 239 - // Return the final position of the moved item 240 - return mobile 241 - ? { x: clonedMovedItem.mobileX, y: clonedMovedItem.mobileY } 242 - : { x: clonedMovedItem.x, y: clonedMovedItem.y }; 243 - } 244 - 245 export function sortItems(a: Item, b: Item) { 246 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x; 247 } ··· 264 ); 265 } 266 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 - 344 - let foundPosition = false; 345 - while (!foundPosition) { 346 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 347 - const collision = items.find((item) => overlaps(newItem, item)); 348 - if (!collision) { 349 - foundPosition = true; 350 - break; 351 - } 352 - } 353 - if (!foundPosition) newItem.y += 1; 354 - } 355 - 356 - let foundMobilePosition = false; 357 - while (!foundMobilePosition) { 358 - for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { 359 - const collision = items.find((item) => overlaps(newItem, item, true)); 360 - 361 - if (!collision) { 362 - foundMobilePosition = true; 363 - break; 364 - } 365 - } 366 - if (!foundMobilePosition) newItem.mobileY! += 1; 367 - } 368 - } 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 - 404 export async function refreshData(data: { updatedAt?: number; handle: string }) { 405 const TEN_MINUTES = 10 * 60 * 1000; 406 const now = Date.now(); ··· 553 originalPublication: string 554 ) { 555 const promises = []; 556 // find all cards that have been updated (where items differ from originalItems) 557 for (let item of currentItems) { 558 - const originalItem = data.cards.find((i) => cardsEqual(i, item)); 559 560 if (!originalItem) { 561 console.log('updated or new item', item);
··· 3 import { CardDefinitionsByType } from './cards'; 4 import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5 import * as TID from '@atcute/tid'; 6 export function clamp(value: number, min: number, max: number): number { 7 return Math.min(Math.max(value, min), max); 8 } ··· 27 'bg-rose-500' 28 ]; 29 30 export function sortItems(a: Item, b: Item) { 31 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x; 32 } ··· 49 ); 50 } 51 52 export async function refreshData(data: { updatedAt?: number; handle: string }) { 53 const TEN_MINUTES = 10 * 60 * 1000; 54 const now = Date.now(); ··· 201 originalPublication: string 202 ) { 203 const promises = []; 204 + 205 + // Build a lookup of original cards by ID for O(1) access 206 + const originalCardsById = new Map<string, Item>(); 207 + for (const card of data.cards) { 208 + originalCardsById.set(card.id, card); 209 + } 210 + 211 // find all cards that have been updated (where items differ from originalItems) 212 for (let item of currentItems) { 213 + const orig = originalCardsById.get(item.id); 214 + const originalItem = orig && cardsEqual(orig, item) ? orig : undefined; 215 216 if (!originalItem) { 217 console.log('updated or new item', item);
+398
src/lib/layout/EditableGrid.svelte
···
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + import type { Item } from '$lib/types'; 4 + import { getGridPosition, pixelToGrid, type DragState, type GridPosition } from './grid'; 5 + import { fixCollisions } from './algorithms'; 6 + 7 + let { 8 + items = $bindable(), 9 + isMobile, 10 + selectedCardId, 11 + isCoarse, 12 + children, 13 + ref = $bindable<HTMLDivElement | undefined>(undefined), 14 + onlayoutchange, 15 + ondeselect, 16 + onfiledrop 17 + }: { 18 + items: Item[]; 19 + isMobile: boolean; 20 + selectedCardId: string | null; 21 + isCoarse: boolean; 22 + children: Snippet; 23 + ref?: HTMLDivElement | undefined; 24 + onlayoutchange: () => void; 25 + ondeselect: () => void; 26 + onfiledrop?: (files: File[], gridX: number, gridY: number) => void; 27 + } = $props(); 28 + 29 + // Internal container ref (synced with bindable ref) 30 + let container: HTMLDivElement | undefined = $state(); 31 + $effect(() => { 32 + ref = container; 33 + }); 34 + 35 + const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 36 + const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 37 + let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 38 + 39 + // --- Drag state --- 40 + type Phase = 'idle' | 'pending' | 'active'; 41 + 42 + let phase: Phase = $state('idle'); 43 + let pointerId: number = $state(0); 44 + let startClientX = $state(0); 45 + let startClientY = $state(0); 46 + 47 + let dragState: DragState = $state({ 48 + item: null as unknown as Item, 49 + mouseDeltaX: 0, 50 + mouseDeltaY: 0, 51 + originalPositions: new Map(), 52 + lastTargetId: null, 53 + lastPlacement: null 54 + }); 55 + 56 + let lastGridPos: GridPosition | null = $state(null); 57 + 58 + // Ref to the dragged card DOM element (for visual feedback) 59 + let draggedCardEl: HTMLElement | null = null; 60 + 61 + // --- File drag state --- 62 + let fileDragOver = $state(false); 63 + 64 + // --- Pointer event handlers --- 65 + 66 + function handlePointerDown(e: PointerEvent) { 67 + if (phase !== 'idle') return; 68 + 69 + const cardEl = (e.target as HTMLElement)?.closest?.('.card') as HTMLDivElement | null; 70 + if (!cardEl) return; 71 + 72 + // On touch devices, only drag the selected card 73 + if (e.pointerType === 'touch' && cardEl.id !== selectedCardId) return; 74 + 75 + // On mouse, don't intercept interactive elements 76 + if (e.pointerType === 'mouse') { 77 + const tag = (e.target as HTMLElement)?.tagName; 78 + if ( 79 + tag === 'BUTTON' || 80 + tag === 'INPUT' || 81 + tag === 'TEXTAREA' || 82 + (e.target as HTMLElement)?.isContentEditable 83 + ) { 84 + return; 85 + } 86 + } 87 + 88 + const item = items.find((i) => i.id === cardEl.id); 89 + if (!item || item.cardData?.locked) return; 90 + 91 + phase = 'pending'; 92 + pointerId = e.pointerId; 93 + startClientX = e.clientX; 94 + startClientY = e.clientY; 95 + draggedCardEl = cardEl; 96 + 97 + // Pre-compute mouse delta from card rect 98 + const rect = cardEl.getBoundingClientRect(); 99 + dragState.item = item; 100 + dragState.mouseDeltaX = rect.left - e.clientX; 101 + dragState.mouseDeltaY = rect.top - e.clientY; 102 + 103 + document.addEventListener('pointermove', handlePointerMove); 104 + document.addEventListener('pointerup', handlePointerUp); 105 + document.addEventListener('pointercancel', handlePointerCancel); 106 + } 107 + 108 + function activateDrag(e: PointerEvent) { 109 + phase = 'active'; 110 + 111 + try { 112 + (e.target as HTMLElement)?.setPointerCapture?.(pointerId); 113 + } catch { 114 + // setPointerCapture can throw if pointer is already released 115 + } 116 + 117 + // Visual feedback: lift the dragged card 118 + draggedCardEl?.classList.add('dragging'); 119 + 120 + // Store original positions of all items 121 + dragState.originalPositions = new Map(); 122 + for (const it of items) { 123 + dragState.originalPositions.set(it.id, { 124 + x: it.x, 125 + y: it.y, 126 + mobileX: it.mobileX, 127 + mobileY: it.mobileY 128 + }); 129 + } 130 + dragState.lastTargetId = null; 131 + dragState.lastPlacement = null; 132 + 133 + document.body.style.userSelect = 'none'; 134 + } 135 + 136 + function handlePointerMove(e: PointerEvent) { 137 + if (!container) return; 138 + 139 + if (phase === 'pending') { 140 + // Check 3px threshold 141 + const dx = e.clientX - startClientX; 142 + const dy = e.clientY - startClientY; 143 + if (dx * dx + dy * dy < 9) return; 144 + activateDrag(e); 145 + } 146 + 147 + if (phase !== 'active') return; 148 + 149 + // Auto-scroll near edges 150 + const scrollZone = 100; 151 + const scrollSpeed = 10; 152 + const viewportHeight = window.innerHeight; 153 + 154 + if (e.clientY < scrollZone) { 155 + const intensity = 1 - e.clientY / scrollZone; 156 + window.scrollBy(0, -scrollSpeed * intensity); 157 + } else if (e.clientY > viewportHeight - scrollZone) { 158 + const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 159 + window.scrollBy(0, scrollSpeed * intensity); 160 + } 161 + 162 + const result = getGridPosition(e.clientX, e.clientY, container, dragState, items, isMobile); 163 + if (!result || !dragState.item) return; 164 + 165 + // Skip redundant work if grid position hasn't changed 166 + if ( 167 + lastGridPos && 168 + lastGridPos.x === result.x && 169 + lastGridPos.y === result.y && 170 + lastGridPos.swapWithId === result.swapWithId && 171 + lastGridPos.placement === result.placement 172 + ) { 173 + return; 174 + } 175 + lastGridPos = result; 176 + 177 + const draggedOrigPos = dragState.originalPositions.get(dragState.item.id); 178 + 179 + // Reset all items to original positions first 180 + for (const it of items) { 181 + const origPos = dragState.originalPositions.get(it.id); 182 + if (origPos && it !== dragState.item) { 183 + if (isMobile) { 184 + it.mobileX = origPos.mobileX; 185 + it.mobileY = origPos.mobileY; 186 + } else { 187 + it.x = origPos.x; 188 + it.y = origPos.y; 189 + } 190 + } 191 + } 192 + 193 + // Update dragged item position 194 + if (isMobile) { 195 + dragState.item.mobileX = result.x; 196 + dragState.item.mobileY = result.y; 197 + } else { 198 + dragState.item.x = result.x; 199 + dragState.item.y = result.y; 200 + } 201 + 202 + // Handle horizontal swap 203 + if (result.swapWithId && draggedOrigPos) { 204 + const swapTarget = items.find((it) => it.id === result.swapWithId); 205 + if (swapTarget) { 206 + if (isMobile) { 207 + swapTarget.mobileX = draggedOrigPos.mobileX; 208 + swapTarget.mobileY = draggedOrigPos.mobileY; 209 + } else { 210 + swapTarget.x = draggedOrigPos.x; 211 + swapTarget.y = draggedOrigPos.y; 212 + } 213 + } 214 + } 215 + 216 + fixCollisions( 217 + items, 218 + dragState.item, 219 + isMobile, 220 + false, 221 + draggedOrigPos 222 + ? { 223 + x: isMobile ? draggedOrigPos.mobileX : draggedOrigPos.x, 224 + y: isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y 225 + } 226 + : undefined 227 + ); 228 + } 229 + 230 + function handlePointerUp() { 231 + if (phase === 'active' && dragState.item) { 232 + fixCollisions(items, dragState.item, isMobile); 233 + onlayoutchange(); 234 + } 235 + cleanup(); 236 + } 237 + 238 + function handlePointerCancel() { 239 + if (phase === 'active') { 240 + // Restore all items to original positions 241 + for (const it of items) { 242 + const origPos = dragState.originalPositions.get(it.id); 243 + if (origPos) { 244 + it.x = origPos.x; 245 + it.y = origPos.y; 246 + it.mobileX = origPos.mobileX; 247 + it.mobileY = origPos.mobileY; 248 + } 249 + } 250 + } 251 + cleanup(); 252 + } 253 + 254 + function cleanup() { 255 + draggedCardEl?.classList.remove('dragging'); 256 + draggedCardEl = null; 257 + phase = 'idle'; 258 + lastGridPos = null; 259 + document.body.style.userSelect = ''; 260 + 261 + document.removeEventListener('pointermove', handlePointerMove); 262 + document.removeEventListener('pointerup', handlePointerUp); 263 + document.removeEventListener('pointercancel', handlePointerCancel); 264 + } 265 + 266 + // Ensure cleanup on unmount 267 + $effect(() => { 268 + return () => { 269 + if (phase !== 'idle') cleanup(); 270 + }; 271 + }); 272 + 273 + // For touch: register non-passive touchstart to prevent scroll when touching selected card 274 + $effect(() => { 275 + if (!container || !selectedCardId) return; 276 + container.addEventListener('touchstart', handleTouchStart, { passive: false }); 277 + return () => { 278 + container?.removeEventListener('touchstart', handleTouchStart); 279 + }; 280 + }); 281 + 282 + // For touch: register non-passive touchmove to prevent scroll during active drag 283 + $effect(() => { 284 + if (phase !== 'active' || !container) return; 285 + function preventTouch(e: TouchEvent) { 286 + e.preventDefault(); 287 + } 288 + container.addEventListener('touchmove', preventTouch, { passive: false }); 289 + return () => { 290 + container?.removeEventListener('touchmove', preventTouch); 291 + }; 292 + }); 293 + 294 + function handleClick(e: MouseEvent) { 295 + // Deselect when tapping empty grid space 296 + if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 297 + ondeselect(); 298 + } 299 + } 300 + 301 + function handleTouchStart(e: TouchEvent) { 302 + // On touch, prevent scrolling when touching the selected card 303 + // This must happen on touchstart (not pointerdown) to claim the gesture 304 + const cardEl = (e.target as HTMLElement)?.closest?.('.card') as HTMLElement | null; 305 + if (cardEl && cardEl.id === selectedCardId) { 306 + const item = items.find((i) => i.id === cardEl.id); 307 + if (item && !item.cardData?.locked) { 308 + e.preventDefault(); 309 + } 310 + } 311 + } 312 + 313 + // --- File drop handlers --- 314 + 315 + function hasImageFile(dt: DataTransfer): boolean { 316 + if (dt.items) { 317 + for (let i = 0; i < dt.items.length; i++) { 318 + const item = dt.items[i]; 319 + if (item && item.kind === 'file' && item.type.startsWith('image/')) { 320 + return true; 321 + } 322 + } 323 + } else if (dt.files) { 324 + for (let i = 0; i < dt.files.length; i++) { 325 + const file = dt.files[i]; 326 + if (file?.type.startsWith('image/')) { 327 + return true; 328 + } 329 + } 330 + } 331 + return false; 332 + } 333 + 334 + function handleFileDragOver(event: DragEvent) { 335 + const dt = event.dataTransfer; 336 + if (!dt) return; 337 + 338 + if (hasImageFile(dt)) { 339 + event.preventDefault(); 340 + event.stopPropagation(); 341 + fileDragOver = true; 342 + } 343 + } 344 + 345 + function handleFileDragLeave(event: DragEvent) { 346 + event.preventDefault(); 347 + event.stopPropagation(); 348 + fileDragOver = false; 349 + } 350 + 351 + function handleFileDrop(event: DragEvent) { 352 + event.preventDefault(); 353 + event.stopPropagation(); 354 + fileDragOver = false; 355 + 356 + if (!event.dataTransfer?.files?.length || !onfiledrop || !container) return; 357 + 358 + const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 359 + f?.type.startsWith('image/') 360 + ); 361 + if (imageFiles.length === 0) return; 362 + 363 + const cardW = isMobile ? 4 : 2; 364 + const { gridX, gridY } = pixelToGrid(event.clientX, event.clientY, container, isMobile, cardW); 365 + 366 + onfiledrop(imageFiles, gridX, gridY); 367 + } 368 + </script> 369 + 370 + <svelte:window 371 + ondragover={handleFileDragOver} 372 + ondragleave={handleFileDragLeave} 373 + ondrop={handleFileDrop} 374 + /> 375 + 376 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 377 + <div 378 + bind:this={container} 379 + onpointerdown={handlePointerDown} 380 + onclick={handleClick} 381 + ondragstart={(e) => e.preventDefault()} 382 + class={[ 383 + '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 384 + fileDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 385 + ]} 386 + > 387 + {@render children()} 388 + 389 + <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 390 + </div> 391 + 392 + <style> 393 + :global(.card.dragging) { 394 + z-index: 50 !important; 395 + scale: 1.03; 396 + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); 397 + } 398 + </style>
+254
src/lib/layout/algorithms.ts
···
··· 1 + import { type LayoutItem, type Layout } from 'react-grid-layout/core'; 2 + import { 3 + collides, 4 + moveElement, 5 + correctBounds, 6 + getFirstCollision, 7 + verticalCompactor 8 + } from 'react-grid-layout/core'; 9 + import type { Item } from '../types'; 10 + import { COLUMNS } from '$lib'; 11 + import { clamp } from '../helper'; 12 + 13 + function toLayoutItem(item: Item, mobile: boolean): LayoutItem { 14 + if (mobile) { 15 + return { 16 + x: item.mobileX, 17 + y: item.mobileY, 18 + w: item.mobileW, 19 + h: item.mobileH, 20 + i: item.id 21 + }; 22 + } 23 + return { 24 + x: item.x, 25 + y: item.y, 26 + w: item.w, 27 + h: item.h, 28 + i: item.id 29 + }; 30 + } 31 + 32 + function toLayout(items: Item[], mobile: boolean): LayoutItem[] { 33 + return items.map((i) => toLayoutItem(i, mobile)); 34 + } 35 + 36 + function applyLayout(items: Item[], layout: LayoutItem[], mobile: boolean): void { 37 + const itemsMap: Map<string, Item> = new Map(); 38 + 39 + for (const item of items) { 40 + itemsMap.set(item.id, item); 41 + } 42 + for (const l of layout) { 43 + const item = itemsMap.get(l.i); 44 + 45 + if (!item) { 46 + console.error('item not found in layout!! this should never happen!'); 47 + continue; 48 + } 49 + 50 + if (mobile) { 51 + item.mobileX = l.x; 52 + item.mobileY = l.y; 53 + } else { 54 + item.x = l.x; 55 + item.y = l.y; 56 + } 57 + } 58 + } 59 + 60 + export function overlaps(a: Item, b: Item, mobile: boolean) { 61 + if (a === b) return false; 62 + return collides(toLayoutItem(a, mobile), toLayoutItem(b, mobile)); 63 + } 64 + 65 + export function fixCollisions( 66 + items: Item[], 67 + item: Item, 68 + mobile: boolean = false, 69 + skipCompact: boolean = false, 70 + originalPos?: { x: number; y: number } 71 + ) { 72 + if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 73 + else item.x = clamp(item.x, 0, COLUMNS - item.w); 74 + 75 + const targetX = mobile ? item.mobileX : item.x; 76 + const targetY = mobile ? item.mobileY : item.y; 77 + 78 + let layout = toLayout(items, mobile); 79 + 80 + const movedLayoutItem = layout.find((i) => i.i === item.id); 81 + 82 + if (!movedLayoutItem) { 83 + console.error('item not found in layout! this should never happen!'); 84 + return; 85 + } 86 + 87 + // If we know the original position, set it on the layout item so 88 + // moveElement can detect direction and push items properly. 89 + if (originalPos) { 90 + movedLayoutItem.x = originalPos.x; 91 + movedLayoutItem.y = originalPos.y; 92 + } 93 + 94 + layout = moveElement(layout, movedLayoutItem, targetX, targetY, true, false, 'vertical', COLUMNS); 95 + 96 + if (!skipCompact) layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 97 + 98 + applyLayout(items, layout, mobile); 99 + } 100 + 101 + export function fixAllCollisions(items: Item[], mobile: boolean) { 102 + let layout = toLayout(items, mobile); 103 + correctBounds(layout as any, { cols: COLUMNS }); 104 + layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 105 + applyLayout(items, layout, mobile); 106 + } 107 + 108 + export function compactItems(items: Item[], mobile: boolean) { 109 + const layout = toLayout(items, mobile); 110 + const compacted = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 111 + applyLayout(items, compacted, mobile); 112 + } 113 + 114 + export function setPositionOfNewItem( 115 + newItem: Item, 116 + items: Item[], 117 + viewportCenter?: { gridY: number; isMobile: boolean } 118 + ) { 119 + const desktopLayout = toLayout(items, false); 120 + const mobileLayout = toLayout(items, true); 121 + 122 + function hasCollision(mobile: boolean): boolean { 123 + const layout = mobile ? mobileLayout : desktopLayout; 124 + return getFirstCollision(layout, toLayoutItem(newItem, mobile)) !== undefined; 125 + } 126 + 127 + if (viewportCenter) { 128 + const { gridY, isMobile } = viewportCenter; 129 + 130 + if (isMobile) { 131 + // Place at viewport center Y 132 + newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); 133 + newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; 134 + 135 + // Try to find a free X at this Y 136 + let found = false; 137 + for ( 138 + newItem.mobileX = 0; 139 + newItem.mobileX <= COLUMNS - newItem.mobileW; 140 + newItem.mobileX += 2 141 + ) { 142 + if (!hasCollision(true)) { 143 + found = true; 144 + break; 145 + } 146 + } 147 + if (!found) { 148 + newItem.mobileX = 0; 149 + } 150 + 151 + // Desktop: derive from mobile 152 + newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); 153 + found = false; 154 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 155 + if (!hasCollision(false)) { 156 + found = true; 157 + break; 158 + } 159 + } 160 + if (!found) { 161 + newItem.x = 0; 162 + } 163 + } else { 164 + // Place at viewport center Y 165 + newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); 166 + 167 + // Try to find a free X at this Y 168 + let found = false; 169 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 170 + if (!hasCollision(false)) { 171 + found = true; 172 + break; 173 + } 174 + } 175 + if (!found) { 176 + newItem.x = 0; 177 + } 178 + 179 + // Mobile: derive from desktop 180 + newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); 181 + found = false; 182 + for ( 183 + newItem.mobileX = 0; 184 + newItem.mobileX <= COLUMNS - newItem.mobileW; 185 + newItem.mobileX += 2 186 + ) { 187 + if (!hasCollision(true)) { 188 + found = true; 189 + break; 190 + } 191 + } 192 + if (!found) { 193 + newItem.mobileX = 0; 194 + } 195 + } 196 + return; 197 + } 198 + 199 + let foundPosition = false; 200 + while (!foundPosition) { 201 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 202 + if (!hasCollision(false)) { 203 + foundPosition = true; 204 + break; 205 + } 206 + } 207 + if (!foundPosition) newItem.y += 1; 208 + } 209 + 210 + let foundMobilePosition = false; 211 + while (!foundMobilePosition) { 212 + for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { 213 + if (!hasCollision(true)) { 214 + foundMobilePosition = true; 215 + break; 216 + } 217 + } 218 + if (!foundMobilePosition) newItem.mobileY! += 1; 219 + } 220 + } 221 + 222 + /** 223 + * Find a valid position for a new item in a single mode (desktop or mobile). 224 + * This modifies the item's position properties in-place. 225 + */ 226 + export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) { 227 + const layout = toLayout(items, mobile); 228 + 229 + if (mobile) { 230 + let foundPosition = false; 231 + newItem.mobileY = 0; 232 + while (!foundPosition) { 233 + for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) { 234 + if (!getFirstCollision(layout, toLayoutItem(newItem, true))) { 235 + foundPosition = true; 236 + break; 237 + } 238 + } 239 + if (!foundPosition) newItem.mobileY! += 1; 240 + } 241 + } else { 242 + let foundPosition = false; 243 + newItem.y = 0; 244 + while (!foundPosition) { 245 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 246 + if (!getFirstCollision(layout, toLayoutItem(newItem, false))) { 247 + foundPosition = true; 248 + break; 249 + } 250 + } 251 + if (!foundPosition) newItem.y += 1; 252 + } 253 + } 254 + }
+190
src/lib/layout/grid.ts
···
··· 1 + import { COLUMNS, margin, mobileMargin } from '$lib'; 2 + import { clamp } from '$lib/helper'; 3 + import type { Item } from '$lib/types'; 4 + 5 + export type GridPosition = { 6 + x: number; 7 + y: number; 8 + swapWithId: string | null; 9 + placement: 'above' | 'below' | null; 10 + }; 11 + 12 + export type DragState = { 13 + item: Item; 14 + mouseDeltaX: number; 15 + mouseDeltaY: number; 16 + originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 17 + lastTargetId: string | null; 18 + lastPlacement: 'above' | 'below' | null; 19 + }; 20 + 21 + /** 22 + * Convert client coordinates to a grid position with swap detection and hysteresis. 23 + * Returns undefined if container or dragState.item is missing. 24 + * Mutates dragState.lastTargetId and dragState.lastPlacement for hysteresis tracking. 25 + */ 26 + export function getGridPosition( 27 + clientX: number, 28 + clientY: number, 29 + container: HTMLElement, 30 + dragState: DragState, 31 + items: Item[], 32 + isMobile: boolean 33 + ): GridPosition | undefined { 34 + if (!dragState.item) return; 35 + 36 + // x, y represent the top-left corner of the dragged card 37 + const x = clientX + dragState.mouseDeltaX; 38 + const y = clientY + dragState.mouseDeltaY; 39 + 40 + const rect = container.getBoundingClientRect(); 41 + const currentMargin = isMobile ? mobileMargin : margin; 42 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 43 + 44 + // Get card dimensions based on current view mode 45 + const cardW = isMobile ? dragState.item.mobileW : dragState.item.w; 46 + const cardH = isMobile ? dragState.item.mobileH : dragState.item.h; 47 + 48 + // Get dragged card's original position 49 + const draggedOrigPos = dragState.originalPositions.get(dragState.item.id); 50 + const draggedOrigY = draggedOrigPos ? (isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y) : 0; 51 + 52 + // Calculate raw grid position based on top-left of dragged card 53 + let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 54 + gridX = Math.floor(gridX / 2) * 2; 55 + 56 + let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 57 + 58 + if (isMobile) { 59 + gridX = Math.floor(gridX / 2) * 2; 60 + gridY = Math.floor(gridY / 2) * 2; 61 + } 62 + 63 + // Find if we're hovering over another card (using ORIGINAL positions) 64 + const centerGridY = gridY + cardH / 2; 65 + const centerGridX = gridX + cardW / 2; 66 + 67 + let swapWithId: string | null = null; 68 + let placement: 'above' | 'below' | null = null; 69 + 70 + for (const other of items) { 71 + if (other === dragState.item) continue; 72 + 73 + // Use original positions for hit testing 74 + const origPos = dragState.originalPositions.get(other.id); 75 + if (!origPos) continue; 76 + 77 + const otherX = isMobile ? origPos.mobileX : origPos.x; 78 + const otherY = isMobile ? origPos.mobileY : origPos.y; 79 + const otherW = isMobile ? other.mobileW : other.w; 80 + const otherH = isMobile ? other.mobileH : other.h; 81 + 82 + // Check if dragged card's center point is within this card's original bounds 83 + if ( 84 + centerGridX >= otherX && 85 + centerGridX < otherX + otherW && 86 + centerGridY >= otherY && 87 + centerGridY < otherY + otherH 88 + ) { 89 + // Check if this is a swap situation: 90 + // Cards have the same dimensions and are on the same row 91 + const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 92 + 93 + if (canSwap) { 94 + // Swap positions 95 + swapWithId = other.id; 96 + gridX = otherX; 97 + gridY = otherY; 98 + placement = null; 99 + 100 + dragState.lastTargetId = other.id; 101 + dragState.lastPlacement = null; 102 + } else { 103 + // Vertical placement (above/below) 104 + // Detect drag direction: if dragging up, always place above 105 + const isDraggingUp = gridY < draggedOrigY; 106 + 107 + if (isDraggingUp) { 108 + // When dragging up, always place above 109 + placement = 'above'; 110 + } else { 111 + // When dragging down, use top/bottom half logic 112 + const midpointY = otherY + otherH / 2; 113 + const hysteresis = 0.3; 114 + 115 + if (dragState.lastTargetId === other.id && dragState.lastPlacement) { 116 + if (dragState.lastPlacement === 'above') { 117 + placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 118 + } else { 119 + placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 120 + } 121 + } else { 122 + placement = centerGridY < midpointY ? 'above' : 'below'; 123 + } 124 + } 125 + 126 + dragState.lastTargetId = other.id; 127 + dragState.lastPlacement = placement; 128 + 129 + if (placement === 'above') { 130 + gridY = otherY; 131 + } else { 132 + gridY = otherY + otherH; 133 + } 134 + } 135 + break; 136 + } 137 + } 138 + 139 + // If we're not over any card, clear the tracking 140 + if (!swapWithId && !placement) { 141 + dragState.lastTargetId = null; 142 + dragState.lastPlacement = null; 143 + } 144 + 145 + return { x: gridX, y: gridY, swapWithId, placement }; 146 + } 147 + 148 + /** 149 + * Get the grid Y coordinate at the viewport center. 150 + */ 151 + export function getViewportCenterGridY( 152 + container: HTMLElement, 153 + isMobile: boolean 154 + ): { gridY: number; isMobile: boolean } { 155 + const rect = container.getBoundingClientRect(); 156 + const currentMargin = isMobile ? mobileMargin : margin; 157 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 158 + const viewportCenterY = window.innerHeight / 2; 159 + const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 160 + return { gridY, isMobile }; 161 + } 162 + 163 + /** 164 + * Convert pixel drop coordinates to grid position. Used for file drops. 165 + */ 166 + export function pixelToGrid( 167 + clientX: number, 168 + clientY: number, 169 + container: HTMLElement, 170 + isMobile: boolean, 171 + cardW: number 172 + ): { gridX: number; gridY: number } { 173 + const rect = container.getBoundingClientRect(); 174 + const currentMargin = isMobile ? mobileMargin : margin; 175 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 176 + 177 + let gridX = clamp( 178 + Math.round((clientX - rect.left - currentMargin) / cellSize), 179 + 0, 180 + COLUMNS - cardW 181 + ); 182 + gridX = Math.floor(gridX / 2) * 2; 183 + 184 + let gridY = Math.max(Math.round((clientY - rect.top - currentMargin) / cellSize), 0); 185 + if (isMobile) { 186 + gridY = Math.floor(gridY / 2) * 2; 187 + } 188 + 189 + return { gridX, gridY }; 190 + }
+15
src/lib/layout/index.ts
···
··· 1 + export { 2 + overlaps, 3 + fixCollisions, 4 + fixAllCollisions, 5 + compactItems, 6 + setPositionOfNewItem, 7 + findValidPosition 8 + } from './algorithms'; 9 + 10 + export { shouldMirror, mirrorItemSize, mirrorLayout } from './mirror'; 11 + 12 + export { getGridPosition, getViewportCenterGridY, pixelToGrid } from './grid'; 13 + export type { GridPosition, DragState } from './grid'; 14 + 15 + export { default as EditableGrid } from './EditableGrid.svelte';
+73
src/lib/layout/mirror.ts
···
··· 1 + import { COLUMNS } from '$lib'; 2 + import { CardDefinitionsByType } from '$lib/cards'; 3 + import { clamp } from '$lib/helper'; 4 + import { fixAllCollisions, findValidPosition } from './algorithms'; 5 + import type { Item } from '$lib/types'; 6 + 7 + /** 8 + * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). 9 + * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both 10 + */ 11 + export function shouldMirror(editedOn: number | undefined): boolean { 12 + return (editedOn ?? 0) !== 3; 13 + } 14 + 15 + /** Snap a value to the nearest even integer (min 2). */ 16 + function snapEven(v: number): number { 17 + return Math.max(2, Math.round(v / 2) * 2); 18 + } 19 + 20 + /** 21 + * Compute the other layout's size for a single item, preserving aspect ratio. 22 + * Clamps to the card definition's minW/maxW/minH/maxH if defined. 23 + * Mutates the item in-place. 24 + */ 25 + export function mirrorItemSize(item: Item, fromMobile: boolean): void { 26 + const def = CardDefinitionsByType[item.cardType]; 27 + 28 + if (fromMobile) { 29 + // Mobile โ†’ Desktop: halve both dimensions, then clamp to card def constraints 30 + // (constraints are in desktop units) 31 + item.w = clamp(snapEven(item.mobileW / 2), def?.minW ?? 2, def?.maxW ?? COLUMNS); 32 + item.h = clamp(Math.round(item.mobileH / 2), def?.minH ?? 1, def?.maxH ?? Infinity); 33 + } else { 34 + // Desktop โ†’ Mobile: double both dimensions 35 + // (don't apply card def constraints โ€” they're in desktop units) 36 + item.mobileW = Math.min(item.w * 2, COLUMNS); 37 + item.mobileH = Math.max(item.h * 2, 2); 38 + } 39 + } 40 + 41 + /** 42 + * Mirror the full layout from one view to the other. 43 + * Copies sizes proportionally and maps positions, then resolves collisions. 44 + * Mutates items in-place. 45 + */ 46 + export function mirrorLayout(items: Item[], fromMobile: boolean): void { 47 + // Mirror sizes first 48 + for (const item of items) { 49 + mirrorItemSize(item, fromMobile); 50 + } 51 + 52 + if (fromMobile) { 53 + // Mobile โ†’ Desktop: reflow items to use the full grid width. 54 + // Sort by mobile position so items are placed in reading order. 55 + const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 56 + 57 + // Place each item into the first available spot on the desktop grid 58 + const placed: Item[] = []; 59 + for (const item of sorted) { 60 + item.x = 0; 61 + item.y = 0; 62 + findValidPosition(item, placed, false); 63 + placed.push(item); 64 + } 65 + } else { 66 + // Desktop โ†’ Mobile: proportional positions 67 + for (const item of items) { 68 + item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 69 + item.mobileY = Math.max(0, Math.round(item.y * 2)); 70 + } 71 + fixAllCollisions(items, true); 72 + } 73 + }
+1 -3
src/lib/website/EditableProfile.svelte
··· 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();
··· 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 } from '@foxui/core'; 7 import MadeWithBlento from './MadeWithBlento.svelte'; 8 9 let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } = 10 $props();
+56 -579
src/lib/website/EditableWebsite.svelte
··· 1 <script lang="ts"> 2 - import { Button, Modal, toast, Toaster, Sidebar } from '@foxui/core'; 3 - import { COLUMNS, margin, mobileMargin } from '$lib'; 4 import { 5 checkAndUploadImage, 6 - clamp, 7 - compactItems, 8 createEmptyCard, 9 - findValidPosition, 10 - fixAllCollisions, 11 - fixCollisions, 12 getHideProfileSection, 13 getProfilePosition, 14 getName, 15 isTyping, 16 savePage, 17 scrollToItem, 18 - setPositionOfNewItem, 19 validateLink, 20 getImage 21 } from '../helper'; ··· 32 import Context from './Context.svelte'; 33 import Head from './Head.svelte'; 34 import Account from './Account.svelte'; 35 - import { SelectThemePopover } from '$lib/components/select-theme'; 36 import EditBar from './EditBar.svelte'; 37 import SaveModal from './SaveModal.svelte'; 38 import FloatingEditButton from './FloatingEditButton.svelte'; ··· 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'; 46 47 let { 48 data ··· 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 - 63 - let imageDragOver = $state(false); 64 - 65 // svelte-ignore state_referenced_locally 66 let items: Item[] = $state(data.cards); 67 68 // svelte-ignore state_referenced_locally 69 let publication = $state(JSON.stringify(data.publication)); 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 ··· 97 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 98 }); 99 100 - let container: HTMLDivElement | undefined = $state(); 101 - 102 - let activeDragElement: { 103 - element: HTMLDivElement | null; 104 - item: Item | null; 105 - w: number; 106 - h: number; 107 - x: number; 108 - y: number; 109 - mouseDeltaX: number; 110 - mouseDeltaY: number; 111 - // For hysteresis - track last decision to prevent flickering 112 - lastTargetId: string | null; 113 - lastPlacement: 'above' | 'below' | null; 114 - // Store original positions to reset from during drag 115 - originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 116 - } = $state({ 117 - element: null, 118 - item: null, 119 - w: 0, 120 - h: 0, 121 - x: -1, 122 - y: -1, 123 - mouseDeltaX: 0, 124 - mouseDeltaY: 0, 125 - lastTargetId: null, 126 - lastPlacement: null, 127 - originalPositions: new Map() 128 - }); 129 130 let showingMobileView = $state(false); 131 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); ··· 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)) { ··· 159 160 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 161 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 162 - 163 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 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 175 function newCard(type: string = 'link', cardData?: any) { 176 selectedCardId = null; ··· 220 if (!newItem.item) return; 221 const item = newItem.item; 222 223 - const viewportCenter = getViewportCenterGridY(); 224 setPositionOfNewItem(item, items, viewportCenter); 225 226 items = [...items, item]; ··· 238 await tick(); 239 cleanupDialogArtifacts(); 240 241 - scrollToItem(item, isMobile, container); 242 } 243 244 let isSaving = $state(false); ··· 266 267 publication = JSON.stringify(data.publication); 268 269 - // Update saved state 270 - savedItems = JSON.stringify(items); 271 - savedPublication = JSON.stringify(data.publication); 272 273 saveSuccess = true; 274 ··· 542 return; 543 } 544 545 - fixAllCollisions(copiedCards); 546 fixAllCollisions(copiedCards, true); 547 - compactItems(copiedCards); 548 compactItems(copiedCards, true); 549 550 items = copiedCards; ··· 558 } 559 } 560 561 - let debugPoint = $state({ x: 0, y: 0 }); 562 - 563 - function getGridPosition( 564 - clientX: number, 565 - clientY: number 566 - ): 567 - | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 568 - | undefined { 569 - if (!container || !activeDragElement.item) return; 570 - 571 - // x, y represent the top-left corner of the dragged card 572 - const x = clientX + activeDragElement.mouseDeltaX; 573 - const y = clientY + activeDragElement.mouseDeltaY; 574 - 575 - const rect = container.getBoundingClientRect(); 576 - const currentMargin = isMobile ? mobileMargin : margin; 577 - const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 578 - 579 - // Get card dimensions based on current view mode 580 - const cardW = isMobile 581 - ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 582 - : activeDragElement.w; 583 - const cardH = isMobile 584 - ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 585 - : activeDragElement.h; 586 - 587 - // Get dragged card's original position 588 - const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 589 - 590 - const draggedOrigY = draggedOrigPos 591 - ? isMobile 592 - ? draggedOrigPos.mobileY 593 - : draggedOrigPos.y 594 - : 0; 595 - 596 - // Calculate raw grid position based on top-left of dragged card 597 - let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 598 - gridX = Math.floor(gridX / 2) * 2; 599 - 600 - let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 601 - 602 - if (isMobile) { 603 - gridX = Math.floor(gridX / 2) * 2; 604 - gridY = Math.floor(gridY / 2) * 2; 605 - } 606 - 607 - // Find if we're hovering over another card (using ORIGINAL positions) 608 - const centerGridY = gridY + cardH / 2; 609 - const centerGridX = gridX + cardW / 2; 610 - 611 - let swapWithId: string | null = null; 612 - let placement: 'above' | 'below' | null = null; 613 - 614 - for (const other of items) { 615 - if (other === activeDragElement.item) continue; 616 - 617 - // Use original positions for hit testing 618 - const origPos = activeDragElement.originalPositions.get(other.id); 619 - if (!origPos) continue; 620 - 621 - const otherX = isMobile ? origPos.mobileX : origPos.x; 622 - const otherY = isMobile ? origPos.mobileY : origPos.y; 623 - const otherW = isMobile ? other.mobileW : other.w; 624 - const otherH = isMobile ? other.mobileH : other.h; 625 - 626 - // Check if dragged card's center point is within this card's original bounds 627 - if ( 628 - centerGridX >= otherX && 629 - centerGridX < otherX + otherW && 630 - centerGridY >= otherY && 631 - centerGridY < otherY + otherH 632 - ) { 633 - // Check if this is a swap situation: 634 - // Cards have the same dimensions and are on the same row 635 - const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 636 - 637 - if (canSwap) { 638 - // Swap positions 639 - swapWithId = other.id; 640 - gridX = otherX; 641 - gridY = otherY; 642 - placement = null; 643 - 644 - activeDragElement.lastTargetId = other.id; 645 - activeDragElement.lastPlacement = null; 646 - } else { 647 - // Vertical placement (above/below) 648 - // Detect drag direction: if dragging up, always place above 649 - const isDraggingUp = gridY < draggedOrigY; 650 - 651 - if (isDraggingUp) { 652 - // When dragging up, always place above 653 - placement = 'above'; 654 - } else { 655 - // When dragging down, use top/bottom half logic 656 - const midpointY = otherY + otherH / 2; 657 - const hysteresis = 0.3; 658 - 659 - if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 660 - if (activeDragElement.lastPlacement === 'above') { 661 - placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 662 - } else { 663 - placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 664 - } 665 - } else { 666 - placement = centerGridY < midpointY ? 'above' : 'below'; 667 - } 668 - } 669 - 670 - activeDragElement.lastTargetId = other.id; 671 - activeDragElement.lastPlacement = placement; 672 - 673 - if (placement === 'above') { 674 - gridY = otherY; 675 - } else { 676 - gridY = otherY + otherH; 677 - } 678 - } 679 - break; 680 - } 681 - } 682 - 683 - // If we're not over any card, clear the tracking 684 - if (!swapWithId && !placement) { 685 - activeDragElement.lastTargetId = null; 686 - activeDragElement.lastPlacement = null; 687 - } 688 - 689 - debugPoint.x = x - rect.left; 690 - debugPoint.y = y - rect.top + currentMargin; 691 - 692 - return { x: gridX, y: gridY, swapWithId, placement }; 693 - } 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 - 839 let linkValue = $state(''); 840 841 function addLink(url: string, specificCardDef?: CardDefinition) { ··· 959 fixCollisions(items, item, isMobile); 960 fixCollisions(items, item, !isMobile); 961 } else { 962 - const viewportCenter = getViewportCenterGridY(); 963 setPositionOfNewItem(item, items, viewportCenter); 964 items = [...items, item]; 965 fixCollisions(items, item, false, true); ··· 972 973 await tick(); 974 975 - scrollToItem(item, isMobile, container); 976 } 977 978 - function handleImageDragOver(event: DragEvent) { 979 - const dt = event.dataTransfer; 980 - if (!dt) return; 981 - 982 - let hasImage = false; 983 - if (dt.items) { 984 - for (let i = 0; i < dt.items.length; i++) { 985 - const item = dt.items[i]; 986 - if (item && item.kind === 'file' && item.type.startsWith('image/')) { 987 - hasImage = true; 988 - break; 989 - } 990 - } 991 - } else if (dt.files) { 992 - for (let i = 0; i < dt.files.length; i++) { 993 - const file = dt.files[i]; 994 - if (file?.type.startsWith('image/')) { 995 - hasImage = true; 996 - break; 997 - } 998 - } 999 - } 1000 - 1001 - if (hasImage) { 1002 - event.preventDefault(); 1003 - event.stopPropagation(); 1004 - 1005 - imageDragOver = true; 1006 - } 1007 - } 1008 - 1009 - function handleImageDragLeave(event: DragEvent) { 1010 - event.preventDefault(); 1011 - event.stopPropagation(); 1012 - imageDragOver = false; 1013 - } 1014 - 1015 - async function handleImageDrop(event: DragEvent) { 1016 - event.preventDefault(); 1017 - event.stopPropagation(); 1018 - const dropX = event.clientX; 1019 - const dropY = event.clientY; 1020 - imageDragOver = false; 1021 - 1022 - if (!event.dataTransfer?.files?.length) return; 1023 - 1024 - const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 1025 - f?.type.startsWith('image/') 1026 - ); 1027 - if (imageFiles.length === 0) return; 1028 - 1029 - // Calculate starting grid position from drop coordinates 1030 - let gridX = 0; 1031 - let gridY = 0; 1032 - if (container) { 1033 - const rect = container.getBoundingClientRect(); 1034 - const currentMargin = isMobile ? mobileMargin : margin; 1035 - const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 1036 - const cardW = isMobile ? 4 : 2; 1037 - 1038 - gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 1039 - gridX = Math.floor(gridX / 2) * 2; 1040 - 1041 - gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 1042 - if (isMobile) { 1043 - gridY = Math.floor(gridY / 2) * 2; 1044 - } 1045 - } 1046 - 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]); 1053 } 1054 } 1055 } ··· 1097 objectUrl 1098 }; 1099 1100 - const viewportCenter = getViewportCenterGridY(); 1101 setPositionOfNewItem(item, items, viewportCenter); 1102 items = [...items, item]; 1103 fixCollisions(items, item, false, true); ··· 1109 1110 await tick(); 1111 1112 - scrollToItem(item, isMobile, container); 1113 } 1114 1115 async function handleVideoInputChange(event: Event) { ··· 1141 }} 1142 /> 1143 1144 - <svelte:window 1145 - ondragover={handleImageDragOver} 1146 - ondragleave={handleImageDragLeave} 1147 - ondrop={handleImageDrop} 1148 - /> 1149 - 1150 <Head 1151 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 1152 title={getName(data)} ··· 1256 ]} 1257 > 1258 <div class="pointer-events-none"></div> 1259 - <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 1260 - <div 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} 1270 - ondragover={(e) => { 1271 - e.preventDefault(); 1272 - 1273 - const result = getDragXY(e); 1274 - if (!result) return; 1275 - 1276 - activeDragElement.x = result.x; 1277 - activeDragElement.y = result.y; 1278 - 1279 - if (activeDragElement.item) { 1280 - // Get dragged card's original position for swapping 1281 - const draggedOrigPos = activeDragElement.originalPositions.get( 1282 - activeDragElement.item.id 1283 - ); 1284 - 1285 - // Reset all items to original positions first 1286 - for (const it of items) { 1287 - const origPos = activeDragElement.originalPositions.get(it.id); 1288 - if (origPos && it !== activeDragElement.item) { 1289 - if (isMobile) { 1290 - it.mobileX = origPos.mobileX; 1291 - it.mobileY = origPos.mobileY; 1292 - } else { 1293 - it.x = origPos.x; 1294 - it.y = origPos.y; 1295 - } 1296 - } 1297 - } 1298 - 1299 - // Update dragged item position 1300 - if (isMobile) { 1301 - activeDragElement.item.mobileX = result.x; 1302 - activeDragElement.item.mobileY = result.y; 1303 - } else { 1304 - activeDragElement.item.x = result.x; 1305 - activeDragElement.item.y = result.y; 1306 - } 1307 - 1308 - // Handle horizontal swap 1309 - if (result.swapWithId && draggedOrigPos) { 1310 - const swapTarget = items.find((it) => it.id === result.swapWithId); 1311 - if (swapTarget) { 1312 - // Move swap target to dragged card's original position 1313 - if (isMobile) { 1314 - swapTarget.mobileX = draggedOrigPos.mobileX; 1315 - swapTarget.mobileY = draggedOrigPos.mobileY; 1316 - } else { 1317 - swapTarget.x = draggedOrigPos.x; 1318 - swapTarget.y = draggedOrigPos.y; 1319 - } 1320 - } 1321 - } 1322 - 1323 - // Now fix collisions (with compacting) 1324 - fixCollisions(items, activeDragElement.item, isMobile); 1325 - } 1326 - 1327 - // Auto-scroll when dragging near top or bottom of viewport 1328 - const scrollZone = 100; 1329 - const scrollSpeed = 10; 1330 - const viewportHeight = window.innerHeight; 1331 - 1332 - if (e.clientY < scrollZone) { 1333 - // Near top - scroll up 1334 - const intensity = 1 - e.clientY / scrollZone; 1335 - window.scrollBy(0, -scrollSpeed * intensity); 1336 - } else if (e.clientY > viewportHeight - scrollZone) { 1337 - // Near bottom - scroll down 1338 - const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 1339 - window.scrollBy(0, scrollSpeed * intensity); 1340 - } 1341 - }} 1342 - ondragend={async (e) => { 1343 - e.preventDefault(); 1344 - // safari fix 1345 - activeDragElement.x = -1; 1346 - activeDragElement.y = -1; 1347 - activeDragElement.element = null; 1348 - activeDragElement.item = null; 1349 - activeDragElement.lastTargetId = null; 1350 - activeDragElement.lastPlacement = null; 1351 - return true; 1352 }} 1353 - class={[ 1354 - '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 1355 - imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 1356 - ]} 1357 > 1358 {#each items as item, i (item.id)} 1359 - <!-- {#if item !== activeDragElement.item} --> 1360 <BaseEditingCard 1361 bind:item={items[i]} 1362 ondelete={() => { ··· 1377 fixCollisions(items, item, isMobile); 1378 onLayoutChanged(); 1379 }} 1380 - ondragstart={(e: DragEvent) => { 1381 - const target = e.currentTarget as HTMLDivElement; 1382 - activeDragElement.element = target; 1383 - activeDragElement.w = item.w; 1384 - activeDragElement.h = item.h; 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()); 1395 - 1396 - // Store original positions of all items 1397 - activeDragElement.originalPositions = new Map(); 1398 - for (const it of items) { 1399 - activeDragElement.originalPositions.set(it.id, { 1400 - x: it.x, 1401 - y: it.y, 1402 - mobileX: it.mobileX, 1403 - mobileY: it.mobileY 1404 - }); 1405 - } 1406 - 1407 - const rect = target.getBoundingClientRect(); 1408 - activeDragElement.mouseDeltaX = rect.left - e.clientX; 1409 - activeDragElement.mouseDeltaY = rect.top - e.clientY; 1410 - }} 1411 > 1412 <EditingCard bind:item={items[i]} /> 1413 </BaseEditingCard> 1414 - <!-- {/if} --> 1415 {/each} 1416 - 1417 - <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 1418 - </div> 1419 </div> 1420 </div> 1421 ··· 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} 1489 </Context>
··· 1 <script lang="ts"> 2 + import { Button, Modal, toast, Toaster } from '@foxui/core'; 3 + import { COLUMNS } from '$lib'; 4 import { 5 checkAndUploadImage, 6 createEmptyCard, 7 getHideProfileSection, 8 getProfilePosition, 9 getName, 10 isTyping, 11 savePage, 12 scrollToItem, 13 validateLink, 14 getImage 15 } from '../helper'; ··· 26 import Context from './Context.svelte'; 27 import Head from './Head.svelte'; 28 import Account from './Account.svelte'; 29 import EditBar from './EditBar.svelte'; 30 import SaveModal from './SaveModal.svelte'; 31 import FloatingEditButton from './FloatingEditButton.svelte'; ··· 34 import { launchConfetti } from '@foxui/visual'; 35 import Controls from './Controls.svelte'; 36 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 37 import { SvelteMap } from 'svelte/reactivity'; 38 + import { 39 + fixCollisions, 40 + compactItems, 41 + fixAllCollisions, 42 + setPositionOfNewItem, 43 + shouldMirror, 44 + mirrorLayout, 45 + getViewportCenterGridY, 46 + EditableGrid 47 + } from '$lib/layout'; 48 49 let { 50 data ··· 55 // Check if floating login button will be visible (to hide MadeWithBlento) 56 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 57 58 // svelte-ignore state_referenced_locally 59 let items: Item[] = $state(data.cards); 60 61 // svelte-ignore state_referenced_locally 62 let publication = $state(JSON.stringify(data.publication)); 63 64 // svelte-ignore state_referenced_locally 65 + let savedItemsSnapshot = JSON.stringify(data.cards); 66 67 let hasUnsavedChanges = $state(false); 68 69 + // Detect card content and publication changes (e.g. sidebar edits) 70 + // The guard ensures JSON.stringify only runs while no changes are detected yet. 71 + // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations 72 + // but the early return makes it effectively free. 73 $effect(() => { 74 + if (hasUnsavedChanges) return; 75 + if ( 76 + JSON.stringify(items) !== savedItemsSnapshot || 77 + JSON.stringify(data.publication) !== publication 78 + ) { 79 + hasUnsavedChanges = true; 80 } 81 }); 82 ··· 93 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 94 }); 95 96 + let gridContainer: HTMLDivElement | undefined = $state(); 97 98 let showingMobileView = $state(false); 99 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); ··· 105 let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 106 107 function onLayoutChanged() { 108 + hasUnsavedChanges = true; 109 // Set the bit for the current layout: desktop=1, mobile=2 110 editedOn = editedOn | (isMobile ? 2 : 1); 111 if (shouldMirror(editedOn)) { ··· 128 129 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 130 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 131 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 132 133 function newCard(type: string = 'link', cardData?: any) { 134 selectedCardId = null; ··· 178 if (!newItem.item) return; 179 const item = newItem.item; 180 181 + const viewportCenter = gridContainer 182 + ? getViewportCenterGridY(gridContainer, isMobile) 183 + : undefined; 184 setPositionOfNewItem(item, items, viewportCenter); 185 186 items = [...items, item]; ··· 198 await tick(); 199 cleanupDialogArtifacts(); 200 201 + scrollToItem(item, isMobile, gridContainer); 202 } 203 204 let isSaving = $state(false); ··· 226 227 publication = JSON.stringify(data.publication); 228 229 + savedItemsSnapshot = JSON.stringify(items); 230 + hasUnsavedChanges = false; 231 232 saveSuccess = true; 233 ··· 501 return; 502 } 503 504 + fixAllCollisions(copiedCards, false); 505 fixAllCollisions(copiedCards, true); 506 + compactItems(copiedCards, false); 507 compactItems(copiedCards, true); 508 509 items = copiedCards; ··· 517 } 518 } 519 520 let linkValue = $state(''); 521 522 function addLink(url: string, specificCardDef?: CardDefinition) { ··· 640 fixCollisions(items, item, isMobile); 641 fixCollisions(items, item, !isMobile); 642 } else { 643 + const viewportCenter = gridContainer 644 + ? getViewportCenterGridY(gridContainer, isMobile) 645 + : undefined; 646 setPositionOfNewItem(item, items, viewportCenter); 647 items = [...items, item]; 648 fixCollisions(items, item, false, true); ··· 655 656 await tick(); 657 658 + scrollToItem(item, isMobile, gridContainer); 659 } 660 661 + async function handleFileDrop(files: File[], gridX: number, gridY: number) { 662 + for (let i = 0; i < files.length; i++) { 663 // First image gets the drop position, rest use normal placement 664 if (i === 0) { 665 + await processImageFile(files[i], gridX, gridY); 666 } else { 667 + await processImageFile(files[i]); 668 } 669 } 670 } ··· 712 objectUrl 713 }; 714 715 + const viewportCenter = gridContainer 716 + ? getViewportCenterGridY(gridContainer, isMobile) 717 + : undefined; 718 setPositionOfNewItem(item, items, viewportCenter); 719 items = [...items, item]; 720 fixCollisions(items, item, false, true); ··· 726 727 await tick(); 728 729 + scrollToItem(item, isMobile, gridContainer); 730 } 731 732 async function handleVideoInputChange(event: Event) { ··· 758 }} 759 /> 760 761 <Head 762 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 763 title={getName(data)} ··· 867 ]} 868 > 869 <div class="pointer-events-none"></div> 870 + <EditableGrid 871 + bind:items 872 + bind:ref={gridContainer} 873 + {isMobile} 874 + {selectedCardId} 875 + {isCoarse} 876 + onlayoutchange={onLayoutChanged} 877 + ondeselect={() => { 878 + selectedCardId = null; 879 }} 880 + onfiledrop={handleFileDrop} 881 > 882 {#each items as item, i (item.id)} 883 <BaseEditingCard 884 bind:item={items[i]} 885 ondelete={() => { ··· 900 fixCollisions(items, item, isMobile); 901 onLayoutChanged(); 902 }} 903 > 904 <EditingCard bind:item={items[i]} /> 905 </BaseEditingCard> 906 {/each} 907 + </EditableGrid> 908 </div> 909 </div> 910 ··· 961 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" 962 > 963 <span>editedOn: {editedOn}</span> 964 </div> 965 {/if} 966 </Context>
+32
src/lib/website/ThemeScript.svelte
··· 1 <script lang="ts"> 2 let { 3 accentColor = 'pink', 4 baseColor = 'stone' ··· 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>
··· 1 <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 4 let { 5 accentColor = 'pink', 6 baseColor = 'stone' ··· 9 baseColor?: string; 10 } = $props(); 11 12 + const allAccentColors = [ 13 + 'red', 14 + 'orange', 15 + 'amber', 16 + 'yellow', 17 + 'lime', 18 + 'green', 19 + 'emerald', 20 + 'teal', 21 + 'cyan', 22 + 'sky', 23 + 'blue', 24 + 'indigo', 25 + 'violet', 26 + 'purple', 27 + 'fuchsia', 28 + 'pink', 29 + 'rose' 30 + ]; 31 + const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate']; 32 + 33 const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c'); 34 35 + // SSR: inline script for initial page load (no FOUC) 36 let script = $derived( 37 `<script>(function(){document.documentElement.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})();<` + 38 '/script>' 39 ); 40 + 41 + // Client: reactive effect for client-side navigations 42 + $effect(() => { 43 + if (!browser) return; 44 + const el = document.documentElement; 45 + el.classList.remove(...allAccentColors, ...allBaseColors); 46 + el.classList.add(accentColor, baseColor); 47 + }); 48 </script> 49 50 <svelte:head>
-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 - }
···
+23 -24
src/lib/website/load.ts
··· 1 import { getDetailedProfile, listRecords, resolveHandle, parseUri, getRecord } from '$lib/atproto'; 2 import { CardDefinitionsByType } from '$lib/cards'; 3 import type { Item, UserCache, WebsiteData } from '$lib/types'; 4 - import { compactItems, fixAllCollisions } from '$lib/helper'; 5 import { error } from '@sveltejs/kit'; 6 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 7 8 import { isDid, isHandle } from '@atcute/lexicons/syntax'; 9 10 const CURRENT_CACHE_VERSION = 1; 11 ··· 73 throw error(404); 74 } 75 76 - const cards = await listRecords({ did, collection: 'app.blento.card' }).catch(() => { 77 - console.error('error getting records for collection app.blento.card'); 78 - return [] as Awaited<ReturnType<typeof listRecords>>; 79 - }); 80 - 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 - }); 94 - 95 - const profile = await getDetailedProfile({ did }); 96 97 const cardTypes = new Set(cards.map((v) => v.value.cardType ?? '') as string[]); 98 const cardTypesArray = Array.from(cardTypes); ··· 144 const stringifiedResult = JSON.stringify(result); 145 await cache?.put?.(handle, stringifiedResult); 146 147 - const parsedResult = JSON.parse(stringifiedResult); 148 149 parsedResult.publication = ( 150 parsedResult.publications as Awaited<ReturnType<typeof listRecords>> ··· 203 const cards = data.cards.filter((v) => v.page === data.page); 204 205 if (cards.length > 0) { 206 - fixAllCollisions(cards); 207 fixAllCollisions(cards, true); 208 209 - compactItems(cards); 210 compactItems(cards, true); 211 } 212
··· 1 import { getDetailedProfile, listRecords, resolveHandle, parseUri, getRecord } from '$lib/atproto'; 2 import { CardDefinitionsByType } from '$lib/cards'; 3 import type { Item, UserCache, WebsiteData } from '$lib/types'; 4 import { error } from '@sveltejs/kit'; 5 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 6 7 import { isDid, isHandle } from '@atcute/lexicons/syntax'; 8 + import { fixAllCollisions, compactItems } from '$lib/layout'; 9 10 const CURRENT_CACHE_VERSION = 1; 11 ··· 73 throw error(404); 74 } 75 76 + const [cards, mainPublication, pages, profile] = await Promise.all([ 77 + listRecords({ did, collection: 'app.blento.card' }).catch(() => { 78 + console.error('error getting records for collection app.blento.card'); 79 + return [] as Awaited<ReturnType<typeof listRecords>>; 80 + }), 81 + 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 + listRecords({ did, collection: 'app.blento.page' }).catch(() => { 90 + console.error('error getting records for collection app.blento.page'); 91 + return [] as Awaited<ReturnType<typeof listRecords>>; 92 + }), 93 + getDetailedProfile({ did }) 94 + ]); 95 96 const cardTypes = new Set(cards.map((v) => v.value.cardType ?? '') as string[]); 97 const cardTypesArray = Array.from(cardTypes); ··· 143 const stringifiedResult = JSON.stringify(result); 144 await cache?.put?.(handle, stringifiedResult); 145 146 + const parsedResult = structuredClone(result) as any; 147 148 parsedResult.publication = ( 149 parsedResult.publications as Awaited<ReturnType<typeof listRecords>> ··· 202 const cards = data.cards.filter((v) => v.page === data.page); 203 204 if (cards.length > 0) { 205 + fixAllCollisions(cards, false); 206 fixAllCollisions(cards, true); 207 208 + compactItems(cards, false); 209 compactItems(cards, true); 210 } 211
+2 -1
src/params/handle.ts
··· 1 import type { ParamMatcher } from '@sveltejs/kit'; 2 3 export const match = ((param: string) => { 4 - return param.includes('.') || param.startsWith('did:'); 5 }) satisfies ParamMatcher;
··· 1 + import { isActorIdentifier } from '@atcute/lexicons/syntax'; 2 import type { ParamMatcher } from '@sveltejs/kit'; 3 4 export const match = ((param: string) => { 5 + return isActorIdentifier(param); 6 }) satisfies ParamMatcher;
+4 -1
src/routes/+layout.svelte
··· 1 <script lang="ts"> 2 import '../app.css'; 3 4 import { ThemeToggle, Toaster, toast } from '@foxui/core'; 5 import { onMount } from 'svelte'; 6 import { initClient } from '$lib/atproto'; ··· 28 }); 29 </script> 30 31 - {@render children()} 32 33 <ThemeToggle class="fixed top-2 left-2 z-10" /> 34 <Toaster />
··· 1 <script lang="ts"> 2 import '../app.css'; 3 4 + import { Tooltip } from 'bits-ui'; 5 import { ThemeToggle, Toaster, toast } from '@foxui/core'; 6 import { onMount } from 'svelte'; 7 import { initClient } from '$lib/atproto'; ··· 29 }); 30 </script> 31 32 + <Tooltip.Provider delayDuration={300}> 33 + {@render children()} 34 + </Tooltip.Provider> 35 36 <ThemeToggle class="fixed top-2 left-2 z-10" /> 37 <Toaster />
+13
src/routes/[handle=handle]/(pages)/+layout.server.ts
···
··· 1 + import { loadData } from '$lib/website/load'; 2 + import { env } from '$env/dynamic/private'; 3 + import { error } from '@sveltejs/kit'; 4 + import type { UserCache } from '$lib/types'; 5 + import type { Handle } from '@atcute/lexicons'; 6 + 7 + export async function load({ params, platform }) { 8 + if (env.PUBLIC_IS_SELFHOSTED) error(404); 9 + 10 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 11 + 12 + return await loadData(params.handle as Handle, cache as UserCache, false, params.page); 13 + }
+13
src/routes/[handle=handle]/(pages)/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import { refreshData } from '$lib/helper.js'; 3 + import Website from '$lib/website/Website.svelte'; 4 + import { onMount } from 'svelte'; 5 + 6 + let { data } = $props(); 7 + 8 + onMount(() => { 9 + refreshData(data); 10 + }); 11 + </script> 12 + 13 + <Website {data} />
+6
src/routes/[handle=handle]/(pages)/edit/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import EditableWebsite from '$lib/website/EditableWebsite.svelte'; 3 + let { data } = $props(); 4 + </script> 5 + 6 + <EditableWebsite {data} />
+13
src/routes/[handle=handle]/(pages)/p/[[page]]/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import { refreshData } from '$lib/helper.js'; 3 + import Website from '$lib/website/Website.svelte'; 4 + import { onMount } from 'svelte'; 5 + 6 + let { data } = $props(); 7 + 8 + onMount(() => { 9 + refreshData(data); 10 + }); 11 + </script> 12 + 13 + <Website {data} />
+252
src/routes/[handle=handle]/(pages)/p/[[page]]/copy/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import { 3 + putRecord, 4 + deleteRecord, 5 + listRecords, 6 + uploadBlob, 7 + getCDNImageBlobUrl 8 + } from '$lib/atproto/methods'; 9 + import { user } from '$lib/atproto/auth.svelte'; 10 + import { goto } from '$app/navigation'; 11 + import * as TID from '@atcute/tid'; 12 + import { Button } from '@foxui/core'; 13 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 14 + 15 + let { data } = $props(); 16 + 17 + let destinationPage = $state(''); 18 + let copying = $state(false); 19 + let error = $state(''); 20 + let success = $state(false); 21 + 22 + const sourceHandle = $derived(data.handle); 23 + 24 + const sourcePage = $derived( 25 + data.page === 'blento.self' ? 'main' : data.page.replace('blento.', '') 26 + ); 27 + const sourceCards = $derived(data.cards); 28 + 29 + // Re-upload blobs from source repo to current user's repo 30 + async function reuploadBlobs(obj: any, sourceDid: string): Promise<void> { 31 + if (!obj || typeof obj !== 'object') return; 32 + 33 + for (const key of Object.keys(obj)) { 34 + const value = obj[key]; 35 + 36 + if (value && typeof value === 'object') { 37 + // Check if this is a blob reference 38 + if (value.$type === 'blob' && value.ref?.$link) { 39 + try { 40 + // Get the blob URL from source repo 41 + const blobUrl = getCDNImageBlobUrl({ did: sourceDid, blob: value }); 42 + if (!blobUrl) continue; 43 + 44 + // Fetch the blob via proxy to avoid CORS 45 + const response = await fetch(`/api/image-proxy?url=${encodeURIComponent(blobUrl)}`); 46 + if (!response.ok) { 47 + console.error('Failed to fetch blob:', blobUrl); 48 + continue; 49 + } 50 + 51 + // Upload to current user's repo 52 + const blob = await response.blob(); 53 + const newBlobRef = await uploadBlob({ blob }); 54 + 55 + if (newBlobRef) { 56 + // Replace with new blob reference 57 + obj[key] = newBlobRef; 58 + } 59 + } catch (err) { 60 + console.error('Failed to re-upload blob:', err); 61 + } 62 + } else { 63 + // Recursively check nested objects 64 + await reuploadBlobs(value, sourceDid); 65 + } 66 + } 67 + } 68 + } 69 + 70 + async function copyPage() { 71 + if (!user.isLoggedIn || !user.did) { 72 + error = 'You must be logged in to copy pages'; 73 + return; 74 + } 75 + 76 + copying = true; 77 + error = ''; 78 + 79 + try { 80 + const targetPage = 81 + destinationPage.trim() === '' ? 'blento.self' : `blento.${destinationPage.trim()}`; 82 + 83 + // Fetch existing cards from destination page and delete them 84 + const existingCards = await listRecords({ 85 + did: user.did, 86 + collection: 'app.blento.card' 87 + }); 88 + 89 + const cardsToDelete = existingCards.filter( 90 + (card: { value: { page?: string } }) => card.value.page === targetPage 91 + ); 92 + 93 + // Delete existing cards from destination page 94 + const deletePromises = cardsToDelete.map((card: { uri: string }) => { 95 + const rkey = card.uri.split('/').pop()!; 96 + return deleteRecord({ 97 + collection: 'app.blento.card', 98 + rkey 99 + }); 100 + }); 101 + 102 + await Promise.all(deletePromises); 103 + 104 + // Copy each card with a new ID to the destination page 105 + // Re-upload blobs from source repo to current user's repo 106 + for (const card of sourceCards) { 107 + const newCard = { 108 + ...structuredClone(card), 109 + id: TID.now(), 110 + page: targetPage, 111 + updatedAt: new Date().toISOString(), 112 + version: 2 113 + }; 114 + 115 + // Re-upload any blobs in cardData 116 + await reuploadBlobs(newCard.cardData, data.did); 117 + 118 + await putRecord({ 119 + collection: 'app.blento.card', 120 + rkey: newCard.id, 121 + record: newCard 122 + }); 123 + } 124 + 125 + const userHandle = user.profile?.handle ?? data.handle; 126 + 127 + // Copy publication data if it exists 128 + if (data.publication) { 129 + const publicationCopy = structuredClone(data.publication) as Record<string, unknown>; 130 + 131 + // Re-upload any blobs in publication (e.g., icon) 132 + await reuploadBlobs(publicationCopy, data.did); 133 + 134 + // Update the URL to point to the user's page 135 + publicationCopy.url = `https://blento.app/${userHandle}`; 136 + if (targetPage !== 'blento.self') { 137 + publicationCopy.url += '/' + targetPage.replace('blento.', ''); 138 + } 139 + 140 + // Save to appropriate collection based on destination page type 141 + if (targetPage === 'blento.self') { 142 + await putRecord({ 143 + collection: 'site.standard.publication', 144 + rkey: targetPage, 145 + record: publicationCopy 146 + }); 147 + } else { 148 + await putRecord({ 149 + collection: 'app.blento.page', 150 + rkey: targetPage, 151 + record: publicationCopy 152 + }); 153 + } 154 + } 155 + 156 + // Refresh the logged-in user's cache 157 + await fetch(`/${userHandle}/api/refresh`); 158 + 159 + success = true; 160 + 161 + // Redirect to the logged-in user's destination page edit 162 + const destPath = destinationPage.trim() === '' ? '' : `/${destinationPage.trim()}`; 163 + setTimeout(() => { 164 + goto(`/${userHandle}${destPath}/edit`); 165 + }, 1000); 166 + } catch (e) { 167 + error = e instanceof Error ? e.message : 'Failed to copy page'; 168 + } finally { 169 + copying = false; 170 + } 171 + } 172 + </script> 173 + 174 + <div class="bg-base-50 dark:bg-base-900 flex min-h-screen items-center justify-center p-4"> 175 + <div class="bg-base-100 dark:bg-base-800 w-full max-w-md rounded-2xl p-6 shadow-lg"> 176 + {#if user.isLoggedIn} 177 + <h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">Copy Page</h1> 178 + 179 + <div class="mb-4"> 180 + <div class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 181 + Source Page 182 + </div> 183 + <div 184 + class="bg-base-200 dark:bg-base-700 text-base-900 dark:text-base-100 rounded-lg px-3 py-2" 185 + > 186 + {sourceHandle}/{sourcePage || 'main'} 187 + </div> 188 + <p class="text-base-500 mt-1 text-sm">{sourceCards.length} cards</p> 189 + </div> 190 + 191 + <div class="mb-6"> 192 + <label 193 + for="destination" 194 + class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium" 195 + > 196 + Destination Page (on your profile: {user.profile?.handle}) 197 + </label> 198 + <input 199 + id="destination" 200 + type="text" 201 + bind:value={destinationPage} 202 + placeholder="Leave empty for main page" 203 + class="bg-base-50 dark:bg-base-700 border-base-300 dark:border-base-600 text-base-900 dark:text-base-100 focus:ring-accent-500 w-full rounded-lg border px-3 py-2 focus:ring-2 focus:outline-none" 204 + /> 205 + </div> 206 + 207 + {#if error} 208 + <div 209 + class="mb-4 rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900/30 dark:text-red-400" 210 + > 211 + {error} 212 + </div> 213 + {/if} 214 + 215 + {#if success} 216 + <div 217 + class="mb-4 rounded-lg bg-green-100 p-3 text-green-700 dark:bg-green-900/30 dark:text-green-400" 218 + > 219 + Page copied successfully! Redirecting... 220 + </div> 221 + {/if} 222 + 223 + <div class="flex gap-3"> 224 + <a 225 + href="/{data.handle}/{sourcePage === 'main' ? '' : sourcePage}" 226 + class="bg-base-200 hover:bg-base-300 dark:bg-base-700 dark:hover:bg-base-600 text-base-700 dark:text-base-300 flex-1 rounded-lg px-4 py-2 text-center font-medium transition-colors" 227 + > 228 + Cancel 229 + </a> 230 + <button 231 + onclick={copyPage} 232 + disabled={copying || success} 233 + class="bg-accent-500 hover:bg-accent-600 flex-1 rounded-lg px-4 py-2 font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50" 234 + > 235 + {#if copying} 236 + Copying... 237 + {:else} 238 + Copy {sourceCards.length} cards 239 + {/if} 240 + </button> 241 + </div> 242 + {:else} 243 + <h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold"> 244 + You must be signed in to copy a page! 245 + </h1> 246 + 247 + <div class="flex w-full justify-center"> 248 + <Button size="lg" onclick={() => loginModalState.show()}>Login</Button> 249 + </div> 250 + {/if} 251 + </div> 252 + </div>
+6
src/routes/[handle=handle]/(pages)/p/[[page]]/edit/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import EditableWebsite from '$lib/website/EditableWebsite.svelte'; 3 + let { data } = $props(); 4 + </script> 5 + 6 + <EditableWebsite {data} />
-13
src/routes/[handle=handle]/[[page]]/+layout.server.ts
··· 1 - import { loadData } from '$lib/website/load'; 2 - import { env } from '$env/dynamic/private'; 3 - import { error } from '@sveltejs/kit'; 4 - import type { UserCache } from '$lib/types'; 5 - import type { Handle } from '@atcute/lexicons'; 6 - 7 - export async function load({ params, platform }) { 8 - if (env.PUBLIC_IS_SELFHOSTED) error(404); 9 - 10 - const cache = platform?.env?.USER_DATA_CACHE as unknown; 11 - 12 - return await loadData(params.handle as Handle, cache as UserCache, false, params.page); 13 - }
···
-13
src/routes/[handle=handle]/[[page]]/+page.svelte
··· 1 - <script lang="ts"> 2 - import { refreshData } from '$lib/helper.js'; 3 - import Website from '$lib/website/Website.svelte'; 4 - import { onMount } from 'svelte'; 5 - 6 - let { data } = $props(); 7 - 8 - onMount(() => { 9 - refreshData(data); 10 - }); 11 - </script> 12 - 13 - <Website {data} />
···
-6
src/routes/[handle=handle]/[[page]]/edit/+page.svelte
··· 1 - <script lang="ts"> 2 - import EditableWebsite from '$lib/website/EditableWebsite.svelte'; 3 - let { data } = $props(); 4 - </script> 5 - 6 - <EditableWebsite {data} />
···
+89
src/routes/api/lastfm/+server.ts
···
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { env } from '$env/dynamic/private'; 4 + 5 + const LASTFM_API_URL = 'https://ws.audioscrobbler.com/2.0/'; 6 + 7 + const ALLOWED_METHODS = [ 8 + 'user.getRecentTracks', 9 + 'user.getTopTracks', 10 + 'user.getTopAlbums', 11 + 'user.getInfo' 12 + ]; 13 + 14 + const CACHE_TTL: Record<string, number> = { 15 + 'user.getRecentTracks': 15 * 60 * 1000, 16 + 'user.getTopTracks': 60 * 60 * 1000, 17 + 'user.getTopAlbums': 60 * 60 * 1000, 18 + 'user.getInfo': 12 * 60 * 60 * 1000 19 + }; 20 + 21 + export const GET: RequestHandler = async ({ url, platform }) => { 22 + const method = url.searchParams.get('method'); 23 + const user = url.searchParams.get('user'); 24 + const period = url.searchParams.get('period') || '7day'; 25 + const limit = url.searchParams.get('limit') || '50'; 26 + 27 + if (!method || !user) { 28 + return json({ error: 'Missing method or user parameter' }, { status: 400 }); 29 + } 30 + 31 + if (!ALLOWED_METHODS.includes(method)) { 32 + return json({ error: 'Method not allowed' }, { status: 400 }); 33 + } 34 + 35 + const cacheKey = `#lastfm:${method}:${user}:${period}:${limit}`; 36 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey); 37 + 38 + if (cachedData) { 39 + const parsed = JSON.parse(cachedData); 40 + const ttl = CACHE_TTL[method] || 60 * 60 * 1000; 41 + 42 + if (Date.now() - (parsed._cachedAt || 0) < ttl) { 43 + return json(parsed); 44 + } 45 + } 46 + 47 + const apiKey = env?.LASTFM_API_KEY; 48 + if (!apiKey) { 49 + return json({ error: 'Last.fm API key not configured' }, { status: 500 }); 50 + } 51 + 52 + try { 53 + const params = new URLSearchParams({ 54 + method, 55 + user, 56 + api_key: apiKey, 57 + format: 'json', 58 + limit 59 + }); 60 + 61 + if (method === 'user.getTopTracks' || method === 'user.getTopAlbums') { 62 + params.set('period', period); 63 + } 64 + 65 + const response = await fetch(`${LASTFM_API_URL}?${params}`); 66 + 67 + if (!response.ok) { 68 + return json( 69 + { error: 'Failed to fetch Last.fm data: ' + response.statusText }, 70 + { status: response.status } 71 + ); 72 + } 73 + 74 + const data = await response.json(); 75 + 76 + if (data.error) { 77 + return json({ error: data.message || 'Last.fm API error' }, { status: 400 }); 78 + } 79 + 80 + data._cachedAt = Date.now(); 81 + 82 + await platform?.env?.USER_DATA_CACHE?.put(cacheKey, JSON.stringify(data)); 83 + 84 + return json(data); 85 + } catch (error) { 86 + console.error('Error fetching Last.fm data:', error); 87 + return json({ error: 'Failed to fetch Last.fm data' }, { status: 500 }); 88 + } 89 + };
+44
src/routes/api/npmx-leaderboard/+server.ts
···
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + 4 + const LEADERBOARD_API_URL = 5 + 'https://npmx-likes-leaderboard-api-production.up.railway.app/api/leaderboard/likes?limit=20'; 6 + 7 + export const GET: RequestHandler = async ({ platform }) => { 8 + const cacheKey = '#npmx-leaderboard:likes'; 9 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey); 10 + 11 + if (cachedData) { 12 + const parsedCache = JSON.parse(cachedData); 13 + 14 + const TWELVE_HOURS = 12 * 60 * 60 * 1000; 15 + const now = Date.now(); 16 + 17 + if (now - (parsedCache.updatedAt || 0) < TWELVE_HOURS) { 18 + return json(parsedCache.data); 19 + } 20 + } 21 + 22 + try { 23 + const response = await fetch(LEADERBOARD_API_URL); 24 + 25 + if (!response.ok) { 26 + return json( 27 + { error: 'Failed to fetch npmx leaderboard ' + response.statusText }, 28 + { status: response.status } 29 + ); 30 + } 31 + 32 + const data = await response.json(); 33 + 34 + await platform?.env?.USER_DATA_CACHE?.put( 35 + cacheKey, 36 + JSON.stringify({ data, updatedAt: Date.now() }) 37 + ); 38 + 39 + return json(data); 40 + } catch (error) { 41 + console.error('Error fetching npmx leaderboard:', error); 42 + return json({ error: 'Failed to fetch npmx leaderboard' }, { status: 500 }); 43 + } 44 + };
+25
src/routes/p/[[page]]/+layout.server.ts
···
··· 1 + import { loadData } from '$lib/website/load'; 2 + import { env } from '$env/dynamic/public'; 3 + import type { UserCache } from '$lib/types'; 4 + import type { Did, Handle } from '@atcute/lexicons'; 5 + 6 + export async function load({ params, platform, request }) { 7 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 8 + 9 + const handle = env.PUBLIC_HANDLE; 10 + 11 + const kv = platform?.env?.CUSTOM_DOMAINS; 12 + 13 + const customDomain = request.headers.get('X-Custom-Domain')?.toLocaleLowerCase(); 14 + 15 + if (kv && customDomain) { 16 + try { 17 + const did = await kv.get(customDomain); 18 + return await loadData(did as Did, cache as UserCache, false, params.page); 19 + } catch { 20 + console.error('failed'); 21 + } 22 + } 23 + 24 + return await loadData(handle as Handle, cache as UserCache, false, params.page); 25 + }
+13
src/routes/p/[[page]]/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import { refreshData } from '$lib/helper.js'; 3 + import Website from '$lib/website/Website.svelte'; 4 + import { onMount } from 'svelte'; 5 + 6 + let { data } = $props(); 7 + 8 + onMount(() => { 9 + refreshData(data); 10 + }); 11 + </script> 12 + 13 + <Website {data} />
+252
src/routes/p/[[page]]/copy/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import { 3 + putRecord, 4 + deleteRecord, 5 + listRecords, 6 + uploadBlob, 7 + getCDNImageBlobUrl 8 + } from '$lib/atproto/methods'; 9 + import { user } from '$lib/atproto/auth.svelte'; 10 + import { goto } from '$app/navigation'; 11 + import * as TID from '@atcute/tid'; 12 + import { Button } from '@foxui/core'; 13 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 14 + 15 + let { data } = $props(); 16 + 17 + let destinationPage = $state(''); 18 + let copying = $state(false); 19 + let error = $state(''); 20 + let success = $state(false); 21 + 22 + const sourceHandle = $derived(data.handle); 23 + 24 + const sourcePage = $derived( 25 + data.page === 'blento.self' ? 'main' : data.page.replace('blento.', '') 26 + ); 27 + const sourceCards = $derived(data.cards); 28 + 29 + // Re-upload blobs from source repo to current user's repo 30 + async function reuploadBlobs(obj: any, sourceDid: string): Promise<void> { 31 + if (!obj || typeof obj !== 'object') return; 32 + 33 + for (const key of Object.keys(obj)) { 34 + const value = obj[key]; 35 + 36 + if (value && typeof value === 'object') { 37 + // Check if this is a blob reference 38 + if (value.$type === 'blob' && value.ref?.$link) { 39 + try { 40 + // Get the blob URL from source repo 41 + const blobUrl = getCDNImageBlobUrl({ did: sourceDid, blob: value }); 42 + if (!blobUrl) continue; 43 + 44 + // Fetch the blob via proxy to avoid CORS 45 + const response = await fetch(`/api/image-proxy?url=${encodeURIComponent(blobUrl)}`); 46 + if (!response.ok) { 47 + console.error('Failed to fetch blob:', blobUrl); 48 + continue; 49 + } 50 + 51 + // Upload to current user's repo 52 + const blob = await response.blob(); 53 + const newBlobRef = await uploadBlob({ blob }); 54 + 55 + if (newBlobRef) { 56 + // Replace with new blob reference 57 + obj[key] = newBlobRef; 58 + } 59 + } catch (err) { 60 + console.error('Failed to re-upload blob:', err); 61 + } 62 + } else { 63 + // Recursively check nested objects 64 + await reuploadBlobs(value, sourceDid); 65 + } 66 + } 67 + } 68 + } 69 + 70 + async function copyPage() { 71 + if (!user.isLoggedIn || !user.did) { 72 + error = 'You must be logged in to copy pages'; 73 + return; 74 + } 75 + 76 + copying = true; 77 + error = ''; 78 + 79 + try { 80 + const targetPage = 81 + destinationPage.trim() === '' ? 'blento.self' : `blento.${destinationPage.trim()}`; 82 + 83 + // Fetch existing cards from destination page and delete them 84 + const existingCards = await listRecords({ 85 + did: user.did, 86 + collection: 'app.blento.card' 87 + }); 88 + 89 + const cardsToDelete = existingCards.filter( 90 + (card: { value: { page?: string } }) => card.value.page === targetPage 91 + ); 92 + 93 + // Delete existing cards from destination page 94 + const deletePromises = cardsToDelete.map((card: { uri: string }) => { 95 + const rkey = card.uri.split('/').pop()!; 96 + return deleteRecord({ 97 + collection: 'app.blento.card', 98 + rkey 99 + }); 100 + }); 101 + 102 + await Promise.all(deletePromises); 103 + 104 + // Copy each card with a new ID to the destination page 105 + // Re-upload blobs from source repo to current user's repo 106 + for (const card of sourceCards) { 107 + const newCard = { 108 + ...structuredClone(card), 109 + id: TID.now(), 110 + page: targetPage, 111 + updatedAt: new Date().toISOString(), 112 + version: 2 113 + }; 114 + 115 + // Re-upload any blobs in cardData 116 + await reuploadBlobs(newCard.cardData, data.did); 117 + 118 + await putRecord({ 119 + collection: 'app.blento.card', 120 + rkey: newCard.id, 121 + record: newCard 122 + }); 123 + } 124 + 125 + const userHandle = user.profile?.handle ?? data.handle; 126 + 127 + // Copy publication data if it exists 128 + if (data.publication) { 129 + const publicationCopy = structuredClone(data.publication) as Record<string, unknown>; 130 + 131 + // Re-upload any blobs in publication (e.g., icon) 132 + await reuploadBlobs(publicationCopy, data.did); 133 + 134 + // Update the URL to point to the user's page 135 + publicationCopy.url = `https://blento.app/${userHandle}`; 136 + if (targetPage !== 'blento.self') { 137 + publicationCopy.url += '/' + targetPage.replace('blento.', ''); 138 + } 139 + 140 + // Save to appropriate collection based on destination page type 141 + if (targetPage === 'blento.self') { 142 + await putRecord({ 143 + collection: 'site.standard.publication', 144 + rkey: targetPage, 145 + record: publicationCopy 146 + }); 147 + } else { 148 + await putRecord({ 149 + collection: 'app.blento.page', 150 + rkey: targetPage, 151 + record: publicationCopy 152 + }); 153 + } 154 + } 155 + 156 + // Refresh the logged-in user's cache 157 + await fetch(`/${userHandle}/api/refresh`); 158 + 159 + success = true; 160 + 161 + // Redirect to the logged-in user's destination page edit 162 + const destPath = destinationPage.trim() === '' ? '' : `/${destinationPage.trim()}`; 163 + setTimeout(() => { 164 + goto(`/${userHandle}${destPath}/edit`); 165 + }, 1000); 166 + } catch (e) { 167 + error = e instanceof Error ? e.message : 'Failed to copy page'; 168 + } finally { 169 + copying = false; 170 + } 171 + } 172 + </script> 173 + 174 + <div class="bg-base-50 dark:bg-base-900 flex min-h-screen items-center justify-center p-4"> 175 + <div class="bg-base-100 dark:bg-base-800 w-full max-w-md rounded-2xl p-6 shadow-lg"> 176 + {#if user.isLoggedIn} 177 + <h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">Copy Page</h1> 178 + 179 + <div class="mb-4"> 180 + <div class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 181 + Source Page 182 + </div> 183 + <div 184 + class="bg-base-200 dark:bg-base-700 text-base-900 dark:text-base-100 rounded-lg px-3 py-2" 185 + > 186 + {sourceHandle}/{sourcePage || 'main'} 187 + </div> 188 + <p class="text-base-500 mt-1 text-sm">{sourceCards.length} cards</p> 189 + </div> 190 + 191 + <div class="mb-6"> 192 + <label 193 + for="destination" 194 + class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium" 195 + > 196 + Destination Page (on your profile: {user.profile?.handle}) 197 + </label> 198 + <input 199 + id="destination" 200 + type="text" 201 + bind:value={destinationPage} 202 + placeholder="Leave empty for main page" 203 + class="bg-base-50 dark:bg-base-700 border-base-300 dark:border-base-600 text-base-900 dark:text-base-100 focus:ring-accent-500 w-full rounded-lg border px-3 py-2 focus:ring-2 focus:outline-none" 204 + /> 205 + </div> 206 + 207 + {#if error} 208 + <div 209 + class="mb-4 rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900/30 dark:text-red-400" 210 + > 211 + {error} 212 + </div> 213 + {/if} 214 + 215 + {#if success} 216 + <div 217 + class="mb-4 rounded-lg bg-green-100 p-3 text-green-700 dark:bg-green-900/30 dark:text-green-400" 218 + > 219 + Page copied successfully! Redirecting... 220 + </div> 221 + {/if} 222 + 223 + <div class="flex gap-3"> 224 + <a 225 + href="/{data.handle}/{sourcePage === 'main' ? '' : sourcePage}" 226 + class="bg-base-200 hover:bg-base-300 dark:bg-base-700 dark:hover:bg-base-600 text-base-700 dark:text-base-300 flex-1 rounded-lg px-4 py-2 text-center font-medium transition-colors" 227 + > 228 + Cancel 229 + </a> 230 + <button 231 + onclick={copyPage} 232 + disabled={copying || success} 233 + class="bg-accent-500 hover:bg-accent-600 flex-1 rounded-lg px-4 py-2 font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50" 234 + > 235 + {#if copying} 236 + Copying... 237 + {:else} 238 + Copy {sourceCards.length} cards 239 + {/if} 240 + </button> 241 + </div> 242 + {:else} 243 + <h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold"> 244 + You must be signed in to copy a page! 245 + </h1> 246 + 247 + <div class="flex w-full justify-center"> 248 + <Button size="lg" onclick={() => loginModalState.show()}>Login</Button> 249 + </div> 250 + {/if} 251 + </div> 252 + </div>
+6
src/routes/p/[[page]]/edit/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import EditableWebsite from '$lib/website/EditableWebsite.svelte'; 3 + let { data } = $props(); 4 + </script> 5 + 6 + <EditableWebsite {data} />