Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/components/data/DownloadsLiveStatusBadge.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from "svelte";
import type { DownloadsLiveStatus } from "@/utils/download";

interface Props {
id: string;
status: DownloadsLiveStatus;
}

let { id, status }: Props = $props();
let container: HTMLDivElement;
let open = $state(false);

const LABELS: Record<DownloadsLiveStatus, string> = {
connecting: "Connecting",
live: "Live",
reconnecting: "Reconnecting",
paused: "Paused",
offline: "Offline",
};

const DETAILS: Record<DownloadsLiveStatus, string> = {
connecting: "Connecting…",
live: "Watching for new builds.",
reconnecting: "Reconnecting. The displayed information may be out of date.",
paused: "Updates pause while this tab is hidden.",
offline: "You're offline. Updates will resume when you reconnect.",
};

const DOT_CLASSES: Record<DownloadsLiveStatus, string> = {
connecting: "bg-amber-400 animate-pulse",
live: "bg-green-500",
reconnecting: "bg-amber-400 animate-pulse",
paused: "bg-gray-400",
offline: "bg-red-500",
};

const panelId = $derived(`${id}-details`);
const headingId = $derived(`${id}-heading`);

onMount(() => {
function handleOutsidePointer(event: PointerEvent) {
if (open && event.target instanceof Node && !container.contains(event.target)) open = false;
}

document.addEventListener("pointerdown", handleOutsidePointer);
return () => document.removeEventListener("pointerdown", handleOutsidePointer);
});

function handleClick() {
open = !open;
}

function handleKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
open = false;
(event.currentTarget as HTMLButtonElement).blur();
}
</script>

<div class="relative ml-auto shrink-0" bind:this={container}>
<button
type="button"
class="flex items-center justify-center gap-2 rounded-full border border-gray-300 px-3 py-1 text-xs whitespace-nowrap text-gray-700 transition-colors hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
aria-label={`Live download updates: ${LABELS[status]}`}
aria-live="polite"
aria-expanded={open}
aria-controls={panelId}
onclick={handleClick}
onkeydown={handleKeydown}
>
<span class={`h-2 w-2 shrink-0 rounded-full ${DOT_CLASSES[status]}`}></span>
<span>{LABELS[status]}</span>
</button>

{#if open}
<div
id={panelId}
role="note"
aria-labelledby={headingId}
class="absolute top-full right-0 z-50 mt-2 w-72 rounded-lg border border-gray-200 bg-white p-3 text-left text-sm leading-5 text-gray-700 shadow-lg dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
>
<p id={headingId} class="font-medium text-gray-900 dark:text-white">Live updates</p>
<p class="mt-1">This page updates automatically when new builds are available.</p>
<p class="mt-2"><span class="font-medium">Current status:</span> {DETAILS[status]}</p>
</div>
{/if}
</div>
186 changes: 186 additions & 0 deletions src/components/data/DownloadsUpdateToast.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Attachment } from "svelte/attachments";
import { fade } from "svelte/transition";

interface Props {
toasts: Array<{ id: number; projectName: string; channel?: string; paused: boolean }>;
durationMs: number;
ondismiss: (id: number) => void;
onpause: (id: number) => void;
onresume: (id: number) => void;
}

let { toasts, durationMs, ondismiss, onpause, onresume }: Props = $props();
let reducedMotion = $state(false);

onMount(() => {
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
const updatePreference = () => (reducedMotion = preference.matches);
updatePreference();
preference.addEventListener("change", updatePreference);
return () => preference.removeEventListener("change", updatePreference);
});

function accentClasses(value?: string) {
switch (value?.toLowerCase()) {
case "alpha":
return "border-channel-alpha-primary";
case "beta":
return "border-channel-beta-primary";
case "recommended":
return "border-channel-recommended-primary";
default:
return "border-blue-500";
}
}

function progressClass(value?: string) {
switch (value?.toLowerCase()) {
case "alpha":
return "bg-channel-alpha-primary";
case "beta":
return "bg-channel-beta-primary";
case "recommended":
return "bg-channel-recommended-primary";
default:
return "bg-blue-500";
}
}

function handleFocusOut(event: FocusEvent, toastId: number) {
if (
event.relatedTarget instanceof Node &&
event.currentTarget instanceof HTMLElement &&
event.currentTarget.contains(event.relatedTarget)
)
return;
if (event.currentTarget instanceof HTMLElement && event.currentTarget.matches(":hover")) return;
onresume(toastId);
}

function handlePointerLeave(event: PointerEvent, toastId: number) {
if (event.currentTarget instanceof HTMLElement && event.currentTarget.contains(document.activeElement)) return;
onresume(toastId);
}

