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

+7 -2
View File
@@ -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),
}, },
}); }));
+4
View File
@@ -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",
+164
View File
@@ -73,6 +73,9 @@ importers:
jszip: jszip:
specifier: ^3.10.1 specifier: ^3.10.1
version: 3.10.1 version: 3.10.1
lenis:
specifier: ^1.3.25
version: 1.3.25([email protected])
lucide-react: lucide-react:
specifier: ^1.25.0 specifier: ^1.25.0
version: 1.25.0([email protected]) version: 1.25.0([email protected])
@@ -94,6 +97,9 @@ importers:
next-intl: next-intl:
specifier: ^4.13.2 specifier: ^4.13.2
version: 4.13.2([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]) version: 4.13.2([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
next-view-transitions:
specifier: ^0.3.5
version: 0.3.5([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
nodemailer: nodemailer:
specifier: ^9.0.3 specifier: ^9.0.3
version: 9.0.3 version: 9.0.3
@@ -140,6 +146,9 @@ importers:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.5.4 specifier: 2.5.4
version: 2.5.4 version: 2.5.4
'@next/bundle-analyzer':
specifier: ^16.2.10
version: 16.2.10
'@tailwindcss/forms': '@tailwindcss/forms':
specifier: ^0.5.11 specifier: ^0.5.11
version: 0.5.11([email protected]) version: 0.5.11([email protected])
@@ -381,6 +390,10 @@ packages:
'@borewit/[email protected]': '@borewit/[email protected]':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
'@discoveryjs/[email protected]':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
'@dnd-kit/[email protected]': '@dnd-kit/[email protected]':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies: peerDependencies:
@@ -1106,6 +1119,9 @@ packages:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1 '@emnapi/runtime': ^1.7.1
'@next/[email protected]':
resolution: {integrity: sha512-KcepWhb3IVniZgm00GSSCQDEUQqZXuXtuXRh8J6e3Un342TcQ77iK4DedeEkct+fcx7yFEDL2J6z4Jeho5JDAw==}
'@next/[email protected]': '@next/[email protected]':
resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==}
@@ -1548,6 +1564,9 @@ packages:
'@pinojs/[email protected]': '@pinojs/[email protected]':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@polka/[email protected]':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@prisma/[email protected]': '@prisma/[email protected]':
resolution: {integrity: sha512-mWsgcfbUjxB3qSzRlLs8E03vsKrqXzYK2zpx3e8u6wIgeHJM/sE46cuOGcYvHiZGmeQLCd3xL6YSSGM9QOLI6w==} resolution: {integrity: sha512-mWsgcfbUjxB3qSzRlLs8E03vsKrqXzYK2zpx3e8u6wIgeHJM/sE46cuOGcYvHiZGmeQLCd3xL6YSSGM9QOLI6w==}
@@ -2506,6 +2525,10 @@ packages:
peerDependencies: peerDependencies:
acorn: ^8.14.0 acorn: ^8.14.0
[email protected]:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -2651,6 +2674,10 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
[email protected]:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
[email protected]: [email protected]:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
@@ -2689,6 +2716,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
[email protected]:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
[email protected]: [email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -2764,6 +2794,9 @@ packages:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'} engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==}
@@ -2959,6 +2992,10 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==}
[email protected]:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
[email protected]: [email protected]:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2973,6 +3010,9 @@ packages:
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
[email protected]:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
@@ -3091,6 +3131,20 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
[email protected]:
resolution: {integrity: sha512-mOKxayErlaONK8fm4LN3XNd99Qu4plTpn9h9qf8wxzjGrJDzuD84FYzZ81HCd6ZsWp++VWVwOzL286Pf2s2u4A==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
vue: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
react:
optional: true
vue:
optional: true
[email protected]: [email protected]:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -3294,6 +3348,10 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
[email protected]:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
[email protected]: [email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -3356,6 +3414,13 @@ packages:
typescript: typescript:
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-yP8OPNydLmKpmE94hLurLGEzPsUy1uyl9iSv8oxaC2JwhSXTD86SVwk1NMMQT7Ado4kMENDJ7fNBIXHy3GU/Lg==}
peerDependencies:
next: '>=14.0.0'
react: '>=18.2.0 || ^19.0.0'
react-dom: '>=18.2.0 || ^19.0.0'
[email protected]: [email protected]:
resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
@@ -3410,6 +3475,10 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
[email protected]:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
@@ -3728,6 +3797,10 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
[email protected]:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
[email protected]: [email protected]:
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -3862,6 +3935,10 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
[email protected]:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
[email protected]: [email protected]:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@@ -4015,6 +4092,11 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
[email protected]:
resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==}
engines: {node: '>= 10.13.0'}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -4048,6 +4130,18 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
[email protected]:
resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==}
engines: {node: '>=8.3.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
[email protected]: [email protected]:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4269,6 +4363,8 @@ snapshots:
'@borewit/[email protected]': {} '@borewit/[email protected]': {}
'@discoveryjs/[email protected]': {}
'@dnd-kit/[email protected]([email protected])': '@dnd-kit/[email protected]([email protected])':
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
@@ -4758,6 +4854,13 @@ snapshots:
'@tybys/wasm-util': 0.10.3 '@tybys/wasm-util': 0.10.3
optional: true optional: true
'@next/[email protected]':
dependencies:
webpack-bundle-analyzer: 4.10.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@next/[email protected]': {} '@next/[email protected]': {}
'@next/[email protected]': '@next/[email protected]':
@@ -5041,6 +5144,8 @@ snapshots:
'@pinojs/[email protected]': {} '@pinojs/[email protected]': {}
'@polka/[email protected]': {}
'@prisma/[email protected]': '@prisma/[email protected]':
dependencies: dependencies:
'@prisma/driver-adapter-utils': 7.8.0 '@prisma/driver-adapter-utils': 7.8.0
@@ -5951,6 +6056,10 @@ snapshots:
dependencies: dependencies:
acorn: 8.17.0 acorn: 8.17.0
[email protected]:
dependencies:
acorn: 8.17.0
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -6080,6 +6189,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -6106,6 +6217,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -6166,6 +6279,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -6369,6 +6484,10 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
duplexer: 0.1.2
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -6377,6 +6496,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
@@ -6512,6 +6633,10 @@ snapshots:
dependencies: dependencies:
dayjs: 1.11.21 dayjs: 1.11.21
[email protected]([email protected]):
optionalDependencies:
react: 19.2.7
[email protected]: [email protected]:
dependencies: dependencies:
immediate: 3.0.6 immediate: 3.0.6
@@ -6640,6 +6765,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -6718,6 +6845,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@swc/helpers' - '@swc/helpers'
[email protected]([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
dependencies:
next: 16.2.10(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react: 19.2.7
react-dom: 19.2.7([email protected])
[email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]): [email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/env': 16.2.10 '@next/env': 16.2.10
@@ -6763,6 +6896,8 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
'@otplib/core': 12.0.1 '@otplib/core': 12.0.1
@@ -7188,6 +7323,12 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -7294,6 +7435,8 @@ snapshots:
'@tokenizer/token': 0.3.0 '@tokenizer/token': 0.3.0
ieee754: 1.2.1 ieee754: 1.2.1
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -7427,6 +7570,25 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
'@discoveryjs/json-ext': 0.5.7
acorn: 8.17.0
acorn-walk: 8.3.5
commander: 7.2.0
debounce: 1.2.1
escape-string-regexp: 4.0.0
gzip-size: 6.0.0
html-escaper: 2.0.2
is-plain-object: 5.0.0
opener: 1.5.2
picocolors: 1.1.1
sirv: 2.0.4
ws: 7.5.13
transitivePeerDependencies:
- bufferutil
- utf-8-validate
[email protected]: {} [email protected]: {}
[email protected]([email protected])([email protected]): [email protected]([email protected])([email protected]):
@@ -7485,6 +7647,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+6 -1
View File
@@ -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 />
{children} <ViewTransitions>
{children}
</ViewTransitions>
<PwaRegister /> <PwaRegister />
<SmoothScroll />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>
+74 -50
View File
@@ -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,61 +59,83 @@ 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>
<button {isOpen ? (
type="button" <motion.div
aria-label="Dismiss" key="confirm-overlay"
className="absolute inset-0 bg-black/50" className="fixed inset-0 z-[200] flex items-center justify-center p-4"
disabled={isLoading} variants={modalOverlayVariants}
onClick={() => { initial="hidden"
if (!isLoading) onOpenChange(false); animate="visible"
}} exit="exit"
/> >
<div <button
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
type="button" type="button"
variant="outline" aria-label="Dismiss"
className="absolute inset-0 bg-black/50"
disabled={isLoading} 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} <div className="flex flex-col gap-2 text-left">
</Button> <h2
<Button id="confirm-dialog-title"
type="button" className="text-lg font-semibold leading-none"
variant={variant === "danger" ? "destructive" : "default"} >
disabled={isLoading} {title}
onClick={handleConfirm} </h2>
className={cn( <p
variant === "danger" && "bg-destructive text-destructive-foreground", id="confirm-dialog-desc"
)} className="text-sm text-muted-foreground"
autoFocus >
> {description}
{confirmLabel} </p>
</Button> </div>
</div> <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
</div> <Button
</div>, 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, document.body,
); );
} }
+59 -46
View File
@@ -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,54 +39,66 @@ export function CmsInfoPopup() {
> >
EpicNext Info EpicNext Info
</button> </button>
{open ? ( <AnimatePresence>
<div {open ? (
className="cms-info-backdrop" <motion.div
role="presentation" key="cms-info-backdrop"
onMouseDown={() => setOpen(false)} className="cms-info-backdrop"
> role="presentation"
<section onMouseDown={() => setOpen(false)}
aria-labelledby="cms-info-title" initial={{ opacity: 0 }}
aria-modal="true" animate={{ opacity: 1 }}
className="cms-info-dialog" exit={{ opacity: 0 }}
role="dialog" transition={{ duration: 0.2 }}
onMouseDown={(event) => event.stopPropagation()}
> >
<button <motion.section
type="button" key="cms-info-dialog"
aria-label="Close CMS info" aria-labelledby="cms-info-title"
className="cms-info-close" aria-modal="true"
onClick={() => setOpen(false)} 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
</button> type="button"
<div className="cms-info-kicker">CMS information</div> aria-label="Close CMS info"
<h2 id="cms-info-title">EpicNext</h2> className="cms-info-close"
<div className="cms-info-badge">Modern Next.js CMS</div> onClick={() => setOpen(false)}
<p className="cms-info-copy"> >
A modern web layer for the hotel community, focused on fast x
navigation, clean administration, and a coherent EpicNext </button>
experience. <div className="cms-info-kicker">CMS information</div>
</p> <h2 id="cms-info-title">EpicNext</h2>
<dl className="cms-info-grid"> <div className="cms-info-badge">Modern Next.js CMS</div>
{cmsDetails.map(([label, value]) => ( <p className="cms-info-copy">
<div key={label}> A modern web layer for the hotel community, focused on fast
<dt>{label}</dt> navigation, clean administration, and a coherent EpicNext
<dd>{value}</dd> experience.
</div> </p>
))} <dl className="cms-info-grid">
</dl> {cmsDetails.map(([label, value]) => (
<div className="cms-info-credit-list" aria-label="Credits"> <div key={label}>
{credits.map(([name, role]) => ( <dt>{label}</dt>
<div className="cms-info-credit" key={name}> <dd>{value}</dd>
<span>{name}</span> </div>
<small>{role}</small> ))}
</div> </dl>
))} <div className="cms-info-credit-list" aria-label="Credits">
</div> {credits.map(([name, role]) => (
</section> <div className="cms-info-credit" key={name}>
</div> <span>{name}</span>
) : null} <small>{role}</small>
</div>
))}
</div>
</motion.section>
</motion.div>
) : null}
</AnimatePresence>
</> </>
); );
} }
+80 -71
View File
@@ -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,86 +84,94 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
))} ))}
</div> </div>
{active ? ( <AnimatePresence>
<div {active ? (
onClick={close} <motion.div
role="dialog" key="lightbox"
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"
onClick={close} 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={{ style={{
position: "absolute", position: "fixed",
top: "1rem", inset: 0,
right: "1rem", zIndex: 1000,
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",
display: "flex", display: "flex",
flexDirection: "column",
alignItems: "center", alignItems: "center",
gap: "0.75rem", justifyContent: "center",
cursor: "default", 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 */} <button
<img type="button"
src={active.url} onClick={close}
alt={active.alt} aria-label="Close"
style={{ style={{
maxWidth: "100%", position: "absolute",
maxHeight: "78vh", top: "1rem",
width: "auto", right: "1rem",
height: "auto", width: "2.5rem",
display: "block", height: "2.5rem",
borderRadius: "var(--radius-md)", borderRadius: "999px",
boxShadow: "var(--shadow-card-hover)", border: 0,
}} background: "rgba(255, 255, 255, 0.16)",
/> color: "#fff",
<figcaption fontSize: "1.4rem",
style={{ lineHeight: 1,
color: "rgba(255, 255, 255, 0.85)", cursor: "pointer",
fontSize: "0.9rem",
textAlign: "center",
}} }}
> >
{active.date} · {active.user} ✕
</figcaption> </button>
</figure> {/* Stop propagation so clicking the image itself doesn't close. */}
</div> <figure
) : null} 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 * 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) {
async function refresh() { setListeners(parseListeners({ listeners: streamData.listeners }));
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));
} }
}, [streamData]);
void refresh(); // Fetch config once on mount (rarely changes).
const id = setInterval(() => void refresh(), POLL_MS); useEffect(() => {
return () => { fetch("/api/radio/config", { cache: "no-store" })
cancelled = true; .then((r) => (r.ok ? r.json() : null))
clearInterval(id); .then((data) => {
}; if (data !== null) setConfig(parseConfig(data));
})
.catch(() => {});
}, []); }, []);
// Keep the <audio> volume in sync with the slider. // 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 };
}
+2 -19
View File
@@ -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