atmo.rsvp
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add themes

Florian 5205ece4 138b8576

+818 -1
+1
package.json
··· 68 68 "@atcute/jetstream": "^1.1.2", 69 69 "@atmo-dev/contrail": "^0.0.6", 70 70 "@ethercorps/sveltekit-og": "^4.2.1", 71 + "@foxui/colors": "^0.8.2", 71 72 "@foxui/core": "^0.8.2", 72 73 "@foxui/social": "^0.8.4", 73 74 "@foxui/text": "^0.8.2",
+3
pnpm-lock.yaml
··· 20 20 '@ethercorps/sveltekit-og': 21 21 specifier: ^4.2.1 22 22 version: 4.2.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))) 23 + '@foxui/colors': 24 + specifier: ^0.8.2 25 + version: 0.8.2(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2) 23 26 '@foxui/core': 24 27 specifier: ^0.8.2 25 28 version: 0.8.2(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2)
+34 -1
src/lib/components/EventEditor.svelte
··· 32 32 import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte'; 33 33 import { designs } from '$lib/components/thumbnails/designs'; 34 34 import type { FlatEventRecord } from '$lib/contrail'; 35 + import ThemePicker from '$lib/components/ThemePicker.svelte'; 36 + import ThemeApply from '$lib/components/ThemeApply.svelte'; 37 + import ThemeBackground from '$lib/components/ThemeBackground.svelte'; 38 + import { defaultTheme, themeBackgrounds, type EventTheme } from '$lib/theme'; 35 39 36 40 let { 37 41 eventData = null, ··· 61 65 startsAt: string; 62 66 endsAt: string; 63 67 timezone?: string; 68 + theme?: EventTheme; 64 69 links: Array<{ uri: string; name: string }>; 65 70 mode?: EventMode; 66 71 thumbnailKey?: string; ··· 79 84 let endsAt = $state(''); 80 85 let timezone = $state(Intl.DateTimeFormat().resolvedOptions().timeZone); 81 86 let mode: EventMode = $state('inperson'); 87 + let eventTheme: EventTheme = $state({ ...defaultTheme }); 88 + let showThemeModal = $state(false); 82 89 let thumbnailFile: File | null = $state(null); 83 90 let thumbnailPreview: string | null = $state(null); 84 91 let selectedPreset: { design: string; seed: number } | null = $state(null); ··· 211 218 endsAt = eventData.endsAt ? isoToDatetimeLocal(eventData.endsAt) : ''; 212 219 mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson'; 213 220 links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : []; 221 + if (eventData.theme) eventTheme = { ...eventData.theme }; 214 222 populateLocationFromEventData(); 215 223 populateThumbnailFromEventData(); 216 224 } ··· 234 242 startsAt = draft.startsAt || ''; 235 243 endsAt = draft.endsAt || ''; 236 244 if (draft.timezone) timezone = draft.timezone; 245 + if (draft.theme) eventTheme = draft.theme; 237 246 links = draft.links || []; 238 247 mode = draft.mode || 'inperson'; 239 248 locationChanged = draft.locationChanged || false; ··· 280 289 startsAt, 281 290 endsAt, 282 291 timezone, 292 + theme: eventTheme, 283 293 links, 284 294 mode, 285 295 thumbnailChanged, ··· 299 309 startsAt, 300 310 endsAt, 301 311 timezone, 312 + JSON.stringify(eventTheme), 302 313 mode, 303 314 JSON.stringify(links), 304 315 JSON.stringify(location) ··· 611 622 mode: `community.lexicon.calendar.event#${mode}`, 612 623 status: 'community.lexicon.calendar.event#scheduled', 613 624 startsAt: datetimeLocalToISO(startsAt, timezone), 614 - createdAt 625 + createdAt, 626 + theme: eventTheme 615 627 }; 616 628 // Remove flattened fields that aren't part of the actual record 617 629 delete record.cid; ··· 845 857 } 846 858 </script> 847 859 860 + <ThemeApply accentColor={eventTheme.accentColor} baseColor={eventTheme.baseColor} /> 861 + <ThemeBackground theme={eventTheme} /> 862 + 848 863 <div class="px-6 py-12 sm:py-12"> 849 864 <div class="mx-auto max-w-3xl"> 850 865 {#if !user.isLoggedIn} ··· 950 965 {/if} 951 966 </div> 952 967 </div> 968 + <Button 969 + variant="secondary" 970 + class="mt-3 w-full" 971 + onclick={() => (showThemeModal = true)} 972 + > 973 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"> 974 + <path stroke-linecap="round" stroke-linejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" /> 975 + </svg> 976 + Theme: {themeBackgrounds[eventTheme.name] || eventTheme.name} 977 + </Button> 953 978 <Button 954 979 type="submit" 955 980 class="mt-3 w-full" ··· 1303 1328 {/if} 1304 1329 </div> 1305 1330 </div> 1331 + 1332 + <!-- Theme modal --> 1333 + <Modal bind:open={showThemeModal}> 1334 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Event theme</p> 1335 + <div class="mt-4"> 1336 + <ThemePicker bind:theme={eventTheme} /> 1337 + </div> 1338 + </Modal> 1306 1339 1307 1340 <!-- Thumbnail modal --> 1308 1341 <Modal bind:open={showThumbnailModal}>
+50
src/lib/components/ThemeApply.svelte
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 4 + let { 5 + accentColor = 'cyan', 6 + baseColor = 'mist' 7 + }: { 8 + accentColor?: string; 9 + baseColor?: string; 10 + } = $props(); 11 + 12 + const allAccentColors = [ 13 + 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 14 + 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 15 + 'fuchsia', 'pink', 'rose' 16 + ]; 17 + const allBaseColors = [ 18 + 'gray', 'stone', 'zinc', 'neutral', 'slate', 'mist', 'sand', 19 + 'olive', 'mauve', 'sage' 20 + ]; 21 + 22 + const allColors = [...allAccentColors, ...allBaseColors]; 23 + 24 + const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c'); 25 + 26 + // SSR: inline script that removes all color classes then adds the correct ones before paint 27 + const allColorsJson = JSON.stringify(allColors); 28 + 29 + let script = $derived( 30 + `<script>(function(){var e=document.documentElement,r=${allColorsJson};r.forEach(function(c){e.classList.remove(c)});e.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})()<` + 31 + '/script>' 32 + ); 33 + 34 + // Client: reactive effect for client-side navigations 35 + $effect(() => { 36 + if (!browser) return; 37 + const el = document.documentElement; 38 + el.classList.remove(...allColors); 39 + el.classList.add(accentColor, baseColor); 40 + 41 + return () => { 42 + el.classList.remove(...allColors); 43 + el.classList.add('cyan', 'mist'); 44 + }; 45 + }); 46 + </script> 47 + 48 + <svelte:head> 49 + {@html script} 50 + </svelte:head>
+30
src/lib/components/ThemeBackground.svelte
··· 1 + <script lang="ts"> 2 + import type { EventTheme } from '$lib/theme'; 3 + import Blobs from './themes/Blobs.svelte'; 4 + import Stars from './themes/Stars.svelte'; 5 + import Matrix from './themes/Matrix.svelte'; 6 + import Fireflies from './themes/Fireflies.svelte'; 7 + import Kaleidoscope from './themes/Kaleidoscope.svelte'; 8 + 9 + let { 10 + theme 11 + }: { 12 + theme: EventTheme; 13 + } = $props(); 14 + 15 + let key = $derived(`${theme.name}-${theme.accentColor}-${theme.baseColor}`); 16 + </script> 17 + 18 + {#key key} 19 + {#if theme.name === 'blobs'} 20 + <Blobs /> 21 + {:else if theme.name === 'warp'} 22 + <Stars /> 23 + {:else if theme.name === 'matrix'} 24 + <Matrix /> 25 + {:else if theme.name === 'fireflies'} 26 + <Fireflies /> 27 + {:else if theme.name === 'kaleidoscope'} 28 + <Kaleidoscope /> 29 + {/if} 30 + {/key}
+100
src/lib/components/ThemePicker.svelte
··· 1 + <script lang="ts"> 2 + import { themeBackgrounds, type EventTheme } from '$lib/theme'; 3 + 4 + let { 5 + theme = $bindable<EventTheme>({ name: 'minimal', accentColor: 'cyan', baseColor: 'mist' }) 6 + }: { 7 + theme: EventTheme; 8 + } = $props(); 9 + 10 + const bgKeys = Object.keys(themeBackgrounds); 11 + 12 + const accentColors = [ 13 + { label: 'red', cls: 'bg-red-500' }, 14 + { label: 'orange', cls: 'bg-orange-500' }, 15 + { label: 'amber', cls: 'bg-amber-500' }, 16 + { label: 'yellow', cls: 'bg-yellow-500' }, 17 + { label: 'lime', cls: 'bg-lime-500' }, 18 + { label: 'green', cls: 'bg-green-500' }, 19 + { label: 'emerald', cls: 'bg-emerald-500' }, 20 + { label: 'teal', cls: 'bg-teal-500' }, 21 + { label: 'cyan', cls: 'bg-cyan-500' }, 22 + { label: 'sky', cls: 'bg-sky-500' }, 23 + { label: 'blue', cls: 'bg-blue-500' }, 24 + { label: 'indigo', cls: 'bg-indigo-500' }, 25 + { label: 'violet', cls: 'bg-violet-500' }, 26 + { label: 'purple', cls: 'bg-purple-500' }, 27 + { label: 'fuchsia', cls: 'bg-fuchsia-500' }, 28 + { label: 'pink', cls: 'bg-pink-500' }, 29 + { label: 'rose', cls: 'bg-rose-500' } 30 + ]; 31 + 32 + const baseColors = [ 33 + { label: 'gray', cls: 'bg-gray-500' }, 34 + { label: 'stone', cls: 'bg-stone-500' }, 35 + { label: 'zinc', cls: 'bg-zinc-500' }, 36 + { label: 'neutral', cls: 'bg-neutral-500' }, 37 + { label: 'slate', cls: 'bg-slate-500' }, 38 + { label: 'olive', cls: 'bg-olive-500' }, 39 + { label: 'mauve', cls: 'bg-mauve-500' }, 40 + { label: 'mist', cls: 'bg-mist-500' }, 41 + { label: 'taupe', cls: 'bg-taupe-500' } 42 + ]; 43 + </script> 44 + 45 + <div class="flex flex-col gap-6"> 46 + <!-- Theme background --> 47 + <div> 48 + <p class="text-base-500 dark:text-base-400 mb-2 text-xs font-medium">Background style</p> 49 + <div class="flex flex-wrap gap-2"> 50 + {#each bgKeys as key} 51 + <button 52 + type="button" 53 + class="relative flex aspect-video w-24 cursor-pointer items-center justify-center overflow-hidden rounded-xl border-2 transition-colors 54 + {theme.name === key 55 + ? 'border-accent-500' 56 + : 'border-base-200 dark:border-base-700 hover:border-accent-400 dark:hover:border-accent-500'}" 57 + onclick={() => (theme = { ...theme, name: key })} 58 + > 59 + <span class="text-base-600 dark:text-base-400 text-xs font-medium"> 60 + {themeBackgrounds[key]} 61 + </span> 62 + </button> 63 + {/each} 64 + </div> 65 + </div> 66 + 67 + <!-- Accent color --> 68 + <div> 69 + <p class="text-base-500 dark:text-base-400 mb-2 text-xs font-medium">Accent color</p> 70 + <div class="flex flex-wrap gap-2"> 71 + {#each accentColors as color} 72 + <button 73 + type="button" 74 + class="size-7 cursor-pointer rounded-full border-2 transition-all {color.cls} 75 + {theme.accentColor === color.label 76 + ? 'border-white scale-110 ring-2 ring-base-400' 77 + : 'border-transparent hover:scale-105'}" 78 + onclick={() => (theme = { ...theme, accentColor: color.label })} 79 + ></button> 80 + {/each} 81 + </div> 82 + </div> 83 + 84 + <!-- Base color --> 85 + <div> 86 + <p class="text-base-500 dark:text-base-400 mb-2 text-xs font-medium">Base color</p> 87 + <div class="flex flex-wrap gap-2"> 88 + {#each baseColors as color} 89 + <button 90 + type="button" 91 + class="size-7 cursor-pointer rounded-full border-2 transition-all {color.cls} 92 + {theme.baseColor === color.label 93 + ? 'border-white scale-110 ring-2 ring-base-400' 94 + : 'border-transparent hover:scale-105'}" 95 + onclick={() => (theme = { ...theme, baseColor: color.label })} 96 + ></button> 97 + {/each} 98 + </div> 99 + </div> 100 + </div>
+35
src/lib/components/themes/Blobs.svelte
··· 1 + <div class="pointer-events-none fixed inset-0 -z-10 overflow-hidden bg-base-50 dark:bg-base-900"> 2 + <div 3 + class="blob-1 absolute rounded-full bg-accent-500 opacity-30 blur-3xl" 4 + style="width: 40vw; height: 40vw; top: -10%; left: -5%;" 5 + ></div> 6 + <div 7 + class="blob-2 absolute rounded-full bg-accent-500 opacity-20 blur-3xl" 8 + style="width: 35vw; height: 35vw; bottom: -5%; right: -10%;" 9 + ></div> 10 + <div 11 + class="blob-3 absolute rounded-full bg-accent-400 opacity-15 blur-3xl" 12 + style="width: 25vw; height: 25vw; top: 40%; left: 50%;" 13 + ></div> 14 + </div> 15 + 16 + <style> 17 + @keyframes blob-float-1 { 18 + 0%, 100% { transform: translate(0, 0) scale(1); } 19 + 33% { transform: translate(5vw, 8vh) scale(1.1); } 20 + 66% { transform: translate(-3vw, 4vh) scale(0.95); } 21 + } 22 + @keyframes blob-float-2 { 23 + 0%, 100% { transform: translate(0, 0) scale(1); } 24 + 33% { transform: translate(-6vw, -5vh) scale(1.05); } 25 + 66% { transform: translate(4vw, -8vh) scale(1.1); } 26 + } 27 + @keyframes blob-float-3 { 28 + 0%, 100% { transform: translate(0, 0) scale(1); } 29 + 33% { transform: translate(8vw, -4vh) scale(1.15); } 30 + 66% { transform: translate(-5vw, 6vh) scale(0.9); } 31 + } 32 + .blob-1 { animation: blob-float-1 20s ease-in-out infinite; } 33 + .blob-2 { animation: blob-float-2 25s ease-in-out infinite; } 34 + .blob-3 { animation: blob-float-3 22s ease-in-out infinite; } 35 + </style>
+130
src/lib/components/themes/Fireflies.svelte
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 4 + let canvas: HTMLCanvasElement | undefined = $state(undefined); 5 + 6 + $effect(() => { 7 + if (!canvas || !browser) return; 8 + 9 + const ctx = canvas.getContext('2d')!; 10 + let animId: number; 11 + 12 + const COUNT = 50; 13 + const DRIFT_SPEED = 15; 14 + const PULSE_SPEED = 0.6; 15 + 16 + interface Firefly { 17 + x: number; 18 + y: number; 19 + vx: number; 20 + vy: number; 21 + phase: number; 22 + pulseRate: number; 23 + size: number; 24 + hueShift: number; 25 + } 26 + 27 + function resize() { 28 + canvas!.width = window.innerWidth; 29 + canvas!.height = window.innerHeight; 30 + } 31 + resize(); 32 + window.addEventListener('resize', resize); 33 + 34 + const flies: Firefly[] = Array.from({ length: COUNT }, () => ({ 35 + x: Math.random() * canvas.width, 36 + y: Math.random() * canvas.height, 37 + vx: (Math.random() - 0.5) * 2, 38 + vy: (Math.random() - 0.5) * 2, 39 + phase: Math.random() * Math.PI * 2, 40 + pulseRate: PULSE_SPEED * (0.6 + Math.random() * 0.8), 41 + size: 3 + Math.random() * 5, 42 + hueShift: (Math.random() - 0.5) * 60 // -30 to +30 degrees 43 + })); 44 + 45 + const accentColor = getComputedStyle(document.documentElement) 46 + .getPropertyValue('--color-accent-500') 47 + .trim(); 48 + 49 + let lastTime = performance.now(); 50 + 51 + function draw(now: number) { 52 + const dt = Math.min((now - lastTime) / 1000, 0.1); 53 + lastTime = now; 54 + 55 + const w = canvas!.width; 56 + const h = canvas!.height; 57 + 58 + ctx.clearRect(0, 0, w, h); 59 + 60 + for (const fly of flies) { 61 + fly.phase += fly.pulseRate * dt; 62 + fly.x += fly.vx * DRIFT_SPEED * dt; 63 + fly.y += fly.vy * DRIFT_SPEED * dt; 64 + 65 + fly.vx += (Math.random() - 0.5) * 2 * dt; 66 + fly.vy += (Math.random() - 0.5) * 2 * dt; 67 + const len = Math.sqrt(fly.vx * fly.vx + fly.vy * fly.vy); 68 + if (len > 1) { 69 + fly.vx /= len; 70 + fly.vy /= len; 71 + } 72 + 73 + if (fly.x < -40) fly.x = w + 40; 74 + if (fly.x > w + 40) fly.x = -40; 75 + if (fly.y < -40) fly.y = h + 40; 76 + if (fly.y > h + 40) fly.y = -40; 77 + 78 + const glow = (Math.sin(fly.phase) + 1) / 2; 79 + const alpha = 0.05 + glow * 0.5; 80 + const radius = fly.size * (0.5 + glow * 0.5); 81 + const glowRadius = radius * 12; 82 + const h_shift = fly.hueShift; 83 + 84 + // Large soft glow 85 + const gradient = ctx.createRadialGradient(fly.x, fly.y, 0, fly.x, fly.y, glowRadius); 86 + gradient.addColorStop( 87 + 0, 88 + accentColor 89 + ? `oklch(from ${accentColor} l c calc(h + ${h_shift}) / ${alpha * 0.4})` 90 + : `rgba(255, 200, 50, ${alpha * 0.4})` 91 + ); 92 + gradient.addColorStop( 93 + 0.4, 94 + accentColor 95 + ? `oklch(from ${accentColor} l c calc(h + ${h_shift}) / ${alpha * 0.15})` 96 + : `rgba(255, 200, 50, ${alpha * 0.15})` 97 + ); 98 + gradient.addColorStop( 99 + 1, 100 + accentColor 101 + ? `oklch(from ${accentColor} l c calc(h + ${h_shift}) / 0)` 102 + : `rgba(255, 200, 50, 0)` 103 + ); 104 + ctx.fillStyle = gradient; 105 + ctx.fillRect(fly.x - glowRadius, fly.y - glowRadius, glowRadius * 2, glowRadius * 2); 106 + 107 + // Bright core 108 + ctx.beginPath(); 109 + ctx.arc(fly.x, fly.y, radius, 0, Math.PI * 2); 110 + ctx.fillStyle = accentColor 111 + ? `oklch(from ${accentColor} calc(l * 1.4) c calc(h + ${h_shift}) / ${alpha})` 112 + : `rgba(255, 230, 100, ${alpha})`; 113 + ctx.fill(); 114 + } 115 + 116 + animId = requestAnimationFrame(draw); 117 + } 118 + 119 + animId = requestAnimationFrame(draw); 120 + 121 + return () => { 122 + cancelAnimationFrame(animId); 123 + window.removeEventListener('resize', resize); 124 + }; 125 + }); 126 + </script> 127 + 128 + <div class="bg-base-50 dark:bg-base-900 pointer-events-none fixed inset-0 -z-10"> 129 + <canvas bind:this={canvas} class="absolute inset-0 h-full w-full opacity-50"></canvas> 130 + </div>
+173
src/lib/components/themes/Kaleidoscope.svelte
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 4 + let canvas: HTMLCanvasElement | undefined = $state(undefined); 5 + 6 + $effect(() => { 7 + if (!canvas || !browser) return; 8 + 9 + const ctx = canvas.getContext('2d')!; 10 + let animId: number; 11 + 12 + const SEGMENTS = 12; 13 + const ROTATION_SPEED = 0.06; 14 + 15 + function resize() { 16 + canvas!.width = window.innerWidth; 17 + canvas!.height = window.innerHeight; 18 + } 19 + resize(); 20 + window.addEventListener('resize', resize); 21 + 22 + const accentColor = getComputedStyle(document.documentElement) 23 + .getPropertyValue('--color-accent-500') 24 + .trim(); 25 + 26 + // Animated blobs in normalized 0-1 space 27 + const BLOB_COUNT = 20; 28 + interface Blob { 29 + x: number; 30 + y: number; 31 + vx: number; 32 + vy: number; 33 + size: number; 34 + hueShift: number; 35 + alpha: number; 36 + } 37 + 38 + const blobs: (Blob & { sharp?: boolean })[] = [ 39 + ...Array.from({ length: BLOB_COUNT }, (): Blob => ({ 40 + x: Math.random(), 41 + y: Math.random() * 0.8, 42 + vx: (Math.random() - 0.5) * 0.04, 43 + vy: (Math.random() - 0.5) * 0.04, 44 + size: 0.04 + Math.random() * 0.1, 45 + hueShift: (Math.random() - 0.5) * 80, 46 + alpha: 0.15 + Math.random() * 0.35 47 + })), 48 + // Small sharp dots 49 + ...Array.from({ length: 10 }, () => ({ 50 + x: Math.random(), 51 + y: Math.random() * 0.8, 52 + vx: (Math.random() - 0.5) * 0.06, 53 + vy: (Math.random() - 0.5) * 0.06, 54 + size: 0.005 + Math.random() * 0.012, 55 + hueShift: (Math.random() - 0.5) * 80, 56 + alpha: 0.15 + Math.random() * 0.2, 57 + sharp: true 58 + })) 59 + ]; 60 + 61 + // Offscreen canvas for one triangle slice 62 + const sliceCanvas = document.createElement('canvas'); 63 + const sliceCtx = sliceCanvas.getContext('2d')!; 64 + 65 + let lastTime = performance.now(); 66 + let globalRotation = 0; 67 + 68 + function renderSlice(r: number, dt: number) { 69 + const segAngle = Math.PI / SEGMENTS; // half of full segment 70 + const sliceW = Math.ceil(r); 71 + const sliceH = Math.ceil(Math.sin(segAngle) * r); 72 + 73 + sliceCanvas.width = sliceW; 74 + sliceCanvas.height = sliceH; 75 + sliceCtx.clearRect(0, 0, sliceW, sliceH); 76 + 77 + // Clip to the triangle 78 + sliceCtx.save(); 79 + sliceCtx.beginPath(); 80 + sliceCtx.moveTo(0, 0); 81 + sliceCtx.lineTo(sliceW, 0); 82 + sliceCtx.lineTo(Math.cos(segAngle) * r, Math.sin(segAngle) * r); 83 + sliceCtx.closePath(); 84 + sliceCtx.clip(); 85 + 86 + for (const b of blobs) { 87 + b.x += b.vx * dt; 88 + b.y += b.vy * dt; 89 + if (b.x < 0 || b.x > 1) b.vx *= -1; 90 + if (b.y < 0 || b.y > 1) b.vy *= -1; 91 + b.x = Math.max(0, Math.min(1, b.x)); 92 + b.y = Math.max(0, Math.min(1, b.y)); 93 + 94 + const px = b.x * sliceW; 95 + const py = b.y * sliceH; 96 + const br = b.size * sliceW; 97 + 98 + if (b.sharp) { 99 + sliceCtx.fillStyle = accentColor 100 + ? `oklch(from ${accentColor} calc(l * 1.4) c calc(h + ${b.hueShift}) / ${b.alpha})` 101 + : `rgba(230, 210, 255, ${b.alpha})`; 102 + sliceCtx.beginPath(); 103 + sliceCtx.arc(px, py, br, 0, Math.PI * 2); 104 + sliceCtx.fill(); 105 + } else { 106 + const g = sliceCtx.createRadialGradient(px, py, 0, px, py, br); 107 + g.addColorStop(0, accentColor 108 + ? `oklch(from ${accentColor} calc(l * 1.2) c calc(h + ${b.hueShift}) / ${b.alpha})` 109 + : `rgba(200, 150, 255, ${b.alpha})`); 110 + g.addColorStop(0.5, accentColor 111 + ? `oklch(from ${accentColor} l c calc(h + ${b.hueShift}) / ${b.alpha * 0.3})` 112 + : `rgba(200, 150, 255, ${b.alpha * 0.3})`); 113 + g.addColorStop(1, 'transparent'); 114 + sliceCtx.fillStyle = g; 115 + sliceCtx.fillRect(px - br, py - br, br * 2, br * 2); 116 + } 117 + } 118 + 119 + sliceCtx.restore(); 120 + return { sliceW, sliceH }; 121 + } 122 + 123 + function draw(now: number) { 124 + const dt = Math.min((now - lastTime) / 1000, 0.1); 125 + lastTime = now; 126 + 127 + const w = canvas!.width; 128 + const h = canvas!.height; 129 + const cx = w / 2; 130 + const cy = h / 2; 131 + const maxR = Math.hypot(cx, cy); 132 + 133 + globalRotation += ROTATION_SPEED * dt; 134 + 135 + renderSlice(maxR, dt); 136 + 137 + ctx.clearRect(0, 0, w, h); 138 + ctx.save(); 139 + ctx.translate(cx, cy); 140 + ctx.rotate(globalRotation); 141 + 142 + const segAngle = (Math.PI * 2) / SEGMENTS; 143 + 144 + for (let i = 0; i < SEGMENTS; i++) { 145 + ctx.save(); 146 + ctx.rotate(segAngle * i); 147 + 148 + // Normal slice 149 + ctx.drawImage(sliceCanvas, 0, 0); 150 + 151 + // Mirrored slice (flip along x-axis to fill the other half) 152 + ctx.scale(1, -1); 153 + ctx.drawImage(sliceCanvas, 0, 0); 154 + 155 + ctx.restore(); 156 + } 157 + 158 + ctx.restore(); 159 + animId = requestAnimationFrame(draw); 160 + } 161 + 162 + animId = requestAnimationFrame(draw); 163 + 164 + return () => { 165 + cancelAnimationFrame(animId); 166 + window.removeEventListener('resize', resize); 167 + }; 168 + }); 169 + </script> 170 + 171 + <div class="pointer-events-none fixed inset-0 -z-10 bg-base-50 dark:bg-base-900"> 172 + <canvas bind:this={canvas} class="absolute inset-0 h-full w-full opacity-50"></canvas> 173 + </div>
+134
src/lib/components/themes/Matrix.svelte
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 4 + let canvas: HTMLCanvasElement | undefined = $state(undefined); 5 + 6 + $effect(() => { 7 + if (!canvas || !browser) return; 8 + 9 + const ctx = canvas.getContext('2d')!; 10 + let animId: number; 11 + 12 + const fontSize = 14; 13 + const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF'; 14 + 15 + // Speeds in units per second 16 + const DROP_SPEED = 8; // rows per second 17 + const FADE_RATE = 2; // alpha per second for trail fade 18 + const MUTATE_CHANCE = 0.8; // chance per column per second 19 + 20 + function resize() { 21 + canvas!.width = window.innerWidth; 22 + canvas!.height = window.innerHeight; 23 + } 24 + resize(); 25 + window.addEventListener('resize', resize); 26 + 27 + let columns = Math.floor(canvas.width / fontSize); 28 + let rows = Math.ceil(canvas.height / fontSize) + 1; 29 + let drops = new Array(columns).fill(0).map(() => Math.random() * -100); 30 + let grid: string[][] = Array.from({ length: columns }, () => 31 + Array.from({ length: rows }, () => chars[Math.floor(Math.random() * chars.length)]) 32 + ); 33 + 34 + const accentColor = getComputedStyle(document.documentElement) 35 + .getPropertyValue('--color-accent-500') 36 + .trim(); 37 + 38 + const bgColor = getComputedStyle(document.documentElement) 39 + .getPropertyValue('--color-base-900') 40 + .trim(); 41 + 42 + let lastResize = canvas.width; 43 + let lastTime = performance.now(); 44 + 45 + function draw(now: number) { 46 + const dt = Math.min((now - lastTime) / 1000, 0.1); // delta in seconds, capped 47 + lastTime = now; 48 + 49 + const w = canvas!.width; 50 + const h = canvas!.height; 51 + 52 + if (w !== lastResize) { 53 + lastResize = w; 54 + columns = Math.floor(w / fontSize); 55 + rows = Math.ceil(h / fontSize) + 1; 56 + drops = new Array(columns).fill(0).map(() => Math.random() * -100); 57 + grid = Array.from({ length: columns }, () => 58 + Array.from({ length: rows }, () => chars[Math.floor(Math.random() * chars.length)]) 59 + ); 60 + } 61 + 62 + // Randomly mutate characters — framerate independent 63 + const mutatePerFrame = MUTATE_CHANCE * dt; 64 + for (let m = 0; m < columns; m++) { 65 + if (Math.random() < mutatePerFrame) { 66 + const row = Math.floor(Math.random() * rows); 67 + grid[m][row] = chars[Math.floor(Math.random() * chars.length)]; 68 + } 69 + } 70 + 71 + // Fade trail — framerate independent 72 + const fadeAlpha = Math.min(1, FADE_RATE * dt); 73 + ctx.fillStyle = bgColor ? `oklch(from ${bgColor} l c h / ${fadeAlpha})` : `rgba(0, 0, 0, ${fadeAlpha})`; 74 + ctx.fillRect(0, 0, w, h); 75 + 76 + ctx.font = `${fontSize}px monospace`; 77 + 78 + const dropStep = DROP_SPEED * dt; 79 + 80 + for (let i = 0; i < columns; i++) { 81 + if (drops[i] * fontSize > h && Math.random() < 0.5 * dt) { 82 + drops[i] = 0; 83 + } 84 + 85 + if (drops[i] < 0) { 86 + drops[i] += dropStep; 87 + continue; 88 + } 89 + 90 + const row = Math.floor(drops[i]); 91 + const y = row * fontSize; 92 + const gridRow = ((row % rows) + rows) % rows; 93 + const char = grid[i]?.[gridRow] ?? '0'; 94 + 95 + // Bright head 96 + ctx.fillStyle = accentColor 97 + ? `oklch(from ${accentColor} calc(l * 1.3) c h / 0.9)` 98 + : `rgba(150, 255, 150, 0.9)`; 99 + ctx.fillText(char, i * fontSize, y); 100 + 101 + // Dimmer trail chars 102 + for (let t = 1; t < 3; t++) { 103 + const trailRow = ((gridRow - t) % rows + rows) % rows; 104 + const trailY = y - t * fontSize; 105 + if (trailY < 0) break; 106 + const trailAlpha = 0.4 - t * 0.12; 107 + ctx.fillStyle = accentColor 108 + ? `oklch(from ${accentColor} l c h / ${trailAlpha})` 109 + : `rgba(100, 200, 100, ${trailAlpha})`; 110 + ctx.fillText(grid[i]?.[trailRow] ?? '0', i * fontSize, trailY); 111 + } 112 + 113 + drops[i] += dropStep; 114 + } 115 + 116 + animId = requestAnimationFrame(draw); 117 + } 118 + 119 + // Fill initial background 120 + ctx.fillStyle = bgColor ? `oklch(from ${bgColor} l c h)` : '#000'; 121 + ctx.fillRect(0, 0, canvas.width, canvas.height); 122 + 123 + animId = requestAnimationFrame(draw); 124 + 125 + return () => { 126 + cancelAnimationFrame(animId); 127 + window.removeEventListener('resize', resize); 128 + }; 129 + }); 130 + </script> 131 + 132 + <div class="pointer-events-none fixed inset-0 -z-10 bg-base-50 dark:bg-base-900"> 133 + <canvas bind:this={canvas} class="absolute inset-0 h-full w-full opacity-40"></canvas> 134 + </div>
+94
src/lib/components/themes/Stars.svelte
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 4 + let canvas: HTMLCanvasElement | undefined = $state(undefined); 5 + 6 + $effect(() => { 7 + if (!canvas || !browser) return; 8 + 9 + const ctx = canvas.getContext('2d')!; 10 + let animId: number; 11 + 12 + const stars: { x: number; y: number; z: number; pz: number }[] = []; 13 + const COUNT = 600; 14 + const SPEED = 300; // pixels per second 15 + 16 + function resize() { 17 + canvas!.width = window.innerWidth; 18 + canvas!.height = window.innerHeight; 19 + } 20 + resize(); 21 + window.addEventListener('resize', resize); 22 + 23 + for (let i = 0; i < COUNT; i++) { 24 + stars.push({ 25 + x: (Math.random() - 0.5) * canvas.width, 26 + y: (Math.random() - 0.5) * canvas.height, 27 + z: Math.random() * canvas.width, 28 + pz: 0 29 + }); 30 + } 31 + stars.forEach((s) => (s.pz = s.z)); 32 + 33 + const accentColor = getComputedStyle(document.documentElement) 34 + .getPropertyValue('--color-accent-500') 35 + .trim(); 36 + 37 + let lastTime = performance.now(); 38 + 39 + function draw(now: number) { 40 + const dt = Math.min((now - lastTime) / 1000, 0.1); 41 + lastTime = now; 42 + 43 + const w = canvas!.width; 44 + const h = canvas!.height; 45 + const cx = w / 2; 46 + const cy = h / 2; 47 + const speed = SPEED * dt; 48 + 49 + ctx.clearRect(0, 0, w, h); 50 + 51 + for (const star of stars) { 52 + star.pz = star.z; 53 + star.z -= speed; 54 + 55 + if (star.z <= 0) { 56 + star.x = (Math.random() - 0.5) * w; 57 + star.y = (Math.random() - 0.5) * h; 58 + star.z = w; 59 + star.pz = w; 60 + } 61 + 62 + const sx = (star.x / star.z) * w + cx; 63 + const sy = (star.y / star.z) * h + cy; 64 + const px = (star.x / star.pz) * w + cx; 65 + const py = (star.y / star.pz) * h + cy; 66 + 67 + const size = Math.max(0, (1 - star.z / w) * 4); 68 + const alpha = Math.max(0, (1 - star.z / w) * 0.9); 69 + 70 + ctx.beginPath(); 71 + ctx.moveTo(px, py); 72 + ctx.lineTo(sx, sy); 73 + ctx.strokeStyle = accentColor 74 + ? `oklch(from ${accentColor} l c h / ${alpha})` 75 + : `rgba(255,255,255,${alpha})`; 76 + ctx.lineWidth = size; 77 + ctx.stroke(); 78 + } 79 + 80 + animId = requestAnimationFrame(draw); 81 + } 82 + 83 + animId = requestAnimationFrame(draw); 84 + 85 + return () => { 86 + cancelAnimationFrame(animId); 87 + window.removeEventListener('resize', resize); 88 + }; 89 + }); 90 + </script> 91 + 92 + <div class="pointer-events-none fixed inset-0 -z-10 bg-base-50 dark:bg-base-900"> 93 + <canvas bind:this={canvas} class="absolute inset-0 h-full w-full opacity-80"></canvas> 94 + </div>
+5
src/lib/event-types.ts
··· 21 21 features: Array<{ $type: string; did?: string; uri?: string; tag?: string }>; 22 22 }>; 23 23 additionalData?: Record<string, unknown>; 24 + theme?: { 25 + name: string; 26 + accentColor: string; 27 + baseColor: string; 28 + }; 24 29 };
+20
src/lib/theme.ts
··· 1 + export interface EventTheme { 2 + name: string; 3 + accentColor: string; 4 + baseColor: string; 5 + } 6 + 7 + export const defaultTheme: EventTheme = { 8 + name: 'minimal', 9 + accentColor: 'cyan', 10 + baseColor: 'mist' 11 + }; 12 + 13 + export const themeBackgrounds: Record<string, string> = { 14 + minimal: 'Minimal', 15 + blobs: 'Blobs', 16 + warp: 'Stars', 17 + matrix: 'Matrix', 18 + fireflies: 'Fireflies', 19 + kaleidoscope: 'Kaleidoscope' 20 + };
+9
src/routes/(app)/p/[actor]/e/[rkey]/+page.svelte
··· 16 16 import { sanitize } from '$lib/cal/sanitize'; 17 17 import { generateICalEvent } from '$lib/cal/ical'; 18 18 import { launchConfetti } from '@foxui/visual'; 19 + import ThemeBackground from '$lib/components/ThemeBackground.svelte'; 20 + import ThemeApply from '$lib/components/ThemeApply.svelte'; 21 + import { defaultTheme, type EventTheme } from '$lib/theme'; 19 22 20 23 let { data } = $props(); 21 24 ··· 24 27 let rkey: string = $derived(data.rkey); 25 28 let hostProfile = $derived(data.hostProfile); 26 29 let attendees = $derived(data.attendees); 30 + 31 + let theme: EventTheme = $derived(eventData.theme ?? defaultTheme); 32 + 27 33 28 34 let hostUrl = $derived(`/p/${hostProfile?.handle || did}`); 29 35 let eventPath = $derived(`/p/${hostProfile?.handle || did}/e/${data.rkey}`); ··· 307 313 <meta name="twitter:description" content={eventData.description || `Event: ${eventData.name}`} /> 308 314 <meta name="twitter:image" content={ogImageUrl} /> 309 315 </svelte:head> 316 + 317 + <ThemeApply accentColor={theme.accentColor} baseColor={theme.baseColor} /> 318 + <ThemeBackground {theme} /> 310 319 311 320 <div class="min-h-screen px-6 py-12 sm:py-12"> 312 321 <div class="mx-auto max-w-3xl">