feat: add view transitions, smooth scroll, bundle analyzer, SSE radio stream, and AnimatePresence animations
Local Build and Deploy / deploy (push) Successful in 1m24s

This commit is contained in:
openhands committed 2026-07-20 14:06:43 +02:00
1 parent 57c3fe601a
commit f53d25e0a5
11 files changed
+519 -229

No files matched your search

+6 -1
View File
@@ -4,8 +4,10 @@ import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
import { ViewTransitions } from "next-view-transitions";
import { PwaRegister } from "@/components/pwa-register";
import { ThemeVars } from "@/components/theme-vars";
import { SmoothScroll } from "@/components/smooth-scroll";
import { enforceSiteAccess } from "@/lib/access-guard";
import { siteSettings } from "@/lib/services/site-settings";
import "./globals.css";
@@ -80,8 +82,11 @@ export default async function RootLayout({
>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeVars />
{children}
<ViewTransitions>
{children}
</ViewTransitions>
<PwaRegister />
<SmoothScroll />
</NextIntlClientProvider>
</body>
</html>
+74 -50
View File
@@ -1,9 +1,11 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { modalContentVariants, modalOverlayVariants } from "@/lib/motion";
export type ConfirmDialogProps = {
open: boolean;
@@ -57,61 +59,83 @@ export function ConfirmDialog({
}
}
if (!open || !mounted) return null;
if (!mounted) return null;
const isOpen = open;
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
<button
type="button"
aria-label="Dismiss"
className="absolute inset-0 bg-black/50"
disabled={isLoading}
onClick={() => {
if (!isLoading) onOpenChange(false);
}}
/>
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-desc"
className="relative z-10 w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground shadow-lg"
>
<div className="flex flex-col gap-2 text-left">
<h2
id="confirm-dialog-title"
className="text-lg font-semibold leading-none"
>
{title}
</h2>
<p id="confirm-dialog-desc" className="text-sm text-muted-foreground">
{description}
</p>
</div>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
<AnimatePresence>
{isOpen ? (
<motion.div
key="confirm-overlay"
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
variants={modalOverlayVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<button
type="button"
variant="outline"
aria-label="Dismiss"
className="absolute inset-0 bg-black/50"
disabled={isLoading}
onClick={() => onOpenChange(false)}
onClick={() => {
if (!isLoading) onOpenChange(false);
}}
/>
<motion.div
key="confirm-content"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-desc"
className="relative z-10 w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground shadow-lg"
variants={modalContentVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{cancelLabel}
</Button>
<Button
type="button"
variant={variant === "danger" ? "destructive" : "default"}
disabled={isLoading}
onClick={handleConfirm}
className={cn(
variant === "danger" && "bg-destructive text-destructive-foreground",
)}
autoFocus
>
{confirmLabel}
</Button>
</div>
</div>
</div>,
<div className="flex flex-col gap-2 text-left">
<h2
id="confirm-dialog-title"
className="text-lg font-semibold leading-none"
>
{title}
</h2>
<p
id="confirm-dialog-desc"
className="text-sm text-muted-foreground"
>
{description}
</p>
</div>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
{cancelLabel}
</Button>
<Button
type="button"
variant={variant === "danger" ? "destructive" : "default"}
disabled={isLoading}
onClick={handleConfirm}
className={cn(
variant === "danger" &&
"bg-destructive text-destructive-foreground",
)}
autoFocus
>
{confirmLabel}
</Button>
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
+59 -46
View File
@@ -1,5 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
const cmsDetails = [
@@ -38,54 +39,66 @@ export function CmsInfoPopup() {
>
EpicNext Info
</button>
{open ? (
<div
className="cms-info-backdrop"
role="presentation"
onMouseDown={() => setOpen(false)}
>
<section
aria-labelledby="cms-info-title"
aria-modal="true"
className="cms-info-dialog"
role="dialog"
onMouseDown={(event) => event.stopPropagation()}
<AnimatePresence>
{open ? (
<motion.div
key="cms-info-backdrop"
className="cms-info-backdrop"
role="presentation"
onMouseDown={() => setOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<button
type="button"
aria-label="Close CMS info"
className="cms-info-close"
onClick={() => setOpen(false)}
<motion.section
key="cms-info-dialog"
aria-labelledby="cms-info-title"
aria-modal="true"
className="cms-info-dialog"
role="dialog"
onMouseDown={(event) => event.stopPropagation()}
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2 }}
>
x
</button>
<div className="cms-info-kicker">CMS information</div>
<h2 id="cms-info-title">EpicNext</h2>
<div className="cms-info-badge">Modern Next.js CMS</div>
<p className="cms-info-copy">
A modern web layer for the hotel community, focused on fast
navigation, clean administration, and a coherent EpicNext
experience.
</p>
<dl className="cms-info-grid">
{cmsDetails.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div className="cms-info-credit-list" aria-label="Credits">
{credits.map(([name, role]) => (
<div className="cms-info-credit" key={name}>
<span>{name}</span>
<small>{role}</small>
</div>
))}
</div>
</section>
</div>
) : null}
<button
type="button"
aria-label="Close CMS info"
className="cms-info-close"
onClick={() => setOpen(false)}
>
x
</button>
<div className="cms-info-kicker">CMS information</div>
<h2 id="cms-info-title">EpicNext</h2>
<div className="cms-info-badge">Modern Next.js CMS</div>
<p className="cms-info-copy">
A modern web layer for the hotel community, focused on fast
navigation, clean administration, and a coherent EpicNext
experience.
</p>
<dl className="cms-info-grid">
{cmsDetails.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div className="cms-info-credit-list" aria-label="Credits">
{credits.map(([name, role]) => (
<div className="cms-info-credit" key={name}>
<span>{name}</span>
<small>{role}</small>
</div>
))}
</div>
</motion.section>
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
+80 -71
View File
@@ -1,5 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useState } from "react";
/**
@@ -83,86 +84,94 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
))}
</div>
{active ? (
<div
onClick={close}
role="dialog"
aria-modal="true"
aria-label={active.alt}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(8px)",
cursor: "zoom-out",
}}
>
<button
type="button"
<AnimatePresence>
{active ? (
<motion.div
key="lightbox"
onClick={close}
aria-label="Close"
role="dialog"
aria-modal="true"
aria-label={active.alt}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
style={{
position: "absolute",
top: "1rem",
right: "1rem",
width: "2.5rem",
height: "2.5rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
}}
>
✕
</button>
{/* Stop propagation so clicking the image itself doesn't close. */}
<figure
onClick={(e) => e.stopPropagation()}
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(8px)",
cursor: "zoom-out",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
<button
type="button"
onClick={close}
aria-label="Close"
style={{
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
position: "absolute",
top: "1rem",
right: "1rem",
width: "2.5rem",
height: "2.5rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
}}
>
{active.date} · {active.user}
</figcaption>
</figure>
</div>
) : null}
✕
</button>
{/* Stop propagation so clicking the image itself doesn't close. */}
<figure
onClick={(e) => e.stopPropagation()}
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
style={{
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
scale: active ? "1" : "0.95",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
}}
>
{active.date} · {active.user}
</figcaption>
</figure>
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
+26 -40
View File
@@ -9,12 +9,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
* radio via a hidden <audio> element, with play/pause, a volume slider, the
* current DJ / now-playing text, and a live listener count.
*
* It is a CLIENT component and must NOT import prisma / siteSettings — all data
* comes from the public JSON API:
* - GET /api/radio/config → { radio_enabled, radio_stream_url, radio_name, … }
* - GET /api/radio/now-playing → provider passthrough ({ nowPlaying } | AzureCast | { song, artist } …)
* - GET /api/radio/listeners → { listeners: number | null }
* These are polled every ~15s; the interval is cleared on unmount.
* It is a CLIENT component and must NOT import prisma / siteSettings.
* Config is fetched once on mount; now-playing + listeners come via SSE
* (/api/radio/stream) for real-time updates.
*
* If the radio is disabled or no stream URL is configured, it renders nothing.
* The server-side <RadioPlayerGate> already guards on the same settings so the
@@ -22,8 +19,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
* for the runtime config refresh.
*/
const POLL_MS = 15000;
type RadioConfig = {
enabled: boolean;
streamUrl: string;
@@ -140,6 +135,8 @@ function parseListeners(data: unknown): number | null {
return null;
}
import { useEventSource } from "@/lib/use-event-source";
export default function RadioPlayer() {
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -151,42 +148,31 @@ export default function RadioPlayer() {
const [listeners, setListeners] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Poll config + now-playing + listeners every POLL_MS; clear on unmount.
// Subscribe to SSE stream for real-time now-playing + listeners.
const { data: streamData } = useEventSource<{
nowPlaying: unknown;
listeners: number | null;
}>("/api/radio/stream", { enabled: true });
useEffect(() => {
let cancelled = false;
async function getJson(url: string): Promise<unknown | null> {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
if (!streamData) return;
if (streamData.nowPlaying != null) {
setNowPlaying(parseNowPlaying(streamData.nowPlaying));
setDj(parseDj(streamData.nowPlaying));
}
async function refresh() {
const [cfg, np, lst] = await Promise.all([
getJson("/api/radio/config"),
getJson("/api/radio/now-playing"),
getJson("/api/radio/listeners"),
]);
if (cancelled) return;
if (cfg !== null) setConfig(parseConfig(cfg));
if (np !== null) {
setNowPlaying(parseNowPlaying(np));
setDj(parseDj(np));
}
if (lst !== null) setListeners(parseListeners(lst));
if (streamData.listeners != null) {
setListeners(parseListeners({ listeners: streamData.listeners }));
}
}, [streamData]);
void refresh();
const id = setInterval(() => void refresh(), POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
// Fetch config once on mount (rarely changes).
useEffect(() => {
fetch("/api/radio/config", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data !== null) setConfig(parseConfig(data));
})
.catch(() => {});
}, []);
// Keep the <audio> volume in sync with the slider.
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Lenis } from "lenis/react";
import { useEffect, useState } from "react";
export function SmoothScroll() {
const [prefersReduced, setPrefersReduced] = useState(true);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReduced(mq.matches);
const onChange = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
if (prefersReduced) return null;
return (
<Lenis
root
options={{
duration: 1.2,
lerp: 0.1,
smoothWheel: true,
wheelMultiplier: 1,
}}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, useState } from "react";
type UseEventSourceOptions = {
onMessage?: (data: unknown) => void;
onError?: (event: Event) => void;
enabled?: boolean;
};
/**
* Subscribe to a Server-Sent Events (SSE) endpoint.
* Reconnects automatically on connection loss.
*/
export function useEventSource<T = unknown>(
url: string | null,
options?: UseEventSourceOptions,
) {
const { onMessage, onError, enabled = true } = options ?? {};
const [data, setData] = useState<T | null>(null);
const [connected, setConnected] = useState(false);
const onMessageRef = useRef(onMessage);
const onErrorRef = useRef(onError);
onMessageRef.current = onMessage;
onErrorRef.current = onError;
useEffect(() => {
if (!url || !enabled) return;
const endpoint: string = url;
let es: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout>;
function connect() {
es = new EventSource(endpoint);
es.onopen = () => setConnected(true);
es.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as T;
setData(parsed);
onMessageRef.current?.(parsed);
} catch {
// ignore malformed messages
}
};
es.onerror = (event) => {
es?.close();
setConnected(false);
onErrorRef.current?.(event);
reconnectTimer = setTimeout(connect, 3000);
};
}
connect();
return () => {
clearTimeout(reconnectTimer);
es?.close();
setConnected(false);
};
}, [url, enabled]);
return { data, connected };
}