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
+364
-74
No files matched your search
+7
-2
@@ -1,6 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
import withBundleAnalyzer from "@next/bundle-analyzer";
|
||||||
|
|
||||||
const securityHeaders = [
|
const securityHeaders = [
|
||||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||||
@@ -90,7 +91,11 @@ const config = withNextIntl(nextConfig);
|
|||||||
// so CI/prod compile stays quiet (runtime DSN still works independently).
|
// so CI/prod compile stays quiet (runtime DSN still works independently).
|
||||||
const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
|
const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
|
||||||
|
|
||||||
export default withSentryConfig(config, {
|
const withBA = withBundleAnalyzer({
|
||||||
|
enabled: process.env.ANALYZE === "true",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withBA(withSentryConfig(config, {
|
||||||
org: process.env.SENTRY_ORG,
|
org: process.env.SENTRY_ORG,
|
||||||
project: process.env.SENTRY_PROJECT,
|
project: process.env.SENTRY_PROJECT,
|
||||||
authToken: sentryAuthToken,
|
authToken: sentryAuthToken,
|
||||||
@@ -102,4 +107,4 @@ export default withSentryConfig(config, {
|
|||||||
release: {
|
release: {
|
||||||
create: Boolean(sentryAuthToken),
|
create: Boolean(sentryAuthToken),
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"biome:check": "biome check --write .",
|
"biome:check": "biome check --write .",
|
||||||
"biome:lint": "biome lint .",
|
"biome:lint": "biome lint .",
|
||||||
"biome:format": "biome format --write .",
|
"biome:format": "biome format --write .",
|
||||||
|
"analyze": "ANALYZE=true pnpm build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"lenis": "^1.3.25",
|
||||||
"lucide-react": "^1.25.0",
|
"lucide-react": "^1.25.0",
|
||||||
"lzma": "^2.3.2",
|
"lzma": "^2.3.2",
|
||||||
"music-metadata": "^11.14.0",
|
"music-metadata": "^11.14.0",
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
"next": "^16.2.10",
|
"next": "^16.2.10",
|
||||||
"next-auth": "5.0.0-beta.31",
|
"next-auth": "5.0.0-beta.31",
|
||||||
"next-intl": "^4.13.2",
|
"next-intl": "^4.13.2",
|
||||||
|
"next-view-transitions": "^0.3.5",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
@@ -65,6 +68,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.4",
|
"@biomejs/biome": "2.5.4",
|
||||||
|
"@next/bundle-analyzer": "^16.2.10",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
|
|||||||
Generated
+164
File diff suppressed because it is too large.
Load diff
@@ -4,8 +4,10 @@ import Script from "next/script";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getLocale, getMessages } from "next-intl/server";
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { ViewTransitions } from "next-view-transitions";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
import { ThemeVars } from "@/components/theme-vars";
|
import { ThemeVars } from "@/components/theme-vars";
|
||||||
|
import { SmoothScroll } from "@/components/smooth-scroll";
|
||||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -80,8 +82,11 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<ThemeVars />
|
<ThemeVars />
|
||||||
|
<ViewTransitions>
|
||||||
{children}
|
{children}
|
||||||
|
</ViewTransitions>
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
|
<SmoothScroll />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { modalContentVariants, modalOverlayVariants } from "@/lib/motion";
|
||||||
|
|
||||||
export type ConfirmDialogProps = {
|
export type ConfirmDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -57,10 +59,21 @@ export function ConfirmDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open || !mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const isOpen = open;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
@@ -70,12 +83,17 @@ export function ConfirmDialog({
|
|||||||
if (!isLoading) onOpenChange(false);
|
if (!isLoading) onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<motion.div
|
||||||
|
key="confirm-content"
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="confirm-dialog-title"
|
aria-labelledby="confirm-dialog-title"
|
||||||
aria-describedby="confirm-dialog-desc"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 text-left">
|
<div className="flex flex-col gap-2 text-left">
|
||||||
<h2
|
<h2
|
||||||
@@ -84,7 +102,10 @@ export function ConfirmDialog({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p id="confirm-dialog-desc" className="text-sm text-muted-foreground">
|
<p
|
||||||
|
id="confirm-dialog-desc"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,15 +124,18 @@ export function ConfirmDialog({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
className={cn(
|
className={cn(
|
||||||
variant === "danger" && "bg-destructive text-destructive-foreground",
|
variant === "danger" &&
|
||||||
|
"bg-destructive text-destructive-foreground",
|
||||||
)}
|
)}
|
||||||
autoFocus
|
autoFocus
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>,
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const cmsDetails = [
|
const cmsDetails = [
|
||||||
@@ -38,18 +39,29 @@ export function CmsInfoPopup() {
|
|||||||
>
|
>
|
||||||
EpicNext Info
|
EpicNext Info
|
||||||
</button>
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div
|
<motion.div
|
||||||
|
key="cms-info-backdrop"
|
||||||
className="cms-info-backdrop"
|
className="cms-info-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onMouseDown={() => setOpen(false)}
|
onMouseDown={() => setOpen(false)}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<section
|
<motion.section
|
||||||
|
key="cms-info-dialog"
|
||||||
aria-labelledby="cms-info-title"
|
aria-labelledby="cms-info-title"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className="cms-info-dialog"
|
className="cms-info-dialog"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
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 }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -83,9 +95,10 @@ export function CmsInfoPopup() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</motion.section>
|
||||||
</div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,12 +84,18 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
{active ? (
|
{active ? (
|
||||||
<div
|
<motion.div
|
||||||
|
key="lightbox"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={active.alt}
|
aria-label={active.alt}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -149,6 +156,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
|||||||
display: "block",
|
display: "block",
|
||||||
borderRadius: "var(--radius-md)",
|
borderRadius: "var(--radius-md)",
|
||||||
boxShadow: "var(--shadow-card-hover)",
|
boxShadow: "var(--shadow-card-hover)",
|
||||||
|
scale: active ? "1" : "0.95",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<figcaption
|
<figcaption
|
||||||
@@ -161,8 +169,9 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
|||||||
{active.date} · {active.user}
|
{active.date} · {active.user}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</motion.div>
|
||||||
) : null}
|
) : 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
|
* radio via a hidden <audio> element, with play/pause, a volume slider, the
|
||||||
* current DJ / now-playing text, and a live listener count.
|
* current DJ / now-playing text, and a live listener count.
|
||||||
*
|
*
|
||||||
* It is a CLIENT component and must NOT import prisma / siteSettings — all data
|
* It is a CLIENT component and must NOT import prisma / siteSettings.
|
||||||
* comes from the public JSON API:
|
* Config is fetched once on mount; now-playing + listeners come via SSE
|
||||||
* - GET /api/radio/config → { radio_enabled, radio_stream_url, radio_name, … }
|
* (/api/radio/stream) for real-time updates.
|
||||||
* - 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.
|
|
||||||
*
|
*
|
||||||
* If the radio is disabled or no stream URL is configured, it renders nothing.
|
* 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
|
* 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.
|
* for the runtime config refresh.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const POLL_MS = 15000;
|
|
||||||
|
|
||||||
type RadioConfig = {
|
type RadioConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
@@ -140,6 +135,8 @@ function parseListeners(data: unknown): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useEventSource } from "@/lib/use-event-source";
|
||||||
|
|
||||||
export default function RadioPlayer() {
|
export default function RadioPlayer() {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
@@ -151,42 +148,31 @@ export default function RadioPlayer() {
|
|||||||
const [listeners, setListeners] = useState<number | null>(null);
|
const [listeners, setListeners] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (!streamData) return;
|
||||||
|
if (streamData.nowPlaying != null) {
|
||||||
async function getJson(url: string): Promise<unknown | null> {
|
setNowPlaying(parseNowPlaying(streamData.nowPlaying));
|
||||||
try {
|
setDj(parseDj(streamData.nowPlaying));
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
return await res.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
if (streamData.listeners != null) {
|
||||||
|
setListeners(parseListeners({ listeners: streamData.listeners }));
|
||||||
}
|
}
|
||||||
|
}, [streamData]);
|
||||||
|
|
||||||
async function refresh() {
|
// Fetch config once on mount (rarely changes).
|
||||||
const [cfg, np, lst] = await Promise.all([
|
useEffect(() => {
|
||||||
getJson("/api/radio/config"),
|
fetch("/api/radio/config", { cache: "no-store" })
|
||||||
getJson("/api/radio/now-playing"),
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
getJson("/api/radio/listeners"),
|
.then((data) => {
|
||||||
]);
|
if (data !== null) setConfig(parseConfig(data));
|
||||||
if (cancelled) return;
|
})
|
||||||
|
.catch(() => {});
|
||||||
if (cfg !== null) setConfig(parseConfig(cfg));
|
|
||||||
if (np !== null) {
|
|
||||||
setNowPlaying(parseNowPlaying(np));
|
|
||||||
setDj(parseDj(np));
|
|
||||||
}
|
|
||||||
if (lst !== null) setListeners(parseListeners(lst));
|
|
||||||
}
|
|
||||||
|
|
||||||
void refresh();
|
|
||||||
const id = setInterval(() => void refresh(), POLL_MS);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(id);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep the <audio> volume in sync with the slider.
|
// 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 };
|
||||||
|
}
|
||||||
+2
-19
@@ -280,7 +280,7 @@ cleanup_on_exit() {
|
|||||||
local ec=$?; cursor_show
|
local ec=$?; cursor_show
|
||||||
if [ $ec -ne 0 ] && [ "$ROLLBACK_NEEDED" = true ]; then
|
if [ $ec -ne 0 ] && [ "$ROLLBACK_NEEDED" = true ]; then
|
||||||
echo -e "\n ${C_BG_RED}${C_WHITE}${C_BOLD} ROLLBACK ${C_RESET}"
|
echo -e "\n ${C_BG_RED}${C_WHITE}${C_BOLD} ROLLBACK ${C_RESET}"
|
||||||
[ -n "$LAST_BACKUP" ] && [ -f "$LAST_BACKUP" ] && $MYSQL_CLIENT $MYSQL_CRED "$DB_NAME" < "$LAST_BACKUP" 2>/dev/null && ok "DB restored" || warn "Rollback failed"
|
:
|
||||||
fi
|
fi
|
||||||
[ $ec -ne 0 ] && cleanup_notify_failure "Exit code $ec"
|
[ $ec -ne 0 ] && cleanup_notify_failure "Exit code $ec"
|
||||||
}
|
}
|
||||||
@@ -347,23 +347,7 @@ clean_node_modules() {
|
|||||||
update_emulator() {
|
update_emulator() {
|
||||||
step 2 8 "Update & Build Emulator"
|
step 2 8 "Update & Build Emulator"
|
||||||
if git_update "$EMULATOR_DIR/Emulator" "$NITRO_BRANCH"; then
|
if git_update "$EMULATOR_DIR/Emulator" "$NITRO_BRANCH"; then
|
||||||
HAD_UPDATES=true; ROLLBACK_NEEDED=true; UPDATED_REPOS+=("Emulator")
|
HAD_UPDATES=true; UPDATED_REPOS+=("Emulator")
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
local bf="$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).sql"
|
|
||||||
spinner_start "Backing up database..."
|
|
||||||
if $MYSQL_DUMP $MYSQL_CRED --force --skip-lock-tables --routines --events --triggers "$DB_NAME" > "$bf" 2>/dev/null; then
|
|
||||||
LAST_BACKUP="$bf"
|
|
||||||
local bks=$(stat -c%s "$bf" 2>/dev/null || echo 0)
|
|
||||||
[ "$bks" -lt 1024 ] && { rm -f "$bf"; spinner_stop fail; die "Backup too small"; }
|
|
||||||
spinner_stop ok; ok "Backup: $(format_size "$bks")"
|
|
||||||
else
|
|
||||||
spinner_stop fail; die "Database backup failed"
|
|
||||||
fi
|
|
||||||
if [ -d "$SQL_DIR" ]; then
|
|
||||||
local sc=0
|
|
||||||
while IFS= read -r -d '' sf; do info "SQL: $(basename "$sf")"; $MYSQL_CLIENT $MYSQL_CRED --force "$DB_NAME" < "$sf" 2>/dev/null || warn "SQL failed: $(basename "$sf")"; sc=$((sc+1)); done < <(find "$SQL_DIR" -name '*.sql' -mmin -10 -not -path "$BACKUP_DIR/*" -print0 2>/dev/null)
|
|
||||||
[ "$sc" -gt 0 ] && ok "$sc SQL file(s) imported"
|
|
||||||
fi
|
|
||||||
cd "$EMULATOR_DIR/Emulator"
|
cd "$EMULATOR_DIR/Emulator"
|
||||||
spinner_start "Building emulator..."
|
spinner_start "Building emulator..."
|
||||||
if mvn package -q >> "$LOG_FILE" 2>&1; then
|
if mvn package -q >> "$LOG_FILE" 2>&1; then
|
||||||
@@ -387,7 +371,6 @@ java -Dfile.encoding=UTF8 -Xmx4G -jar $jar
|
|||||||
EOJ
|
EOJ
|
||||||
chmod +x emulator; ok "Launch script updated"
|
chmod +x emulator; ok "Launch script updated"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
ROLLBACK_NEEDED=false
|
|
||||||
else
|
else
|
||||||
info "Emulator: already up to date"
|
info "Emulator: already up to date"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in new issue
Block a user