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:
@@ -1,54 +1,72 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
target: Date;
|
||||
compact?: boolean;
|
||||
pastMessage?: string;
|
||||
}
|
||||
interface Props {
|
||||
target: Date;
|
||||
compact?: boolean;
|
||||
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(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
const diffMs = $derived(Math.max(0, target.getTime() - now));
|
||||
const isPast = $derived(diffMs <= 0);
|
||||
const diffMs = $derived(Math.max(0, target.getTime() - now));
|
||||
const isPast = $derived(diffMs <= 0);
|
||||
|
||||
const days = $derived(Math.floor(diffMs / 86_400_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 seconds = $derived(Math.floor((diffMs % 60_000) / 1000));
|
||||
const days = $derived(Math.floor(diffMs / 86_400_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 seconds = $derived(Math.floor((diffMs % 60_000) / 1000));
|
||||
|
||||
const units = $derived([
|
||||
{ label: 'days', value: days },
|
||||
{ label: 'hours', value: hours },
|
||||
{ label: 'minutes', value: minutes },
|
||||
{ label: 'seconds', value: seconds }
|
||||
]);
|
||||
const units = $derived([
|
||||
{ label: "days", value: days },
|
||||
{ label: "hours", value: hours },
|
||||
{ label: "minutes", value: minutes },
|
||||
{ label: "seconds", value: seconds },
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
<div class="flex items-baseline gap-2 text-lg sm:text-xl" role="timer" aria-live="polite">
|
||||
{#each units as unit, i (unit.label)}
|
||||
{#if i > 0}<span class="text-secondary-300/60">·</span>{/if}
|
||||
<span class="font-semibold tabular-nums">{unit.value}</span>
|
||||
<span class="text-secondary-100/80 text-base">{unit.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2 z-10" role="timer" aria-live="polite">
|
||||
{#each units as unit, i (unit.label)}
|
||||
{#if i > 0}<span class="text-secondary-300/60">·</span>{/if}
|
||||
<span class="font-semibold tabular-nums text-md md:text-3xl"
|
||||
>{unit.value}</span
|
||||
>
|
||||
<span class="text-secondary-100/80 text-md md:text-3xl">{unit.label}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-4 sm:gap-8" role="timer" aria-live="polite">
|
||||
{#each units as unit (unit.label)}
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-secondary-400 text-4xl font-bold tabular-nums sm:text-6xl">
|
||||
{String(unit.value).padStart(2, '0')}
|
||||
</span>
|
||||
<span class="text-surface-200 text-base tracking-wide uppercase sm:text-lg">{unit.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-4 sm:gap-8" role="timer" aria-live="polite">
|
||||
{#each units as unit (unit.label)}
|
||||
<div class="flex flex-col items-center">
|
||||
<span
|
||||
class="text-secondary-400 text-4xl font-bold tabular-nums sm:text-6xl"
|
||||
>
|
||||
{String(unit.value).padStart(2, "0")}
|
||||
</span>
|
||||
<span
|
||||
class="text-surface-200 text-base tracking-wide uppercase sm:text-lg"
|
||||
>{unit.label}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,87 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { backOut, cubicIn } from 'svelte/easing';
|
||||
import { browser } from '$app/environment';
|
||||
import { backOut, cubicIn } from "svelte/easing";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const modules = import.meta.glob('../../../assets/photos/*.{jpg,jpeg,png,JPG,JPEG,PNG}', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
query: '?url'
|
||||
}) as Record<string, string>;
|
||||
const modules = import.meta.glob(
|
||||
"../../../assets/photos/*.{jpg,jpeg,png,JPG,JPEG,PNG}",
|
||||
{
|
||||
eager: true,
|
||||
import: "default",
|
||||
query: "?url",
|
||||
},
|
||||
) as Record<string, string>;
|
||||
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
const result = [...items];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
const result = [...items];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Randomized client-side only: shuffling during SSR/prerender would bake one
|
||||
// order into the static HTML while the client picks another, causing a
|
||||
// hydration mismatch on the <img src>.
|
||||
let photos = $state<string[]>([]);
|
||||
const intervalMs = 6000;
|
||||
// Randomized client-side only: shuffling during SSR/prerender would bake one
|
||||
// order into the static HTML while the client picks another, causing a
|
||||
// hydration mismatch on the <img src>.
|
||||
let photos = $state<string[]>([]);
|
||||
const intervalMs = 6000;
|
||||
|
||||
let index = $state(0);
|
||||
let reducedMotion = $state(false);
|
||||
let index = $state(0);
|
||||
let reducedMotion = $state(false);
|
||||
|
||||
// Each photo flies in/out from a random angle with its own spin and pop, so
|
||||
// no two transitions look alike.
|
||||
function funky(node: HTMLElement, { duration, variant }: { duration: number; variant: 'in' | 'out' }) {
|
||||
if (reducedMotion) {
|
||||
return { duration: 400, css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = 55 + Math.random() * 55;
|
||||
const rot = Math.random() * 80 - 40;
|
||||
const scaleFrom = 0.5 + Math.random() * 0.3;
|
||||
const x = Math.cos(angle) * dist;
|
||||
const y = Math.sin(angle) * dist;
|
||||
return {
|
||||
duration: duration + Math.random() * 300,
|
||||
easing: variant === 'in' ? backOut : cubicIn,
|
||||
css: (t: number, u: number) => `
|
||||
// Each photo flies in/out from a random angle with its own spin and pop, so
|
||||
// no two transitions look alike.
|
||||
function funky(
|
||||
node: HTMLElement,
|
||||
{ duration, variant }: { duration: number; variant: "in" | "out" },
|
||||
) {
|
||||
if (reducedMotion) {
|
||||
return { duration: 400, css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = 55 + Math.random() * 55;
|
||||
const rot = Math.random() * 80 - 40;
|
||||
const scaleFrom = 0.5 + Math.random() * 0.3;
|
||||
const x = Math.cos(angle) * dist;
|
||||
const y = Math.sin(angle) * dist;
|
||||
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)});
|
||||
opacity: ${t};
|
||||
`
|
||||
};
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
photos = shuffle(Object.values(modules));
|
||||
});
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
photos = shuffle(Object.values(modules));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotion = query.matches;
|
||||
const onChange = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
|
||||
query.addEventListener('change', onChange);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
});
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
reducedMotion = query.matches;
|
||||
const onChange = (event: MediaQueryListEvent) =>
|
||||
(reducedMotion = event.matches);
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (photos.length < 2) return;
|
||||
const id = setInterval(() => {
|
||||
index = (index + 1) % photos.length;
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
$effect(() => {
|
||||
if (photos.length < 2) return;
|
||||
const id = setInterval(() => {
|
||||
index = (index + 1) % photos.length;
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if photos.length > 0}
|
||||
<div class="pointer-events-none bg-surface-950 absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
{#key index}
|
||||
<img
|
||||
src={photos[index]}
|
||||
alt=""
|
||||
class="absolute inset-0 h-full w-full object-contain"
|
||||
in:funky={{ duration: 900, variant: 'in' }}
|
||||
out:funky={{ duration: 700, variant: 'out' }}
|
||||
/>
|
||||
{/key}
|
||||
<div class="absolute inset-0 bg-surface-950/65 dark:bg-surface-950/75"></div>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#key index}
|
||||
<img
|
||||
src={photos[index]}
|
||||
alt=""
|
||||
class="absolute inset-0 h-full w-full object-cover md:object-contain"
|
||||
in:funky={{ duration: 900, variant: "in" }}
|
||||
out:funky={{ duration: 700, variant: "out" }}
|
||||
/>
|
||||
{/key}
|
||||
<div
|
||||
class="absolute inset-0 bg-surface-950/65 dark:bg-surface-950/75"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user