atmo.rsvp
1
fork

Configure Feed

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

Merge pull request #24 from flo-bit/feat/spaces

some small improvements

authored by

Florian and committed by
GitHub
f3ec3ab1 edaf97dc

+641 -345
+17 -104
src/lib/components/EventEditor.svelte
··· 12 12 } from '@foxui/core'; 13 13 import { goto } from '$app/navigation'; 14 14 import { onMount } from 'svelte'; 15 - import { browser, dev } from '$app/environment'; 16 - import { getImage, deleteImage } from '$lib/components/image-store'; 15 + import { dev } from '$app/environment'; 17 16 import { PlainTextEditor } from '@foxui/text'; 18 17 import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 19 18 import TimezonePicker from '$lib/components/TimezonePicker.svelte'; ··· 22 21 import type { FlatEventRecord } from '$lib/contrail'; 23 22 import ThemeApply from '$lib/components/ThemeApply.svelte'; 24 23 import ThemeBackground from '$lib/components/ThemeBackground.svelte'; 25 - import { defaultTheme, type EventTheme } from '$lib/theme'; 24 + import { defaultTheme, randomAccentColor, type EventTheme } from '$lib/theme'; 26 25 27 26 import type { Readable } from 'svelte/store'; 28 27 import { get } from 'svelte/store'; ··· 35 34 import RecurringModal from './editor/RecurringModal.svelte'; 36 35 import { 37 36 stripModePrefix, 38 - type EventDraft, 39 37 type EventLocation, 40 38 type EventMode, 41 39 type Visibility 42 40 } from './editor/types'; 43 - import { clearDraft, migrateLegacyDraft, readDraft, writeDraft } from './editor/draft'; 44 41 import { buildEventRecord, buildThumbnailMedia, renderPresetThumbnail } from './editor/save'; 42 + import { DEFAULT_PRESET, hashSeed } from './thumbnails/designs'; 45 43 46 44 let { 47 45 eventData = null, ··· 58 56 59 57 let isNew = $derived(eventData === null); 60 58 61 - let thumbnailKey: string | null = $state(null); 62 59 let thumbnailChanged = $state(false); 63 60 64 61 // svelte-ignore state_referenced_locally ··· 70 67 let mode: EventMode = $state('inperson'); 71 68 // svelte-ignore state_referenced_locally 72 69 let visibility: Visibility = $state(privateMode && dev ? 'private' : 'public'); 73 - let eventTheme: EventTheme = $state({ ...defaultTheme }); 70 + let eventTheme: EventTheme = $state( 71 + eventData === null 72 + ? { ...defaultTheme, accentColor: randomAccentColor() } 73 + : { ...defaultTheme } 74 + ); 74 75 let thumbnailFile: File | null = $state(null); 75 76 let thumbnailPreview: string | null = $state(null); 76 - let selectedPreset: { design: string; seed: number } | null = $state(null); 77 + let selectedPreset: string | null = $state(eventData === null ? DEFAULT_PRESET : null); 77 78 let submitting = $state(false); 78 79 let error: string | null = $state(null); 79 80 let titleEditor: Readable<Editor> | undefined = $state(undefined); ··· 82 83 let locationChanged = $state(false); 83 84 84 85 let links: Array<{ uri: string; name: string }> = $state([]); 85 - 86 - let draftLoaded = $state(false); 87 86 88 87 let showRecurringModal = $state(false); 89 88 ··· 144 143 populateThumbnailFromEventData(); 145 144 } 146 145 147 - onMount(async () => { 148 - if (isNew) migrateLegacyDraft(rkey); 149 - 150 - const draft = readDraft(rkey); 151 - if (draft) { 152 - name = draft.name || ''; 153 - description = draft.description || ''; 154 - startsAt = draft.startsAt || ''; 155 - endsAt = draft.endsAt || ''; 156 - if (draft.timezone) timezone = draft.timezone; 157 - if (draft.theme) eventTheme = draft.theme; 158 - links = draft.links || []; 159 - mode = draft.mode || 'inperson'; 160 - if (draft.visibility && (draft.visibility !== 'private' || dev)) 161 - visibility = draft.visibility; 162 - else if (privateMode && dev) visibility = 'private'; 163 - locationChanged = draft.locationChanged || false; 164 - if (draft.locationChanged) { 165 - location = draft.location || null; 166 - } else if (!isNew) { 167 - populateLocationFromEventData(); 168 - } 169 - thumbnailChanged = draft.thumbnailChanged || false; 170 - 171 - if (draft.thumbnailKey) { 172 - const img = await getImage(draft.thumbnailKey); 173 - if (img) { 174 - thumbnailKey = draft.thumbnailKey; 175 - thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 176 - thumbnailPreview = URL.createObjectURL(img.blob); 177 - thumbnailChanged = true; 178 - } 179 - } else if (!thumbnailChanged && !isNew) { 180 - populateThumbnailFromEventData(); 181 - } 182 - } else if (!isNew) { 183 - populateFromEventData(); 184 - } 185 - draftLoaded = true; 146 + onMount(() => { 147 + if (!isNew) populateFromEventData(); 186 148 if (titleEditor) get(titleEditor)?.commands.focus(); 187 149 }); 188 150 189 - let saveDraftTimeout: ReturnType<typeof setTimeout> | undefined; 190 - 191 - function saveDraft() { 192 - if (!draftLoaded || !browser) return; 193 - clearTimeout(saveDraftTimeout); 194 - saveDraftTimeout = setTimeout(() => { 195 - const draft: EventDraft = { 196 - name, 197 - description, 198 - startsAt, 199 - endsAt, 200 - timezone, 201 - theme: eventTheme, 202 - links, 203 - mode, 204 - visibility, 205 - thumbnailChanged, 206 - locationChanged 207 - }; 208 - if (locationChanged) draft.location = location; 209 - if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 210 - writeDraft(rkey, draft); 211 - }, 500); 212 - } 213 - 214 - $effect(() => { 215 - // track all draft fields by reading them 216 - void [ 217 - name, 218 - description, 219 - startsAt, 220 - endsAt, 221 - timezone, 222 - JSON.stringify(eventTheme), 223 - mode, 224 - visibility, 225 - JSON.stringify(links), 226 - JSON.stringify(location), 227 - thumbnailKey, 228 - thumbnailChanged, 229 - locationChanged 230 - ]; 231 - saveDraft(); 232 - }); 233 - 234 151 let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || ''); 235 152 236 153 let thumbnailDateStr = $derived.by(() => { ··· 278 195 // Generate thumbnail from preset if selected and no custom upload 279 196 if (selectedPreset && !thumbnailFile) { 280 197 const rendered = await renderPresetThumbnail({ 281 - design: selectedPreset.design, 282 - seed: selectedPreset.seed, 198 + design: selectedPreset, 199 + seed: hashSeed(rkey), 283 200 name, 284 - dateStr: thumbnailDateStr 201 + dateStr: thumbnailDateStr, 202 + accent: eventTheme.accentColor 285 203 }); 286 204 if (rendered) { 287 205 thumbnailFile = rendered; ··· 317 235 if (visibility === 'private') { 318 236 const { createPrivateEvent } = await import('$lib/spaces/server/spaces.remote'); 319 237 const { spaceUri, rkey: eventRkey } = await createPrivateEvent({ key: rkey, record }); 320 - clearDraft(rkey); 321 - if (thumbnailKey) deleteImage(thumbnailKey); 322 238 const spaceKey = spaceUri.split('/').pop(); 323 239 const handle = 324 240 user.profile?.handle && user.profile.handle !== 'handle.invalid' ··· 337 253 if (response.ok) { 338 254 const eventUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 339 255 await notifyContrailOfUpdate(eventUri); 340 - clearDraft(rkey); 341 - if (thumbnailKey) deleteImage(thumbnailKey); 342 256 const handle = 343 257 user.profile?.handle && user.profile.handle !== 'handle.invalid' 344 258 ? user.profile.handle ··· 367 281 }); 368 282 const eventUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 369 283 await notifyContrailOfUpdate(eventUri); 370 - clearDraft(rkey); 371 - if (thumbnailKey) deleteImage(thumbnailKey); 372 284 const handle = 373 285 user.profile?.handle && user.profile.handle !== 'handle.invalid' 374 286 ? user.profile.handle ··· 413 325 {rkey} 414 326 {name} 415 327 dateStr={thumbnailDateStr} 328 + accent={eventTheme.accentColor} 416 329 bind:thumbnailFile 417 330 bind:thumbnailPreview 418 - bind:thumbnailKey 419 331 bind:thumbnailChanged 420 332 bind:selectedPreset 421 333 /> ··· 621 533 {thumbnailFile} 622 534 {thumbnailChanged} 623 535 {selectedPreset} 536 + accent={eventTheme.accentColor} 624 537 />
+23
src/lib/components/EventView.svelte
··· 9 9 import EventCard from '$lib/components/EventCard.svelte'; 10 10 import EventAttendees from './EventAttendees.svelte'; 11 11 import VodPlayer, { type VodPlayerApi } from '$lib/components/VodPlayer.svelte'; 12 + import StreamPlacePlayer from './event-view/StreamPlacePlayer.svelte'; 12 13 import { page } from '$app/state'; 13 14 import { launchConfetti } from '@foxui/visual'; 14 15 import ThemeBackground from '$lib/components/ThemeBackground.svelte'; ··· 93 94 94 95 let isOngoing = $derived(isEventOngoing(eventData.startsAt, eventData.endsAt)); 95 96 let isPast = $derived(endDate ? endDate < new Date() : false); 97 + 98 + let streamPlaceHandle = $derived.by(() => { 99 + const uris = eventData.uris; 100 + if (!uris) return null; 101 + for (const { uri } of uris) { 102 + const m = uri.match(/^https?:\/\/stream\.place\/([^/?#]+)/i); 103 + if (m) return m[1]; 104 + } 105 + return null; 106 + }); 96 107 97 108 let descriptionHtml = $derived( 98 109 buildDescriptionHtml(eventData.description, eventData.facets) ··· 247 258 onrsvp={handleRsvp} 248 259 oncancel={handleRsvpCancel} 249 260 /> 261 + {/if} 262 + 263 + <!-- Live stream --> 264 + {#if isOngoing && streamPlaceHandle} 265 + <div class="mt-8 mb-8"> 266 + <p 267 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 268 + > 269 + Live now 270 + </p> 271 + <StreamPlacePlayer handle={streamPlaceHandle} title={eventData.name} /> 272 + </div> 250 273 {/if} 251 274 252 275 <!-- About Event -->
+22 -19
src/lib/components/ThumbnailPresets.svelte
··· 1 1 <script lang="ts"> 2 2 // @ts-nocheck 3 - import { designs } from './thumbnails/designs'; 3 + import { designs, resolveAccentColor } from './thumbnails/designs'; 4 4 import { tick } from 'svelte'; 5 5 6 6 let { 7 7 name = '', 8 8 dateStr = '', 9 - selected = $bindable<{ design: string; seed: number } | null>(null), 9 + accent = '', 10 + seed = 1, 11 + selected = $bindable<string | null>(null), 10 12 onselect 11 13 }: { 12 14 name?: string; 13 15 dateStr?: string; 14 - selected?: { design: string; seed: number } | null; 16 + accent?: string; 17 + seed?: number; 18 + selected?: string | null; 15 19 onselect?: () => void; 16 20 } = $props(); 17 21 18 22 const presetKeys = Object.keys(designs); 19 - const seeds = [0, 1, 2, 3]; 20 23 const previewSize = 200; 21 24 22 25 let containerEl: HTMLDivElement | undefined = $state(undefined); 23 26 24 27 function renderAll() { 25 28 if (!containerEl) return; 29 + const color = resolveAccentColor(accent); 26 30 const canvases = containerEl.querySelectorAll<HTMLCanvasElement>('canvas'); 27 31 canvases.forEach((canvas) => { 28 32 const key = canvas.dataset.key!; 29 - const seed = parseInt(canvas.dataset.seed!, 10); 30 33 const ctx = canvas.getContext('2d'); 31 34 if (!ctx) return; 32 35 canvas.width = previewSize; 33 36 canvas.height = previewSize; 34 - designs[key](ctx, previewSize, previewSize, name || 'Event', dateStr, seed); 37 + designs[key](ctx, previewSize, previewSize, name || 'Event', dateStr, seed, color); 35 38 }); 36 39 } 37 40 38 41 $effect(() => { 39 42 void name; 40 43 void dateStr; 44 + void accent; 45 + void seed; 41 46 void containerEl; 42 47 tick().then(renderAll); 43 48 }); ··· 45 50 46 51 <div class="flex flex-col gap-3"> 47 52 <p class="text-base-500 dark:text-base-400 text-xs font-medium">Preset thumbnails</p> 48 - <div class="grid grid-cols-4 gap-2" bind:this={containerEl}> 53 + <div class="grid grid-cols-3 gap-2" bind:this={containerEl}> 49 54 {#each presetKeys as key} 50 - {#each seeds as seed} 51 - <button 52 - type="button" 53 - class="aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-colors 54 - {selected?.design === key && selected?.seed === seed 55 - ? 'border-accent-500' 56 - : 'border-base-200 dark:border-base-700 hover:border-accent-400 dark:hover:border-accent-500'}" 57 - onclick={() => { selected = { design: key, seed }; onselect?.(); }} 58 - > 59 - <canvas data-key={key} data-seed={seed} class="h-full w-full"></canvas> 60 - </button> 61 - {/each} 55 + <button 56 + type="button" 57 + class="aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-colors 58 + {selected === key 59 + ? 'border-accent-500' 60 + : 'border-base-200 dark:border-base-700 hover:border-accent-400 dark:hover:border-accent-500'}" 61 + onclick={() => { selected = key; onselect?.(); }} 62 + > 63 + <canvas data-key={key} class="h-full w-full"></canvas> 64 + </button> 62 65 {/each} 63 66 </div> 64 67 </div>
+9 -5
src/lib/components/editor/RecurringModal.svelte
··· 8 8 import type { FlatEventRecord } from '$lib/contrail'; 9 9 import type { EventLocation, EventMode } from './types'; 10 10 import { buildThumbnailMedia, renderPresetThumbnail } from './save'; 11 + import { hashSeed } from '$lib/components/thumbnails/designs'; 11 12 12 13 let { 13 14 open = $bindable(), ··· 25 26 thumbnailDateStr, 26 27 thumbnailFile, 27 28 thumbnailChanged, 28 - selectedPreset 29 + selectedPreset, 30 + accent 29 31 }: { 30 32 open: boolean; 31 33 rkey: string; ··· 42 44 thumbnailDateStr: string; 43 45 thumbnailFile: File | null; 44 46 thumbnailChanged: boolean; 45 - selectedPreset: { design: string; seed: number } | null; 47 + selectedPreset: string | null; 48 + accent: string; 46 49 } = $props(); 47 50 48 51 let interval = $state(1); ··· 88 91 let hasNewThumbnail = thumbnailChanged; 89 92 if (selectedPreset && !fileForUpload) { 90 93 const rendered = await renderPresetThumbnail({ 91 - design: selectedPreset.design, 92 - seed: selectedPreset.seed, 94 + design: selectedPreset, 95 + seed: hashSeed(rkey), 93 96 name, 94 - dateStr: thumbnailDateStr 97 + dateStr: thumbnailDateStr, 98 + accent 95 99 }); 96 100 if (rendered) { 97 101 fileForUpload = rendered;
+15 -19
src/lib/components/editor/ThumbnailSection.svelte
··· 2 2 import { Button, Modal } from '@foxui/core'; 3 3 import Avatar from 'svelte-boring-avatars'; 4 4 import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte'; 5 - import { designs } from '$lib/components/thumbnails/designs'; 6 - import { deleteImage, putImage } from '$lib/components/image-store'; 5 + import { designs, hashSeed, resolveAccentColor } from '$lib/components/thumbnails/designs'; 7 6 8 7 let { 9 8 rkey, 10 9 name, 11 10 dateStr, 11 + accent, 12 12 thumbnailFile = $bindable(), 13 13 thumbnailPreview = $bindable(), 14 - thumbnailKey = $bindable(), 15 14 thumbnailChanged = $bindable(), 16 15 selectedPreset = $bindable() 17 16 }: { 18 17 rkey: string; 19 18 name: string; 20 19 dateStr: string; 20 + accent: string; 21 21 thumbnailFile: File | null; 22 22 thumbnailPreview: string | null; 23 - thumbnailKey: string | null; 24 23 thumbnailChanged: boolean; 25 - selectedPreset: { design: string; seed: number } | null; 24 + selectedPreset: string | null; 26 25 } = $props(); 27 26 28 27 let fileInput: HTMLInputElement | undefined = $state(); ··· 30 29 let showModal = $state(false); 31 30 let isDragOver = $state(false); 32 31 33 - async function setThumbnail(file: File) { 32 + const seed = $derived(hashSeed(rkey)); 33 + 34 + function setThumbnail(file: File) { 34 35 thumbnailFile = file; 35 36 thumbnailChanged = true; 36 37 selectedPreset = null; 37 38 if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 38 39 thumbnailPreview = URL.createObjectURL(file); 39 - 40 - if (thumbnailKey) await deleteImage(thumbnailKey); 41 - thumbnailKey = crypto.randomUUID(); 42 - await putImage(thumbnailKey, file, file.name); 43 40 } 44 41 45 42 function onFileChange(e: Event) { ··· 77 74 URL.revokeObjectURL(thumbnailPreview); 78 75 thumbnailPreview = null; 79 76 } 80 - if (thumbnailKey) { 81 - deleteImage(thumbnailKey); 82 - thumbnailKey = null; 83 - } 84 77 if (fileInput) fileInput.value = ''; 85 78 } 86 79 87 - // Render preset preview canvas whenever the selection, name, or date changes. 80 + // Render preset preview canvas whenever the selection, name, date, or accent changes. 88 81 $effect(() => { 89 - if (selectedPreset && presetPreviewCanvas && designs[selectedPreset.design]) { 82 + if (selectedPreset && presetPreviewCanvas && designs[selectedPreset]) { 90 83 const ctx = presetPreviewCanvas.getContext('2d'); 91 84 if (!ctx) return; 92 85 presetPreviewCanvas.width = 800; 93 86 presetPreviewCanvas.height = 800; 94 - designs[selectedPreset.design]( 87 + designs[selectedPreset]( 95 88 ctx, 96 89 800, 97 90 800, 98 91 name || 'Event', 99 92 dateStr, 100 - selectedPreset.seed 93 + seed, 94 + resolveAccentColor(accent) 101 95 ); 102 96 } 103 97 }); ··· 124 118 alt="Thumbnail preview" 125 119 class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 126 120 /> 127 - {:else if selectedPreset && designs[selectedPreset.design]} 121 + {:else if selectedPreset && designs[selectedPreset]} 128 122 <div 129 123 class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border" 130 124 > ··· 211 205 <ThumbnailPresets 212 206 {name} 213 207 {dateStr} 208 + {accent} 209 + {seed} 214 210 bind:selected={selectedPreset} 215 211 onselect={() => { 216 212 showModal = false;
-36
src/lib/components/editor/draft.ts
··· 1 - import type { EventDraft } from './types'; 2 - 3 - const OLD_DRAFT_KEY = 'blento-event-draft'; 4 - 5 - export function draftKeyFor(rkey: string): string { 6 - return `blento-event-edit-${rkey}`; 7 - } 8 - 9 - /** Promote any pre-existing shared "new event" draft into a per-rkey draft. */ 10 - export function migrateLegacyDraft(rkey: string): void { 11 - const key = draftKeyFor(rkey); 12 - const old = localStorage.getItem(OLD_DRAFT_KEY); 13 - if (old && !localStorage.getItem(key)) { 14 - localStorage.setItem(key, old); 15 - localStorage.removeItem(OLD_DRAFT_KEY); 16 - } 17 - } 18 - 19 - export function readDraft(rkey: string): EventDraft | null { 20 - const saved = localStorage.getItem(draftKeyFor(rkey)); 21 - if (!saved) return null; 22 - try { 23 - return JSON.parse(saved) as EventDraft; 24 - } catch { 25 - localStorage.removeItem(draftKeyFor(rkey)); 26 - return null; 27 - } 28 - } 29 - 30 - export function writeDraft(rkey: string, draft: EventDraft): void { 31 - localStorage.setItem(draftKeyFor(rkey), JSON.stringify(draft)); 32 - } 33 - 34 - export function clearDraft(rkey: string): void { 35 - localStorage.removeItem(draftKeyFor(rkey)); 36 - }
+11 -2
src/lib/components/editor/save.ts
··· 3 3 import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 4 4 import type { Handle } from '@atcute/lexicons'; 5 5 import { datetimeLocalToISO } from '$lib/date-format'; 6 - import { designs } from '$lib/components/thumbnails/designs'; 6 + import { designs, resolveAccentColor } from '$lib/components/thumbnails/designs'; 7 7 import type { FlatEventRecord } from '$lib/contrail'; 8 8 import type { EventTheme } from '$lib/theme'; 9 9 import type { EventLocation, EventMode, Visibility } from './types'; ··· 56 56 seed: number; 57 57 name: string; 58 58 dateStr: string; 59 + accent?: string; 59 60 }): Promise<File | null> { 60 61 const drawer = designs[args.design]; 61 62 if (!drawer) return null; ··· 64 65 canvas.height = 800; 65 66 const ctx = canvas.getContext('2d'); 66 67 if (!ctx) return null; 67 - drawer(ctx, 800, 800, args.name.trim() || 'Event', args.dateStr, args.seed); 68 + drawer( 69 + ctx, 70 + 800, 71 + 800, 72 + args.name.trim() || 'Event', 73 + args.dateStr, 74 + args.seed, 75 + resolveAccentColor(args.accent) 76 + ); 68 77 const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 69 78 if (!blob) return null; 70 79 return new File([blob], 'thumbnail.png', { type: 'image/png' });
-18
src/lib/components/editor/types.ts
··· 1 - import type { EventTheme } from '$lib/theme'; 2 - 3 1 export type EventMode = 'inperson' | 'virtual' | 'hybrid'; 4 2 export type Visibility = 'public' | 'private' | 'unlisted'; 5 3 ··· 8 6 locality?: string; 9 7 region?: string; 10 8 country?: string; 11 - } 12 - 13 - export interface EventDraft { 14 - name: string; 15 - description: string; 16 - startsAt: string; 17 - endsAt: string; 18 - timezone?: string; 19 - theme?: EventTheme; 20 - links: Array<{ uri: string; name: string }>; 21 - mode?: EventMode; 22 - visibility?: Visibility; 23 - thumbnailKey?: string; 24 - thumbnailChanged?: boolean; 25 - location?: EventLocation | null; 26 - locationChanged?: boolean; 27 9 } 28 10 29 11 export function stripModePrefix(modeStr: string): EventMode {
+9 -5
src/lib/components/event-view/EventDateBlock.svelte
··· 1 1 <script lang="ts"> 2 + import { Badge } from '@foxui/core'; 2 3 import { formatDay, formatFullDate, formatMonth, formatTime, formatWeekday } from './format'; 3 4 4 5 let { startDate, endDate }: { startDate: Date; endDate: Date | null } = $props(); ··· 29 30 - {formatWeekday(endDate)}, {formatFullDate(endDate)} 30 31 {/if} 31 32 </p> 32 - <p class="text-base-500 dark:text-base-400 text-sm"> 33 - {formatTime(startDate)} 34 - {#if endDate && isSameDay} 35 - - {formatTime(endDate)} 36 - {/if} 33 + <p class="text-base-500 dark:text-base-400 flex flex-wrap items-center gap-2 text-sm"> 34 + <span> 35 + {formatTime(startDate)} 36 + {#if endDate && isSameDay} 37 + - {formatTime(endDate)} 38 + {/if} 39 + </span> 40 + <Badge size="sm" variant="secondary">local time</Badge> 37 41 </p> 38 42 </div> 39 43 </div>
+222
src/lib/components/event-view/StreamPlacePlayer.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import 'plyr/dist/plyr.css'; 4 + import type PlyrType from 'plyr'; 5 + 6 + let { 7 + handle, 8 + title 9 + }: { 10 + handle: string; 11 + title: string; 12 + } = $props(); 13 + 14 + let videoEl: HTMLVideoElement | undefined = $state(); 15 + let overlayEl: HTMLAnchorElement | undefined = $state(); 16 + let error = $state(false); 17 + let controlsVisible = $state(true); 18 + 19 + let pc: RTCPeerConnection | null = null; 20 + let plyr: PlyrType | null = null; 21 + 22 + const posterUrl = $derived( 23 + `https://stream.place/api/playback/${encodeURIComponent(handle)}/stream.jpg` 24 + ); 25 + 26 + onMount(() => { 27 + init(); 28 + return () => { 29 + if (pc) { 30 + pc.close(); 31 + pc = null; 32 + } 33 + if (videoEl) { 34 + videoEl.srcObject = null; 35 + } 36 + plyr?.destroy(); 37 + }; 38 + }); 39 + 40 + function waitForIceGathering(peer: RTCPeerConnection, timeoutMs = 2000): Promise<void> { 41 + return new Promise((resolve) => { 42 + if (peer.iceGatheringState === 'complete') { 43 + resolve(); 44 + return; 45 + } 46 + 47 + let done = false; 48 + const finish = () => { 49 + if (done) return; 50 + done = true; 51 + peer.removeEventListener('icegatheringstatechange', onStateChange); 52 + clearTimeout(timer); 53 + resolve(); 54 + }; 55 + 56 + const onStateChange = () => { 57 + if (peer.iceGatheringState === 'complete') { 58 + finish(); 59 + } 60 + }; 61 + 62 + peer.addEventListener('icegatheringstatechange', onStateChange); 63 + const timer = setTimeout(finish, timeoutMs); 64 + }); 65 + } 66 + 67 + async function init() { 68 + if (!videoEl) return; 69 + 70 + try { 71 + pc = new RTCPeerConnection({ 72 + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], 73 + bundlePolicy: 'max-bundle' 74 + }); 75 + 76 + pc.addTransceiver('video', { direction: 'recvonly' }); 77 + pc.addTransceiver('audio', { direction: 'recvonly' }); 78 + 79 + pc.addEventListener('track', (event) => { 80 + if (!videoEl) return; 81 + if (event.streams && event.streams[0]) { 82 + videoEl.srcObject = event.streams[0]; 83 + } else { 84 + let stream = videoEl.srcObject as MediaStream | null; 85 + if (!stream) { 86 + stream = new MediaStream(); 87 + videoEl.srcObject = stream; 88 + } 89 + stream.addTrack(event.track); 90 + } 91 + }); 92 + 93 + const offer = await pc.createOffer(); 94 + await pc.setLocalDescription(offer); 95 + await waitForIceGathering(pc, 2000); 96 + 97 + const sdp = pc.localDescription?.sdp; 98 + if (!sdp) { 99 + error = true; 100 + pc.close(); 101 + pc = null; 102 + return; 103 + } 104 + 105 + const response = await fetch( 106 + `https://stream.place/api/playback/${encodeURIComponent(handle)}/webrtc?rendition=source`, 107 + { 108 + method: 'POST', 109 + headers: { 'Content-Type': 'application/sdp' }, 110 + body: sdp 111 + } 112 + ); 113 + 114 + if (!response.ok) { 115 + error = true; 116 + pc.close(); 117 + pc = null; 118 + return; 119 + } 120 + 121 + const answerSdp = await response.text(); 122 + await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); 123 + 124 + const { default: Plyr } = await import('plyr'); 125 + 126 + plyr = new Plyr(videoEl, { 127 + controls: ['play', 'mute', 'volume', 'fullscreen'], 128 + settings: [], 129 + ratio: '16:9' 130 + }); 131 + 132 + plyr.on('controlshidden', () => { 133 + controlsVisible = false; 134 + }); 135 + plyr.on('controlsshown', () => { 136 + controlsVisible = true; 137 + }); 138 + 139 + // Move the overlay link inside Plyr's container so mousemove over the 140 + // button bubbles into Plyr's activity tracker — otherwise hovering it 141 + // hides the controls, which hides the button, which reactivates Plyr 142 + // (a flicker loop). 143 + const plyrEl = videoEl.closest('.plyr'); 144 + if (plyrEl && overlayEl) plyrEl.appendChild(overlayEl); 145 + } catch { 146 + error = true; 147 + if (pc) { 148 + pc.close(); 149 + pc = null; 150 + } 151 + } 152 + } 153 + </script> 154 + 155 + {#snippet watchOnStreamPlace(hidden = false)} 156 + <a 157 + bind:this={overlayEl} 158 + href="https://stream.place/{handle}" 159 + target="_blank" 160 + rel="noopener noreferrer" 161 + onclick={() => plyr?.pause()} 162 + class="absolute top-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm transition-opacity duration-200 hover:bg-black/75 {hidden 163 + ? 'pointer-events-none opacity-0' 164 + : 'opacity-100'}" 165 + > 166 + Watch on stream.place 167 + <svg 168 + xmlns="http://www.w3.org/2000/svg" 169 + viewBox="0 0 20 20" 170 + fill="currentColor" 171 + class="size-3" 172 + aria-hidden="true" 173 + > 174 + <path 175 + fill-rule="evenodd" 176 + d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z" 177 + clip-rule="evenodd" 178 + /> 179 + <path 180 + fill-rule="evenodd" 181 + d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z" 182 + clip-rule="evenodd" 183 + /> 184 + </svg> 185 + </a> 186 + {/snippet} 187 + 188 + {#if error} 189 + <div 190 + class="bg-base-100 dark:bg-base-900 border-base-200 dark:border-base-800 relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-xl border" 191 + > 192 + <div 193 + class="absolute inset-0 bg-cover bg-center" 194 + style="background-image: url({posterUrl});" 195 + ></div> 196 + <div class="absolute inset-0 bg-black/60"></div> 197 + {@render watchOnStreamPlace()} 198 + <p class="text-base-100 relative text-sm">Stream is offline</p> 199 + </div> 200 + {:else} 201 + <div 202 + class="border-base-300 dark:border-base-400/40 relative aspect-video w-full max-w-full overflow-hidden rounded-xl border" 203 + > 204 + {@render watchOnStreamPlace(!controlsVisible)} 205 + <video 206 + bind:this={videoEl} 207 + class="h-full w-full" 208 + aria-label={title} 209 + poster={posterUrl} 210 + autoplay 211 + playsinline 212 + muted 213 + crossorigin="anonymous" 214 + ></video> 215 + </div> 216 + {/if} 217 + 218 + <style> 219 + * { 220 + --plyr-color-main: var(--color-accent-500); 221 + } 222 + </style>
-58
src/lib/components/image-store.ts
··· 1 - const DB_NAME = 'blento-images'; 2 - const STORE_NAME = 'images'; 3 - 4 - function openDB(): Promise<IDBDatabase> { 5 - return new Promise((resolve, reject) => { 6 - const request = indexedDB.open(DB_NAME, 1); 7 - request.onupgradeneeded = () => { 8 - request.result.createObjectStore(STORE_NAME); 9 - }; 10 - request.onsuccess = () => resolve(request.result); 11 - request.onerror = () => reject(request.error); 12 - }); 13 - } 14 - 15 - export async function putImage(key: string, blob: Blob, name: string): Promise<void> { 16 - const db = await openDB(); 17 - const data = await blob.arrayBuffer(); 18 - return new Promise((resolve, reject) => { 19 - const tx = db.transaction(STORE_NAME, 'readwrite'); 20 - tx.objectStore(STORE_NAME).put({ data, type: blob.type, name }, key); 21 - tx.oncomplete = () => resolve(); 22 - tx.onerror = () => reject(tx.error); 23 - }); 24 - } 25 - 26 - export async function getImage(key: string): Promise<{ blob: Blob; name: string } | null> { 27 - const db = await openDB(); 28 - return new Promise((resolve, reject) => { 29 - const tx = db.transaction(STORE_NAME, 'readonly'); 30 - const request = tx.objectStore(STORE_NAME).get(key); 31 - request.onsuccess = () => { 32 - if (!request.result) return resolve(null); 33 - const { data, type, name } = request.result; 34 - resolve({ blob: new Blob([data], { type }), name }); 35 - }; 36 - request.onerror = () => reject(request.error); 37 - }); 38 - } 39 - 40 - export async function deleteImage(key: string): Promise<void> { 41 - const db = await openDB(); 42 - return new Promise((resolve, reject) => { 43 - const tx = db.transaction(STORE_NAME, 'readwrite'); 44 - tx.objectStore(STORE_NAME).delete(key); 45 - tx.oncomplete = () => resolve(); 46 - tx.onerror = () => reject(tx.error); 47 - }); 48 - } 49 - 50 - export async function clearImages(): Promise<void> { 51 - const db = await openDB(); 52 - return new Promise((resolve, reject) => { 53 - const tx = db.transaction(STORE_NAME, 'readwrite'); 54 - tx.objectStore(STORE_NAME).clear(); 55 - tx.oncomplete = () => resolve(); 56 - tx.onerror = () => reject(tx.error); 57 - }); 58 - }
+15 -3
src/lib/components/themes/Matrix.svelte
··· 39 39 .getPropertyValue('--color-accent-500') 40 40 .trim(); 41 41 42 - const bgColor = getComputedStyle(document.documentElement) 43 - .getPropertyValue('--color-base-900') 44 - .trim(); 42 + let bgColor = ''; 43 + function readBgColor() { 44 + const isDark = document.documentElement.classList.contains('dark'); 45 + bgColor = getComputedStyle(document.documentElement) 46 + .getPropertyValue(isDark ? '--color-base-900' : '--color-base-50') 47 + .trim(); 48 + } 49 + readBgColor(); 50 + 51 + const themeObserver = new MutationObserver(readBgColor); 52 + themeObserver.observe(document.documentElement, { 53 + attributes: true, 54 + attributeFilter: ['class'] 55 + }); 45 56 46 57 let lastResize = canvas.width; 47 58 let lastTime = performance.now(); ··· 129 140 return () => { 130 141 cancelAnimationFrame(animId); 131 142 window.removeEventListener('resize', resize); 143 + themeObserver.disconnect(); 132 144 }; 133 145 }); 134 146 </script>
+288 -76
src/lib/components/thumbnails/designs.ts
··· 4 4 h: number, 5 5 name: string, 6 6 dateStr: string, 7 - seed: number 7 + seed: number, 8 + accent: string 8 9 ) => void; 9 10 10 - function hue(seed: number, offset: number) { 11 - return (seed * 137.5 + offset) % 360; 11 + function lch(accent: string, l: number | string, c: number | string = 'c', hShift = 0, a = 1) { 12 + const lStr = typeof l === 'number' ? l.toFixed(3) : l; 13 + const cStr = typeof c === 'number' ? c.toFixed(3) : c; 14 + const hStr = hShift === 0 ? 'h' : `calc(h + ${hShift})`; 15 + return `oklch(from ${accent} ${lStr} ${cStr} ${hStr} / ${a})`; 12 16 } 13 17 14 - function hsl(h: number, s: number, l: number) { 15 - return `hsl(${h}, ${s}%, ${l}%)`; 16 - } 17 - 18 - function hsla(h: number, s: number, l: number, a: number) { 19 - return `hsla(${h}, ${s}%, ${l}%, ${a})`; 18 + /** Monochrome film-grain overlay. Reads back pixels and perturbs each RGB 19 + * channel by ±intensity/255 using a seeded LCG — deterministic, so picker 20 + * previews and the uploaded PNG match. Call as the final step of a design. */ 21 + function addNoise( 22 + ctx: CanvasRenderingContext2D, 23 + w: number, 24 + h: number, 25 + seed: number, 26 + intensity = 7 27 + ) { 28 + const img = ctx.getImageData(0, 0, w, h); 29 + const data = img.data; 30 + let s = seed | 0 || 1; 31 + for (let i = 0; i < data.length; i += 4) { 32 + s = (Math.imul(s, 1664525) + 1013904223) | 0; 33 + const n = ((s >>> 16) & 0xff) / 255 - 0.5; 34 + const d = n * 2 * intensity; 35 + data[i] = Math.max(0, Math.min(255, data[i] + d)); 36 + data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + d)); 37 + data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + d)); 38 + } 39 + ctx.putImageData(img, 0, 0); 20 40 } 21 41 22 42 function drawText( ··· 35 55 ctx.textAlign = align; 36 56 ctx.textBaseline = 'middle'; 37 57 38 - // Word wrap 39 58 const words = text.split(' '); 40 59 const lines: string[] = []; 41 60 let line = ''; ··· 61 80 return totalHeight; 62 81 } 63 82 64 - export const gradientMesh: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed) => { 65 - const h1 = hue(seed, 0); 66 - const h2 = hue(seed, 120); 67 - const h3 = hue(seed, 240); 83 + export const gradientMesh: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed, accent) => { 84 + const angle = (seed * 47) % 360; 85 + const rad = (angle * Math.PI) / 180; 86 + const cx = w / 2 + (Math.cos(rad) * w) / 2; 87 + const cy = h / 2 + (Math.sin(rad) * h) / 2; 88 + 89 + const shiftA = ((seed * 37) % 60) - 30; 90 + const shiftB = ((seed * 71) % 70) + 20; 68 91 69 - // Background gradient 70 - const bg = ctx.createLinearGradient(0, 0, w, h); 71 - bg.addColorStop(0, hsl(h1, 70, 45)); 72 - bg.addColorStop(0.5, hsl(h2, 65, 40)); 73 - bg.addColorStop(1, hsl(h3, 75, 35)); 92 + const bg = ctx.createLinearGradient(w - cx, h - cy, cx, cy); 93 + bg.addColorStop(0, lch(accent, 0.55, 'c', shiftA)); 94 + bg.addColorStop(0.5, lch(accent, 0.45, 'c', 0)); 95 + bg.addColorStop(1, lch(accent, 0.35, 'c', -shiftB)); 74 96 ctx.fillStyle = bg; 75 97 ctx.fillRect(0, 0, w, h); 76 98 77 - // Blurred blobs (circles with radial gradients) 78 - function blob(x: number, y: number, r: number, color: string, alpha: number) { 99 + function blob(x: number, y: number, r: number, hShift: number, alpha: number) { 79 100 const g = ctx.createRadialGradient(x, y, 0, x, y, r); 80 - g.addColorStop(0, hsla(parseFloat(color), 80, 60, alpha)); 81 - g.addColorStop(1, hsla(parseFloat(color), 80, 60, 0)); 101 + g.addColorStop(0, lch(accent, 0.75, 'c', hShift, alpha)); 102 + g.addColorStop(1, lch(accent, 0.75, 'c', hShift, 0)); 82 103 ctx.fillStyle = g; 83 104 ctx.fillRect(x - r, y - r, r * 2, r * 2); 84 105 } 85 - blob(w * -0.1, h * -0.1, w * 0.4, String(h2), 0.4); 86 - blob(w * 1.1, h * 1.1, w * 0.35, String(h1), 0.3); 87 - blob(w * 0.4, h * 0.3, w * 0.3, String(h3), 0.25); 106 + 107 + const p = (i: number, m: number) => ((seed * i) % m) / m; 108 + blob(w * (-0.1 + p(17, 30) * 0.3), h * (-0.1 + p(23, 30) * 0.3), w * 0.4, shiftA * 1.5, 0.45); 109 + blob(w * (1.1 - p(13, 30) * 0.3), h * (1.1 - p(19, 30) * 0.3), w * 0.35, shiftB, 0.35); 110 + blob(w * (0.3 + p(29, 40) * 0.4), h * (0.3 + p(31, 40) * 0.4), w * 0.3, -shiftA, 0.3); 88 111 89 - // Text 90 112 if (name) { 91 113 const th = drawText(ctx, name, w / 2, h / 2 - 10, w * 0.75, w * 0.09, 'bold', 'white'); 92 114 if (dateStr) { 93 - drawText(ctx, dateStr, w / 2, h / 2 + th / 2 + w * 0.03, w * 0.7, w * 0.04, '500', 'rgba(255,255,255,0.8)'); 115 + drawText( 116 + ctx, 117 + dateStr, 118 + w / 2, 119 + h / 2 + th / 2 + w * 0.03, 120 + w * 0.7, 121 + w * 0.04, 122 + '500', 123 + 'rgba(255,255,255,0.85)' 124 + ); 94 125 } 95 126 } 127 + 128 + addNoise(ctx, w, h, seed); 96 129 }; 97 130 98 - export const boldType: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed) => { 99 - const hu = hue(seed, 0); 131 + export const boldType: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed, accent) => { 132 + ctx.fillStyle = lch(accent, 0.12, 0.025); 133 + ctx.fillRect(0, 0, w, h); 100 134 101 - ctx.fillStyle = hsl(hu, 15, 10); 135 + const angle = (seed * 53) % 360; 136 + const rad = (angle * Math.PI) / 180; 137 + const hl = ctx.createLinearGradient( 138 + w / 2 - (Math.cos(rad) * w) / 2, 139 + h / 2 - (Math.sin(rad) * h) / 2, 140 + w / 2 + (Math.cos(rad) * w) / 2, 141 + h / 2 + (Math.sin(rad) * h) / 2 142 + ); 143 + hl.addColorStop(0, lch(accent, 0.22, 'c', 0, 0.4)); 144 + hl.addColorStop(1, lch(accent, 0.1, 0.02, 0, 0)); 145 + ctx.fillStyle = hl; 102 146 ctx.fillRect(0, 0, w, h); 103 147 104 148 if (name) { 105 - drawText(ctx, name, w * 0.07, h * 0.72, w * 0.86, w * 0.11, '900', hsl(hu, 70, 65), 'left'); 149 + drawText( 150 + ctx, 151 + name, 152 + w * 0.07, 153 + h * 0.72, 154 + w * 0.86, 155 + w * 0.11, 156 + '900', 157 + lch(accent, 0.75, 'c'), 158 + 'left' 159 + ); 106 160 } 107 161 if (dateStr) { 108 - ctx.fillStyle = 'rgba(255,255,255,0.5)'; 162 + ctx.fillStyle = lch(accent, 0.55, 0.03, 0, 0.9); 109 163 ctx.font = `500 ${w * 0.04}px system-ui, -apple-system, sans-serif`; 110 164 ctx.textAlign = 'left'; 111 165 ctx.textBaseline = 'top'; 112 166 ctx.fillText(dateStr, w * 0.07, h * 0.88); 113 167 } 168 + 169 + addNoise(ctx, w, h, seed); 114 170 }; 115 171 116 - export const minimal: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed) => { 117 - const hu = hue(seed, 0); 172 + export const minimal: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed, accent) => { 173 + ctx.fillStyle = lch(accent, 0.97, 0.02); 174 + ctx.fillRect(0, 0, w, h); 118 175 119 - ctx.fillStyle = hsl(hu, 20, 95); 120 - ctx.fillRect(0, 0, w, h); 176 + const edge = seed % 4; 177 + ctx.fillStyle = lch(accent, 0.55, 'c'); 178 + if (edge === 0) ctx.fillRect(0, h * 0.5 - 1.5, w * 0.12, 3); 179 + else if (edge === 1) ctx.fillRect(w * 0.88, h * 0.5 - 1.5, w * 0.12, 3); 180 + else if (edge === 2) ctx.fillRect(w * 0.44, 0, 3, h * 0.12); 181 + else ctx.fillRect(w * 0.44, h * 0.88, 3, h * 0.12); 121 182 122 183 if (name) { 123 - const th = drawText(ctx, name, w / 2, h / 2 - 10, w * 0.75, w * 0.09, '600', hsl(hu, 30, 20)); 184 + const th = drawText( 185 + ctx, 186 + name, 187 + w / 2, 188 + h / 2 - 10, 189 + w * 0.75, 190 + w * 0.09, 191 + '600', 192 + lch(accent, 0.25, 'c') 193 + ); 124 194 if (dateStr) { 125 - drawText(ctx, dateStr, w / 2, h / 2 + th / 2 + w * 0.03, w * 0.7, w * 0.04, 'normal', hsl(hu, 20, 50)); 195 + drawText( 196 + ctx, 197 + dateStr, 198 + w / 2, 199 + h / 2 + th / 2 + w * 0.03, 200 + w * 0.7, 201 + w * 0.04, 202 + 'normal', 203 + lch(accent, 0.5, 'c') 204 + ); 126 205 } 127 206 } 207 + 208 + addNoise(ctx, w, h, seed); 128 209 }; 129 210 130 - export const geometric: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed) => { 131 - const h1 = hue(seed, 0); 132 - const h2 = (h1 + 30) % 360; 133 - 134 - ctx.fillStyle = hsl(h1, 60, 50); 211 + export const geometric: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed, accent) => { 212 + ctx.fillStyle = lch(accent, 0.55, 'c'); 135 213 ctx.fillRect(0, 0, w, h); 136 214 137 - // Shapes 138 - ctx.globalAlpha = 0.15; 215 + ctx.globalAlpha = 0.18; 139 216 for (let i = 0; i < 6; i++) { 140 - const x = ((seed * 31 + i * 73) % 100) / 100 * w; 141 - const y = ((seed * 47 + i * 59) % 100) / 100 * h; 142 - const size = (15 + ((seed * 13 + i * 41) % 25)) / 100 * w; 217 + const x = (((seed * 31 + i * 73) % 100) / 100) * w; 218 + const y = (((seed * 47 + i * 59) % 100) / 100) * h; 219 + const size = ((15 + ((seed * 13 + i * 41) % 25)) / 100) * w; 143 220 const type = i % 3; 221 + const hShift = (((seed * 19 + i * 53) % 90) - 45) * 0.6; 144 222 145 - ctx.fillStyle = hsl(h2, 70, 70); 223 + ctx.fillStyle = lch(accent, 0.85, 'c', hShift); 146 224 ctx.save(); 147 225 ctx.translate(x, y); 148 - ctx.rotate(((seed * 23 + i * 67) % 360) * Math.PI / 180); 226 + ctx.rotate((((seed * 23 + i * 67) % 360) * Math.PI) / 180); 149 227 150 228 if (type === 0) { 151 229 ctx.beginPath(); ··· 168 246 if (name) { 169 247 const th = drawText(ctx, name, w / 2, h / 2 - 10, w * 0.75, w * 0.09, 'bold', 'white'); 170 248 if (dateStr) { 171 - drawText(ctx, dateStr, w / 2, h / 2 + th / 2 + w * 0.03, w * 0.7, w * 0.04, '500', 'rgba(255,255,255,0.7)'); 249 + drawText( 250 + ctx, 251 + dateStr, 252 + w / 2, 253 + h / 2 + th / 2 + w * 0.03, 254 + w * 0.7, 255 + w * 0.04, 256 + '500', 257 + 'rgba(255,255,255,0.8)' 258 + ); 172 259 } 173 260 } 261 + 262 + addNoise(ctx, w, h, seed); 174 263 }; 175 264 176 - export const darkGradient: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed) => { 177 - const hu = hue(seed, 0); 178 - 179 - const bg = ctx.createLinearGradient(0, 0, w * 0.3, h); 180 - bg.addColorStop(0, hsl(hu, 50, 15)); 181 - bg.addColorStop(1, hsl(hu, 30, 5)); 265 + export const darkGradient: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed, accent) => { 266 + const angle = (seed * 67) % 360; 267 + const rad = (angle * Math.PI) / 180; 268 + const bg = ctx.createLinearGradient( 269 + w / 2 - (Math.cos(rad) * w) / 2, 270 + h / 2 - (Math.sin(rad) * h) / 2, 271 + w / 2 + (Math.cos(rad) * w) / 2, 272 + h / 2 + (Math.sin(rad) * h) / 2 273 + ); 274 + bg.addColorStop(0, lch(accent, 0.18, 'c')); 275 + bg.addColorStop(1, lch(accent, 0.06, 0.03)); 182 276 ctx.fillStyle = bg; 183 277 ctx.fillRect(0, 0, w, h); 184 278 185 - // Accent line at top 279 + const edge = seed % 4; 280 + const shift = ((seed * 43) % 120) - 60; 186 281 const line = ctx.createLinearGradient(0, 0, w, 0); 187 - line.addColorStop(0, hsl(hu, 80, 55)); 188 - line.addColorStop(1, hsl((hu + 60) % 360, 80, 55)); 282 + line.addColorStop(0, lch(accent, 0.65, 'c', shift)); 283 + line.addColorStop(1, lch(accent, 0.7, 'c', -shift)); 189 284 ctx.fillStyle = line; 190 - ctx.fillRect(0, 0, w, h * 0.01); 285 + if (edge === 0) ctx.fillRect(0, 0, w, h * 0.012); 286 + else if (edge === 1) ctx.fillRect(0, h * 0.988, w, h * 0.012); 287 + else if (edge === 2) ctx.fillRect(0, 0, w * 0.012, h); 288 + else ctx.fillRect(w * 0.988, 0, w * 0.012, h); 191 289 192 290 if (name) { 193 291 drawText(ctx, name, w * 0.07, h * 0.72, w * 0.86, w * 0.09, 'bold', 'white', 'left'); 194 292 } 195 293 if (dateStr) { 196 - ctx.fillStyle = hsl(hu, 40, 60); 294 + ctx.fillStyle = lch(accent, 0.65, 'c'); 197 295 ctx.font = `normal ${w * 0.04}px system-ui, -apple-system, sans-serif`; 198 296 ctx.textAlign = 'left'; 199 297 ctx.textBaseline = 'top'; 200 298 ctx.fillText(dateStr, w * 0.07, h * 0.88); 201 299 } 300 + 301 + addNoise(ctx, w, h, seed); 202 302 }; 203 303 204 - export const waves: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed) => { 205 - const h1 = hue(seed, 0); 206 - const h2 = (h1 + 40) % 360; 207 - 208 - ctx.fillStyle = hsl(h1, 45, 92); 304 + export const waves: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed, accent) => { 305 + ctx.fillStyle = lch(accent, 0.95, 0.04); 209 306 ctx.fillRect(0, 0, w, h); 210 307 211 - // Wave layers 212 - function wave(yBase: number, amplitude: number, color: string, alpha: number) { 308 + function wave( 309 + yBase: number, 310 + amplitude: number, 311 + frequency: number, 312 + phase: number, 313 + color: string, 314 + alpha: number 315 + ) { 213 316 ctx.globalAlpha = alpha; 214 317 ctx.fillStyle = color; 215 318 ctx.beginPath(); 216 319 ctx.moveTo(0, yBase); 217 320 for (let x = 0; x <= w; x += 2) { 218 - const y = yBase + Math.sin((x / w) * Math.PI * 2 + seed) * amplitude; 321 + const y = yBase + Math.sin((x / w) * Math.PI * frequency + phase) * amplitude; 219 322 ctx.lineTo(x, y); 220 323 } 221 324 ctx.lineTo(w, h); ··· 224 327 ctx.fill(); 225 328 } 226 329 227 - wave(h * 0.7, h * 0.05, hsl(h1, 55, 75), 0.5); 228 - wave(h * 0.78, h * 0.04, hsl(h2, 55, 65), 0.4); 229 - wave(h * 0.85, h * 0.03, hsl(h1, 50, 55), 0.3); 330 + const freq = 2 + (seed % 3); 331 + const phase = seed; 332 + 333 + wave(h * 0.7, h * 0.05, freq, phase, lch(accent, 0.78, 'c', 20), 0.55); 334 + wave(h * 0.78, h * 0.04, freq + 1, phase + 1, lch(accent, 0.65, 'c', 0), 0.5); 335 + wave(h * 0.85, h * 0.03, freq + 2, phase + 2, lch(accent, 0.5, 'c', -20), 0.55); 230 336 ctx.globalAlpha = 1; 231 337 232 338 if (name) { 233 - const th = drawText(ctx, name, w / 2, h * 0.4, w * 0.75, w * 0.09, 'bold', hsl(h1, 40, 25)); 339 + const th = drawText( 340 + ctx, 341 + name, 342 + w / 2, 343 + h * 0.4, 344 + w * 0.75, 345 + w * 0.09, 346 + 'bold', 347 + lch(accent, 0.25, 'c') 348 + ); 234 349 if (dateStr) { 235 - drawText(ctx, dateStr, w / 2, h * 0.4 + th / 2 + w * 0.03, w * 0.7, w * 0.04, '500', hsl(h1, 30, 45)); 350 + drawText( 351 + ctx, 352 + dateStr, 353 + w / 2, 354 + h * 0.4 + th / 2 + w * 0.03, 355 + w * 0.7, 356 + w * 0.04, 357 + '500', 358 + lch(accent, 0.45, 'c') 359 + ); 236 360 } 237 361 } 362 + 363 + addNoise(ctx, w, h, seed); 238 364 }; 239 365 366 + /** Textless gradient-mesh preset — built from 6 overlapping radial hotspots 367 + * placed around a loose 3×2 grid with seeded jitter. Used as the default 368 + * thumbnail for new events. */ 369 + export const plainMesh: ThumbnailRenderer = (ctx, w, h, _name, _dateStr, seed, accent) => { 370 + const p = (i: number, m: number) => ((seed * i) % m) / m; 371 + const jitter = (i: number, amt: number) => (p(i, 100) - 0.5) * amt; 372 + 373 + // Mid-tone accent base so the hotspots read as both lighter and darker pools 374 + ctx.fillStyle = lch(accent, 0.5, 'c'); 375 + ctx.fillRect(0, 0, w, h); 376 + 377 + // Six hotspots anchored to a 3x2 grid, each jittered and hue-shifted 378 + // independently so no two thumbnails look the same. 379 + const anchors = [ 380 + { x: 0.18, y: 0.22 }, 381 + { x: 0.55, y: 0.15 }, 382 + { x: 0.85, y: 0.35 }, 383 + { x: 0.2, y: 0.72 }, 384 + { x: 0.55, y: 0.85 }, 385 + { x: 0.88, y: 0.78 } 386 + ]; 387 + // Alternating light/dark levels and hue shifts spread across the spectrum 388 + const levels = [0.8, 0.62, 0.78, 0.38, 0.72, 0.48]; 389 + const shifts = [-55, 25, 55, -25, 40, -40]; 390 + 391 + for (let i = 0; i < anchors.length; i++) { 392 + const a = anchors[i]; 393 + const x = (a.x + jitter(11 + i * 7, 0.18)) * w; 394 + const y = (a.y + jitter(17 + i * 11, 0.18)) * h; 395 + const r = w * (0.45 + p(23 + i * 5, 40) * 0.25); 396 + const hShift = shifts[i] + jitter(29 + i * 13, 20); 397 + const l = levels[i] + jitter(31 + i * 3, 0.08); 398 + const alpha = 0.55 + p(37 + i * 3, 20) * 0.1; 399 + 400 + const g = ctx.createRadialGradient(x, y, 0, x, y, r); 401 + g.addColorStop(0, lch(accent, l, 'c', hShift, alpha)); 402 + g.addColorStop(1, lch(accent, l, 'c', hShift, 0)); 403 + ctx.fillStyle = g; 404 + ctx.fillRect(0, 0, w, h); 405 + } 406 + 407 + addNoise(ctx, w, h, seed); 408 + }; 409 + 410 + export const DEFAULT_PRESET = 'plain'; 411 + 240 412 export const designs: Record<string, ThumbnailRenderer> = { 413 + plain: plainMesh, 241 414 gradient: gradientMesh, 242 415 bold: boldType, 243 416 minimal, ··· 245 418 dark: darkGradient, 246 419 waves 247 420 }; 421 + 422 + /** Concrete oklch() values for each Tailwind accent-500, so canvas rendering 423 + * stays in sync with the theme without depending on CSS-var resolution timing 424 + * (also works during SSR and before the theme class is applied). */ 425 + const ACCENT_OKLCH: Record<string, string> = { 426 + red: 'oklch(0.637 0.237 25.331)', 427 + orange: 'oklch(0.705 0.213 47.604)', 428 + amber: 'oklch(0.769 0.188 70.08)', 429 + yellow: 'oklch(0.795 0.184 86.047)', 430 + lime: 'oklch(0.768 0.233 130.85)', 431 + green: 'oklch(0.723 0.219 149.579)', 432 + emerald: 'oklch(0.696 0.17 162.48)', 433 + teal: 'oklch(0.704 0.14 182.503)', 434 + cyan: 'oklch(0.715 0.143 215.221)', 435 + sky: 'oklch(0.685 0.169 237.323)', 436 + blue: 'oklch(0.623 0.214 259.815)', 437 + indigo: 'oklch(0.585 0.233 277.117)', 438 + violet: 'oklch(0.606 0.25 292.717)', 439 + purple: 'oklch(0.627 0.265 303.9)', 440 + fuchsia: 'oklch(0.667 0.295 322.15)', 441 + pink: 'oklch(0.656 0.241 354.308)', 442 + rose: 'oklch(0.645 0.246 16.439)' 443 + }; 444 + 445 + export function resolveAccentColor(name?: string): string { 446 + return (name && ACCENT_OKLCH[name]) || ACCENT_OKLCH.cyan; 447 + } 448 + 449 + /** Stable non-zero integer seed derived from a string (e.g. an event rkey). 450 + * Same input always produces the same seed, so the picker preview matches 451 + * the uploaded PNG for a given event. */ 452 + export function hashSeed(s: string): number { 453 + let h = 2166136261; 454 + for (let i = 0; i < s.length; i++) { 455 + h ^= s.charCodeAt(i); 456 + h = Math.imul(h, 16777619); 457 + } 458 + return Math.abs(h) || 1; 459 + }
+10
src/lib/theme.ts
··· 4 4 baseColor: string; 5 5 } 6 6 7 + export const accentColors = [ 8 + 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 9 + 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 10 + 'fuchsia', 'pink', 'rose' 11 + ] as const; 12 + 7 13 export const defaultTheme: EventTheme = { 8 14 name: 'minimal', 9 15 accentColor: 'cyan', 10 16 baseColor: 'mist' 11 17 }; 18 + 19 + export function randomAccentColor(): string { 20 + return accentColors[Math.floor(Math.random() * accentColors.length)]; 21 + } 12 22 13 23 export const themeBackgrounds: Record<string, string> = { 14 24 minimal: 'Minimal',