your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+451 -2
+1 -1
.gitignore
··· 22 22 vite.config.js.timestamp-* 23 23 vite.config.ts.timestamp-* 24 24 25 - react-grid-layout 25 + references
+1
package.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", 82 83 "plyr": "^3.8.4", 83 84 "qr-code-styling": "^1.8.6", 84 85 "react-grid-layout": "^2.2.2",
+69 -1
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 131 134 plyr: 132 135 specifier: ^3.8.4 133 136 version: 3.8.4 ··· 978 981 peerDependencies: 979 982 svelte: ^4 || ^5 980 983 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 + 981 987 '@polka/url@1.0.0-next.29': 982 988 resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} 983 989 ··· 1508 1514 '@types/cookie@0.6.0': 1509 1515 resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 1510 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 + 1511 1520 '@types/estree@1.0.8': 1512 1521 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 1513 1522 ··· 1624 1633 '@webgpu/types@0.1.69': 1625 1634 resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} 1626 1635 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 + 1627 1640 acorn-jsx@5.3.2: 1628 1641 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} 1629 1642 peerDependencies: ··· 1853 1866 resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} 1854 1867 1855 1868 earcut@3.0.2: 1856 - resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} 1869 + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==, tarball: https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz} 1857 1870 1858 1871 emoji-picker-element@1.28.1: 1859 1872 resolution: {integrity: sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==} ··· 1964 1977 resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} 1965 1978 engines: {node: '>=0.10.0'} 1966 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 + 1967 1983 exsolve@1.0.8: 1968 1984 resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} 1969 1985 ··· 2017 2033 geojson-vt@4.0.2: 2018 2034 resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} 2019 2035 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 + 2020 2039 gl-matrix@3.4.4: 2021 2040 resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} 2022 2041 ··· 2102 2121 isexe@2.0.0: 2103 2122 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 2104 2123 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 + 2105 2127 iso-datestring-validator@2.2.2: 2106 2128 resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 2107 2129 ··· 2109 2131 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 2110 2132 hasBin: true 2111 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 + 2112 2137 js-tokens@4.0.0: 2113 2138 resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} 2114 2139 ··· 2385 2410 parse-css-color@0.2.1: 2386 2411 resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} 2387 2412 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 + 2388 2416 parse5-htmlparser2-tree-adapter@7.1.0: 2389 2417 resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} 2390 2418 ··· 2422 2450 resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 2423 2451 engines: {node: '>=12'} 2424 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 + 2425 2456 pkg-types@1.3.1: 2426 2457 resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} 2427 2458 ··· 2897 2928 tiny-inflate@1.0.3: 2898 2929 resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} 2899 2930 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 + 2900 2935 tinyglobby@0.2.15: 2901 2936 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 2902 2937 engines: {node: '>=12.0.0'} ··· 3750 3785 number-flow: 0.5.9 3751 3786 svelte: 5.48.0 3752 3787 3788 + '@pixi/colord@2.9.6': {} 3789 + 3753 3790 '@polka/url@1.0.0-next.29': {} 3754 3791 3755 3792 '@poppinss/colors@4.1.6': ··· 4224 4261 4225 4262 '@types/cookie@0.6.0': {} 4226 4263 4264 + '@types/earcut@3.0.0': {} 4265 + 4227 4266 '@types/estree@1.0.8': {} 4228 4267 4229 4268 '@types/geojson-vt@3.2.5': ··· 4373 4412 4374 4413 '@webgpu/types@0.1.69': {} 4375 4414 4415 + '@xmldom/xmldom@0.8.11': {} 4416 + 4376 4417 acorn-jsx@5.3.2(acorn@8.15.0): 4377 4418 dependencies: 4378 4419 acorn: 8.15.0 ··· 4783 4824 4784 4825 esutils@2.0.3: {} 4785 4826 4827 + eventemitter3@5.0.4: {} 4828 + 4786 4829 exsolve@1.0.8: {} 4787 4830 4788 4831 fast-deep-equal@3.1.3: {} ··· 4822 4865 4823 4866 geojson-vt@4.0.2: {} 4824 4867 4868 + gifuct-js@2.1.2: 4869 + dependencies: 4870 + js-binary-schema-parser: 2.0.3 4871 + 4825 4872 gl-matrix@3.4.4: {} 4826 4873 4827 4874 glob-parent@6.0.2: ··· 4891 4938 4892 4939 isexe@2.0.0: {} 4893 4940 4941 + ismobilejs@1.1.1: {} 4942 + 4894 4943 iso-datestring-validator@2.2.2: {} 4895 4944 4896 4945 jiti@2.6.1: {} 4897 4946 4947 + js-binary-schema-parser@2.0.3: {} 4948 + 4898 4949 js-tokens@4.0.0: {} 4899 4950 4900 4951 js-yaml@4.1.1: ··· 5165 5216 color-name: 1.1.4 5166 5217 hex-rgb: 4.3.0 5167 5218 5219 + parse-svg-path@0.1.2: {} 5220 + 5168 5221 parse5-htmlparser2-tree-adapter@7.1.0: 5169 5222 dependencies: 5170 5223 domhandler: 5.0.3 ··· 5196 5249 5197 5250 picomatch@4.0.3: {} 5198 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 + 5199 5265 pkg-types@1.3.1: 5200 5266 dependencies: 5201 5267 confbox: 0.1.8 ··· 5710 5776 5711 5777 tiny-inflate@1.0.3: {} 5712 5778 5779 + tiny-lru@11.4.7: {} 5780 + 5713 5781 tinyglobby@0.2.15: 5714 5782 dependencies: 5715 5783 fdir: 6.5.0(picomatch@4.0.3)
+2
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'; 32 33 import { TimerCardDefinition } from './utilities/TimerCard'; 33 34 import { ClockCardDefinition } from './utilities/ClockCard'; 34 35 import { CountdownCardDefinition } from './utilities/CountdownCard'; ··· 75 76 EventCardDefinition, 76 77 VCardCardDefinition, 77 78 DrawCardDefinition, 79 + RecordVisualizerCardDefinition, 78 80 TimerCardDefinition, 79 81 ClockCardDefinition, 80 82 CountdownCardDefinition,
+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' };