feat(ui): enhance Countdown and layout components

* Improve Countdown component styling and structure
* Update layout to include responsive menu and background pattern
* Adjust PhotoBackground and page styles for better presentation
* Add damask pattern SVG for background use
This commit is contained in:
2026-08-11 23:58:28 +02:00
parent 39f5b8d5cc
commit 143161c234
8 changed files with 262 additions and 168 deletions
+59 -41
View File
@@ -1,54 +1,72 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
target: Date; target: Date;
compact?: boolean; compact?: boolean;
pastMessage?: string; pastMessage?: string;
} }
let { target, compact = false, pastMessage = 'The big day is here!' }: Props = $props(); let {
target,
compact = false,
pastMessage = "The big day is here!",
}: Props = $props();
let now = $state(Date.now()); let now = $state(Date.now());
$effect(() => { $effect(() => {
const id = setInterval(() => (now = Date.now()), 1000); const id = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(id); return () => clearInterval(id);
}); });
const diffMs = $derived(Math.max(0, target.getTime() - now)); const diffMs = $derived(Math.max(0, target.getTime() - now));
const isPast = $derived(diffMs <= 0); const isPast = $derived(diffMs <= 0);
const days = $derived(Math.floor(diffMs / 86_400_000)); const days = $derived(Math.floor(diffMs / 86_400_000));
const hours = $derived(Math.floor((diffMs % 86_400_000) / 3_600_000)); const hours = $derived(Math.floor((diffMs % 86_400_000) / 3_600_000));
const minutes = $derived(Math.floor((diffMs % 3_600_000) / 60_000)); const minutes = $derived(Math.floor((diffMs % 3_600_000) / 60_000));
const seconds = $derived(Math.floor((diffMs % 60_000) / 1000)); const seconds = $derived(Math.floor((diffMs % 60_000) / 1000));
const units = $derived([ const units = $derived([
{ label: 'days', value: days }, { label: "days", value: days },
{ label: 'hours', value: hours }, { label: "hours", value: hours },
{ label: 'minutes', value: minutes }, { label: "minutes", value: minutes },
{ label: 'seconds', value: seconds } { label: "seconds", value: seconds },
]); ]);
</script> </script>
{#if isPast} {#if isPast}
<p class={compact ? 'text-lg font-medium sm:text-xl' : 'text-surface-50 text-xl font-medium'}>{pastMessage}</p> <p
class={compact
? "text-lg font-medium sm:text-xl sm:text-md"
: "text-surface-50 text-2xl sm:text-md font-medium"}
>
{pastMessage}
</p>
{:else if compact} {:else if compact}
<div class="flex items-baseline gap-2 text-lg sm:text-xl" role="timer" aria-live="polite"> <div class="flex items-baseline gap-2 z-10" role="timer" aria-live="polite">
{#each units as unit, i (unit.label)} {#each units as unit, i (unit.label)}
{#if i > 0}<span class="text-secondary-300/60">·</span>{/if} {#if i > 0}<span class="text-secondary-300/60">·</span>{/if}
<span class="font-semibold tabular-nums">{unit.value}</span> <span class="font-semibold tabular-nums text-md md:text-3xl"
<span class="text-secondary-100/80 text-base">{unit.label}</span> >{unit.value}</span
{/each} >
</div> <span class="text-secondary-100/80 text-md md:text-3xl">{unit.label}</span
>
{/each}
</div>
{:else} {:else}
<div class="flex gap-4 sm:gap-8" role="timer" aria-live="polite"> <div class="flex gap-4 sm:gap-8" role="timer" aria-live="polite">
{#each units as unit (unit.label)} {#each units as unit (unit.label)}
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span class="text-secondary-400 text-4xl font-bold tabular-nums sm:text-6xl"> <span
{String(unit.value).padStart(2, '0')} class="text-secondary-400 text-4xl font-bold tabular-nums sm:text-6xl"
</span> >
<span class="text-surface-200 text-base tracking-wide uppercase sm:text-lg">{unit.label}</span> {String(unit.value).padStart(2, "0")}
</div> </span>
{/each} <span
</div> class="text-surface-200 text-base tracking-wide uppercase sm:text-lg"
>{unit.label}</span
>
</div>
{/each}
</div>
{/if} {/if}
+84 -72
View File
@@ -1,87 +1,99 @@
<script lang="ts"> <script lang="ts">
import { backOut, cubicIn } from 'svelte/easing'; import { backOut, cubicIn } from "svelte/easing";
import { browser } from '$app/environment'; import { browser } from "$app/environment";
const modules = import.meta.glob('../../../assets/photos/*.{jpg,jpeg,png,JPG,JPEG,PNG}', { const modules = import.meta.glob(
eager: true, "../../../assets/photos/*.{jpg,jpeg,png,JPG,JPEG,PNG}",
import: 'default', {
query: '?url' eager: true,
}) as Record<string, string>; import: "default",
query: "?url",
},
) as Record<string, string>;
function shuffle<T>(items: T[]): T[] { function shuffle<T>(items: T[]): T[] {
const result = [...items]; const result = [...items];
for (let i = result.length - 1; i > 0; i--) { for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]]; [result[i], result[j]] = [result[j], result[i]];
} }
return result; return result;
} }
// Randomized client-side only: shuffling during SSR/prerender would bake one // Randomized client-side only: shuffling during SSR/prerender would bake one
// order into the static HTML while the client picks another, causing a // order into the static HTML while the client picks another, causing a
// hydration mismatch on the <img src>. // hydration mismatch on the <img src>.
let photos = $state<string[]>([]); let photos = $state<string[]>([]);
const intervalMs = 6000; const intervalMs = 6000;
let index = $state(0); let index = $state(0);
let reducedMotion = $state(false); let reducedMotion = $state(false);
// Each photo flies in/out from a random angle with its own spin and pop, so // Each photo flies in/out from a random angle with its own spin and pop, so
// no two transitions look alike. // no two transitions look alike.
function funky(node: HTMLElement, { duration, variant }: { duration: number; variant: 'in' | 'out' }) { function funky(
if (reducedMotion) { node: HTMLElement,
return { duration: 400, css: (t: number) => `opacity: ${t}` }; { duration, variant }: { duration: number; variant: "in" | "out" },
} ) {
const angle = Math.random() * Math.PI * 2; if (reducedMotion) {
const dist = 55 + Math.random() * 55; return { duration: 400, css: (t: number) => `opacity: ${t}` };
const rot = Math.random() * 80 - 40; }
const scaleFrom = 0.5 + Math.random() * 0.3; const angle = Math.random() * Math.PI * 2;
const x = Math.cos(angle) * dist; const dist = 55 + Math.random() * 55;
const y = Math.sin(angle) * dist; const rot = Math.random() * 80 - 40;
return { const scaleFrom = 0.5 + Math.random() * 0.3;
duration: duration + Math.random() * 300, const x = Math.cos(angle) * dist;
easing: variant === 'in' ? backOut : cubicIn, const y = Math.sin(angle) * dist;
css: (t: number, u: number) => ` return {
duration: duration + Math.random() * 300,
easing: variant === "in" ? backOut : cubicIn,
css: (t: number, u: number) => `
transform: translate(${u * x}%, ${u * y}%) rotate(${u * rot}deg) scale(${1 - u * (1 - scaleFrom)}); transform: translate(${u * x}%, ${u * y}%) rotate(${u * rot}deg) scale(${1 - u * (1 - scaleFrom)});
opacity: ${t}; opacity: ${t};
` `,
}; };
} }
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
photos = shuffle(Object.values(modules)); photos = shuffle(Object.values(modules));
}); });
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const query = window.matchMedia('(prefers-reduced-motion: reduce)'); const query = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion = query.matches; reducedMotion = query.matches;
const onChange = (event: MediaQueryListEvent) => (reducedMotion = event.matches); const onChange = (event: MediaQueryListEvent) =>
query.addEventListener('change', onChange); (reducedMotion = event.matches);
return () => query.removeEventListener('change', onChange); query.addEventListener("change", onChange);
}); return () => query.removeEventListener("change", onChange);
});
$effect(() => { $effect(() => {
if (photos.length < 2) return; if (photos.length < 2) return;
const id = setInterval(() => { const id = setInterval(() => {
index = (index + 1) % photos.length; index = (index + 1) % photos.length;
}, intervalMs); }, intervalMs);
return () => clearInterval(id); return () => clearInterval(id);
}); });
</script> </script>
{#if photos.length > 0} {#if photos.length > 0}
<div class="pointer-events-none bg-surface-950 absolute inset-0 overflow-hidden" aria-hidden="true"> <div
{#key index} class="pointer-events-none absolute inset-0 overflow-hidden"
<img aria-hidden="true"
src={photos[index]} >
alt="" {#key index}
class="absolute inset-0 h-full w-full object-contain" <img
in:funky={{ duration: 900, variant: 'in' }} src={photos[index]}
out:funky={{ duration: 700, variant: 'out' }} alt=""
/> class="absolute inset-0 h-full w-full object-cover md:object-contain"
{/key} in:funky={{ duration: 900, variant: "in" }}
<div class="absolute inset-0 bg-surface-950/65 dark:bg-surface-950/75"></div> out:funky={{ duration: 700, variant: "out" }}
</div> />
{/key}
<div
class="absolute inset-0 bg-surface-950/65 dark:bg-surface-950/75"
></div>
</div>
{/if} {/if}
+84 -36
View File
@@ -1,45 +1,93 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import "../app.css";
import favicon from '$lib/assets/favicon.svg'; import favicon from "$lib/assets/favicon.svg";
import { page } from '$app/state'; import { page } from "$app/state";
import Countdown from '$lib/components/Countdown.svelte'; import Countdown from "$lib/components/Countdown.svelte";
import { createWeddingInfo } from '$lib/wedding-info.svelte'; import { createWeddingInfo } from "$lib/wedding-info.svelte";
let { children } = $props(); let { children } = $props();
const wedding = createWeddingInfo(); const wedding = createWeddingInfo();
const links = [ const links = [
{ href: '/', label: 'Home' }, { href: "/", label: "Home" },
{ href: '/rsvp', label: 'RSVP' }, { href: "/rsvp", label: "RSVP" },
{ href: '/photos', label: 'Photos' } { href: "/photos", label: "Photos" },
]; ];
let menuOpen = $state(false);
$effect(() => {
page.url.pathname;
menuOpen = false;
});
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
<div class="bg-surface-50 dark:bg-surface-950 flex min-h-screen flex-col"> <div
<div class="sticky top-0 z-20 flex flex-col"> class="bg-surface-50 dark:bg-surface-950 flex min-h-screen flex-col bg-[url('/patterns/damask.svg')] bg-repeat [background-size:160px_160px]"
<nav class="bg-primary-950 flex items-center justify-between px-4 py-3 sm:px-8"> >
<a href="/" class="text-surface-50 font-malibu text-xl tracking-wide">Zoé &amp; Shaldon</a> <div class="sticky top-0 z-20 flex flex-col">
<div class="flex gap-2"> <nav
{#each links as link (link.href)} class="bg-primary-950 flex items-center justify-between px-4 py-3 sm:px-8"
<a >
href={link.href} <a
class="btn field-sm {page.url.pathname === link.href href="/"
? 'preset-filled-secondary-500' class="text-surface-50 font-malibu hidden text-3xl tracking-wide sm:inline"
: 'text-surface-100 hover:text-secondary-400'}" >Zoé &amp; Shaldon</a
> >
{link.label} <Countdown
</a> target={wedding.date}
{/each} compact
</div> pastMessage="Already married! 🎉"
</nav> />
<div class="text-surface-50 flex justify-center px-4 py-3 drop-shadow-[0_2px_6px_rgba(0,0,0,0.6)]"> <div class="hidden gap-2 sm:flex">
<Countdown target={wedding.date} compact pastMessage="Already married! 🎉" /> {#each links as link (link.href)}
</div> <a
</div> href={link.href}
<main class="flex flex-1 flex-col"> class="btn field-sm {page.url.pathname === link.href
{@render children()} ? 'preset-filled-secondary-500'
</main> : 'text-surface-100 hover:text-secondary-400'}"
>
{link.label}
</a>
{/each}
</div>
<button
type="button"
class="text-surface-50 flex items-center justify-center p-2 sm:hidden"
aria-label="Menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
{#if menuOpen}
<svg viewBox="0 0 24 24" class="size-6" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
{:else}
<svg viewBox="0 0 24 24" class="size-6" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
{/if}
</button>
</nav>
{#if menuOpen}
<div class="bg-primary-950 border-primary-800 flex flex-col gap-1 border-t px-4 py-3 sm:hidden">
{#each links as link (link.href)}
<a
href={link.href}
class="btn field-sm w-full {page.url.pathname === link.href
? 'preset-filled-secondary-500'
: 'text-surface-100 hover:text-secondary-400'}"
>
{link.label}
</a>
{/each}
</div>
{/if}
</div>
<main class="flex flex-1 flex-col">
{@render children()}
</main>
</div> </div>
+1 -1
View File
@@ -8,7 +8,7 @@
</script> </script>
<section <section
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 text-center" class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 text-center z-10"
> >
<PhotoBackground /> <PhotoBackground />
+14 -14
View File
@@ -65,7 +65,7 @@
{#if !isOpen} {#if !isOpen}
<div class="flex flex-col items-center gap-6 text-center"> <div class="flex flex-col items-center gap-6 text-center">
<h1 class="font-serif text-4xl">Photo uploads open on the wedding day</h1> <h1 class="font-serif text-4xl">Photo uploads open on the wedding day</h1>
<p class="text-surface-600-400 text-lg">Come back on {formatFullDate(wedding.date)} to share your photos with Zoé &amp; Shaldon.</p> <p class="text-surface-600-400 text-2xl">Come back on {formatFullDate(wedding.date)} to share your photos with Zoé &amp; Shaldon.</p>
</div> </div>
{:else if status === 'done'} {:else if status === 'done'}
<div class="preset-filled-primary-500 rounded-base max-w-md p-4 text-center"> <div class="preset-filled-primary-500 rounded-base max-w-md p-4 text-center">
@@ -74,36 +74,36 @@
{:else} {:else}
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<h1 class="font-serif text-4xl">Share your photos</h1> <h1 class="font-serif text-4xl">Share your photos</h1>
<p class="text-surface-600-400 mt-2 mb-8 text-lg"> <p class="text-surface-600-400 mt-2 mb-8 text-2xl">
Upload photos from the day — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each. Upload photos from the day — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
</p> </p>
<form class="flex flex-col gap-4" onsubmit={onSubmit}> <form class="flex flex-col gap-4" onsubmit={onSubmit}>
<label class="label"> <label class="label">
<span class="label-text">Name *</span> <span class="label-text text-2xl">Name *</span>
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="text" required bind:value={name} disabled={status === 'submitting'} /> <input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="text" required bind:value={name} disabled={status === 'submitting'} />
</label> </label>
<label class="label"> <label class="label">
<span class="label-text">Message to the couple (optional)</span> <span class="label-text text-2xl">Message to the couple (optional)</span>
<textarea class="textarea border border-surface-300 focus:border-primary-500 dark:border-surface-700" rows="3" bind:value={message} disabled={status === 'submitting'} <textarea class="textarea border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" rows="3" bind:value={message} disabled={status === 'submitting'}
></textarea> ></textarea>
</label> </label>
<label class="label"> <label class="label">
<span class="label-text">Email (optional)</span> <span class="label-text text-2xl">Email (optional)</span>
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="email" bind:value={email} disabled={status === 'submitting'} /> <input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="email" bind:value={email} disabled={status === 'submitting'} />
</label> </label>
<label class="label"> <label class="label">
<span class="label-text">Phone (optional)</span> <span class="label-text text-2xl">Phone (optional)</span>
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="tel" bind:value={phone} disabled={status === 'submitting'} /> <input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="tel" bind:value={phone} disabled={status === 'submitting'} />
</label> </label>
<label class="label"> <label class="label">
<span class="label-text">Photos *</span> <span class="label-text text-2xl">Photos *</span>
<input <input
class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl"
type="file" type="file"
accept="image/*" accept="image/*"
multiple multiple
@@ -114,10 +114,10 @@
</label> </label>
{#if status === 'error'} {#if status === 'error'}
<p class="text-error-500 text-base">{errorMessage}</p> <p class="text-error-500 text-2xl">{errorMessage}</p>
{/if} {/if}
<button class="btn preset-filled-secondary-500 mt-2" type="submit" disabled={status === 'submitting'}> <button class="btn preset-filled-secondary-500 mt-2 text-2xl" type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Uploading…' : 'Upload photos'} {status === 'submitting' ? 'Uploading…' : 'Upload photos'}
</button> </button>
</form> </form>
+2 -2
View File
@@ -7,7 +7,7 @@
<section class="flex flex-1 items-center justify-center px-4 py-16"> <section class="flex flex-1 items-center justify-center px-4 py-16">
<div class="border-surface-300 dark:border-surface-700 flex flex-col items-center gap-4 border px-6 py-10 text-center"> <div class="border-surface-300 dark:border-surface-700 flex flex-col items-center gap-4 border px-6 py-10 text-center">
<p class="text-primary-600-400 text-base tracking-[0.25em] uppercase">Scan to visit</p> <p class="text-primary-600-400 text-2xl tracking-[0.25em] uppercase">Scan to visit</p>
<h1 class="font-malibu text-4xl">Zoé &amp; Shaldon</h1> <h1 class="font-malibu text-4xl">Zoé &amp; Shaldon</h1>
<QrCode value={wedding.siteUrl} class="flex flex-col items-center gap-4"> <QrCode value={wedding.siteUrl} class="flex flex-col items-center gap-4">
@@ -15,7 +15,7 @@
<QrCode.Pattern /> <QrCode.Pattern />
</QrCode.Frame> </QrCode.Frame>
<p class="text-surface-700 dark:text-surface-300 text-base break-all">{wedding.siteUrl}</p> <p class="text-surface-700 dark:text-surface-300 text-2xl break-all">{wedding.siteUrl}</p>
<QrCode.DownloadTrigger mimeType="image/png" fileName="zoe-and-shaldon-qr.png"> <QrCode.DownloadTrigger mimeType="image/png" fileName="zoe-and-shaldon-qr.png">
Download PNG Download PNG
+2 -2
View File
@@ -60,7 +60,7 @@
</script> </script>
<section <section
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16" class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
> >
<PhotoBackground /> <PhotoBackground />
@@ -159,7 +159,7 @@
</div> </div>
<label class="label"> <label class="label">
<span class="label-text text-surface-100" <span class="label-text text-surface-100 text-2xl"
>Message to the couple (optional)</span >Message to the couple (optional)</span
> >
<textarea <textarea
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
<g fill="none" stroke="#D9A441" stroke-width="1" stroke-opacity="0.35">
<path d="M80,0 L160,80 L80,160 L0,80 Z" />
</g>
<g fill="#D9A441">
<circle cx="80" cy="80" r="3" fill-opacity="0.28" />
<circle cx="80" cy="71" r="7" fill-opacity="0.14" />
<circle cx="80" cy="89" r="7" fill-opacity="0.14" />
<circle cx="71" cy="80" r="7" fill-opacity="0.14" />
<circle cx="89" cy="80" r="7" fill-opacity="0.14" />
<circle cx="80" cy="0" r="2" fill-opacity="0.3" />
<circle cx="160" cy="80" r="2" fill-opacity="0.3" />
<circle cx="80" cy="160" r="2" fill-opacity="0.3" />
<circle cx="0" cy="80" r="2" fill-opacity="0.3" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 736 B