your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+2254 -657
+1 -2
package.json
··· 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev", 8 - "build": "vite build", 8 + "build": "NODE_OPTIONS='--max-old-space-size=4096' vite build", 9 9 "preview": "pnpm run build && wrangler dev", 10 10 "prepare": "svelte-kit sync || echo ''", 11 11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", ··· 79 79 "mapbox-gl": "^3.18.1", 80 80 "marked": "^17.0.1", 81 81 "perfect-freehand": "^1.2.2", 82 - "pixi.js": "^8.16.0", 83 82 "plyr": "^3.8.4", 84 83 "qr-code-styling": "^1.8.6", 85 84 "react-grid-layout": "^2.2.2",
+1 -69
pnpm-lock.yaml
··· 128 128 perfect-freehand: 129 129 specifier: ^1.2.2 130 130 version: 1.2.2 131 - pixi.js: 132 - specifier: ^8.16.0 133 - version: 8.16.0 134 131 plyr: 135 132 specifier: ^3.8.4 136 133 version: 3.8.4 ··· 981 978 peerDependencies: 982 979 svelte: ^4 || ^5 983 980 984 - '@pixi/colord@2.9.6': 985 - resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==, tarball: https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz} 986 - 987 981 '@polka/url@1.0.0-next.29': 988 982 resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} 989 983 ··· 1513 1507 1514 1508 '@types/cookie@0.6.0': 1515 1509 resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 1516 - 1517 - '@types/earcut@3.0.0': 1518 - resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==, tarball: https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz} 1519 1510 1520 1511 '@types/estree@1.0.8': 1521 1512 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} ··· 1633 1624 '@webgpu/types@0.1.69': 1634 1625 resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} 1635 1626 1636 - '@xmldom/xmldom@0.8.11': 1637 - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==, tarball: https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz} 1638 - engines: {node: '>=10.0.0'} 1639 - 1640 1627 acorn-jsx@5.3.2: 1641 1628 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} 1642 1629 peerDependencies: ··· 1866 1853 resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} 1867 1854 1868 1855 earcut@3.0.2: 1869 - resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==, tarball: https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz} 1856 + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} 1870 1857 1871 1858 emoji-picker-element@1.28.1: 1872 1859 resolution: {integrity: sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==} ··· 1976 1963 esutils@2.0.3: 1977 1964 resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} 1978 1965 engines: {node: '>=0.10.0'} 1979 - 1980 - eventemitter3@5.0.4: 1981 - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz} 1982 1966 1983 1967 exsolve@1.0.8: 1984 1968 resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} ··· 2033 2017 geojson-vt@4.0.2: 2034 2018 resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} 2035 2019 2036 - gifuct-js@2.1.2: 2037 - resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==, tarball: https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz} 2038 - 2039 2020 gl-matrix@3.4.4: 2040 2021 resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} 2041 2022 ··· 2121 2102 isexe@2.0.0: 2122 2103 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 2123 2104 2124 - ismobilejs@1.1.1: 2125 - resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==, tarball: https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz} 2126 - 2127 2105 iso-datestring-validator@2.2.2: 2128 2106 resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 2129 2107 2130 2108 jiti@2.6.1: 2131 2109 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 2132 2110 hasBin: true 2133 - 2134 - js-binary-schema-parser@2.0.3: 2135 - resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==, tarball: https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz} 2136 2111 2137 2112 js-tokens@4.0.0: 2138 2113 resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} ··· 2410 2385 parse-css-color@0.2.1: 2411 2386 resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} 2412 2387 2413 - parse-svg-path@0.1.2: 2414 - resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==, tarball: https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz} 2415 - 2416 2388 parse5-htmlparser2-tree-adapter@7.1.0: 2417 2389 resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} 2418 2390 ··· 2449 2421 picomatch@4.0.3: 2450 2422 resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 2451 2423 engines: {node: '>=12'} 2452 - 2453 - pixi.js@8.16.0: 2454 - resolution: {integrity: sha512-gu2xw3sZGAn3cWBtk0HqTQT+v19YAfiaYXwUGgWoJl5NKz4cEZJUgWrwkmdfDszGyYBAGqOvJNbd2M9+vzLLMg==, tarball: https://registry.npmjs.org/pixi.js/-/pixi.js-8.16.0.tgz} 2455 2424 2456 2425 pkg-types@1.3.1: 2457 2426 resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} ··· 2928 2897 tiny-inflate@1.0.3: 2929 2898 resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} 2930 2899 2931 - tiny-lru@11.4.7: 2932 - resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==, tarball: https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz} 2933 - engines: {node: '>=12'} 2934 - 2935 2900 tinyglobby@0.2.15: 2936 2901 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 2937 2902 engines: {node: '>=12.0.0'} ··· 3785 3750 number-flow: 0.5.9 3786 3751 svelte: 5.48.0 3787 3752 3788 - '@pixi/colord@2.9.6': {} 3789 - 3790 3753 '@polka/url@1.0.0-next.29': {} 3791 3754 3792 3755 '@poppinss/colors@4.1.6': ··· 4261 4224 4262 4225 '@types/cookie@0.6.0': {} 4263 4226 4264 - '@types/earcut@3.0.0': {} 4265 - 4266 4227 '@types/estree@1.0.8': {} 4267 4228 4268 4229 '@types/geojson-vt@3.2.5': ··· 4411 4372 '@use-gesture/core': 10.3.1 4412 4373 4413 4374 '@webgpu/types@0.1.69': {} 4414 - 4415 - '@xmldom/xmldom@0.8.11': {} 4416 4375 4417 4376 acorn-jsx@5.3.2(acorn@8.15.0): 4418 4377 dependencies: ··· 4824 4783 4825 4784 esutils@2.0.3: {} 4826 4785 4827 - eventemitter3@5.0.4: {} 4828 - 4829 4786 exsolve@1.0.8: {} 4830 4787 4831 4788 fast-deep-equal@3.1.3: {} ··· 4864 4821 optional: true 4865 4822 4866 4823 geojson-vt@4.0.2: {} 4867 - 4868 - gifuct-js@2.1.2: 4869 - dependencies: 4870 - js-binary-schema-parser: 2.0.3 4871 4824 4872 4825 gl-matrix@3.4.4: {} 4873 4826 ··· 4938 4891 4939 4892 isexe@2.0.0: {} 4940 4893 4941 - ismobilejs@1.1.1: {} 4942 - 4943 4894 iso-datestring-validator@2.2.2: {} 4944 4895 4945 4896 jiti@2.6.1: {} 4946 - 4947 - js-binary-schema-parser@2.0.3: {} 4948 4897 4949 4898 js-tokens@4.0.0: {} 4950 4899 ··· 5216 5165 color-name: 1.1.4 5217 5166 hex-rgb: 4.3.0 5218 5167 5219 - parse-svg-path@0.1.2: {} 5220 - 5221 5168 parse5-htmlparser2-tree-adapter@7.1.0: 5222 5169 dependencies: 5223 5170 domhandler: 5.0.3 ··· 5248 5195 picocolors@1.1.1: {} 5249 5196 5250 5197 picomatch@4.0.3: {} 5251 - 5252 - pixi.js@8.16.0: 5253 - dependencies: 5254 - '@pixi/colord': 2.9.6 5255 - '@types/earcut': 3.0.0 5256 - '@webgpu/types': 0.1.69 5257 - '@xmldom/xmldom': 0.8.11 5258 - earcut: 3.0.2 5259 - eventemitter3: 5.0.4 5260 - gifuct-js: 2.1.2 5261 - ismobilejs: 1.1.1 5262 - parse-svg-path: 0.1.2 5263 - tiny-lru: 11.4.7 5264 5198 5265 5199 pkg-types@1.3.1: 5266 5200 dependencies: ··· 5775 5709 three@0.176.0: {} 5776 5710 5777 5711 tiny-inflate@1.0.3: {} 5778 - 5779 - tiny-lru@11.4.7: {} 5780 5712 5781 5713 tinyglobby@0.2.15: 5782 5714 dependencies:
+13 -3
src/lib/cards/index.ts
··· 29 29 import { EventCardDefinition } from './social/EventCard'; 30 30 import { VCardCardDefinition } from './social/VCardCard'; 31 31 import { DrawCardDefinition } from './visual/DrawCard'; 32 - import { RecordVisualizerCardDefinition } from './visual/RecordVisualizerCard'; 33 32 import { TimerCardDefinition } from './utilities/TimerCard'; 34 33 import { ClockCardDefinition } from './utilities/ClockCard'; 35 34 import { CountdownCardDefinition } from './utilities/CountdownCard'; ··· 41 40 import { GitHubContributorsCardDefinition } from './social/GitHubContributorsCard'; 42 41 import { ProductHuntCardDefinition } from './social/ProductHuntCard'; 43 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'; 44 49 // import { Model3DCardDefinition } from './visual/Model3DCard'; 45 50 46 51 export const AllCardDefinitions = [ ··· 76 81 EventCardDefinition, 77 82 VCardCardDefinition, 78 83 DrawCardDefinition, 79 - RecordVisualizerCardDefinition, 80 84 TimerCardDefinition, 81 85 ClockCardDefinition, 82 86 CountdownCardDefinition, ··· 86 90 FriendsCardDefinition, 87 91 GitHubContributorsCardDefinition, 88 92 ProductHuntCardDefinition, 89 - KickstarterCardDefinition 93 + KickstarterCardDefinition, 94 + NpmxLikesCardDefinition, 95 + NpmxLikesLeaderboardCardDefinition, 96 + LastFMRecentTracksCardDefinition, 97 + LastFMTopTracksCardDefinition, 98 + LastFMTopAlbumsCardDefinition, 99 + LastFMProfileCardDefinition 90 100 ] as const; 91 101 92 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 7 8 8 const platform = $derived(item.cardData.platform as string); 9 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}`); 10 18 </script> 11 19 12 20 <div 13 21 class="flex h-full w-full items-center justify-center p-10" 14 - style={`background-color: #${item.cardData.color}`} 22 + style={useBrandBackground ? `background-color: ${brandColor}` : ''} 15 23 > 16 24 <div 17 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 36 return item; 37 37 }, 38 38 name: 'Social Icon', 39 - allowSetColor: false, 40 - defaultColor: 'transparent', 39 + allowSetColor: true, 40 + defaultColor: 'base', 41 41 minW: 2, 42 42 minH: 2, 43 43 onUrlHandler: (url, item) => { ··· 167 167 168 168 tangled: /(?:tangled\.org)/i, 169 169 170 - mail: /(?:mailto:)/i 170 + mail: /(?:mailto:)/i, 171 + 172 + npmx: /(?:npmx\.dev)/i 171 173 }; 172 174 173 175 export const platformsData: Record<string, SimpleIcon> = { ··· 277 279 </g> 278 280 <defs> 279 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"> 280 301 <rect width="24" height="24" fill="white"/> 281 302 </clipPath> 282 303 </defs>
+14 -135
src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import type { ContentComponentProps } from '../../types'; 4 - import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context'; 4 + import { getAdditionalUserData, getCanEdit } from '$lib/website/context'; 5 5 import type { GitHubContributor, GitHubContributorsLoadedData } from '.'; 6 + import ImageGrid from '$lib/components/ImageGrid.svelte'; 6 7 7 8 let { item }: ContentComponentProps = $props(); 8 9 9 - const isMobile = getIsMobile(); 10 10 const canEdit = getCanEdit(); 11 11 const additionalData = getAdditionalUserData(); 12 12 ··· 53 53 } 54 54 } 55 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' 56 + let gridItems = $derived( 57 + namedContributors.map((c) => ({ 58 + imageUrl: c.avatarUrl, 59 + link: `https://github.com/${c.username}`, 60 + label: c.username 61 + })) 142 62 ); 143 - 144 - let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); 145 63 </script> 146 64 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()} 65 + {#if !owner || !repo} 66 + {#if canEdit()} 67 + <div class="flex h-full w-full items-center justify-center"> 154 68 <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 155 69 Enter a repository 156 70 </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 71 </div> 195 72 {/if} 196 - </div> 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 46 47 47 let result = [...(await Promise.all(profiles)), ...existingUsersArray]; 48 48 49 - result = result.filter((v) => v && v.handle !== 'handle.invalid'); 49 + result = result.filter( 50 + (v) => v && v.handle !== 'handle.invalid' && !v.handle.endsWith('.pds.rip') 51 + ); 50 52 51 53 if (cache) { 52 54 await cache?.put('updatedBlentos', JSON.stringify(result)); 53 55 } 54 - return JSON.parse(JSON.stringify(result)); 56 + return JSON.parse(JSON.stringify(result.slice(0, 20))); 55 57 } catch (error) { 56 58 console.error('error fetching updated blentos', error); 57 59 return [];
+86 -10
src/lib/cards/visual/FluidTextCard/FluidTextCard.svelte
··· 1 1 <script lang="ts"> 2 - import { colorToHue, getCSSVar, getHexOfCardColor } from '../../helper'; 2 + import { colorToHue, getHexCSSVar, getHexOfCardColor } from '../../helper'; 3 3 import type { ContentComponentProps } from '../../types'; 4 4 import { onMount, onDestroy, tick } from 'svelte'; 5 5 let { item }: ContentComponentProps = $props(); ··· 7 7 let container: HTMLDivElement; 8 8 let fluidCanvas: HTMLCanvasElement; 9 9 let maskCanvas: HTMLCanvasElement; 10 + let shadowCanvas: HTMLCanvasElement; 10 11 let animationId: number; 11 12 let splatIntervalId: ReturnType<typeof setInterval>; 12 13 let maskDrawRaf = 0; 13 14 let maskReady = false; 14 15 let isInitialized = $state(false); 15 16 let resizeObserver: ResizeObserver | null = null; 17 + let themeObserver: MutationObserver | null = null; 16 18 17 19 // Pure hash function for shader keyword caching 18 20 function hashCode(s: string) { ··· 122 124 if (width === 0 || height === 0) return; 123 125 124 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 + } 125 162 126 163 maskCanvas.width = width * dpr; 127 164 maskCanvas.height = height * dpr; ··· 132 169 ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); 133 170 ctx.scale(dpr, dpr); 134 171 135 - //const color = getCSSVar('--color-base-900'); 136 - 137 - ctx.fillStyle = 'black'; 172 + const bgColor = 173 + item.color === 'transparent' 174 + ? getHexCSSVar(isDark ? '--color-base-900' : '--color-base-50') 175 + : 'black'; 176 + ctx.fillStyle = bgColor; 138 177 ctx.fillRect(0, 0, width, height); 139 178 140 179 // Font size as percentage of container width 141 180 const textFontSize = Math.round(width * fontSize); 142 181 ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; 143 182 144 - ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; 145 - ctx.lineWidth = 2; 183 + ctx.lineWidth = 3; 146 184 ctx.textAlign = 'center'; 147 185 148 186 const metrics = ctx.measureText(text); ··· 157 195 ctx.textBaseline = 'middle'; 158 196 } 159 197 160 - ctx.strokeText(text, width / 2, textY); 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 + 161 215 ctx.globalCompositeOperation = 'destination-out'; 162 216 ctx.fillText(text, width / 2, textY); 163 217 ctx.globalCompositeOperation = 'source-over'; ··· 214 268 if (isInitialized) scheduleMaskDraw(); 215 269 }); 216 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 + } 217 282 }); 218 283 219 284 onDestroy(() => { ··· 221 286 if (splatIntervalId) clearInterval(splatIntervalId); 222 287 if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf); 223 288 if (resizeObserver) resizeObserver.disconnect(); 289 + if (themeObserver) themeObserver.disconnect(); 224 290 }); 225 291 226 292 function initFluidSimulation(startHue: number, endHue: number) { ··· 246 312 COLOR_UPDATE_SPEED: 10, 247 313 PAUSED: false, 248 314 BACK_COLOR: { r: 0, g: 0, b: 0 }, 249 - TRANSPARENT: false, 315 + TRANSPARENT: item.color === 'transparent', 250 316 BLOOM: false, 251 317 BLOOM_ITERATIONS: 8, 252 318 BLOOM_RESOLUTION: 256, ··· 1701 1767 } 1702 1768 </script> 1703 1769 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> 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> 1706 1782 <canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas> 1707 1783 </div>
-277
src/lib/cards/visual/RecordVisualizerCard/RecordVisualizerCard.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { browser } from '$app/environment'; 4 - import * as PIXI from 'pixi.js'; 5 - import type { ContentComponentProps } from '../../types'; 6 - 7 - let { item }: ContentComponentProps = $props(); 8 - 9 - type RecordVisualizerCardData = { 10 - emoji?: string; 11 - collection?: string; 12 - direction?: 'down' | 'up'; 13 - speed?: number; 14 - }; 15 - 16 - let cardData = $derived(item.cardData as RecordVisualizerCardData); 17 - 18 - let emoji = $derived(cardData.emoji || '๐Ÿ’™'); 19 - let collection = $derived(cardData.collection || 'app.bsky.feed.like'); 20 - let direction = $derived(cardData.direction || 'down'); 21 - let speed = $derived(Math.max(0.5, Math.min(2, cardData.speed || 1))); 22 - 23 - let containerEl: HTMLDivElement | null = null; 24 - let canvasEl: HTMLCanvasElement | null = null; 25 - let app: PIXI.Application | null = null; 26 - let ws: WebSocket | null = null; 27 - let prevCollection = $state<string | null>(null); 28 - let prevEmoji = $state<string | null>(null); 29 - let reconnectTimeout: ReturnType<typeof setTimeout> | null = null; 30 - 31 - const RECONNECT_DEBOUNCE = 1000; 32 - const MAX_PARTICLES = 10000; 33 - 34 - // Particle system 35 - interface ParticleSprite extends PIXI.Sprite { 36 - speedX: number; 37 - speedY: number; 38 - age: number; 39 - maxAge: number; 40 - initialSize: number; 41 - } 42 - 43 - let particles: ParticleSprite[] = []; 44 - let particlePool: ParticleSprite[] = []; 45 - let particleContainer: PIXI.Container | null = null; 46 - let emojiTexture: PIXI.Texture | null = null; 47 - 48 - function createEmojiTexture(emojiChar: string): PIXI.Texture { 49 - const canvas = document.createElement('canvas'); 50 - const size = 64; 51 - canvas.width = size; 52 - canvas.height = size; 53 - const ctx = canvas.getContext('2d')!; 54 - ctx.font = `${size * 0.8}px serif`; 55 - ctx.textAlign = 'center'; 56 - ctx.textBaseline = 'middle'; 57 - ctx.fillText(emojiChar, size / 2, size / 2); 58 - return PIXI.Texture.from(canvas); 59 - } 60 - 61 - function spawnParticle() { 62 - if (!app || !particleContainer || !emojiTexture) return; 63 - 64 - let particle: ParticleSprite; 65 - if (particlePool.length > 0) { 66 - particle = particlePool.pop()!; 67 - particle.texture = emojiTexture; 68 - } else if (particles.length < MAX_PARTICLES) { 69 - particle = new PIXI.Sprite(emojiTexture) as ParticleSprite; 70 - particle.anchor.set(0.5, 0.5); 71 - particleContainer.addChild(particle); 72 - } else { 73 - return; 74 - } 75 - 76 - const w = app.screen.width; 77 - const h = app.screen.height; 78 - 79 - // Parallax: random scale from 0.3 (far/small) to 1.0 (near/large) 80 - const scale = Math.random() * 0.7 + 0.3; 81 - const baseSize = (Math.random() * 30 + 15) * scale; 82 - 83 - particle.visible = true; 84 - particle.x = Math.random() * w; 85 - particle.y = direction === 'down' ? -baseSize : h + baseSize; 86 - particle.width = particle.height = baseSize; 87 - particle.alpha = 0.4 + scale * 0.6; 88 - particle.rotation = (Math.random() - 0.5) * 0.3; 89 - particle.zIndex = Math.round(scale * 10); 90 - 91 - // Speed based on scale (smaller = slower for parallax) 92 - const baseSpeed = 80 * speed; 93 - const effectiveSpeed = baseSpeed * scale; 94 - particle.speedX = (Math.random() - 0.5) * 20; 95 - particle.speedY = direction === 'down' ? effectiveSpeed : -effectiveSpeed; 96 - 97 - particle.age = 0; 98 - particle.maxAge = (h + baseSize * 2) / effectiveSpeed + 2; 99 - particle.initialSize = baseSize; 100 - 101 - particles.push(particle); 102 - } 103 - 104 - function removeParticle(particle: ParticleSprite) { 105 - const index = particles.indexOf(particle); 106 - if (index !== -1) { 107 - particle.visible = false; 108 - particles.splice(index, 1); 109 - particlePool.push(particle); 110 - } 111 - } 112 - 113 - function updateParticles(deltaTime: number) { 114 - if (!app) return; 115 - const h = app.screen.height; 116 - 117 - for (let i = particles.length - 1; i >= 0; i--) { 118 - const particle = particles[i]; 119 - particle.x += particle.speedX * deltaTime; 120 - particle.y += particle.speedY * deltaTime; 121 - particle.age += deltaTime; 122 - 123 - // Remove if off screen or too old 124 - const isOffScreen = 125 - direction === 'down' 126 - ? particle.y > h + particle.initialSize 127 - : particle.y < -particle.initialSize; 128 - 129 - if (particle.age >= particle.maxAge || isOffScreen) { 130 - removeParticle(particle); 131 - } 132 - } 133 - } 134 - 135 - async function initPixi() { 136 - if (!browser || !containerEl || !canvasEl) return; 137 - 138 - // Clean up existing app 139 - if (app) { 140 - app.destroy(true, { children: true, texture: true }); 141 - app = null; 142 - } 143 - 144 - particles = []; 145 - particlePool = []; 146 - 147 - app = new PIXI.Application(); 148 - await app.init({ 149 - canvas: canvasEl, 150 - width: containerEl.clientWidth, 151 - height: containerEl.clientHeight, 152 - backgroundAlpha: 0, 153 - antialias: true, 154 - resolution: window.devicePixelRatio || 1, 155 - autoDensity: true 156 - }); 157 - 158 - particleContainer = new PIXI.Container(); 159 - particleContainer.sortableChildren = true; 160 - app.stage.addChild(particleContainer); 161 - 162 - emojiTexture = createEmojiTexture(emoji); 163 - 164 - app.ticker.add((ticker) => { 165 - updateParticles(ticker.deltaMS * 0.001); 166 - }); 167 - 168 - // Handle resize 169 - const resizeObserver = new ResizeObserver(() => { 170 - if (app && containerEl) { 171 - app.renderer.resize(containerEl.clientWidth, containerEl.clientHeight); 172 - } 173 - }); 174 - resizeObserver.observe(containerEl); 175 - 176 - return () => { 177 - resizeObserver.disconnect(); 178 - }; 179 - } 180 - 181 - function connectWebSocket() { 182 - if (!browser) return; 183 - 184 - if (ws) { 185 - ws.close(); 186 - } 187 - 188 - const wsUrl = `wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=${encodeURIComponent(collection)}`; 189 - 190 - try { 191 - ws = new WebSocket(wsUrl); 192 - 193 - ws.onmessage = (event) => { 194 - try { 195 - const data = JSON.parse(event.data); 196 - if (data.kind === 'commit' && data.commit?.operation === 'create') { 197 - spawnParticle(); 198 - } 199 - } catch { 200 - // Ignore parse errors 201 - } 202 - }; 203 - 204 - ws.onerror = () => { 205 - // Silently handle errors 206 - }; 207 - 208 - ws.onclose = () => { 209 - setTimeout(() => { 210 - if (containerEl) { 211 - connectWebSocket(); 212 - } 213 - }, 5000); 214 - }; 215 - } catch { 216 - // Failed to create WebSocket 217 - } 218 - } 219 - 220 - onMount(() => { 221 - let cleanupResize: (() => void) | undefined; 222 - 223 - initPixi().then((cleanup) => { 224 - cleanupResize = cleanup; 225 - }); 226 - connectWebSocket(); 227 - 228 - return () => { 229 - if (ws) { 230 - ws.close(); 231 - ws = null; 232 - } 233 - if (reconnectTimeout) { 234 - clearTimeout(reconnectTimeout); 235 - } 236 - if (app) { 237 - app.destroy(true, { children: true, texture: true }); 238 - app = null; 239 - } 240 - cleanupResize?.(); 241 - }; 242 - }); 243 - 244 - // Reconnect when collection changes (debounced) 245 - $effect(() => { 246 - const currentCollection = collection; 247 - 248 - if (prevCollection !== null && prevCollection !== currentCollection) { 249 - if (reconnectTimeout) { 250 - clearTimeout(reconnectTimeout); 251 - } 252 - reconnectTimeout = setTimeout(() => { 253 - if (ws) { 254 - ws.close(); 255 - } 256 - connectWebSocket(); 257 - }, RECONNECT_DEBOUNCE); 258 - } 259 - 260 - prevCollection = currentCollection; 261 - }); 262 - 263 - // Update emoji texture when emoji changes 264 - $effect(() => { 265 - const currentEmoji = emoji; 266 - 267 - if (prevEmoji !== null && prevEmoji !== currentEmoji && app) { 268 - emojiTexture = createEmojiTexture(currentEmoji); 269 - } 270 - 271 - prevEmoji = currentEmoji; 272 - }); 273 - </script> 274 - 275 - <div bind:this={containerEl} class="h-full w-full overflow-hidden"> 276 - <canvas bind:this={canvasEl} class="h-full w-full"></canvas> 277 - </div>
-71
src/lib/cards/visual/RecordVisualizerCard/RecordVisualizerSettings.svelte
··· 1 - <script lang="ts"> 2 - import type { Item } from '$lib/types'; 3 - import type { SettingsComponentProps } from '../../types'; 4 - import { Input, Label } from '@foxui/core'; 5 - 6 - let { item = $bindable<Item>() }: SettingsComponentProps = $props(); 7 - 8 - type RecordVisualizerCardData = { 9 - emoji?: string; 10 - collection?: string; 11 - direction?: 'down' | 'up'; 12 - speed?: number; 13 - }; 14 - 15 - let cardData = $derived(item.cardData as RecordVisualizerCardData); 16 - 17 - // Initialize defaults if not set 18 - if (item.cardData.emoji === undefined) { 19 - item.cardData.emoji = '๐Ÿ’™'; 20 - } 21 - if (item.cardData.collection === undefined) { 22 - item.cardData.collection = 'app.bsky.feed.like'; 23 - } 24 - if (item.cardData.direction === undefined) { 25 - item.cardData.direction = 'down'; 26 - } 27 - if (item.cardData.speed === undefined) { 28 - item.cardData.speed = 1; 29 - } 30 - </script> 31 - 32 - <div class="flex flex-col gap-3"> 33 - <div> 34 - <Label class="mb-1 text-xs">Emoji</Label> 35 - <Input bind:value={item.cardData.emoji} placeholder="๐Ÿ’™" class="w-full" /> 36 - </div> 37 - 38 - <div> 39 - <Label class="mb-1 text-xs">Collection</Label> 40 - <Input bind:value={item.cardData.collection} placeholder="app.bsky.feed.like" class="w-full" /> 41 - </div> 42 - 43 - <div> 44 - <Label class="mb-1 text-xs">Direction</Label> 45 - <select 46 - value={cardData.direction ?? 'down'} 47 - onchange={(e) => { 48 - item.cardData.direction = (e.target as HTMLSelectElement).value; 49 - }} 50 - class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-600 w-full rounded-md border px-3 py-2 text-sm" 51 - > 52 - <option value="down">Down</option> 53 - <option value="up">Up</option> 54 - </select> 55 - </div> 56 - 57 - <div> 58 - <Label class="mb-1 text-xs">Speed ({cardData.speed?.toFixed(1) ?? '1.0'}x)</Label> 59 - <input 60 - type="range" 61 - min="0.5" 62 - max="2" 63 - step="0.1" 64 - value={cardData.speed ?? 1} 65 - oninput={(e) => { 66 - item.cardData.speed = parseFloat(e.currentTarget.value); 67 - }} 68 - class="bg-base-200 dark:bg-base-700 h-2 w-full cursor-pointer appearance-none rounded-lg" 69 - /> 70 - </div> 71 - </div>
-30
src/lib/cards/visual/RecordVisualizerCard/index.ts
··· 1 - import type { CardDefinition } from '../../types'; 2 - import RecordVisualizerCard from './RecordVisualizerCard.svelte'; 3 - import RecordVisualizerSettings from './RecordVisualizerSettings.svelte'; 4 - 5 - export const RecordVisualizerCardDefinition = { 6 - type: 'record-visualizer', 7 - contentComponent: RecordVisualizerCard, 8 - createNew: (card) => { 9 - card.cardType = 'record-visualizer'; 10 - card.cardData = { 11 - emoji: '๐Ÿ’™', 12 - collection: 'app.bsky.feed.like', 13 - direction: 'down', 14 - speed: 1 15 - }; 16 - card.w = 2; 17 - card.h = 2; 18 - card.mobileW = 4; 19 - card.mobileH = 4; 20 - }, 21 - settingsComponent: RecordVisualizerSettings, 22 - minW: 1, 23 - minH: 2, 24 - canHaveLabel: true, 25 - 26 - keywords: ['emoji', 'particles', 'animation', 'bluesky', 'atproto', 'live', 'realtime', 'stream'], 27 - groups: ['Visual'], 28 - name: 'Record Visualizer', 29 - icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" /></svg>` 30 - } as CardDefinition & { type: 'record-visualizer' };
+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 18 const LONG_PRESS_DURATION = 500; 19 19 let longPressTimer: ReturnType<typeof setTimeout> | null = null; 20 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'); 21 26 22 27 function getHref() { 23 28 return params.href || (node as HTMLAnchorElement).href || ''; 24 29 } 25 30 26 - function startLongPress() { 31 + function startLongPress(e: PointerEvent) { 27 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'; 28 36 isLongPress = false; 29 37 longPressTimer = setTimeout(() => { 30 38 isLongPress = true; ··· 37 45 clearTimeout(longPressTimer); 38 46 longPressTimer = null; 39 47 } 48 + touchActive = false; 40 49 } 41 50 42 51 function handleClick(e: MouseEvent) { 43 52 if (isLongPress) { 44 53 e.preventDefault(); 45 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 ?? {}); 46 62 } 47 63 } 48 64 49 - function handleContextMenu(e: MouseEvent) { 50 - if (params.disabled) return; 51 - e.preventDefault(); 52 - openModal?.(getHref(), params.context ?? {}); 65 + function handleContextMenu(e: Event) { 66 + // Prevent context menu during touch to avoid iOS preview 67 + if (touchActive || isLongPress) { 68 + e.preventDefault(); 69 + } 53 70 } 54 71 55 72 node.addEventListener('pointerdown', startLongPress); ··· 71 88 node.removeEventListener('click', handleClick); 72 89 node.removeEventListener('contextmenu', handleContextMenu); 73 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 + } 74 97 } 75 98 }; 76 99 }
+1 -3
src/lib/website/EditableProfile.svelte
··· 3 3 import { getImage, compressImage, getProfilePosition } from '$lib/helper'; 4 4 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 - import { Avatar, Button } from '@foxui/core'; 7 - import { getIsMobile } from './context'; 6 + import { Avatar } from '@foxui/core'; 8 7 import MadeWithBlento from './MadeWithBlento.svelte'; 9 - import { SelectThemePopover } from '$lib/components/select-theme'; 10 8 11 9 let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } = 12 10 $props();
-12
src/lib/website/EditableWebsite.svelte
··· 961 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 962 > 963 963 <span>editedOn: {editedOn}</span> 964 - <button class="underline" onclick={addAllCardTypes}>+ all cards</button> 965 - <input 966 - bind:value={copyInput} 967 - placeholder="handle/page" 968 - class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5" 969 - onkeydown={(e) => { 970 - if (e.key === 'Enter') copyPageFrom(); 971 - }} 972 - /> 973 - <button class="underline" onclick={copyPageFrom} disabled={isCopying}> 974 - {isCopying ? 'copying...' : 'copy'} 975 - </button> 976 964 </div> 977 965 {/if} 978 966 </Context>
+32
src/lib/website/ThemeScript.svelte
··· 1 1 <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 2 4 let { 3 5 accentColor = 'pink', 4 6 baseColor = 'stone' ··· 7 9 baseColor?: string; 8 10 } = $props(); 9 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 + 10 33 const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c'); 11 34 35 + // SSR: inline script for initial page load (no FOUC) 12 36 let script = $derived( 13 37 `<script>(function(){document.documentElement.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})();<` + 14 38 '/script>' 15 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 + }); 16 48 </script> 17 49 18 50 <svelte:head>
+2 -1
src/params/handle.ts
··· 1 + import { isActorIdentifier } from '@atcute/lexicons/syntax'; 1 2 import type { ParamMatcher } from '@sveltejs/kit'; 2 3 3 4 export const match = ((param: string) => { 4 - return param.includes('.') || param.startsWith('did:'); 5 + return isActorIdentifier(param); 5 6 }) satisfies ParamMatcher;
+4 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 4 + import { Tooltip } from 'bits-ui'; 4 5 import { ThemeToggle, Toaster, toast } from '@foxui/core'; 5 6 import { onMount } from 'svelte'; 6 7 import { initClient } from '$lib/atproto'; ··· 28 29 }); 29 30 </script> 30 31 31 - {@render children()} 32 + <Tooltip.Provider delayDuration={300}> 33 + {@render children()} 34 + </Tooltip.Provider> 32 35 33 36 <ThemeToggle class="fixed top-2 left-2 z-10" /> 34 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} />