feat: add view transitions, smooth scroll, bundle analyzer, SSE radio stream, and AnimatePresence animations
Local Build and Deploy / deploy (push) Successful in 1m24s
Local Build and Deploy / deploy (push) Successful in 1m24s
This commit is contained in:
1 parent
57c3fe601a
commit
f53d25e0a5
11 files changed
+519
-229
No files matched your search
+6
-1
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user