const animateStack: Attachment<HTMLDivElement> = (element) => {
let previousHeight = element.offsetHeight;
let stackAnimation: Animation | undefined;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Imperative animation registry; mutations never drive rendering.
const itemAnimations = new Map<HTMLElement, Animation>();
let positions = new Map<HTMLElement, number>();

const items = () => Array.from(element.querySelectorAll<HTMLElement>("[data-toast-id]"));
const position = (item: HTMLElement) => element.offsetTop + item.offsetTop;

const observer = new MutationObserver((mutations) => {
const currentItems = items();
const currentHeight = element.offsetHeight;
const added = mutations.some((mutation) =>
Array.from(mutation.addedNodes).some((node) => node instanceof HTMLElement && node.matches("[data-toast-id]"))
);
const removed = mutations.some((mutation) =>
Array.from(mutation.removedNodes).some((node) => node instanceof HTMLElement && node.matches("[data-toast-id]"))
);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (!reduceMotion && added && currentHeight > previousHeight) {
const transform = getComputedStyle(element).transform;
const currentOffset = transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m42;
stackAnimation?.cancel();
stackAnimation = element.animate(
[{ transform: `translateY(${currentOffset + currentHeight - previousHeight}px)` }, { transform: "translateY(0)" }],
{
duration: 1_000,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
}
);
} else if (!reduceMotion && removed) {
for (const item of currentItems) {
const previousPosition = positions.get(item);
if (previousPosition === undefined) continue;
const offset = previousPosition - position(item);
if (Math.abs(offset) <= 0.5) continue;

itemAnimations.get(item)?.cancel();
const animation = item.animate([{ transform: `translateY(${offset}px)` }, { transform: "translateY(0)" }], {
duration: 1_000,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
});
itemAnimations.set(item, animation);
animation.addEventListener("finish", () => itemAnimations.delete(item), { once: true });
}
}

previousHeight = currentHeight;
positions = new Map(currentItems.map((item) => [item, position(item)]));
});

observer.observe(element, { childList: true });
return () => {
observer.disconnect();
stackAnimation?.cancel();
for (const animation of itemAnimations.values()) animation.cancel();
};
};
</script>

