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

+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.