atmo.rsvp
3
fork

Configure Feed

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

add thumbnail templates

Florian 138b8576 fc4a3a67

+409 -8
+95 -5
src/lib/components/EventEditor.svelte
··· 29 29 import Avatar from 'svelte-boring-avatars'; 30 30 import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 31 31 import TimezonePicker from '$lib/components/TimezonePicker.svelte'; 32 + import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte'; 33 + import { designs } from '$lib/components/thumbnails/designs'; 32 34 import type { FlatEventRecord } from '$lib/contrail'; 33 35 34 36 let { ··· 79 81 let mode: EventMode = $state('inperson'); 80 82 let thumbnailFile: File | null = $state(null); 81 83 let thumbnailPreview: string | null = $state(null); 84 + let selectedPreset: { design: string; seed: number } | null = $state(null); 85 + let presetPreviewCanvas: HTMLCanvasElement | undefined = $state(undefined); 86 + let showThumbnailModal = $state(false); 82 87 let submitting = $state(false); 83 88 let error: string | null = $state(null); 84 89 import type { Readable } from 'svelte/store'; ··· 385 390 async function setThumbnail(file: File) { 386 391 thumbnailFile = file; 387 392 thumbnailChanged = true; 393 + selectedPreset = null; 388 394 if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 389 395 thumbnailPreview = URL.createObjectURL(file); 390 396 ··· 425 431 function removeThumbnail() { 426 432 thumbnailFile = null; 427 433 thumbnailChanged = true; 434 + selectedPreset = null; 428 435 if (thumbnailPreview) { 429 436 URL.revokeObjectURL(thumbnailPreview); 430 437 thumbnailPreview = null; ··· 436 443 if (fileInput) fileInput.value = ''; 437 444 saveDraft(); 438 445 } 446 + 447 + let thumbnailDateStr = $derived.by(() => { 448 + if (!startsAt) return ''; 449 + const d = new Date(startsAt); 450 + if (isNaN(d.getTime())) return ''; 451 + return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); 452 + }); 453 + 454 + // Render preset preview canvas 455 + $effect(() => { 456 + if (selectedPreset && presetPreviewCanvas && designs[selectedPreset.design]) { 457 + const ctx = presetPreviewCanvas.getContext('2d'); 458 + if (!ctx) return; 459 + presetPreviewCanvas.width = 800; 460 + presetPreviewCanvas.height = 800; 461 + designs[selectedPreset.design](ctx, 800, 800, name || 'Event', thumbnailDateStr, selectedPreset.seed); 462 + } 463 + }); 439 464 440 465 // Auto-set end date to 1 hour after start if empty 441 466 $effect(() => { ··· 523 548 submitting = true; 524 549 525 550 try { 551 + // Generate thumbnail from preset if selected and no custom upload 552 + if (selectedPreset && !thumbnailFile && designs[selectedPreset.design]) { 553 + const canvas = document.createElement('canvas'); 554 + canvas.width = 800; 555 + canvas.height = 800; 556 + const ctx = canvas.getContext('2d')!; 557 + designs[selectedPreset.design](ctx, 800, 800, name.trim() || 'Event', thumbnailDateStr, selectedPreset.seed); 558 + const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 559 + if (blob) { 560 + thumbnailFile = new File([blob], 'thumbnail.png', { type: 'image/png' }); 561 + thumbnailChanged = true; 562 + } 563 + } 564 + 526 565 let media: Array<Record<string, unknown>> | undefined; 527 566 528 567 // Start with existing media, excluding thumbnail role ··· 689 728 const startNum = detectedStartNumber ?? 1; 690 729 const hasHash = titleNumberMatch ? titleNumberMatch[0].includes('#') : false; 691 730 731 + // Generate thumbnail from preset if selected and no custom upload 732 + if (selectedPreset && !thumbnailFile && designs[selectedPreset.design]) { 733 + const canvas = document.createElement('canvas'); 734 + canvas.width = 800; 735 + canvas.height = 800; 736 + const ctx = canvas.getContext('2d')!; 737 + designs[selectedPreset.design](ctx, 800, 800, name.trim() || 'Event', thumbnailDateStr, selectedPreset.seed); 738 + const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 739 + if (blob) { 740 + thumbnailFile = new File([blob], 'thumbnail.png', { type: 'image/png' }); 741 + thumbnailChanged = true; 742 + } 743 + } 744 + 692 745 // Build the same record shape as handleSubmit 693 746 let media: Array<Record<string, unknown>> | undefined; 694 747 const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>; ··· 826 879 bind:this={fileInput} 827 880 type="file" 828 881 accept="image/*" 829 - onchange={onFileChange} 882 + onchange={(e) => { onFileChange(e); showThumbnailModal = false; }} 830 883 class="hidden" 831 884 /> 832 885 <div class="group relative"> ··· 836 889 alt="Thumbnail preview" 837 890 class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 838 891 /> 892 + {:else if selectedPreset && designs[selectedPreset.design]} 893 + <div class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border"> 894 + <canvas bind:this={presetPreviewCanvas} class="h-full w-full"></canvas> 895 + </div> 839 896 {:else} 840 897 <div 841 898 class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" ··· 849 906 /> 850 907 </div> 851 908 {/if} 852 - <!-- Upload overlay on hover --> 853 909 <button 854 910 type="button" 855 - onclick={() => fileInput?.click()} 911 + onclick={() => (showThumbnailModal = true)} 856 912 class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-2xl bg-black/0 text-white/0 transition-colors group-hover:bg-black/40 group-hover:text-white/90 {isDragOver 857 913 ? 'bg-black/40 text-white/90' 858 914 : ''}" ··· 871 927 d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 872 928 /> 873 929 </svg> 874 - <span class="text-sm font-medium">Upload thumbnail</span> 930 + <span class="text-sm font-medium">Change thumbnail</span> 875 931 </button> 876 - {#if thumbnailPreview} 932 + {#if thumbnailPreview || selectedPreset} 877 933 <Button 878 934 variant="ghost" 879 935 size="iconSm" ··· 1247 1303 {/if} 1248 1304 </div> 1249 1305 </div> 1306 + 1307 + <!-- Thumbnail modal --> 1308 + <Modal bind:open={showThumbnailModal}> 1309 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Choose thumbnail</p> 1310 + <div class="mt-4 flex max-h-[70vh] flex-col gap-6 overflow-y-auto"> 1311 + <Button 1312 + variant="secondary" 1313 + class="w-full" 1314 + onclick={() => fileInput?.click()} 1315 + > 1316 + <svg 1317 + xmlns="http://www.w3.org/2000/svg" 1318 + fill="none" 1319 + viewBox="0 0 24 24" 1320 + stroke-width="1.5" 1321 + stroke="currentColor" 1322 + class="size-4" 1323 + > 1324 + <path 1325 + stroke-linecap="round" 1326 + stroke-linejoin="round" 1327 + d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" 1328 + /> 1329 + </svg> 1330 + Upload own thumbnail 1331 + </Button> 1332 + <ThumbnailPresets 1333 + name={name} 1334 + dateStr={thumbnailDateStr} 1335 + bind:selected={selectedPreset} 1336 + onselect={() => { showThumbnailModal = false; thumbnailPreview = null; thumbnailFile = null; thumbnailChanged = true; }} 1337 + /> 1338 + </div> 1339 + </Modal> 1250 1340 1251 1341 <!-- Location modal --> 1252 1342 <Modal bind:open={showLocationModal}>
+64
src/lib/components/ThumbnailPresets.svelte
··· 1 + <script lang="ts"> 2 + // @ts-nocheck 3 + import { designs } from './thumbnails/designs'; 4 + import { tick } from 'svelte'; 5 + 6 + let { 7 + name = '', 8 + dateStr = '', 9 + selected = $bindable<{ design: string; seed: number } | null>(null), 10 + onselect 11 + }: { 12 + name?: string; 13 + dateStr?: string; 14 + selected?: { design: string; seed: number } | null; 15 + onselect?: () => void; 16 + } = $props(); 17 + 18 + const presetKeys = Object.keys(designs); 19 + const seeds = [0, 1, 2, 3]; 20 + const previewSize = 200; 21 + 22 + let containerEl: HTMLDivElement | undefined = $state(undefined); 23 + 24 + function renderAll() { 25 + if (!containerEl) return; 26 + const canvases = containerEl.querySelectorAll<HTMLCanvasElement>('canvas'); 27 + canvases.forEach((canvas) => { 28 + const key = canvas.dataset.key!; 29 + const seed = parseInt(canvas.dataset.seed!, 10); 30 + const ctx = canvas.getContext('2d'); 31 + if (!ctx) return; 32 + canvas.width = previewSize; 33 + canvas.height = previewSize; 34 + designs[key](ctx, previewSize, previewSize, name || 'Event', dateStr, seed); 35 + }); 36 + } 37 + 38 + $effect(() => { 39 + void name; 40 + void dateStr; 41 + void containerEl; 42 + tick().then(renderAll); 43 + }); 44 + </script> 45 + 46 + <div class="flex flex-col gap-3"> 47 + <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}> 49 + {#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} 62 + {/each} 63 + </div> 64 + </div>
+1 -1
src/lib/components/TimePicker.svelte
··· 119 119 <!-- svelte-ignore a11y_click_events_have_key_events --> 120 120 <!-- svelte-ignore a11y_no_static_element_interactions --> 121 121 <div 122 - class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex shrink-0 cursor-pointer items-center whitespace-nowrap rounded-xl border px-2.5 py-1.5 text-sm transition-colors" 122 + class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex shrink-0 cursor-pointer items-center whitespace-nowrap rounded-xl border px-2.5 py-1.5 text-sm min-w-[7.5rem] transition-colors" 123 123 onfocusin={() => (isOpen = true)} 124 124 > 125 125 <TimeField.Input>
+2 -2
src/lib/components/TimezonePicker.svelte
··· 73 73 <!-- svelte-ignore a11y_click_events_have_key_events --> 74 74 <!-- svelte-ignore a11y_no_static_element_interactions --> 75 75 <div 76 - class="border-base-300 bg-base-100 text-base-900 dark:border-base-700 dark:bg-base-800 dark:text-base-100 flex h-full shrink-0 cursor-pointer items-center gap-3 whitespace-nowrap rounded-xl border px-3 py-2 text-xs transition-colors" 76 + class="border-base-300 bg-base-100 text-base-900 dark:border-base-700 dark:bg-base-800 dark:text-base-100 flex h-full shrink-0 cursor-pointer flex-col items-center justify-center gap-1.5 whitespace-nowrap rounded-xl border px-5 py-2 text-xs transition-colors" 77 77 onclick={() => (isOpen = !isOpen)} 78 78 > 79 79 <svg ··· 90 90 d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.794 1.708-5.282" 91 91 /> 92 92 </svg> 93 - <div class="flex flex-col gap-0.5 leading-tight"> 93 + <div class="flex flex-col items-center gap-0.5 leading-tight"> 94 94 <span class="text-base-500 dark:text-base-400">{displayOffset}</span> 95 95 <span>{displayCity}</span> 96 96 </div>
+247
src/lib/components/thumbnails/designs.ts
··· 1 + export type ThumbnailRenderer = ( 2 + ctx: CanvasRenderingContext2D, 3 + w: number, 4 + h: number, 5 + name: string, 6 + dateStr: string, 7 + seed: number 8 + ) => void; 9 + 10 + function hue(seed: number, offset: number) { 11 + return (seed * 137.5 + offset) % 360; 12 + } 13 + 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})`; 20 + } 21 + 22 + function drawText( 23 + ctx: CanvasRenderingContext2D, 24 + text: string, 25 + x: number, 26 + y: number, 27 + maxWidth: number, 28 + fontSize: number, 29 + fontWeight: string, 30 + color: string, 31 + align: CanvasTextAlign = 'center' 32 + ) { 33 + ctx.fillStyle = color; 34 + ctx.font = `${fontWeight} ${fontSize}px system-ui, -apple-system, sans-serif`; 35 + ctx.textAlign = align; 36 + ctx.textBaseline = 'middle'; 37 + 38 + // Word wrap 39 + const words = text.split(' '); 40 + const lines: string[] = []; 41 + let line = ''; 42 + for (const word of words) { 43 + const test = line ? `${line} ${word}` : word; 44 + if (ctx.measureText(test).width > maxWidth && line) { 45 + lines.push(line); 46 + line = word; 47 + } else { 48 + line = test; 49 + } 50 + } 51 + if (line) lines.push(line); 52 + 53 + const lineHeight = fontSize * 1.2; 54 + const totalHeight = lines.length * lineHeight; 55 + const startY = y - totalHeight / 2 + lineHeight / 2; 56 + 57 + for (let i = 0; i < lines.length; i++) { 58 + ctx.fillText(lines[i], x, startY + i * lineHeight, maxWidth); 59 + } 60 + 61 + return totalHeight; 62 + } 63 + 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); 68 + 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)); 74 + ctx.fillStyle = bg; 75 + ctx.fillRect(0, 0, w, h); 76 + 77 + // Blurred blobs (circles with radial gradients) 78 + function blob(x: number, y: number, r: number, color: string, alpha: number) { 79 + 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)); 82 + ctx.fillStyle = g; 83 + ctx.fillRect(x - r, y - r, r * 2, r * 2); 84 + } 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); 88 + 89 + // Text 90 + if (name) { 91 + const th = drawText(ctx, name, w / 2, h / 2 - 10, w * 0.75, w * 0.09, 'bold', 'white'); 92 + 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)'); 94 + } 95 + } 96 + }; 97 + 98 + export const boldType: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed) => { 99 + const hu = hue(seed, 0); 100 + 101 + ctx.fillStyle = hsl(hu, 15, 10); 102 + ctx.fillRect(0, 0, w, h); 103 + 104 + if (name) { 105 + drawText(ctx, name, w * 0.07, h * 0.72, w * 0.86, w * 0.11, '900', hsl(hu, 70, 65), 'left'); 106 + } 107 + if (dateStr) { 108 + ctx.fillStyle = 'rgba(255,255,255,0.5)'; 109 + ctx.font = `500 ${w * 0.04}px system-ui, -apple-system, sans-serif`; 110 + ctx.textAlign = 'left'; 111 + ctx.textBaseline = 'top'; 112 + ctx.fillText(dateStr, w * 0.07, h * 0.88); 113 + } 114 + }; 115 + 116 + export const minimal: ThumbnailRenderer = (ctx, w, h, name, dateStr, seed) => { 117 + const hu = hue(seed, 0); 118 + 119 + ctx.fillStyle = hsl(hu, 20, 95); 120 + ctx.fillRect(0, 0, w, h); 121 + 122 + if (name) { 123 + const th = drawText(ctx, name, w / 2, h / 2 - 10, w * 0.75, w * 0.09, '600', hsl(hu, 30, 20)); 124 + 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)); 126 + } 127 + } 128 + }; 129 + 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); 135 + ctx.fillRect(0, 0, w, h); 136 + 137 + // Shapes 138 + ctx.globalAlpha = 0.15; 139 + 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; 143 + const type = i % 3; 144 + 145 + ctx.fillStyle = hsl(h2, 70, 70); 146 + ctx.save(); 147 + ctx.translate(x, y); 148 + ctx.rotate(((seed * 23 + i * 67) % 360) * Math.PI / 180); 149 + 150 + if (type === 0) { 151 + ctx.beginPath(); 152 + ctx.arc(0, 0, size / 2, 0, Math.PI * 2); 153 + ctx.fill(); 154 + } else if (type === 1) { 155 + ctx.fillRect(-size / 2, -size / 2, size, size); 156 + } else { 157 + ctx.beginPath(); 158 + ctx.moveTo(0, -size / 2); 159 + ctx.lineTo(-size / 2, size / 2); 160 + ctx.lineTo(size / 2, size / 2); 161 + ctx.closePath(); 162 + ctx.fill(); 163 + } 164 + ctx.restore(); 165 + } 166 + ctx.globalAlpha = 1; 167 + 168 + if (name) { 169 + const th = drawText(ctx, name, w / 2, h / 2 - 10, w * 0.75, w * 0.09, 'bold', 'white'); 170 + 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)'); 172 + } 173 + } 174 + }; 175 + 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)); 182 + ctx.fillStyle = bg; 183 + ctx.fillRect(0, 0, w, h); 184 + 185 + // Accent line at top 186 + 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)); 189 + ctx.fillStyle = line; 190 + ctx.fillRect(0, 0, w, h * 0.01); 191 + 192 + if (name) { 193 + drawText(ctx, name, w * 0.07, h * 0.72, w * 0.86, w * 0.09, 'bold', 'white', 'left'); 194 + } 195 + if (dateStr) { 196 + ctx.fillStyle = hsl(hu, 40, 60); 197 + ctx.font = `normal ${w * 0.04}px system-ui, -apple-system, sans-serif`; 198 + ctx.textAlign = 'left'; 199 + ctx.textBaseline = 'top'; 200 + ctx.fillText(dateStr, w * 0.07, h * 0.88); 201 + } 202 + }; 203 + 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); 209 + ctx.fillRect(0, 0, w, h); 210 + 211 + // Wave layers 212 + function wave(yBase: number, amplitude: number, color: string, alpha: number) { 213 + ctx.globalAlpha = alpha; 214 + ctx.fillStyle = color; 215 + ctx.beginPath(); 216 + ctx.moveTo(0, yBase); 217 + for (let x = 0; x <= w; x += 2) { 218 + const y = yBase + Math.sin((x / w) * Math.PI * 2 + seed) * amplitude; 219 + ctx.lineTo(x, y); 220 + } 221 + ctx.lineTo(w, h); 222 + ctx.lineTo(0, h); 223 + ctx.closePath(); 224 + ctx.fill(); 225 + } 226 + 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); 230 + ctx.globalAlpha = 1; 231 + 232 + if (name) { 233 + const th = drawText(ctx, name, w / 2, h * 0.4, w * 0.75, w * 0.09, 'bold', hsl(h1, 40, 25)); 234 + 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)); 236 + } 237 + } 238 + }; 239 + 240 + export const designs: Record<string, ThumbnailRenderer> = { 241 + gradient: gradientMesh, 242 + bold: boldType, 243 + minimal, 244 + geometric, 245 + dark: darkGradient, 246 + waves 247 + };