<div
class="pointer-events-none fixed right-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] left-4 z-60 flex flex-col gap-3 sm:right-6 sm:bottom-[calc(1.5rem+env(safe-area-inset-bottom))] sm:left-auto sm:w-88"
aria-live="polite"
{@attach animateStack}
>
{#each toasts as toast (toast.id)}
<aside
data-toast-id={toast.id}
class={`pointer-events-auto relative overflow-hidden rounded-lg border bg-white px-4 py-3 text-gray-900 shadow-md dark:bg-gray-900 dark:text-white ${accentClasses(toast.channel)}`}
role="status"
out:fade={{ duration: reducedMotion ? 0 : 800 }}
onpointerenter={() => onpause(toast.id)}
onpointerleave={(event) => handlePointerLeave(event, toast.id)}
onfocusin={() => onpause(toast.id)}
onfocusout={(event) => handleFocusOut(event, toast.id)}
>
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1">
<p class="font-medium">Downloads updated</p>
<p class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">A new {toast.projectName} build is now available.</p>
</div>
<button
type="button"
class="-m-1 grid size-7 shrink-0 place-items-center rounded-md text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-900 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white"
aria-label={`Dismiss ${toast.projectName} update notification`}
onclick={() => ondismiss(toast.id)}
>
<svg viewBox="0 0 20 20" class="size-4" aria-hidden="true">
<path d="m5 5 10 10M15 5 5 15" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
</svg>
</button>
</div>
<span
class={`toast-progress absolute bottom-0 left-0 hidden h-0.5 w-full origin-left motion-safe:block ${progressClass(toast.channel)} ${toast.paused ? "paused" : ""}`}
style:animation-duration={`${durationMs}ms`}
aria-hidden="true"
></span>
</aside>
{/each}
</div>

<style>
.toast-progress {
animation-name: toast-countdown;
animation-timing-function: linear;
animation-fill-mode: forwards;
}

.toast-progress.paused {
animation-play-state: paused;
}

@keyframes toast-countdown {
to {
transform: scaleX(0);
}
}
</style>
60 changes: 58 additions & 2 deletions src/components/data/SoftwareDownload.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@

import SoftwareDownloadButton from "@/components/data/SoftwareDownloadButton.svelte";
import SoftwareBuilds from "@/components/data/SoftwareBuilds.svelte";
import DownloadsLiveStatusBadge from "@/components/data/DownloadsLiveStatusBadge.svelte";
import DownloadsUpdateToast from "@/components/data/DownloadsUpdateToast.svelte";

import PaperIconUrl from "@/assets/brand/paper.svg?url";
import VelocityIconUrl from "@/assets/brand/velocity.svg?url";
import FoliaIconUrl from "@/assets/brand/folia.svg?url";
import WaterfallIconUrl from "@/assets/brand/waterfall-white.svg?url";
import type { Snippet } from "svelte";
import { type ProjectBuildsOrError } from "@/utils/download";
import { onDestroy, untrack, type Snippet } from "svelte";
import { type DownloadsLiveStatus, type ProjectBuildsOrError } from "@/utils/download";

interface Props {
id: "paper" | "velocity" | "folia" | "waterfall" | (string & {});
Expand All @@ -20,6 +22,8 @@
Description?: Snippet;
experimentalWarning?: string;
eol?: boolean;
liveStatus: DownloadsLiveStatus;
updateNotification: number;
}

let {
Expand All @@ -31,6 +35,8 @@
Description = undefined,
experimentalWarning = undefined,
eol = false,
liveStatus,
updateNotification,
}: Props = $props();

const ICONS: Record<string, string | undefined> = {
Expand All @@ -55,6 +61,53 @@
}

let builds = $derived(isStable ? stableBuilds : (experimentalBuilds ?? stableBuilds));
const TOAST_DURATION_MS = 15_000;
let toasts = $state<Array<{ id: number; projectName: string; channel?: string; paused: boolean }>>([]);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Imperative timer registry; mutations never drive rendering.
const toastTimers = new Map<number, { timer: ReturnType<typeof setTimeout>; remainingMs: number; startedAt: number }>();
let nextToastId = 0;

function dismissToast(toastId: number) {
const state = toastTimers.get(toastId);
if (state) clearTimeout(state.timer);
toastTimers.delete(toastId);
toasts = toasts.filter((toast) => toast.id !== toastId);
}

function showUpdateToast(projectName = project.name, channel = builds?.value?.latest?.channel) {
const toast = { id: ++nextToastId, projectName, channel, paused: false };
toasts = [...toasts, toast];
toastTimers.set(toast.id, {
timer: setTimeout(() => dismissToast(toast.id), TOAST_DURATION_MS),
remainingMs: TOAST_DURATION_MS,
startedAt: performance.now(),
});
}

function pauseToast(toastId: number) {
const state = toastTimers.get(toastId);
if (!state || toasts.find((toast) => toast.id === toastId)?.paused) return;
clearTimeout(state.timer);
state.remainingMs = Math.max(0, state.remainingMs - (performance.now() - state.startedAt));
toasts = toasts.map((toast) => (toast.id === toastId ? { ...toast, paused: true } : toast));
}

function resumeToast(toastId: number) {
const state = toastTimers.get(toastId);
if (!state || !toasts.find((toast) => toast.id === toastId)?.paused) return;
state.startedAt = performance.now();
state.timer = setTimeout(() => dismissToast(toastId), state.remainingMs);
toasts = toasts.map((toast) => (toast.id === toastId ? { ...toast, paused: false } : toast));
}

$effect(() => {
if (updateNotification > 0) untrack(showUpdateToast);
});

onDestroy(() => {
for (const state of toastTimers.values()) clearTimeout(state.timer);
toastTimers.clear();
});
</script>

<header class="mx-auto flex max-w-7xl flex-row flex-wrap gap-16 px-4 pt-32 pb-16 lg:pt-48 lg:pb-26">
Expand All @@ -72,6 +125,7 @@
{/if}
</div>
<h1 class="text-xl font-medium">Downloads</h1>
<DownloadsLiveStatusBadge id="downloads-live-status" status={liveStatus} />
</div>

<h2 class="text-4xl leading-normal font-medium lg:text-5xl lg:leading-normal">
Expand All @@ -84,14 +138,14 @@
{#if Description}
{@render Description()}
{:else if typeof description === "string"}
{@html description}

Check warning on line 141 in src/components/data/SoftwareDownload.svelte

View workflow job for this annotation

GitHub Actions / lint

`{@html}` can lead to XSS attack

Check warning on line 141 in src/components/data/SoftwareDownload.svelte

View workflow job for this annotation

GitHub Actions / lint

`{@html}` can lead to XSS attack
{/if}
{:else if experimentalWarning}
{experimentalWarning}
{:else if Description}
{@render Description()}
{:else if typeof description === "string"}
{@html description}

Check warning on line 148 in src/components/data/SoftwareDownload.svelte

View workflow job for this annotation

GitHub Actions / lint

`{@html}` can lead to XSS attack

Check warning on line 148 in src/components/data/SoftwareDownload.svelte

View workflow job for this annotation

GitHub Actions / lint

`{@html}` can lead to XSS attack
{/if}
</p>

Expand Down Expand Up @@ -139,3 +193,5 @@
<div class="hidden"></div>
</div>
</header>

<DownloadsUpdateToast {toasts} durationMs={TOAST_DURATION_MS} ondismiss={dismissToast} onpause={pauseToast} onresume={resumeToast} />
Loading
Loading