Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbfda83e14 | ||
|
|
8fde1c9362 | ||
|
|
9febb8bb60 | ||
|
|
a754fc0282 | ||
|
|
6f17a5d4a8 | ||
|
|
9ed3f7169e | ||
|
|
c4a9d47180 | ||
|
|
8971748d5c | ||
|
|
0ac6636d08 | ||
|
|
f6faf97cdf | ||
|
|
6bb3ee4bc7 | ||
|
|
7bda138d69 | ||
|
|
9b51d61519 | ||
|
|
8e42862d20 | ||
|
|
f593b2c7b8 | ||
|
|
fc65fdb451 | ||
|
|
8171354e2a | ||
|
|
cb16d9757e | ||
|
|
96d5535293 | ||
|
|
f5b49d49ee |
@@ -31,8 +31,8 @@
|
|||||||
"facehash": "^0.0.7",
|
"facehash": "^0.0.7",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^19.2.7",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.3.0",
|
||||||
"react-imask": "^7.6.1",
|
"react-imask": "^7.6.1",
|
||||||
"redaxios": "^0.5.1",
|
"redaxios": "^0.5.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
"@lingui/cli": "^6.5.0",
|
"@lingui/cli": "^6.5.0",
|
||||||
"@lingui/vite-plugin": "^6.5.0",
|
"@lingui/vite-plugin": "^6.5.0",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
@@ -655,9 +655,9 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="],
|
"@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||||
|
|
||||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
|
||||||
@@ -1329,9 +1329,9 @@
|
|||||||
|
|
||||||
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
|
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
|
||||||
|
|
||||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
|
||||||
|
|
||||||
"react-imask": ["react-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA=="],
|
"react-imask": ["react-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA=="],
|
||||||
|
|
||||||
@@ -1397,7 +1397,7 @@
|
|||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||||
|
|
||||||
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
|
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -43,8 +43,8 @@
|
|||||||
"facehash": "^0.0.7",
|
"facehash": "^0.0.7",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^19.2.7",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.3.0",
|
||||||
"react-imask": "^7.6.1",
|
"react-imask": "^7.6.1",
|
||||||
"redaxios": "^0.5.1",
|
"redaxios": "^0.5.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -61,8 +61,8 @@
|
|||||||
"@lingui/cli": "^6.5.0",
|
"@lingui/cli": "^6.5.0",
|
||||||
"@lingui/vite-plugin": "^6.5.0",
|
"@lingui/vite-plugin": "^6.5.0",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const { count, size, warnings } = await generateSW({
|
|||||||
'icon-192x192.png',
|
'icon-192x192.png',
|
||||||
'icon-512x512.png',
|
'icon-512x512.png',
|
||||||
'site.webmanifest',
|
'site.webmanifest',
|
||||||
'styles.css',
|
|
||||||
'push-handlers.js',
|
'push-handlers.js',
|
||||||
],
|
],
|
||||||
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into
|
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into
|
||||||
|
|||||||
+17
-8
@@ -1,5 +1,5 @@
|
|||||||
// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
|
// Grant or revoke a role for a user by phone number (US +1 assumed).
|
||||||
// Usage: bun run scripts/make-admin.ts <phone> [--revoke]
|
// Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import SuperTokens from "supertokens-node";
|
import SuperTokens from "supertokens-node";
|
||||||
import Session from "supertokens-node/recipe/session";
|
import Session from "supertokens-node/recipe/session";
|
||||||
@@ -26,10 +26,19 @@ SuperTokens.init({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const VALID_ROLES = ["Admin", "Developer"] as const;
|
||||||
|
|
||||||
const raw = process.argv[2];
|
const raw = process.argv[2];
|
||||||
const revoke = process.argv.includes("--revoke");
|
const revoke = process.argv.includes("--revoke");
|
||||||
|
const roleFlagIndex = process.argv.indexOf("--role");
|
||||||
|
const role = roleFlagIndex === -1 ? "Admin" : process.argv[roleFlagIndex + 1];
|
||||||
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]");
|
console.error("Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) {
|
||||||
|
console.error(`Invalid role "${role}". Valid roles: ${VALID_ROLES.join(", ")}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +69,15 @@ if (!user) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
await UserRoles.createNewRoleOrAddPermissions("Admin", []);
|
await UserRoles.createNewRoleOrAddPermissions(role, []);
|
||||||
|
|
||||||
if (revoke) {
|
if (revoke) {
|
||||||
const res = await UserRoles.removeUserRole("public", user.id, "Admin");
|
const res = await UserRoles.removeUserRole("public", user.id, role);
|
||||||
console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
|
console.log(`Removed ${role} from ${matched} (user ${user.id}):`, res.status);
|
||||||
} else {
|
} else {
|
||||||
const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
|
const res = await UserRoles.addRoleToUser("public", user.id, role);
|
||||||
console.log(
|
console.log(
|
||||||
`Granted Admin to ${matched} (user ${user.id}):`,
|
`Granted ${role} to ${matched} (user ${user.id}):`,
|
||||||
res.status === "OK"
|
res.status === "OK"
|
||||||
? res.didUserAlreadyHaveRole
|
? res.didUserAlreadyHaveRole
|
||||||
? "already had it"
|
? "already had it"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
|||||||
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||||
|
import RoutePending from "../components/route-pending";
|
||||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||||
|
|
||||||
export function getRouter() {
|
export function getRouter() {
|
||||||
@@ -30,6 +31,9 @@ export function getRouter() {
|
|||||||
},
|
},
|
||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
defaultPreloadStaleTime: 60_000,
|
defaultPreloadStaleTime: 60_000,
|
||||||
|
defaultPendingMs: 100,
|
||||||
|
defaultPendingMinMs: 0,
|
||||||
|
defaultPendingComponent: RoutePending,
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { getRootMeta } from "@/lib/i18n/meta";
|
|||||||
import FullScreenLoader from "@/components/full-screen-loader";
|
import FullScreenLoader from "@/components/full-screen-loader";
|
||||||
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
||||||
import mantineCssUrl from '@mantine/core/styles.css?url'
|
import mantineCssUrl from '@mantine/core/styles.css?url'
|
||||||
|
import appStylesUrl from '../styles.css?url'
|
||||||
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
|
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
|
||||||
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
|
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
|
||||||
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
|
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
|
||||||
@@ -101,6 +102,7 @@ export const Route = createRootRouteWithContext<{
|
|||||||
},
|
},
|
||||||
{ rel: "manifest", href: "/site.webmanifest" },
|
{ rel: "manifest", href: "/site.webmanifest" },
|
||||||
{ rel: "icon", href: "/favicon.ico" },
|
{ rel: "icon", href: "/favicon.ico" },
|
||||||
|
{ rel: 'stylesheet', href: appStylesUrl },
|
||||||
{ rel: 'stylesheet', href: mantineCssUrl },
|
{ rel: 'stylesheet', href: mantineCssUrl },
|
||||||
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
||||||
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
||||||
@@ -183,7 +185,19 @@ function RootComponent() {
|
|||||||
ensureSuperTokensFrontend();
|
ensureSuperTokensFrontend();
|
||||||
initTelemetry(router);
|
initTelemetry(router);
|
||||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => track('sw_register_failed'));
|
// first install claims the page too; only reload on real updates
|
||||||
|
const hadController = !!navigator.serviceWorker.controller;
|
||||||
|
let reloading = false;
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
if (!hadController || reloading) return;
|
||||||
|
reloading = true;
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
navigator.serviceWorker.register('/sw.js').then((registration) => {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') registration.update().catch(() => {});
|
||||||
|
});
|
||||||
|
}).catch(() => track('sw_register_failed'));
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
@@ -231,7 +245,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}',
|
'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
style={{
|
style={{
|
||||||
@@ -243,6 +256,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="app">{children}</div>
|
<div className="app">{children}</div>
|
||||||
|
<div className="flxn-theme-overlay" aria-hidden="true" />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Outlet, createFileRoute, useNavigate, useRouterState } from "@tanstack/react-router";
|
import { Outlet, createFileRoute, redirect, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||||
import { Tabs } from "@mantine/core";
|
import { Tabs } from "@mantine/core";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
|
import { isDeveloperRole } from "@/features/core/utils/roles";
|
||||||
|
|
||||||
const TAB_ROUTES = {
|
const TAB_ROUTES = {
|
||||||
usage: "/admin/telemetry/usage",
|
usage: "/admin/telemetry/usage",
|
||||||
@@ -15,6 +16,11 @@ type TabValue = keyof typeof TAB_ROUTES;
|
|||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/telemetry")({
|
export const Route = createFileRoute("/_authed/admin/telemetry")({
|
||||||
component: TelemetryLayout,
|
component: TelemetryLayout,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (!isDeveloperRole(context.auth?.roles)) {
|
||||||
|
throw redirect({ to: "/admin" });
|
||||||
|
}
|
||||||
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
title: msg`Telemetry`,
|
title: msg`Telemetry`,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
|
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
|
||||||
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
|
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { getServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
import StartedTournament from "@/features/tournaments/components/started-tournament";
|
import StartedTournament from "@/features/tournaments/components/started-tournament";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
|
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
|
||||||
@@ -10,16 +11,13 @@ export const Route = createFileRoute("/_authed/")({
|
|||||||
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
|
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
|
||||||
<Home />
|
<Home />
|
||||||
</Suspense>,
|
</Suspense>,
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
const queryClient = context.queryClient;
|
prefetchServerQuery(context.queryClient, tournamentQueries.current());
|
||||||
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
|
|
||||||
|
|
||||||
return { tournament }
|
|
||||||
},
|
},
|
||||||
loader: ({ context }) => ({
|
loader: ({ context }) => ({
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
header: {
|
header: {
|
||||||
title: context.tournament.name || "FLXN"
|
title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN"
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
pendingComponent: () => <UpcomingTournamentSkeleton />
|
pendingComponent: () => <UpcomingTournamentSkeleton />
|
||||||
|
|||||||
@@ -14,26 +14,12 @@ const searchSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute("/_authed/profile/$playerId")({
|
export const Route = createFileRoute("/_authed/profile/$playerId")({
|
||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ params, context }) => {
|
beforeLoad: ({ params, context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await Promise.all([
|
prefetchServerQuery(queryClient, playerQueries.details(params.playerId));
|
||||||
prefetchServerQuery(
|
prefetchServerQuery(queryClient, playerQueries.matches(params.playerId));
|
||||||
queryClient,
|
prefetchServerQuery(queryClient, playerQueries.stats(params.playerId));
|
||||||
playerQueries.details(params.playerId)
|
prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId));
|
||||||
),
|
|
||||||
prefetchServerQuery(
|
|
||||||
queryClient,
|
|
||||||
playerQueries.matches(params.playerId)
|
|
||||||
),
|
|
||||||
prefetchServerQuery(
|
|
||||||
queryClient,
|
|
||||||
playerQueries.stats(params.playerId)
|
|
||||||
),
|
|
||||||
prefetchServerQuery(
|
|
||||||
queryClient,
|
|
||||||
badgeQueries.playerBadges(params.playerId)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
header: {
|
header: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
||||||
import { Suspense, useState, useDeferredValue, useEffect } from "react";
|
import { Suspense, useState, useDeferredValue, useEffect, useRef } from "react";
|
||||||
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
||||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
|
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
|
||||||
@@ -9,6 +9,7 @@ import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/cor
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/stats")({
|
export const Route = createFileRoute("/_authed/stats")({
|
||||||
component: Stats,
|
component: Stats,
|
||||||
@@ -32,6 +33,14 @@ function Stats() {
|
|||||||
const isStale = viewType !== deferredViewType;
|
const isStale = viewType !== deferredViewType;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const viewTypeRef = useRef(viewType);
|
||||||
|
const changeViewType = (next: typeof viewType) => {
|
||||||
|
if (viewTypeRef.current === next) return;
|
||||||
|
viewTypeRef.current = next;
|
||||||
|
track("stats_filtered", { value: next });
|
||||||
|
setViewType(next);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
|
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
|
||||||
@@ -54,21 +63,21 @@ function Stats() {
|
|||||||
<Button
|
<Button
|
||||||
variant={viewType === 'all' ? 'filled' : 'light'}
|
variant={viewType === 'all' ? 'filled' : 'light'}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
onClick={() => setViewType('all')}
|
onClick={() => changeViewType('all')}
|
||||||
>
|
>
|
||||||
<Trans>All</Trans>
|
<Trans>All</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={viewType === 'mainline' ? 'filled' : 'light'}
|
variant={viewType === 'mainline' ? 'filled' : 'light'}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
onClick={() => setViewType('mainline')}
|
onClick={() => changeViewType('mainline')}
|
||||||
>
|
>
|
||||||
<Trans>Mainline</Trans>
|
<Trans>Mainline</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={viewType === 'regional' ? 'filled' : 'light'}
|
variant={viewType === 'regional' ? 'filled' : 'light'}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
onClick={() => setViewType('regional')}
|
onClick={() => changeViewType('regional')}
|
||||||
>
|
>
|
||||||
<Trans>Regional</Trans>
|
<Trans>Regional</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ const searchSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute("/_authed/teams/$teamId")({
|
export const Route = createFileRoute("/_authed/teams/$teamId")({
|
||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ params, context }) => {
|
beforeLoad: ({ params, context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
|
prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
|
||||||
},
|
},
|
||||||
loader: ({ params }) => ({
|
loader: ({ params }) => ({
|
||||||
header: {
|
header: {
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
serverEvents.on(type, handleEvent);
|
serverEvents.on(type, handleEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Under the shortest idle timeout observed on proxies/PWA paths.
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
send({ type: "ping", timestamp: Date.now() });
|
send({ type: "ping", timestamp: Date.now() });
|
||||||
}, 15000);
|
}, 8000);
|
||||||
|
|
||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
if (cleanedUp) return;
|
if (cleanedUp) return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { isAdminRole } from "@/features/core/utils/roles";
|
||||||
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -19,7 +20,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
const i18n = localizedFor(context.metadata);
|
const i18n = localizedFor(context.metadata);
|
||||||
try {
|
try {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = isAdminRole(context.roles);
|
||||||
|
|
||||||
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { msg } from "@lingui/core/macro";
|
import { msg } from "@lingui/core/macro";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { isAdminRole } from "@/features/core/utils/roles";
|
||||||
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -19,7 +20,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
|||||||
const i18n = localizedFor(context.metadata);
|
const i18n = localizedFor(context.metadata);
|
||||||
try {
|
try {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = isAdminRole(context.roles);
|
||||||
|
|
||||||
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||||
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
|
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ textarea,
|
|||||||
transform: scale(0.90) translateY(-4px);
|
transform: scale(0.90) translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* themes paint their full-page texture through this element */
|
||||||
|
.flxn-theme-overlay {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.app,
|
.app,
|
||||||
[data-drawer-level] {
|
[data-drawer-level] {
|
||||||
@@ -53,12 +53,14 @@ export function Countdown({ date, label, color }: CountdownProps) {
|
|||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{label && <Text size='sm' fw={500}>{label}:</Text>}
|
{label && <Text size='sm' fw={500}>{label}:</Text>}
|
||||||
|
{/* Server and client render different seconds; the first client tick corrects it. */}
|
||||||
<Text
|
<Text
|
||||||
size='sm'
|
size='sm'
|
||||||
fw={600}
|
fw={600}
|
||||||
c={isFinalStretch ? 'red' : color}
|
c={isFinalStretch ? 'red' : color}
|
||||||
ff="monospace"
|
ff="monospace"
|
||||||
className={classes.time}
|
className={classes.time}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{prefix}
|
{prefix}
|
||||||
<span
|
<span
|
||||||
@@ -66,6 +68,7 @@ export function Countdown({ date, label, color }: CountdownProps) {
|
|||||||
className={`${classes.seconds} ${
|
className={`${classes.seconds} ${
|
||||||
isFinalStretch ? classes.finalTick : classes.tick
|
isFinalStretch ? classes.finalTick : classes.tick
|
||||||
}`}
|
}`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{pad(timeLeft.seconds)}
|
{pad(timeLeft.seconds)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import { FootballHelmetIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
const HeadToHeadIcon = ({ size = 14 }: { size?: number }) => (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
display: "inline-block",
|
||||||
|
width: size * (27.5 / 14),
|
||||||
|
height: size * (16 / 14),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FootballHelmetIcon
|
||||||
|
size={size}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
transform: "rotate(25deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FootballHelmetIcon
|
||||||
|
size={size}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
transform: "scaleX(-1) rotate(25deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HeadToHeadIcon;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Skeleton, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
const RoutePending = () => (
|
||||||
|
<Stack gap="md" p="md" w="100%">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={`route-pending-${index}`}
|
||||||
|
height={96}
|
||||||
|
w="100%"
|
||||||
|
radius="md"
|
||||||
|
style={{ opacity: Math.max(1 - index * 0.15, 0.3) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RoutePending;
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
ReactNode,
|
ReactNode,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
startTransition,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useRouter } from "@tanstack/react-router";
|
import { useRouter } from "@tanstack/react-router";
|
||||||
|
|
||||||
@@ -48,6 +48,9 @@ function SwipeableTabs({
|
|||||||
}, [search?.tab, tabs, defaultTab]);
|
}, [search?.tab, tabs, defaultTab]);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
|
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
|
||||||
|
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<number>>(
|
||||||
|
() => new Set([getActiveTabFromUrl()])
|
||||||
|
);
|
||||||
const [stuck, setStuck] = useState(false);
|
const [stuck, setStuck] = useState(false);
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||||
@@ -60,6 +63,9 @@ function SwipeableTabs({
|
|||||||
if (index === activeTab || index < 0 || index >= tabs.length) return;
|
if (index === activeTab || index < 0 || index >= tabs.length) return;
|
||||||
|
|
||||||
setActiveTab(index);
|
setActiveTab(index);
|
||||||
|
startTransition(() => {
|
||||||
|
setVisitedTabs((prev) => (prev.has(index) ? prev : new Set(prev).add(index)));
|
||||||
|
});
|
||||||
embla?.scrollTo(index);
|
embla?.scrollTo(index);
|
||||||
onTabChange?.(index, tabs[index]);
|
onTabChange?.(index, tabs[index]);
|
||||||
|
|
||||||
@@ -225,7 +231,7 @@ function SwipeableTabs({
|
|||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<Carousel.Slide key={`${tab.label}-content-${index}`}>
|
<Carousel.Slide key={`${tab.label}-content-${index}`}>
|
||||||
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
|
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
|
||||||
{tab.content}
|
{(visitedTabs.has(index) || index === activeTab) ? tab.content : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Carousel.Slide>
|
</Carousel.Slide>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensDeveloperFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -17,7 +17,7 @@ const activitySearchParamsSchema = z.object({
|
|||||||
|
|
||||||
export const searchActivities = createServerFn()
|
export const searchActivities = createServerFn()
|
||||||
.validator(activitySearchParamsSchema)
|
.validator(activitySearchParamsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<ActivityListResult>(async () => {
|
toServerResult<ActivityListResult>(async () => {
|
||||||
const result = await pbAdmin.searchActivities(data);
|
const result = await pbAdmin.searchActivities(data);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { List, Text } from "@mantine/core";
|
import { List, Text } from "@mantine/core";
|
||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
DatabaseIcon,
|
|
||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
MedalIcon,
|
MedalIcon,
|
||||||
@@ -10,12 +9,14 @@ import {
|
|||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
|
import { useIsDeveloper } from "@/features/core/hooks/use-is-developer";
|
||||||
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
|
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
|
||||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
|
|
||||||
export const ToolsList = () => {
|
export const ToolsList = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
const isDeveloper = useIsDeveloper();
|
||||||
|
|
||||||
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
|
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
|
||||||
queryKey: ["admin", "badge-migration-status"],
|
queryKey: ["admin", "badge-migration-status"],
|
||||||
@@ -51,11 +52,13 @@ export const ToolsList = () => {
|
|||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
to="/admin/tournaments"
|
to="/admin/tournaments"
|
||||||
/>
|
/>
|
||||||
<ListLink
|
{isDeveloper && (
|
||||||
label={t`Telemetry`}
|
<ListLink
|
||||||
Icon={PulseIcon}
|
label={t`Telemetry`}
|
||||||
to="/admin/telemetry/usage"
|
Icon={PulseIcon}
|
||||||
/>
|
to="/admin/telemetry/usage"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ListLink
|
<ListLink
|
||||||
label={t`Award Badges`}
|
label={t`Award Badges`}
|
||||||
Icon={CrownIcon}
|
Icon={CrownIcon}
|
||||||
@@ -91,13 +94,6 @@ export const ToolsList = () => {
|
|||||||
<Trans>Badge recalculation failed. Check the server logs.</Trans>
|
<Trans>Badge recalculation failed. Check the server logs.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<ListButton
|
|
||||||
label={t`Open Pocketbase`}
|
|
||||||
Icon={DatabaseIcon}
|
|
||||||
onClick={() =>
|
|
||||||
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ListLink
|
<ListLink
|
||||||
label={t`Bracket Preview`}
|
label={t`Bracket Preview`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
|
|||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import PlayerList from '@/features/players/components/player-list';
|
|||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
import { Player } from '@/features/players/types';
|
import { Player } from '@/features/players/types';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { track } from '@/lib/telemetry/client';
|
||||||
|
|
||||||
const BadgeStatsTable = () => {
|
const BadgeStatsTable = () => {
|
||||||
const { data: allBadges } = useAllBadges();
|
const { data: allBadges } = useAllBadges();
|
||||||
@@ -141,7 +142,13 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={badge.id}>
|
<Box key={badge.id}>
|
||||||
<UnstyledButton onClick={badgeSheet.open} w='100%'>
|
<UnstyledButton
|
||||||
|
onClick={() => {
|
||||||
|
track("badge_viewed", { value: badge.key });
|
||||||
|
badgeSheet.open();
|
||||||
|
}}
|
||||||
|
w='100%'
|
||||||
|
>
|
||||||
<Grid p={'xs'} align='center'>
|
<Grid p={'xs'} align='center'>
|
||||||
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
|
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<BadgeIcon badge={badge} filled={true} />
|
<BadgeIcon badge={badge} filled={true} />
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{!showControls && <MatchReport match={match} compact />}
|
{!showControls && <MatchReport match={match} compact source="match_card" />}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
import { FootballHelmetIcon } from "@phosphor-icons/react";
|
import HeadToHeadIcon from "@/components/head-to-head-icon";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import TeamAvatar from "@/components/team-avatar";
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
@@ -24,6 +24,7 @@ import { MatchReport } from "./match-report";
|
|||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
|||||||
ended={ended}
|
ended={ended}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MatchReport match={match} />
|
<MatchReport match={match} source="match_dock" />
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -166,7 +167,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
|||||||
</Center>
|
</Center>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<EmojiBar matchId={match.id} />
|
<EmojiBar matchId={match.id} source="match_dock" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
{!hasPrivate && (
|
{!hasPrivate && (
|
||||||
@@ -174,33 +175,20 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={h2hSheet.open}
|
onClick={() => {
|
||||||
|
track("h2h_viewed", {
|
||||||
|
source: "match_dock",
|
||||||
|
context: [match?.home?.name, match?.away?.name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" v "),
|
||||||
|
});
|
||||||
|
h2hSheet.open();
|
||||||
|
}}
|
||||||
aria-label={t`View head-to-head`}
|
aria-label={t`View head-to-head`}
|
||||||
w={40}
|
w={40}
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Group
|
<HeadToHeadIcon />
|
||||||
style={{ position: "relative", width: 27.5, height: 16 }}
|
|
||||||
>
|
|
||||||
<FootballHelmetIcon
|
|
||||||
size={14}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
transform: "rotate(25deg)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FootballHelmetIcon
|
|
||||||
size={14}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
transform: "scaleX(-1) rotate(25deg)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Sheet from "@/components/sheet/sheet";
|
|||||||
import { MatchForm } from "./match-form";
|
import { MatchForm } from "./match-form";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
const teamId = (t: Match["home"]): string | undefined =>
|
const teamId = (t: Match["home"]): string | undefined =>
|
||||||
!t ? undefined : typeof t === "string" ? t : t.id;
|
!t ? undefined : typeof t === "string" ? t : t.id;
|
||||||
@@ -26,9 +27,10 @@ const teamName = (t: Match["home"]): string | undefined =>
|
|||||||
interface MatchReportProps {
|
interface MatchReportProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
|
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact, source }) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const formSheet = useSheet();
|
const formSheet = useSheet();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -129,8 +131,9 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
|
|||||||
|
|
||||||
const openForm = useCallback(() => {
|
const openForm = useCallback(() => {
|
||||||
setAcknowledgedReport(pendingSignature);
|
setAcknowledgedReport(pendingSignature);
|
||||||
|
track("score_report_opened", source ? { source } : undefined);
|
||||||
formSheet.open();
|
formSheet.open();
|
||||||
}, [pendingSignature, formSheet.open]);
|
}, [pendingSignature, formSheet.open, source]);
|
||||||
|
|
||||||
const dismissTakeover = useCallback(() => {
|
const dismissTakeover = useCallback(() => {
|
||||||
setAcknowledgedReport(pendingSignature);
|
setAcknowledgedReport(pendingSignature);
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import { AppShell, Box } from '@mantine/core';
|
import { AppShell } from '@mantine/core';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { useLocation } from '@tanstack/react-router';
|
|
||||||
import Header from './header';
|
import Header from './header';
|
||||||
import Navbar from './navbar';
|
import Navbar from './navbar';
|
||||||
import Pullable from './pullable';
|
import Pullable from './pullable';
|
||||||
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
|
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
|
||||||
import useRouterConfig from '../hooks/use-router-config';
|
import useRouterConfig from '../hooks/use-router-config';
|
||||||
import Page from '@/components/page';
|
import Page from '@/components/page';
|
||||||
import './route-transition.css';
|
|
||||||
|
|
||||||
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const { header, withPadding, fullWidth } = useRouterConfig();
|
const { header, withPadding, fullWidth } = useRouterConfig();
|
||||||
const viewport = useVisualViewportSize();
|
const viewport = useVisualViewportSize();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
id='app-shell'
|
id='app-shell'
|
||||||
layout='alt'
|
layout='alt'
|
||||||
|
// 0 = header collapse is instant; Mantine's default animates main's padding
|
||||||
|
transitionDuration={0}
|
||||||
header={{
|
header={{
|
||||||
// Old installs froze black-translucent at add-to-home-screen time, so
|
// Old installs froze black-translucent at add-to-home-screen time, so
|
||||||
// the webview can extend under the status bar; inset is 0 elsewhere.
|
// the webview can extend under the status bar; inset is 0 elsewhere.
|
||||||
@@ -48,16 +47,14 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
pos='relative'
|
pos='relative'
|
||||||
h='100%'
|
h='100%'
|
||||||
mah='100%'
|
mah='100%'
|
||||||
pb={{ base: 'calc(65px + env(safe-area-inset-bottom, 0px))', sm: 0 }}
|
pb={{ base: 'calc(73px + env(safe-area-inset-bottom, 0px))', sm: 0 }}
|
||||||
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||||
maw='100dvw'
|
maw='100dvw'
|
||||||
style={{ overflow: 'hidden' }}
|
style={{ overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<Pullable>
|
<Pullable>
|
||||||
<Page noPadding={!withPadding} fullWidth={fullWidth}>
|
<Page noPadding={!withPadding} fullWidth={fullWidth}>
|
||||||
<Box key={location.pathname} className="flxn-route-enter">
|
{children}
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
</Page>
|
</Page>
|
||||||
</Pullable>
|
</Pullable>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { useRouterState } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
const TRAVEL_MS = 333;
|
||||||
|
|
||||||
|
export const useNavIndicator = (isMobile: boolean) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const indicatorRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const shapeRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const indicator = indicatorRef.current;
|
||||||
|
const shape = shapeRef.current;
|
||||||
|
if (!container || !indicator || !shape) return;
|
||||||
|
|
||||||
|
let last: number | null = null;
|
||||||
|
|
||||||
|
const place = () => {
|
||||||
|
const active = container.querySelector<HTMLElement>("[data-active]");
|
||||||
|
if (!active) {
|
||||||
|
delete indicator.dataset.placed;
|
||||||
|
last = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = active.offsetLeft;
|
||||||
|
const y = active.offsetTop;
|
||||||
|
const travelled = isMobile ? x : y;
|
||||||
|
const first = indicator.dataset.placed !== "1";
|
||||||
|
|
||||||
|
if (first) indicator.style.transition = "none";
|
||||||
|
indicator.style.setProperty("--nav-x", `${x}px`);
|
||||||
|
indicator.style.setProperty("--nav-y", `${y}px`);
|
||||||
|
indicator.style.setProperty("--nav-w", `${active.offsetWidth}px`);
|
||||||
|
indicator.style.setProperty("--nav-h", `${active.offsetHeight}px`);
|
||||||
|
indicator.dataset.placed = "1";
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
indicator.getBoundingClientRect();
|
||||||
|
indicator.style.transition = "";
|
||||||
|
} else if (
|
||||||
|
last !== null &&
|
||||||
|
Math.abs(travelled - last) > 1 &&
|
||||||
|
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
) {
|
||||||
|
const axis = isMobile ? "scaleX" : "scaleY";
|
||||||
|
shape.animate(
|
||||||
|
[
|
||||||
|
{ transform: `${axis}(1)`, offset: 0 },
|
||||||
|
{ transform: `${axis}(0.88)`, offset: 0.4 },
|
||||||
|
{ transform: `${axis}(1)`, offset: 1 },
|
||||||
|
],
|
||||||
|
{ duration: TRAVEL_MS, easing: "ease-in-out" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last = travelled;
|
||||||
|
};
|
||||||
|
|
||||||
|
place();
|
||||||
|
const observer = new ResizeObserver(place);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [pathname, isMobile]);
|
||||||
|
|
||||||
|
return { containerRef, indicatorRef, shapeRef };
|
||||||
|
};
|
||||||
@@ -32,30 +32,19 @@ export const NavLink = ({
|
|||||||
<Box
|
<Box
|
||||||
component={Link}
|
component={Link}
|
||||||
to={href}
|
to={href}
|
||||||
className={styles.navLinkBox}
|
className={`flxn-nav-link ${styles.navLinkBox}`}
|
||||||
p={{ base: 0, sm: 8 }}
|
p={{ base: 0, sm: 8 }}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-active={isActive || undefined}
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
direction={{ base: "column", md: "row" }}
|
direction={{ base: "column", md: "row" }}
|
||||||
align="center"
|
align="center"
|
||||||
gap={{ base: 0, md: "xs" }}
|
gap={{ base: 0, md: "xs" }}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon weight={isActive ? "fill" : "regular"} size={28} />
|
||||||
weight={isActive ? "fill" : "regular"}
|
<Text visibleFrom="md" ta="center" size="md" fw={isActive ? 800 : 500}>
|
||||||
size={28}
|
|
||||||
style={{
|
|
||||||
color: isActive ? "var(--mantine-primary-color-filled)" : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
visibleFrom="md"
|
|
||||||
ta="center"
|
|
||||||
size="md"
|
|
||||||
fw={isActive ? 800 : 500}
|
|
||||||
c={isActive ? "var(--mantine-primary-color-filled)" : undefined}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -11,11 +11,6 @@
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLinkBox svg,
|
|
||||||
.navLinkBox p {
|
|
||||||
transition: color 150ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.navLinkBox {
|
.navLinkBox {
|
||||||
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
|
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { useAuth } from "@/contexts/auth-context";
|
|||||||
import { useLinks } from "../hooks/use-links";
|
import { useLinks } from "../hooks/use-links";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useIsPWA } from "@/hooks/use-is-pwa";
|
import { useIsPWA } from "@/hooks/use-is-pwa";
|
||||||
|
import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, roles } = useAuth()
|
const { user, roles } = useAuth()
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const isPWA = useIsPWA();
|
const isPWA = useIsPWA();
|
||||||
|
const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
|
||||||
|
|
||||||
const links = useLinks(user?.id, roles);
|
const links = useLinks(user?.id, roles);
|
||||||
|
|
||||||
@@ -17,9 +19,16 @@ const Navbar = () => {
|
|||||||
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
|
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
|
||||||
: 'env(safe-area-inset-bottom, 0px)';
|
: 'env(safe-area-inset-bottom, 0px)';
|
||||||
|
|
||||||
|
const indicator = (
|
||||||
|
<span ref={indicatorRef} className="flxn-nav-indicator" aria-hidden="true">
|
||||||
|
<span ref={shapeRef} className="flxn-nav-indicator-shape" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
if (isMobile) return (
|
if (isMobile) return (
|
||||||
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
|
<Paper className="flxn-nav" component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
|
||||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
<Group ref={containerRef} pos='relative' gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||||
|
{indicator}
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
))}
|
))}
|
||||||
@@ -27,9 +36,10 @@ const Navbar = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AppShell.Navbar p="xs" role='navigation'>
|
return <AppShell.Navbar className="flxn-nav" p="xs" role='navigation'>
|
||||||
<AppShell.Section grow component={ScrollArea}>
|
<AppShell.Section grow component={ScrollArea}>
|
||||||
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
|
<Stack ref={containerRef} pos='relative' gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
|
||||||
|
{indicator}
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.flxn-route-enter {
|
|
||||||
animation: flxn-route-fade-in 180ms ease-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flxn-route-fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { isDeveloperRole } from "../utils/roles";
|
||||||
|
|
||||||
|
export const useIsDeveloper = (): boolean => {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
return isDeveloperRole(roles);
|
||||||
|
};
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
export const ADMIN_ROLE = "Admin";
|
export const ADMIN_ROLE = "Admin";
|
||||||
|
export const DEVELOPER_ROLE = "Developer";
|
||||||
|
|
||||||
|
export const isDeveloperRole = (roles?: string[] | null): boolean =>
|
||||||
|
!!roles?.includes(DEVELOPER_ROLE);
|
||||||
|
|
||||||
export const isAdminRole = (roles?: string[] | null): boolean =>
|
export const isAdminRole = (roles?: string[] | null): boolean =>
|
||||||
!!roles?.includes(ADMIN_ROLE);
|
!!roles?.includes(ADMIN_ROLE) || isDeveloperRole(roles);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const useConsumeCode = (onWrongCode: () => void) => {
|
|||||||
} else {
|
} else {
|
||||||
queryClient.setQueryData(playerKeys.auth, response.data);
|
queryClient.setQueryData(playerKeys.auth, response.data);
|
||||||
toast.success(t`Successfully logged in. Welcome back!`);
|
toast.success(t`Successfully logged in. Welcome back!`);
|
||||||
navigate({ to: '/' })
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
|
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
|
||||||
onWrongCode();
|
onWrongCode();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const useCreateUser = () => {
|
|||||||
...old,
|
...old,
|
||||||
user: data
|
user: data
|
||||||
}));
|
}));
|
||||||
navigate({ to: '/' });
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core";
|
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core";
|
||||||
import { FootballHelmetIcon } from "@phosphor-icons/react";
|
import HeadToHeadIcon from "@/components/head-to-head-icon";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { Match } from "../types";
|
import { Match } from "../types";
|
||||||
import TeamAvatar from "@/components/team-avatar";
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
@@ -10,6 +10,7 @@ import Sheet from "@/components/sheet/sheet";
|
|||||||
import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
|
import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
|
||||||
import AnimatedScore from "./animated-score";
|
import AnimatedScore from "./animated-score";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -41,6 +42,10 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
|||||||
|
|
||||||
const handleH2HClick = (e: React.MouseEvent) => {
|
const handleH2HClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
track("h2h_viewed", {
|
||||||
|
source: "match_card",
|
||||||
|
context: [match.home?.name, match.away?.name].filter(Boolean).join(" v "),
|
||||||
|
});
|
||||||
h2hSheet.open();
|
h2hSheet.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,26 +94,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
|||||||
aria-label={t`View head-to-head`}
|
aria-label={t`View head-to-head`}
|
||||||
w={40}
|
w={40}
|
||||||
>
|
>
|
||||||
<Group style={{ position: 'relative', width: 27.5, height: 16 }}>
|
<HeadToHeadIcon />
|
||||||
<FootballHelmetIcon
|
|
||||||
size={14}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
transform: 'rotate(25deg)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FootballHelmetIcon
|
|
||||||
size={14}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
transform: 'scaleX(-1) rotate(25deg)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -218,7 +204,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<EmojiBar matchId={match.id} />
|
<EmojiBar matchId={match.id} source="match_card" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Stack, Text } from "@mantine/core";
|
import { MantineSpacing, Stack, Text } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Match } from "../types";
|
import { Match } from "../types";
|
||||||
import MatchCard from "./match-card";
|
import MatchCard from "./match-card";
|
||||||
@@ -7,9 +7,10 @@ import { Trans } from "@lingui/react/macro";
|
|||||||
interface MatchListProps {
|
interface MatchListProps {
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
hideH2H?: boolean;
|
hideH2H?: boolean;
|
||||||
|
p?: MantineSpacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
|
const MatchList = ({ matches, hideH2H = false, p = "md" }: MatchListProps) => {
|
||||||
const filteredMatches = useMemo(
|
const filteredMatches = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...(matches ?? [])]
|
[...(matches ?? [])]
|
||||||
@@ -27,7 +28,7 @@ const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
|
|||||||
const isRegional = filteredMatches[0]?.tournament?.regional;
|
const isRegional = filteredMatches[0]?.tournament?.regional;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="md" gap="sm">
|
<Stack p={p} gap="sm">
|
||||||
{isRegional && (
|
{isRegional && (
|
||||||
<Text size="xs" c="dimmed" ta="center" px="md">
|
<Text size="xs" c="dimmed" ta="center" px="md">
|
||||||
<Trans>Matches for regionals are unordered</Trans>
|
<Trans>Matches for regionals are unordered</Trans>
|
||||||
|
|||||||
@@ -200,10 +200,10 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm" fw={600} px="md">
|
<Text size="sm" fw={600}>
|
||||||
<Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans>
|
<Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<MatchList matches={matches} hideH2H />
|
<MatchList matches={matches} hideH2H p={0} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core";
|
import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useRef } from "react";
|
||||||
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
|
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useAllPlayerStats } from "../queries";
|
import { useAllPlayerStats } from "../queries";
|
||||||
@@ -8,6 +8,7 @@ import { useSheet } from "@/hooks/use-sheet";
|
|||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
|
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
|
||||||
import PlayerAvatar from "@/components/player-avatar";
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
const LeagueHeadToHead = () => {
|
const LeagueHeadToHead = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -15,6 +16,7 @@ const LeagueHeadToHead = () => {
|
|||||||
const [player1Id, setPlayer1Id] = useState<string | null>(null);
|
const [player1Id, setPlayer1Id] = useState<string | null>(null);
|
||||||
const [player2Id, setPlayer2Id] = useState<string | null>(null);
|
const [player2Id, setPlayer2Id] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const searchedRef = useRef(false);
|
||||||
const { data: allPlayerStats } = useAllPlayerStats();
|
const { data: allPlayerStats } = useAllPlayerStats();
|
||||||
const h2hSheet = useSheet();
|
const h2hSheet = useSheet();
|
||||||
|
|
||||||
@@ -51,6 +53,12 @@ const LeagueHeadToHead = () => {
|
|||||||
setPlayer1Id(playerId);
|
setPlayer1Id(playerId);
|
||||||
} else {
|
} else {
|
||||||
setPlayer2Id(playerId);
|
setPlayer2Id(playerId);
|
||||||
|
const opponent =
|
||||||
|
allPlayerStats?.find((p) => p.player_id === playerId)?.player_name || "";
|
||||||
|
track("h2h_viewed", {
|
||||||
|
source: "stats",
|
||||||
|
context: [player1Name, opponent].filter(Boolean).join(" v "),
|
||||||
|
});
|
||||||
h2hSheet.open();
|
h2hSheet.open();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -128,7 +136,14 @@ const LeagueHeadToHead = () => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
placeholder={t`Search players`}
|
placeholder={t`Search players`}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
if (value && !searchedRef.current) {
|
||||||
|
searchedRef.current = true;
|
||||||
|
track("player_searched");
|
||||||
|
}
|
||||||
|
setSearch(value);
|
||||||
|
}}
|
||||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
size="md"
|
size="md"
|
||||||
px="md"
|
px="md"
|
||||||
|
|||||||
@@ -260,10 +260,10 @@ const PlayerHeadToHeadContent = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm" fw={600} px="md">
|
<Text size="sm" fw={600}>
|
||||||
<Trans>Match History ({totalMatches})</Trans>
|
<Trans>Match History ({totalMatches})</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<MatchList matches={matches} hideH2H />
|
<MatchList matches={matches} hideH2H p={0} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useNavigate } from "@tanstack/react-router";
|
|||||||
import { useAllPlayerStats } from "../queries";
|
import { useAllPlayerStats } from "../queries";
|
||||||
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
|
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
type SortKey = keyof PlayerStats | "mmr";
|
type SortKey = keyof PlayerStats | "mmr";
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
@@ -177,6 +178,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const deferredSearch = useDeferredValue(search);
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
const searchedRef = useRef(false);
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
key: "mmr" as SortKey,
|
key: "mmr" as SortKey,
|
||||||
direction: "desc",
|
direction: "desc",
|
||||||
@@ -291,10 +293,10 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const handleSort = (key: SortKey) => {
|
const handleSort = (key: SortKey) => {
|
||||||
setSortConfig((prev) => ({
|
const direction =
|
||||||
key,
|
sortConfig.key === key && sortConfig.direction === "desc" ? "asc" : "desc";
|
||||||
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
track("stats_sorted", { value: key, context: direction });
|
||||||
}));
|
setSortConfig({ key, direction });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSortIcon = (key: SortKey) => {
|
const getSortIcon = (key: SortKey) => {
|
||||||
@@ -328,7 +330,14 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
placeholder={t`Search players`}
|
placeholder={t`Search players`}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
if (value && !searchedRef.current) {
|
||||||
|
searchedRef.current = true;
|
||||||
|
track("player_searched");
|
||||||
|
}
|
||||||
|
setSearch(value);
|
||||||
|
}}
|
||||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
size="md"
|
size="md"
|
||||||
px="md"
|
px="md"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
|
import { Flex, Title, ActionIcon, Stack, Button } from "@mantine/core";
|
||||||
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
|
import { PencilIcon } from "@phosphor-icons/react";
|
||||||
|
import HeadToHeadIcon from "@/components/head-to-head-icon";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import NameUpdateForm from "./name-form";
|
import NameUpdateForm from "./name-form";
|
||||||
@@ -9,6 +10,7 @@ import PlayerAvatar from "@/components/player-avatar";
|
|||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { Player } from "../../types";
|
import { Player } from "../../types";
|
||||||
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
|
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
player: Player;
|
player: Player;
|
||||||
@@ -53,30 +55,17 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={h2hSheet.open}
|
onClick={() => {
|
||||||
|
track("h2h_viewed", {
|
||||||
|
source: "profile",
|
||||||
|
context: [player.first_name, player.last_name].filter(Boolean).join(" "),
|
||||||
|
});
|
||||||
|
h2hSheet.open();
|
||||||
|
}}
|
||||||
w={40}
|
w={40}
|
||||||
display={!owner ? 'block' : 'none'}
|
display={!owner ? 'block' : 'none'}
|
||||||
>
|
>
|
||||||
<Box style={{ position: 'relative', width: 27.5, height: 16 }}>
|
<HeadToHeadIcon />
|
||||||
<FootballHelmetIcon
|
|
||||||
size={14}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
transform: 'rotate(25deg)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FootballHelmetIcon
|
|
||||||
size={14}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
transform: 'scaleX(-1) rotate(25deg)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
|
import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
|
||||||
import { Suspense, useState, useDeferredValue } from "react";
|
import { Suspense, useState, useDeferredValue, useRef } from "react";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
@@ -10,6 +10,7 @@ import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
|
|||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
import BadgeShowcase from "@/features/badges/components/badge-showcase";
|
import BadgeShowcase from "@/features/badges/components/badge-showcase";
|
||||||
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
|
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,14 +21,22 @@ const StatsWithFilter = ({ id }: { id: string }) => {
|
|||||||
const deferredViewType = useDeferredValue(viewType);
|
const deferredViewType = useDeferredValue(viewType);
|
||||||
const isStale = viewType !== deferredViewType;
|
const isStale = viewType !== deferredViewType;
|
||||||
|
|
||||||
|
const viewTypeRef = useRef(viewType);
|
||||||
|
const changeViewType = (next: typeof viewType) => {
|
||||||
|
if (viewTypeRef.current === next) return;
|
||||||
|
viewTypeRef.current = next;
|
||||||
|
track("stats_filtered", { value: next, source: "profile" });
|
||||||
|
setViewType(next);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group gap="xs" px="md" justify="space-between" align="center">
|
<Group gap="xs" px="md" justify="space-between" align="center">
|
||||||
<Text size="md" fw={700}><Trans>Statistics</Trans></Text>
|
<Text size="md" fw={700}><Trans>Statistics</Trans></Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('all')}><Trans>All</Trans></Button>
|
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('all')}><Trans>All</Trans></Button>
|
||||||
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('mainline')}><Trans>Mainline</Trans></Button>
|
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('mainline')}><Trans>Mainline</Trans></Button>
|
||||||
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('regional')}><Trans>Regional</Trans></Button>
|
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('regional')}><Trans>Regional</Trans></Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
||||||
@@ -90,7 +99,12 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
<>
|
<>
|
||||||
<Header player={player} />
|
<Header player={player} />
|
||||||
<Box mt="lg">
|
<Box mt="lg">
|
||||||
<SwipeableTabs tabs={tabs} />
|
<SwipeableTabs
|
||||||
|
tabs={tabs}
|
||||||
|
onTabChange={(_, tab) => {
|
||||||
|
if (tab.value === "matches") track("match_history_viewed", { source: "profile" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { CaretLeftIcon, CaretRightIcon, InfoIcon } from "@phosphor-icons/react";
|
import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";
|
||||||
|
import HeadToHeadIcon from "@/components/head-to-head-icon";
|
||||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
import { PredictionBracket } from "./prediction-bracket";
|
import { PredictionBracket } from "./prediction-bracket";
|
||||||
import { MatchupSheet } from "./matchup-sheet";
|
import { MatchupSheet } from "./matchup-sheet";
|
||||||
import { WinnerSelector } from "./winner-selector";
|
import { WinnerSelector } from "./winner-selector";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
interface PredictionEditorProps {
|
interface PredictionEditorProps {
|
||||||
tournament: Tournament;
|
tournament: Tournament;
|
||||||
@@ -215,11 +217,19 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
onClick={matchupSheet.open}
|
onClick={() => {
|
||||||
|
if (activeResolved?.home && activeResolved?.away) {
|
||||||
|
track("h2h_viewed", {
|
||||||
|
source: "predictions",
|
||||||
|
context: `${activeResolved.home.name} v ${activeResolved.away.name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
matchupSheet.open();
|
||||||
|
}}
|
||||||
disabled={!activeResolved?.home && !activeResolved?.away}
|
disabled={!activeResolved?.home && !activeResolved?.away}
|
||||||
aria-label={t`Matchup details`}
|
aria-label={t`Matchup details`}
|
||||||
>
|
>
|
||||||
<InfoIcon size={16} />
|
<HeadToHeadIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" align="center" wrap="nowrap">
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import EmojiPicker from "./emoji-picker";
|
|||||||
import { useMatchReactions, useToggleMatchReaction } from "../queries";
|
import { useMatchReactions, useToggleMatchReaction } from "../queries";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Reaction } from "@/features/matches/server";
|
import { Reaction } from "@/features/matches/server";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
import classes from "./emoji-bar.module.css";
|
import classes from "./emoji-bar.module.css";
|
||||||
|
|
||||||
interface EmojiBarProps {
|
interface EmojiBarProps {
|
||||||
matchId: string;
|
matchId: string;
|
||||||
onReactionPress?: (emoji: string) => void;
|
onReactionPress?: (emoji: string) => void;
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||||
@@ -56,6 +58,7 @@ const TickingCount = ({ value, ...textProps }: TickingCountProps) => {
|
|||||||
const EmojiBar = ({
|
const EmojiBar = ({
|
||||||
matchId,
|
matchId,
|
||||||
onReactionPress,
|
onReactionPress,
|
||||||
|
source,
|
||||||
}: EmojiBarProps) => {
|
}: EmojiBarProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -85,6 +88,7 @@ const EmojiBar = ({
|
|||||||
if (onReactionPress) {
|
if (onReactionPress) {
|
||||||
onReactionPress(emoji);
|
onReactionPress(emoji);
|
||||||
} else {
|
} else {
|
||||||
|
if (!userReactions.includes(emoji)) track("reaction_sent", { emoji, source });
|
||||||
toggleReaction.mutate({ data: { matchId, emoji } });
|
toggleReaction.mutate({ data: { matchId, emoji } });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -198,7 +202,7 @@ const EmojiBar = ({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</Group>
|
</Group>
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))}
|
onSelect={handleReactionClick}
|
||||||
userReactions={userReactions}
|
userReactions={userReactions}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useWebPush } from "@/hooks/use-web-push";
|
import { useWebPush } from "@/hooks/use-web-push";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
import { useIsDeveloper } from "@/features/core/hooks/use-is-developer";
|
||||||
import { updateUserTelemetryAlerts } from "@/features/settings/server";
|
import { updateUserTelemetryAlerts } from "@/features/settings/server";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ const TelemetryAlertsSwitch = () => {
|
|||||||
export function NotificationsSection() {
|
export function NotificationsSection() {
|
||||||
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
|
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
|
||||||
useWebPush();
|
useWebPush();
|
||||||
const isAdmin = useIsAdmin();
|
const isDeveloper = useIsDeveloper();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
if (!supported || !configured) {
|
if (!supported || !configured) {
|
||||||
@@ -99,7 +99,7 @@ export function NotificationsSection() {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
label={t`Enable notifications on this device`}
|
label={t`Enable notifications on this device`}
|
||||||
/>
|
/>
|
||||||
{subscribed && isAdmin && <TelemetryAlertsSwitch />}
|
{subscribed && isDeveloper && <TelemetryAlertsSwitch />}
|
||||||
{subscribed && (
|
{subscribed && (
|
||||||
<Group>
|
<Group>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import {
|
import {
|
||||||
superTokensAdminFunctionMiddleware,
|
superTokensDeveloperFunctionMiddleware,
|
||||||
superTokensFunctionMiddleware,
|
superTokensFunctionMiddleware,
|
||||||
} from "@/utils/supertokens";
|
} from "@/utils/supertokens";
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ export const updateUserAccentColor = createServerFn({ method: "POST" })
|
|||||||
|
|
||||||
export const updateUserTelemetryAlerts = createServerFn({ method: "POST" })
|
export const updateUserTelemetryAlerts = createServerFn({ method: "POST" })
|
||||||
.validator((data: boolean) => data === true)
|
.validator((data: boolean) => data === true)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) => {
|
.handler(async ({ context, data }) => {
|
||||||
const { userAuthId, metadata } = context;
|
const { userAuthId, metadata } = context;
|
||||||
if (!userAuthId) return;
|
if (!userAuthId) return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { superTokensFunctionMiddleware, superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware, superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { isAdminRole } from "@/features/core/utils/roles";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -35,7 +36,7 @@ export const createTeam = createServerFn()
|
|||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = isAdminRole(context.roles);
|
||||||
|
|
||||||
//if (!isAdmin && !data.players.includes(userId)) {
|
//if (!isAdmin && !data.players.includes(userId)) {
|
||||||
// throw new Error("You can only create teams that include yourself as a player");
|
// throw new Error("You can only create teams that include yourself as a player");
|
||||||
@@ -57,7 +58,7 @@ export const updateTeam = createServerFn()
|
|||||||
.handler(async ({ data: { id, updates }, context }) =>
|
.handler(async ({ data: { id, updates }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = isAdminRole(context.roles);
|
||||||
|
|
||||||
const team = await pbAdmin.getTeam(id);
|
const team = await pbAdmin.getTeam(id);
|
||||||
if (!team) {
|
if (!team) {
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ export const SessionTimelineView = ({ sessionId }: { sessionId: string }) => {
|
|||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
>
|
>
|
||||||
{entry.label}
|
{entry.label}
|
||||||
|
{entry.detail && entry.kind !== "error" && (
|
||||||
|
<Text component="span" size="sm" c="dimmed">
|
||||||
|
{" "}
|
||||||
|
· {entry.detail}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
{entry.kind === "server_fn" && entry.success === false && (
|
{entry.kind === "server_fn" && entry.success === false && (
|
||||||
<Text size="xs" c="red" style={{ flexShrink: 0 }}>
|
<Text size="xs" c="red" style={{ flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
||||||
import { useRollupRange } from "../queries";
|
import { useRollupRange } from "../queries";
|
||||||
|
import type { RollupRow } from "../types";
|
||||||
import { fillDailySeries, lastNDays, latestValue, listDays, sumByDim } from "../range";
|
import { fillDailySeries, lastNDays, latestValue, listDays, sumByDim } from "../range";
|
||||||
import { BarList, Sparkline, StatTile } from "./charts";
|
import { BarList, Sparkline, StatTile } from "./charts";
|
||||||
import { DetailSheet } from "./shared";
|
import { DetailSheet } from "./shared";
|
||||||
@@ -25,6 +27,8 @@ export const USAGE_METRICS = [
|
|||||||
"server_fn.count",
|
"server_fn.count",
|
||||||
"client_error.count",
|
"client_error.count",
|
||||||
"vital.p75",
|
"vital.p75",
|
||||||
|
"event.count",
|
||||||
|
"event.detail.count",
|
||||||
];
|
];
|
||||||
|
|
||||||
const TOP_LIST_SIZE = 8;
|
const TOP_LIST_SIZE = 8;
|
||||||
@@ -122,10 +126,37 @@ const FnDurationDetail = ({ fn, from, to }: { fn: string; from: string; to: stri
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EventBreakdown = ({ name, rows }: { name: string; rows: RollupRow[] }) => {
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
const prefix = `${name}|`;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.metric !== "event.detail.count" || !row.dim.startsWith(prefix)) continue;
|
||||||
|
const detail = row.dim.slice(prefix.length);
|
||||||
|
totals.set(detail, (totals.get(detail) ?? 0) + row.value);
|
||||||
|
}
|
||||||
|
return [...totals.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([label, value]) => ({ label, value }));
|
||||||
|
}, [name, rows]);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
<Trans>No breakdown recorded</Trans>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BarList items={items} />;
|
||||||
|
};
|
||||||
|
|
||||||
const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const [selectedFn, setSelectedFn] = useState<string | null>(null);
|
const [selectedFn, setSelectedFn] = useState<string | null>(null);
|
||||||
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
|
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
|
||||||
|
const { isOpen: eventDetailOpened, open: openEventDetail, close: closeEventDetail } = useSheet();
|
||||||
|
|
||||||
const { data: rows } = useRollupRange({ metrics: USAGE_METRICS, from, to });
|
const { data: rows } = useRollupRange({ metrics: USAGE_METRICS, from, to });
|
||||||
|
|
||||||
@@ -142,6 +173,10 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
|||||||
setSelectedFn(dim);
|
setSelectedFn(dim);
|
||||||
openFnDetail();
|
openFnDetail();
|
||||||
});
|
});
|
||||||
|
const eventItems = topItems(sumByDim(rows, "event.count"), (dim) => {
|
||||||
|
setSelectedEvent(dim);
|
||||||
|
openEventDetail();
|
||||||
|
});
|
||||||
|
|
||||||
const vitals = (["LCP", "INP", "CLS"] as VitalName[]).map((name) => ({
|
const vitals = (["LCP", "INP", "CLS"] as VitalName[]).map((name) => ({
|
||||||
name,
|
name,
|
||||||
@@ -186,6 +221,26 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans>Events</Trans>
|
||||||
|
</Title>
|
||||||
|
{eventItems.length > 0 ? (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<Trans>Top interactions, tap for breakdown</Trans>
|
||||||
|
</Text>
|
||||||
|
<BarList items={eventItems} />
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
<Trans>No events recorded in this range</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Title order={4}>
|
<Title order={4}>
|
||||||
<Trans>Server functions</Trans>
|
<Trans>Server functions</Trans>
|
||||||
@@ -230,6 +285,25 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<DetailSheet
|
||||||
|
title={selectedEvent ?? t`Event`}
|
||||||
|
opened={eventDetailOpened}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedEvent(null);
|
||||||
|
closeEventDetail();
|
||||||
|
}}
|
||||||
|
fields={
|
||||||
|
selectedEvent
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: <Trans>Breakdown</Trans>,
|
||||||
|
value: <EventBreakdown name={selectedEvent} rows={rows} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DetailSheet
|
<DetailSheet
|
||||||
title={selectedFn ?? t`Function`}
|
title={selectedFn ?? t`Function`}
|
||||||
opened={fnDetailOpened}
|
opened={fnDetailOpened}
|
||||||
@@ -251,36 +325,61 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
|||||||
|
|
||||||
export const UsageScreen = () => {
|
export const UsageScreen = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [rangeDays, setRangeDays] = useState<"7" | "30">("7");
|
const [preset, setPreset] = useState<"7" | "30" | "180" | "custom">("7");
|
||||||
const range = useMemo(() => lastNDays(Number(rangeDays)), [rangeDays]);
|
const [customRange, setCustomRange] = useState<[string | null, string | null]>([null, null]);
|
||||||
|
|
||||||
|
const range = useMemo(() => {
|
||||||
|
if (preset !== "custom") return lastNDays(Number(preset));
|
||||||
|
const [start, end] = customRange;
|
||||||
|
if (!start || !end) return null;
|
||||||
|
return { from: start, to: end };
|
||||||
|
}, [preset, customRange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" px="md" pb="md">
|
<Stack gap="md" px="md" pb="md">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={rangeDays}
|
value={preset}
|
||||||
onChange={(value) => setRangeDays(value as "7" | "30")}
|
onChange={(value) => setPreset(value as typeof preset)}
|
||||||
data={[
|
data={[
|
||||||
{ value: "7", label: t`7d` },
|
{ value: "7", label: t`7d` },
|
||||||
{ value: "30", label: t`30d` },
|
{ value: "30", label: t`30d` },
|
||||||
|
{ value: "180", label: t`180d` },
|
||||||
|
{ value: "custom", label: t`Custom` },
|
||||||
]}
|
]}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Suspense
|
{preset === "custom" && (
|
||||||
fallback={
|
<DatePickerInput
|
||||||
<Stack gap="lg">
|
type="range"
|
||||||
<StatTileSkeleton align="row" />
|
value={customRange}
|
||||||
<SimpleGrid cols={3} spacing="sm">
|
onChange={setCustomRange}
|
||||||
<StatTileSkeleton />
|
maxDate={new Date()}
|
||||||
<StatTileSkeleton />
|
placeholder={t`Pick a date range`}
|
||||||
<StatTileSkeleton />
|
/>
|
||||||
</SimpleGrid>
|
)}
|
||||||
<ListSkeleton rows={6} withTrailing="text" />
|
|
||||||
</Stack>
|
{range ? (
|
||||||
}
|
<Suspense
|
||||||
>
|
fallback={
|
||||||
<UsageSections from={range.from} to={range.to} />
|
<Stack gap="lg">
|
||||||
</Suspense>
|
<StatTileSkeleton align="row" />
|
||||||
|
<SimpleGrid cols={3} spacing="sm">
|
||||||
|
<StatTileSkeleton />
|
||||||
|
<StatTileSkeleton />
|
||||||
|
<StatTileSkeleton />
|
||||||
|
</SimpleGrid>
|
||||||
|
<ListSkeleton rows={6} withTrailing="text" />
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UsageSections from={range.from} to={range.to} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
<Trans>Pick a date range to see usage</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensDeveloperFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +37,7 @@ const clientEventSearchSchema = z.object({
|
|||||||
|
|
||||||
export const searchClientEvents = createServerFn()
|
export const searchClientEvents = createServerFn()
|
||||||
.validator(clientEventSearchSchema)
|
.validator(clientEventSearchSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<TelemetryListResult<ClientEvent>>(async () => {
|
toServerResult<TelemetryListResult<ClientEvent>>(async () => {
|
||||||
const result = await pbAdmin.searchClientEvents(data);
|
const result = await pbAdmin.searchClientEvents(data);
|
||||||
@@ -58,7 +58,7 @@ const clientErrorSearchSchema = z.object({
|
|||||||
|
|
||||||
export const searchClientErrors = createServerFn()
|
export const searchClientErrors = createServerFn()
|
||||||
.validator(clientErrorSearchSchema)
|
.validator(clientErrorSearchSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<TelemetryListResult<ClientError>>(async () => {
|
toServerResult<TelemetryListResult<ClientError>>(async () => {
|
||||||
const result = await pbAdmin.searchClientErrors(data);
|
const result = await pbAdmin.searchClientErrors(data);
|
||||||
@@ -73,59 +73,75 @@ const errorGroupsSchema = z.object({
|
|||||||
limit: z.number().max(100).optional(),
|
limit: z.number().max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Aggregates raw rows, not rollups: a brand-new error must appear immediately,
|
||||||
|
// not on the next rollup tick.
|
||||||
export const getClientErrorGroups = createServerFn()
|
export const getClientErrorGroups = createServerFn()
|
||||||
.validator(errorGroupsSchema)
|
.validator(errorGroupsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<ClientErrorGroup[]>(async () => {
|
toServerResult<ClientErrorGroup[]>(async () => {
|
||||||
const { from, to, includeResolved = false, limit = 50 } = data;
|
const { from, to, includeResolved = false, limit = 50 } = data;
|
||||||
|
|
||||||
const rollups = await pbAdmin.getRollupRange({
|
const toExclusive = new Date(`${to}T00:00:00.000Z`);
|
||||||
metrics: ["client_error.count"],
|
toExclusive.setUTCDate(toExclusive.getUTCDate() + 1);
|
||||||
from,
|
const filter = `created >= '${from} 00:00:00.000Z' && created < '${toExclusive
|
||||||
to,
|
.toISOString()
|
||||||
});
|
.replace("T", " ")}'`;
|
||||||
|
|
||||||
const countByGroup = new Map<string, number>();
|
const byGroup = new Map<string, ClientErrorGroup>();
|
||||||
for (const rollup of rollups) {
|
await pbAdmin.pageCollection<{
|
||||||
if (!rollup.dim) continue;
|
group_hash: string;
|
||||||
countByGroup.set(rollup.dim, (countByGroup.get(rollup.dim) ?? 0) + rollup.value);
|
message: string;
|
||||||
}
|
stack?: string;
|
||||||
|
source?: string;
|
||||||
|
path?: string;
|
||||||
|
resolved?: boolean;
|
||||||
|
created: string;
|
||||||
|
}>(
|
||||||
|
"client_errors",
|
||||||
|
{ filter, fields: "group_hash,message,stack,source,path,resolved,created" },
|
||||||
|
(rows) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = byGroup.get(row.group_hash);
|
||||||
|
if (existing) {
|
||||||
|
existing.count++;
|
||||||
|
// Paged in created-asc order, so each row is the newest so far.
|
||||||
|
existing.lastSeen = row.created;
|
||||||
|
existing.resolved = !!row.resolved;
|
||||||
|
existing.sample = {
|
||||||
|
message: row.message,
|
||||||
|
stack: row.stack,
|
||||||
|
path: row.path,
|
||||||
|
source: row.source,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
byGroup.set(row.group_hash, {
|
||||||
|
group_hash: row.group_hash,
|
||||||
|
count: 1,
|
||||||
|
lastSeen: row.created,
|
||||||
|
resolved: !!row.resolved,
|
||||||
|
sample: {
|
||||||
|
message: row.message,
|
||||||
|
stack: row.stack,
|
||||||
|
path: row.path,
|
||||||
|
source: row.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const topGroups = [...countByGroup.entries()]
|
return [...byGroup.values()]
|
||||||
.sort((a, b) => b[1] - a[1])
|
.filter((group) => includeResolved || !group.resolved)
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
const groups: ClientErrorGroup[] = [];
|
|
||||||
for (const [groupHash, count] of topGroups) {
|
|
||||||
const latest = await pbAdmin.searchClientErrors({ groupHash, perPage: 1 });
|
|
||||||
const sample = latest.items[0];
|
|
||||||
if (!sample) continue;
|
|
||||||
|
|
||||||
const resolved = !!sample.resolved;
|
|
||||||
if (resolved && !includeResolved) continue;
|
|
||||||
|
|
||||||
groups.push({
|
|
||||||
group_hash: groupHash,
|
|
||||||
count,
|
|
||||||
lastSeen: sample.created,
|
|
||||||
resolved,
|
|
||||||
sample: {
|
|
||||||
message: sample.message,
|
|
||||||
stack: sample.stack,
|
|
||||||
path: sample.path,
|
|
||||||
source: sample.source,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const resolveClientErrorGroup = createServerFn({ method: "POST" })
|
export const resolveClientErrorGroup = createServerFn({ method: "POST" })
|
||||||
.validator(z.object({ groupHash: z.string(), resolved: z.boolean().optional() }))
|
.validator(z.object({ groupHash: z.string(), resolved: z.boolean().optional() }))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<{ updated: number }>(async () => {
|
toServerResult<{ updated: number }>(async () => {
|
||||||
const updated = await pbAdmin.resolveErrorsByGroup(data.groupHash, data.resolved ?? true);
|
const updated = await pbAdmin.resolveErrorsByGroup(data.groupHash, data.resolved ?? true);
|
||||||
@@ -134,7 +150,7 @@ export const resolveClientErrorGroup = createServerFn({ method: "POST" })
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rollupRangeSchema = z.object({
|
const rollupRangeSchema = z.object({
|
||||||
metrics: z.array(z.string()).min(1).max(8),
|
metrics: z.array(z.string()).min(1).max(12),
|
||||||
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
dim: z.string().optional(),
|
dim: z.string().optional(),
|
||||||
@@ -142,7 +158,7 @@ const rollupRangeSchema = z.object({
|
|||||||
|
|
||||||
export const getRollupRange = createServerFn()
|
export const getRollupRange = createServerFn()
|
||||||
.validator(rollupRangeSchema)
|
.validator(rollupRangeSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<RollupRow[]>(async () => pbAdmin.getRollupRange(data))
|
toServerResult<RollupRow[]>(async () => pbAdmin.getRollupRange(data))
|
||||||
);
|
);
|
||||||
@@ -151,7 +167,7 @@ export const listTelemetryAlerts = createServerFn()
|
|||||||
.validator(
|
.validator(
|
||||||
z.object({ page: z.number().optional(), perPage: z.number().max(200).optional() })
|
z.object({ page: z.number().optional(), perPage: z.number().max(200).optional() })
|
||||||
)
|
)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<TelemetryListResult<TelemetryAlert>>(async () =>
|
toServerResult<TelemetryListResult<TelemetryAlert>>(async () =>
|
||||||
pbAdmin.listTelemetryAlerts(data.page, data.perPage)
|
pbAdmin.listTelemetryAlerts(data.page, data.perPage)
|
||||||
@@ -181,7 +197,7 @@ export const listSessions = createServerFn()
|
|||||||
perPage: z.number().max(100).optional(),
|
perPage: z.number().max(100).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<TelemetryListResult<SessionSummary>>(async () => {
|
toServerResult<TelemetryListResult<SessionSummary>>(async () => {
|
||||||
const { days = 7, page = 1, perPage = 30 } = data;
|
const { days = 7, page = 1, perPage = 30 } = data;
|
||||||
@@ -269,7 +285,7 @@ const MAX_TIMELINE_ENTRIES = 500;
|
|||||||
|
|
||||||
export const getSessionTimeline = createServerFn()
|
export const getSessionTimeline = createServerFn()
|
||||||
.validator(z.object({ sessionId: z.string().min(8).max(64) }))
|
.validator(z.object({ sessionId: z.string().min(8).max(64) }))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<SessionTimeline>(async () => {
|
toServerResult<SessionTimeline>(async () => {
|
||||||
const events = await pbAdmin.searchClientEvents({
|
const events = await pbAdmin.searchClientEvents({
|
||||||
@@ -302,11 +318,18 @@ export const getSessionTimeline = createServerFn()
|
|||||||
|
|
||||||
for (const event of events.items) {
|
for (const event of events.items) {
|
||||||
if (event.name.startsWith("vital.")) continue;
|
if (event.name.startsWith("vital.")) continue;
|
||||||
|
const props = event.props as
|
||||||
|
| { emoji?: string; source?: string; tournament?: string; value?: string; context?: string }
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
const breakdown =
|
||||||
|
props?.emoji ?? props?.value ?? props?.source ?? props?.tournament;
|
||||||
|
const detail = [breakdown, props?.context].filter(Boolean).join(" · ");
|
||||||
entries.push({
|
entries.push({
|
||||||
ts: event.created,
|
ts: event.created,
|
||||||
kind: event.name === "page_view" ? "page_view" : "event",
|
kind: event.name === "page_view" ? "page_view" : "event",
|
||||||
label: event.name === "page_view" ? event.path || event.route_id || "?" : event.name,
|
label: event.name === "page_view" ? event.path || event.route_id || "?" : event.name,
|
||||||
detail: event.name === "page_view" ? undefined : event.path,
|
detail: event.name === "page_view" ? undefined : (detail || event.path),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +369,43 @@ export const getSessionTimeline = createServerFn()
|
|||||||
|
|
||||||
entries.sort((a, b) => (a.ts < b.ts ? -1 : 1));
|
entries.sort((a, b) => (a.ts < b.ts ? -1 : 1));
|
||||||
|
|
||||||
const players = await playerInfoById(new Set(playerId ? [playerId] : []));
|
const profileIds = new Set<string>();
|
||||||
|
const teamIds = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.kind !== "page_view") continue;
|
||||||
|
const profileMatch = entry.label.match(/^\/profile\/([^/]+)$/);
|
||||||
|
if (profileMatch) profileIds.add(profileMatch[1]);
|
||||||
|
const teamMatch = entry.label.match(/^\/teams\/([^/]+)$/);
|
||||||
|
if (teamMatch) teamIds.add(teamMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = await playerInfoById(
|
||||||
|
new Set([...(playerId ? [playerId] : []), ...profileIds])
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamNames = new Map<string, string>();
|
||||||
|
if (teamIds.size > 0) {
|
||||||
|
try {
|
||||||
|
const teams = await pbAdmin.listTeamInfos();
|
||||||
|
for (const team of teams) {
|
||||||
|
if (teamIds.has(team.id)) teamNames.set(team.id, team.name);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.kind !== "page_view" || entry.detail) continue;
|
||||||
|
const profileMatch = entry.label.match(/^\/profile\/([^/]+)$/);
|
||||||
|
if (profileMatch) {
|
||||||
|
const player = players.get(profileMatch[1]);
|
||||||
|
if (player) {
|
||||||
|
entry.detail = [player.first_name, player.last_name].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const teamMatch = entry.label.match(/^\/teams\/([^/]+)$/);
|
||||||
|
if (teamMatch) entry.detail = teamNames.get(teamMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session: {
|
session: {
|
||||||
@@ -367,7 +426,7 @@ export const getSessionTimeline = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getTelemetryRuntimeStatus = createServerFn()
|
export const getTelemetryRuntimeStatus = createServerFn()
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensDeveloperFunctionMiddleware])
|
||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult<TelemetryRuntimeStatus>(async () => {
|
toServerResult<TelemetryRuntimeStatus>(async () => {
|
||||||
ensureTelemetryScheduler();
|
ensureTelemetryScheduler();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
@@ -7,6 +7,7 @@ import { useTournament } from "../../queries";
|
|||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
import { TournamentStats } from "../tournament-stats";
|
import { TournamentStats } from "../tournament-stats";
|
||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +16,14 @@ interface ProfileProps {
|
|||||||
const Profile = ({ id }: ProfileProps) => {
|
const Profile = ({ id }: ProfileProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { data: tournament } = useTournament(id);
|
const { data: tournament } = useTournament(id);
|
||||||
|
const trackedId = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tournament || trackedId.current === tournament.id) return;
|
||||||
|
trackedId.current = tournament.id;
|
||||||
|
track("tournament_viewed", { tournament: tournament.name });
|
||||||
|
}, [tournament]);
|
||||||
|
|
||||||
if (!tournament) return null;
|
if (!tournament) return null;
|
||||||
|
|
||||||
const tabs = useMemo(() => [
|
const tabs = useMemo(() => [
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
|
|||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
|
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useMounted } from "@mantine/hooks";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import GlitchAvatar from "@/components/glitch-avatar";
|
import GlitchAvatar from "@/components/glitch-avatar";
|
||||||
|
|
||||||
const Header = ({ tournament }: { tournament: Tournament }) => {
|
const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
// Local-timezone text cannot be server-rendered: the pod formats in UTC and
|
||||||
|
// hydration mismatches on every load.
|
||||||
|
const mounted = useMounted();
|
||||||
const tournamentStart = useMemo(
|
const tournamentStart = useMemo(
|
||||||
() => new Date(tournament.start_time),
|
() => new Date(tournament.start_time),
|
||||||
[tournament.start_time]
|
[tournament.start_time]
|
||||||
@@ -48,18 +52,20 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
<CalendarIcon size={14} />
|
<CalendarIcon size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
<Trans>
|
{mounted && (
|
||||||
{tournamentStart.toLocaleDateString(i18n.locale, {
|
<Trans>
|
||||||
weekday: "short",
|
{tournamentStart.toLocaleDateString(i18n.locale, {
|
||||||
month: "short",
|
weekday: "short",
|
||||||
day: "numeric",
|
month: "short",
|
||||||
})}{" "}
|
day: "numeric",
|
||||||
at{" "}
|
})}{" "}
|
||||||
{tournamentStart.toLocaleTimeString(i18n.locale, {
|
at{" "}
|
||||||
hour: "2-digit",
|
{tournamentStart.toLocaleTimeString(i18n.locale, {
|
||||||
minute: "2-digit",
|
hour: "2-digit",
|
||||||
})}
|
minute: "2-digit",
|
||||||
</Trans>
|
})}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useSheet } from "@/hooks/use-sheet";
|
|||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
import { Tournament } from "../../../types";
|
import { Tournament } from "../../../types";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
import classes from "./nav-grid.module.css";
|
import classes from "./nav-grid.module.css";
|
||||||
|
|
||||||
const RulesContent = lazy(
|
const RulesContent = lazy(
|
||||||
@@ -86,9 +87,10 @@ const NavGrid = ({
|
|||||||
<NavTile
|
<NavTile
|
||||||
label={t`Bracket`}
|
label={t`Bracket`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
navigate({ to: `/tournaments/${tournament.id}/bracket` })
|
track("bracket_viewed", { source: "nav_grid" });
|
||||||
}
|
navigate({ to: `/tournaments/${tournament.id}/bracket` });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{isPredictable && !predictionsLocked && (
|
{isPredictable && !predictionsLocked && (
|
||||||
<NavTile
|
<NavTile
|
||||||
@@ -118,9 +120,19 @@ const NavGrid = ({
|
|||||||
: t`Teams`
|
: t`Teams`
|
||||||
}
|
}
|
||||||
Icon={UsersIcon}
|
Icon={UsersIcon}
|
||||||
onClick={teamsSheet.open}
|
onClick={() => {
|
||||||
|
track("teams_viewed", { source: "nav_grid" });
|
||||||
|
teamsSheet.open();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NavTile
|
||||||
|
label={t`Rules`}
|
||||||
|
Icon={ListIcon}
|
||||||
|
onClick={() => {
|
||||||
|
track("rules_viewed", { source: "nav_grid" });
|
||||||
|
rulesSheet.open();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<NavTile label={t`Rules`} Icon={ListIcon} onClick={rulesSheet.open} />
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Sheet title={t`Enrolled Teams`} {...teamsSheet.props}>
|
<Sheet title={t`Enrolled Teams`} {...teamsSheet.props}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import MatchCard from "@/features/matches/components/match-card";
|
import MatchCard from "@/features/matches/components/match-card";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
import SectionHeading from "./section-heading";
|
import SectionHeading from "./section-heading";
|
||||||
|
|
||||||
interface RecentResultsProps {
|
interface RecentResultsProps {
|
||||||
@@ -39,9 +40,10 @@ const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) =
|
|||||||
type="button"
|
type="button"
|
||||||
size="xs"
|
size="xs"
|
||||||
fw={600}
|
fw={600}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
navigate({ to: `/tournaments/${tournamentId}/bracket` })
|
track("bracket_viewed", { source: "recent_results" });
|
||||||
}
|
navigate({ to: `/tournaments/${tournamentId}/bracket` });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={2} align="center" wrap="nowrap">
|
<Group gap={2} align="center" wrap="nowrap">
|
||||||
<Trans>View all results</Trans>
|
<Trans>View all results</Trans>
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<MatchReport match={match} />
|
<MatchReport match={match} source="score_cta" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TournamentInfo } from "../types";
|
|||||||
import { Fragment, useCallback } from "react";
|
import { Fragment, useCallback } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
|
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
|
||||||
|
import { useMounted } from "@mantine/hooks";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface TournamentListProps {
|
interface TournamentListProps {
|
||||||
@@ -18,6 +19,7 @@ interface TournamentListItemProps {
|
|||||||
|
|
||||||
const TournamentListItem = React.memo(({ tournament }: TournamentListItemProps) => {
|
const TournamentListItem = React.memo(({ tournament }: TournamentListItemProps) => {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
const mounted = useMounted();
|
||||||
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
|
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,11 +45,12 @@ const TournamentListItem = React.memo(({ tournament }: TournamentListItemProps)
|
|||||||
<CalendarIcon size={10} />
|
<CalendarIcon size={10} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{startDate.toLocaleDateString(i18n.locale, {
|
{mounted &&
|
||||||
month: 'short',
|
startDate.toLocaleDateString(i18n.locale, {
|
||||||
day: 'numeric',
|
month: 'short',
|
||||||
year: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
year: 'numeric'
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+5
-1
@@ -9,6 +9,7 @@ import { Text } from "@mantine/core";
|
|||||||
import PlayerList from "@/features/players/components/player-list";
|
import PlayerList from "@/features/players/components/player-list";
|
||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
import { NavTile } from "../started-tournament/sections/nav-grid";
|
import { NavTile } from "../started-tournament/sections/nav-grid";
|
||||||
|
import { track } from "@/lib/telemetry/client";
|
||||||
|
|
||||||
interface EnrolledPlayersListButtonProps {
|
interface EnrolledPlayersListButtonProps {
|
||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
@@ -33,7 +34,10 @@ const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ t
|
|||||||
values: { count },
|
values: { count },
|
||||||
})}
|
})}
|
||||||
Icon={UserListIcon}
|
Icon={UserListIcon}
|
||||||
onClick={open}
|
onClick={() => {
|
||||||
|
track("players_viewed", { source: "upcoming" });
|
||||||
|
open();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet title={t`Enrolled Players`} opened={isOpen} onChange={toggle}>
|
<Sheet title={t`Enrolled Players`} opened={isOpen} onChange={toggle}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTournament } from "../../queries"
|
|||||||
import { Button, Flex, Loader, Stack } from "@mantine/core"
|
import { Button, Flex, Loader, Stack } from "@mantine/core"
|
||||||
import { lazy, Suspense } from "react"
|
import { lazy, Suspense } from "react"
|
||||||
import { NavTile } from "../started-tournament/sections/nav-grid"
|
import { NavTile } from "../started-tournament/sections/nav-grid"
|
||||||
|
import { track } from "@/lib/telemetry/client"
|
||||||
|
|
||||||
const RulesContent = lazy(() => import("./rules-content"));
|
const RulesContent = lazy(() => import("./rules-content"));
|
||||||
|
|
||||||
@@ -20,7 +21,14 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavTile label={t`Rules`} Icon={ListIcon} onClick={open} />
|
<NavTile
|
||||||
|
label={t`Rules`}
|
||||||
|
Icon={ListIcon}
|
||||||
|
onClick={() => {
|
||||||
|
track("rules_viewed", { source: "upcoming" });
|
||||||
|
open();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Sheet title={t`Tournament Rules`} opened={isOpen} onChange={toggle}>
|
<Sheet title={t`Tournament Rules`} opened={isOpen} onChange={toggle}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useMemo } from "react"
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { NavTile } from "../started-tournament/sections/nav-grid"
|
import { NavTile } from "../started-tournament/sections/nav-grid"
|
||||||
|
import { track } from "@/lib/telemetry/client"
|
||||||
|
|
||||||
interface TeamListButtonProps {
|
interface TeamListButtonProps {
|
||||||
teams: TeamInfo[]
|
teams: TeamInfo[]
|
||||||
@@ -25,7 +26,10 @@ const TeamListButton: React.FC<TeamListButtonProps> = ({ teams, isRegional }) =>
|
|||||||
values: { count },
|
values: { count },
|
||||||
})}
|
})}
|
||||||
Icon={UsersIcon}
|
Icon={UsersIcon}
|
||||||
onClick={open}
|
onClick={() => {
|
||||||
|
track("teams_viewed", { source: "upcoming" });
|
||||||
|
open();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet title={t`Enrolled Teams`} opened={isOpen} onChange={toggle}>
|
<Sheet title={t`Enrolled Teams`} opened={isOpen} onChange={toggle}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { isAdminRole } from "@/features/core/utils/roles";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/types";
|
import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/types";
|
||||||
@@ -46,7 +47,7 @@ export const getTournament = createServerFn()
|
|||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId, context }) => {
|
.handler(async ({ data: tournamentId, context }) => {
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = isAdminRole(context.roles);
|
||||||
return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin));
|
return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export const enrollTeam = createServerFn()
|
|||||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = isAdminRole(context.roles);
|
||||||
|
|
||||||
const team = await pbAdmin.getTeam(teamId);
|
const team = await pbAdmin.getTeam(teamId);
|
||||||
if (!team) { throw new Error('Team not found'); }
|
if (!team) { throw new Error('Team not found'); }
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const INVALIDATE_JITTER_MS = 1500;
|
|||||||
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const WATCHDOG_MS = 45_000;
|
const WATCHDOG_MS = 45_000;
|
||||||
|
const REFRESH_ATTEMPT_COOLDOWN_MS = 60_000;
|
||||||
|
let lastRefreshAttemptAt = 0;
|
||||||
|
|
||||||
function debouncedInvalidate(
|
function debouncedInvalidate(
|
||||||
queryClient: ReturnType<typeof useQueryClient>,
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
@@ -164,11 +166,14 @@ export function useServerEvents() {
|
|||||||
// session is genuinely gone, stop retrying so we don't hammer the
|
// session is genuinely gone, stop retrying so we don't hammer the
|
||||||
// endpoint forever — a real re-login (or a wake event) will reconnect.
|
// endpoint forever — a real re-login (or a wake event) will reconnect.
|
||||||
let sessionValid = true;
|
let sessionValid = true;
|
||||||
try {
|
if (Date.now() - lastRefreshAttemptAt > REFRESH_ATTEMPT_COOLDOWN_MS) {
|
||||||
const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
|
lastRefreshAttemptAt = Date.now();
|
||||||
sessionValid = await attemptRefreshingSession();
|
try {
|
||||||
} catch {
|
const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
|
||||||
// Refresh call itself failed (likely offline) — treat as transient.
|
sessionValid = await attemptRefreshingSession();
|
||||||
|
} catch {
|
||||||
|
// Refresh call itself failed (likely offline) — treat as transient.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { QueryClient, QueryKey } from "@tanstack/react-query";
|
import { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||||
import { ServerResult } from "../types";
|
import { ServerResult } from "../types";
|
||||||
|
|
||||||
|
export function getServerQueryData<TData>(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
query: {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
queryFn: () => Promise<ServerResult<TData>>;
|
||||||
|
}
|
||||||
|
): TData | undefined {
|
||||||
|
return queryClient.getQueryData<TData>(query.queryKey);
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureServerQueryData<TData>(
|
export async function ensureServerQueryData<TData>(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { msg } from "@lingui/core/macro";
|
|||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { sendPushToPlayer } from "@/lib/push";
|
import { sendPushToPlayer } from "@/lib/push";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import { ADMIN_ROLE } from "@/features/core/utils/roles";
|
import { DEVELOPER_ROLE } from "@/features/core/utils/roles";
|
||||||
|
|
||||||
const logger = new Logger("Telemetry > Alerts");
|
const logger = new Logger("Telemetry > Alerts");
|
||||||
|
|
||||||
@@ -22,23 +22,23 @@ const fnFailureThreshold = (): number => {
|
|||||||
|
|
||||||
const pbTimestamp = (date: Date): string => date.toISOString().replace("T", " ");
|
const pbTimestamp = (date: Date): string => date.toISOString().replace("T", " ");
|
||||||
|
|
||||||
const ADMIN_CACHE_TTL_MS = 10 * 60 * 1000;
|
const DEVELOPER_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
let adminPlayersCache: { playerIds: string[]; expiresAt: number } | null = null;
|
let developerPlayersCache: { playerIds: string[]; expiresAt: number } | null = null;
|
||||||
|
|
||||||
const getAdminPlayerIds = async (): Promise<string[]> => {
|
const getDeveloperPlayerIds = async (): Promise<string[]> => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (adminPlayersCache && adminPlayersCache.expiresAt > now) {
|
if (developerPlayersCache && developerPlayersCache.expiresAt > now) {
|
||||||
return adminPlayersCache.playerIds;
|
return developerPlayersCache.playerIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserRoles = (await import("supertokens-node/recipe/userroles")).default;
|
const UserRoles = (await import("supertokens-node/recipe/userroles")).default;
|
||||||
const UserMetadata = (await import("supertokens-node/recipe/usermetadata")).default;
|
const UserMetadata = (await import("supertokens-node/recipe/usermetadata")).default;
|
||||||
const response = await UserRoles.getUsersThatHaveRole("public", ADMIN_ROLE);
|
const response = await UserRoles.getUsersThatHaveRole("public", DEVELOPER_ROLE);
|
||||||
const users = response.status === "OK" ? response.users : [];
|
const users = response.status === "OK" ? response.users : [];
|
||||||
const playerIds: string[] = [];
|
const playerIds: string[] = [];
|
||||||
for (const userId of users) {
|
for (const userId of users) {
|
||||||
try {
|
try {
|
||||||
// Opt-in: only admins who enabled telemetry alerts in settings.
|
// Opt-in: only developers who enabled telemetry alerts in settings.
|
||||||
const { metadata } = await UserMetadata.getUserMetadata(userId);
|
const { metadata } = await UserMetadata.getUserMetadata(userId);
|
||||||
if (metadata?.telemetryAlerts !== true) continue;
|
if (metadata?.telemetryAlerts !== true) continue;
|
||||||
const player = await pbAdmin.getPlayerByAuthId(userId);
|
const player = await pbAdmin.getPlayerByAuthId(userId);
|
||||||
@@ -46,7 +46,7 @@ const getAdminPlayerIds = async (): Promise<string[]> => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
adminPlayersCache = { playerIds, expiresAt: now + ADMIN_CACHE_TTL_MS };
|
developerPlayersCache = { playerIds, expiresAt: now + DEVELOPER_CACHE_TTL_MS };
|
||||||
return playerIds;
|
return playerIds;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,8 +71,8 @@ const fireAlert = async (candidate: AlertCandidate) => {
|
|||||||
value: candidate.value,
|
value: candidate.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
const admins = await getAdminPlayerIds();
|
const developers = await getDeveloperPlayerIds();
|
||||||
for (const playerId of admins) {
|
for (const playerId of developers) {
|
||||||
try {
|
try {
|
||||||
await sendPushToPlayer(playerId, {
|
await sendPushToPlayer(playerId, {
|
||||||
title: candidate.title,
|
title: candidate.title,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import OverrideableBuilder from "supertokens-js-override";
|
||||||
|
import { withPasswordlessTelemetry } from "./auth-events.server";
|
||||||
|
|
||||||
|
vi.mock("@/lib/pocketbase/client", () => ({
|
||||||
|
pbAdmin: {
|
||||||
|
authPromise: Promise.resolve(),
|
||||||
|
getPlayerByAuthId: async () => undefined,
|
||||||
|
createActivity: async () => undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeOriginalImpl = () => ({
|
||||||
|
createCode: async (input: any) => ({ status: "OK", input }),
|
||||||
|
consumeCode: async (input: any) => ({ status: "OK", user: { id: "u1" }, input }),
|
||||||
|
listCodes: async () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withPasswordlessTelemetry", () => {
|
||||||
|
it("composes with an inner override that spreads the proxy (twilio recipe shape)", async () => {
|
||||||
|
const inner = (originalImplementation: any) => ({
|
||||||
|
...originalImplementation,
|
||||||
|
consumeCode: async (input: any) => originalImplementation.consumeCode(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
const builder = new OverrideableBuilder(makeOriginalImpl() as any);
|
||||||
|
builder.override(withPasswordlessTelemetry(inner) as any);
|
||||||
|
const impl = builder.build() as any;
|
||||||
|
|
||||||
|
await expect(impl.createCode({ phoneNumber: "+15555550100" })).resolves.toMatchObject({
|
||||||
|
status: "OK",
|
||||||
|
});
|
||||||
|
await expect(impl.consumeCode({ preAuthSessionId: "x" })).resolves.toMatchObject({
|
||||||
|
status: "OK",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works without an inner override", async () => {
|
||||||
|
const builder = new OverrideableBuilder(makeOriginalImpl() as any);
|
||||||
|
builder.override(withPasswordlessTelemetry() as any);
|
||||||
|
const impl = builder.build() as any;
|
||||||
|
|
||||||
|
await expect(impl.createCode({ phoneNumber: "+15555550100" })).resolves.toMatchObject({
|
||||||
|
status: "OK",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,11 +60,14 @@ export const withPasswordlessTelemetry =
|
|||||||
(originalImplementation: any) => {
|
(originalImplementation: any) => {
|
||||||
const base = existing ? existing(originalImplementation) : originalImplementation;
|
const base = existing ? existing(originalImplementation) : originalImplementation;
|
||||||
|
|
||||||
|
const callBase = (name: string, input: any) =>
|
||||||
|
(base[name] ?? originalImplementation[name]).call(originalImplementation, input);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
createCode: async (input: any) => {
|
createCode: async (input: any) => {
|
||||||
try {
|
try {
|
||||||
const response = await base.createCode(input);
|
const response = await callBase("createCode", input);
|
||||||
recordAuthEvent("auth.otp_sent", {
|
recordAuthEvent("auth.otp_sent", {
|
||||||
phone: input.phoneNumber,
|
phone: input.phoneNumber,
|
||||||
success: response.status === "OK",
|
success: response.status === "OK",
|
||||||
@@ -82,7 +85,7 @@ export const withPasswordlessTelemetry =
|
|||||||
},
|
},
|
||||||
consumeCode: async (input: any) => {
|
consumeCode: async (input: any) => {
|
||||||
try {
|
try {
|
||||||
const response = await base.consumeCode(input);
|
const response = await callBase("consumeCode", input);
|
||||||
if (response.status === "OK") {
|
if (response.status === "OK") {
|
||||||
recordAuthEvent("auth.otp_consumed", {
|
recordAuthEvent("auth.otp_consumed", {
|
||||||
authUserId: response.user?.id,
|
authUserId: response.user?.id,
|
||||||
|
|||||||
@@ -17,19 +17,37 @@ const recentErrors = new Map<string, number>();
|
|||||||
|
|
||||||
const isEnabled = (): boolean => typeof window !== "undefined";
|
const isEnabled = (): boolean => typeof window !== "undefined";
|
||||||
|
|
||||||
const getSessionId = (): string => {
|
// A session ends after 30 idle minutes, not when the tab closes — installed
|
||||||
|
// PWAs keep sessionStorage alive for days.
|
||||||
|
const SESSION_IDLE_MS = 30 * 60 * 1000;
|
||||||
|
const SESSION_KEY = "flxn-tsid";
|
||||||
|
|
||||||
|
const readSession = (): { id: string; t: number } | null => {
|
||||||
try {
|
try {
|
||||||
let id = sessionStorage.getItem("flxn-tsid");
|
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||||
if (!id) {
|
if (!raw) return null;
|
||||||
id = crypto.randomUUID();
|
const parsed = JSON.parse(raw);
|
||||||
sessionStorage.setItem("flxn-tsid", id);
|
if (typeof parsed?.id === "string" && typeof parsed?.t === "number") {
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
return id;
|
return null;
|
||||||
} catch {
|
} catch {
|
||||||
return "anon-session";
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const touchSession = () => {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const current = readSession();
|
||||||
|
const id =
|
||||||
|
current && now - current.t <= SESSION_IDLE_MS ? current.id : crypto.randomUUID();
|
||||||
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ id, t: now }));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionId = (): string => readSession()?.id ?? "anon-session";
|
||||||
|
|
||||||
const location = (): { path: string; routeId?: string } => ({
|
const location = (): { path: string; routeId?: string } => ({
|
||||||
path: window.location.pathname,
|
path: window.location.pathname,
|
||||||
routeId: lastRouteId,
|
routeId: lastRouteId,
|
||||||
@@ -64,6 +82,7 @@ const flush = (useBeacon = false) => {
|
|||||||
|
|
||||||
const enqueue = (event: QueuedEvent) => {
|
const enqueue = (event: QueuedEvent) => {
|
||||||
if (!isEnabled()) return;
|
if (!isEnabled()) return;
|
||||||
|
touchSession();
|
||||||
queue.push(event);
|
queue.push(event);
|
||||||
if (queue.length >= FLUSH_AT) {
|
if (queue.length >= FLUSH_AT) {
|
||||||
flush();
|
flush();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface ClientEventRow {
|
|||||||
session_id?: string;
|
session_id?: string;
|
||||||
route_id?: string;
|
route_id?: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
|
props?: { source?: string; emoji?: string; tournament?: string; value?: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientErrorRow {
|
export interface ClientErrorRow {
|
||||||
@@ -64,6 +65,8 @@ export interface ClientEventAccumulator {
|
|||||||
sessions: Set<string>;
|
sessions: Set<string>;
|
||||||
pageViewsByRoute: Map<string, number>;
|
pageViewsByRoute: Map<string, number>;
|
||||||
vitals: Map<string, number[]>;
|
vitals: Map<string, number[]>;
|
||||||
|
eventCounts: Map<string, number>;
|
||||||
|
eventDetails: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newClientEventAccumulator = (): ClientEventAccumulator => ({
|
export const newClientEventAccumulator = (): ClientEventAccumulator => ({
|
||||||
@@ -71,8 +74,15 @@ export const newClientEventAccumulator = (): ClientEventAccumulator => ({
|
|||||||
sessions: new Set(),
|
sessions: new Set(),
|
||||||
pageViewsByRoute: new Map(),
|
pageViewsByRoute: new Map(),
|
||||||
vitals: new Map(),
|
vitals: new Map(),
|
||||||
|
eventCounts: new Map(),
|
||||||
|
eventDetails: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// emoji first: reaction_sent carries both emoji and source, and its aggregate
|
||||||
|
// breakdown should be by emoji.
|
||||||
|
const eventDetail = (row: ClientEventRow): string | undefined =>
|
||||||
|
row.props?.emoji ?? row.props?.value ?? row.props?.source ?? row.props?.tournament;
|
||||||
|
|
||||||
export const accumulateClientEvents = (acc: ClientEventAccumulator, rows: ClientEventRow[]) => {
|
export const accumulateClientEvents = (acc: ClientEventAccumulator, rows: ClientEventRow[]) => {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.player) acc.players.add(row.player);
|
if (row.player) acc.players.add(row.player);
|
||||||
@@ -86,6 +96,13 @@ export const accumulateClientEvents = (acc: ClientEventAccumulator, rows: Client
|
|||||||
const values = acc.vitals.get(metric) ?? [];
|
const values = acc.vitals.get(metric) ?? [];
|
||||||
values.push(row.value);
|
values.push(row.value);
|
||||||
acc.vitals.set(metric, values);
|
acc.vitals.set(metric, values);
|
||||||
|
} else {
|
||||||
|
acc.eventCounts.set(row.name, (acc.eventCounts.get(row.name) ?? 0) + 1);
|
||||||
|
const detail = eventDetail(row);
|
||||||
|
if (detail) {
|
||||||
|
const key = `${row.name}|${detail}`;
|
||||||
|
acc.eventDetails.set(key, (acc.eventDetails.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -157,6 +174,13 @@ export const buildDayRollups = (
|
|||||||
push("page_view.count", "", totalPageViews);
|
push("page_view.count", "", totalPageViews);
|
||||||
push("page_view.sessions", "", clientEvents.sessions.size);
|
push("page_view.sessions", "", clientEvents.sessions.size);
|
||||||
|
|
||||||
|
for (const [name, count] of clientEvents.eventCounts) {
|
||||||
|
push("event.count", name, count);
|
||||||
|
}
|
||||||
|
for (const [key, count] of clientEvents.eventDetails) {
|
||||||
|
push("event.detail.count", key, count);
|
||||||
|
}
|
||||||
|
|
||||||
push("client_error.count", "", clientErrors.total);
|
push("client_error.count", "", clientErrors.total);
|
||||||
for (const [group, count] of clientErrors.byGroup) {
|
for (const [group, count] of clientErrors.byGroup) {
|
||||||
push("client_error.count", group, count);
|
push("client_error.count", group, count);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const runRollups = async (
|
|||||||
);
|
);
|
||||||
await pbAdmin.pageCollection<ClientEventRow>(
|
await pbAdmin.pageCollection<ClientEventRow>(
|
||||||
"client_events",
|
"client_events",
|
||||||
{ filter: dayFilter(dateStr), fields: "name,player,session_id,route_id,value,created" },
|
{ filter: dayFilter(dateStr), fields: "name,player,session_id,route_id,value,props,created" },
|
||||||
(rows) => accumulateClientEvents(clientEvents, rows)
|
(rows) => accumulateClientEvents(clientEvents, rows)
|
||||||
);
|
);
|
||||||
await pbAdmin.pageCollection<ClientErrorRow>(
|
await pbAdmin.pageCollection<ClientErrorRow>(
|
||||||
|
|||||||
+240
-204
File diff suppressed because it is too large
Load Diff
+240
-204
File diff suppressed because it is too large
Load Diff
+240
-204
File diff suppressed because it is too large
Load Diff
+240
-204
File diff suppressed because it is too large
Load Diff
@@ -178,15 +178,14 @@
|
|||||||
/* 3. Fixed sky backdrop with static bubbles (below all content) */
|
/* 3. Fixed sky backdrop with static bubbles (below all content) */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
[data-theme="aero"] body::before {
|
[data-theme="aero"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="aero"][data-mantine-color-scheme="light"] body::before {
|
[data-theme="aero"][data-mantine-color-scheme="light"] .flxn-theme-overlay {
|
||||||
background-color: #e9f6fe;
|
background-color: #e9f6fe;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle 0.6rem at 13.5% 20%, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0) 70%),
|
radial-gradient(circle 0.6rem at 13.5% 20%, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0) 70%),
|
||||||
@@ -198,7 +197,7 @@
|
|||||||
linear-gradient(180deg, #a5daf6 0%, #c9e9fa 32%, #e9f6fe 60%, #ffffff 100%);
|
linear-gradient(180deg, #a5daf6 0%, #c9e9fa 32%, #e9f6fe 60%, #ffffff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="aero"][data-mantine-color-scheme="dark"] body::before {
|
[data-theme="aero"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
|
||||||
background-color: #071e2d;
|
background-color: #071e2d;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle 4rem at 16% 24%, rgba(120, 205, 245, 0) 60%, rgba(120, 205, 245, 0.2) 67%, rgba(120, 205, 245, 0) 74%),
|
radial-gradient(circle 4rem at 16% 24%, rgba(120, 205, 245, 0) 60%, rgba(120, 205, 245, 0.2) 67%, rgba(120, 205, 245, 0) 74%),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
explicit [data-mantine-color-scheme] blocks. */
|
explicit [data-mantine-color-scheme] blocks. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* bottom-nav indicator travel: spring (stiffness 320, damping 30) */
|
||||||
|
--flxn-nav-travel: linear(0, 0.0102, 0.0376, 0.0778, 0.1272, 0.1829, 0.2422, 0.3033, 0.3646, 0.4247, 0.4829, 0.5383, 0.5906, 0.6393, 0.6843, 0.7256, 0.7631, 0.7969, 0.8273, 0.8543, 0.8783, 0.8993, 0.9176, 0.9335, 0.9472, 0.9589, 0.9688, 0.9772, 0.9841, 0.9898, 0.9945, 0.9982, 1.0011, 1.0034, 1.0051, 1.0063, 1.0072, 1.0077, 1.0079, 1.0079, 1.0078);
|
||||||
|
--flxn-nav-travel-ms: 333ms;
|
||||||
|
|
||||||
/* accolades */
|
/* accolades */
|
||||||
--flxn-gold: gold;
|
--flxn-gold: gold;
|
||||||
--flxn-silver: var(--mantine-color-gray-5);
|
--flxn-silver: var(--mantine-color-gray-5);
|
||||||
|
|||||||
@@ -203,8 +203,7 @@
|
|||||||
|
|
||||||
/* ---------- 4. Backdrop — studio key light + clay grain ---------- */
|
/* ---------- 4. Backdrop — studio key light + clay grain ---------- */
|
||||||
|
|
||||||
[data-theme="clay"] body::before {
|
[data-theme="clay"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1; /* behind content — never tints text or surfaces */
|
z-index: -1; /* behind content — never tints text or surfaces */
|
||||||
@@ -212,7 +211,7 @@
|
|||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="clay"][data-mantine-color-scheme="light"] body::before {
|
[data-theme="clay"][data-mantine-color-scheme="light"] .flxn-theme-overlay {
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
120% 70% at 50% 0%,
|
120% 70% at 50% 0%,
|
||||||
rgba(255, 252, 244, 0.65),
|
rgba(255, 252, 244, 0.65),
|
||||||
@@ -221,7 +220,7 @@
|
|||||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="clay"][data-mantine-color-scheme="dark"] body::before {
|
[data-theme="clay"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
110% 65% at 50% 0%,
|
110% 65% at 50% 0%,
|
||||||
rgba(255, 208, 158, 0.09),
|
rgba(255, 208, 158, 0.09),
|
||||||
|
|||||||
@@ -363,8 +363,7 @@
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* 4. Sunburst fan — faint gold rays from top center */
|
/* 4. Sunburst fan — faint gold rays from top center */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
[data-theme="deco"] body::before {
|
[data-theme="deco"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
|
|||||||
@@ -409,8 +409,7 @@
|
|||||||
* 3. Full-page decoration: candlelit vignette (dark scheme only)
|
* 3. Full-page decoration: candlelit vignette (dark scheme only)
|
||||||
* ---------------------------------------------------------------- */
|
* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
[data-theme="gothic"][data-mantine-color-scheme="dark"] body::before {
|
[data-theme="gothic"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
|
|||||||
@@ -67,3 +67,61 @@
|
|||||||
border: 1px solid var(--mantine-color-default-border);
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom-nav active indicator: one element glides between tabs. The surface is
|
||||||
|
neutral on purpose — the accent belongs to the glyph, not a tint behind it. */
|
||||||
|
.flxn-nav-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: var(--nav-w, 0);
|
||||||
|
height: var(--nav-h, 0);
|
||||||
|
translate: var(--nav-x, 0) var(--nav-y, 0);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 0;
|
||||||
|
transition: translate var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
|
||||||
|
width var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
|
||||||
|
height var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
|
||||||
|
opacity 140ms ease-out;
|
||||||
|
}
|
||||||
|
.flxn-nav-indicator[data-placed="1"] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.flxn-nav-indicator-shape {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% + var(--flxn-nav-pad-x, 12px));
|
||||||
|
height: calc(100% + var(--flxn-nav-pad-y, 6px));
|
||||||
|
margin: calc(var(--flxn-nav-pad-y, 6px) / -2) calc(var(--flxn-nav-pad-x, 12px) / -2);
|
||||||
|
background: var(--flxn-nav-surface, var(--mantine-color-default));
|
||||||
|
border: 1px solid var(--flxn-nav-border, var(--mantine-color-default-border));
|
||||||
|
border-radius: var(--flxn-nav-radius, var(--mantine-radius-md));
|
||||||
|
box-shadow: var(--mantine-shadow-xs);
|
||||||
|
}
|
||||||
|
.flxn-nav-link {
|
||||||
|
/* lifted above the indicator, or an opaque fill covers the glyph */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.flxn-nav-link svg,
|
||||||
|
.flxn-nav-link p {
|
||||||
|
transition: color 140ms ease-out;
|
||||||
|
}
|
||||||
|
.flxn-nav-link[data-active] {
|
||||||
|
color: var(--flxn-nav-accent, var(--mantine-primary-color-filled));
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.flxn-nav-link[data-active] svg {
|
||||||
|
animation: flxn-nav-pop 320ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes flxn-nav-pop {
|
||||||
|
0% { scale: 1; }
|
||||||
|
38% { scale: 1.16; }
|
||||||
|
100% { scale: 1; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.flxn-nav-indicator {
|
||||||
|
transition: opacity 120ms ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,8 +153,7 @@
|
|||||||
/* 3. Fixed backdrop (below all content) */
|
/* 3. Fixed backdrop (below all content) */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
[data-theme="ocean"] body::before {
|
[data-theme="ocean"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
@@ -163,7 +162,7 @@
|
|||||||
|
|
||||||
/* Abyssal descent: deep teal fading to ink navy, sparse static bubbles
|
/* Abyssal descent: deep teal fading to ink navy, sparse static bubbles
|
||||||
rising (bigger and brighter near the sea floor, fading toward the top). */
|
rising (bigger and brighter near the sea floor, fading toward the top). */
|
||||||
[data-theme="ocean"][data-mantine-color-scheme="dark"] body::before {
|
[data-theme="ocean"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
|
||||||
background-color: #062632;
|
background-color: #062632;
|
||||||
background-image:
|
background-image:
|
||||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='640' viewBox='0 0 260 640'%3E%3Cg fill='none' stroke='%239beee2'%3E%3Ccircle cx='36' cy='584' r='5' stroke-opacity='.13'/%3E%3Ccircle cx='44' cy='512' r='3' stroke-opacity='.1'/%3E%3Ccircle cx='210' cy='612' r='4' stroke-opacity='.12'/%3E%3Ccircle cx='202' cy='540' r='2.5' stroke-opacity='.09'/%3E%3Ccircle cx='128' cy='430' r='3.5' stroke-opacity='.1'/%3E%3Ccircle cx='120' cy='360' r='2' stroke-opacity='.08'/%3E%3Ccircle cx='232' cy='300' r='3' stroke-opacity='.09'/%3E%3Ccircle cx='60' cy='250' r='2.5' stroke-opacity='.08'/%3E%3Ccircle cx='150' cy='160' r='2' stroke-opacity='.07'/%3E%3Ccircle cx='90' cy='80' r='1.5' stroke-opacity='.06'/%3E%3C/g%3E%3Cg fill='%239beee2'%3E%3Ccircle cx='70' cy='470' r='1.2' fill-opacity='.1'/%3E%3Ccircle cx='180' cy='390' r='1' fill-opacity='.09'/%3E%3Ccircle cx='40' cy='330' r='1' fill-opacity='.08'/%3E%3Ccircle cx='222' cy='190' r='1' fill-opacity='.07'/%3E%3Ccircle cx='140' cy='40' r='1' fill-opacity='.05'/%3E%3C/g%3E%3C/svg%3E"),
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='640' viewBox='0 0 260 640'%3E%3Cg fill='none' stroke='%239beee2'%3E%3Ccircle cx='36' cy='584' r='5' stroke-opacity='.13'/%3E%3Ccircle cx='44' cy='512' r='3' stroke-opacity='.1'/%3E%3Ccircle cx='210' cy='612' r='4' stroke-opacity='.12'/%3E%3Ccircle cx='202' cy='540' r='2.5' stroke-opacity='.09'/%3E%3Ccircle cx='128' cy='430' r='3.5' stroke-opacity='.1'/%3E%3Ccircle cx='120' cy='360' r='2' stroke-opacity='.08'/%3E%3Ccircle cx='232' cy='300' r='3' stroke-opacity='.09'/%3E%3Ccircle cx='60' cy='250' r='2.5' stroke-opacity='.08'/%3E%3Ccircle cx='150' cy='160' r='2' stroke-opacity='.07'/%3E%3Ccircle cx='90' cy='80' r='1.5' stroke-opacity='.06'/%3E%3C/g%3E%3Cg fill='%239beee2'%3E%3Ccircle cx='70' cy='470' r='1.2' fill-opacity='.1'/%3E%3Ccircle cx='180' cy='390' r='1' fill-opacity='.09'/%3E%3Ccircle cx='40' cy='330' r='1' fill-opacity='.08'/%3E%3Ccircle cx='222' cy='190' r='1' fill-opacity='.07'/%3E%3Ccircle cx='140' cy='40' r='1' fill-opacity='.05'/%3E%3C/g%3E%3C/svg%3E"),
|
||||||
@@ -175,7 +174,7 @@
|
|||||||
|
|
||||||
/* Sunlit shallows: seafoam fading to a sandy floor, with a faint white
|
/* Sunlit shallows: seafoam fading to a sandy floor, with a faint white
|
||||||
caustic ripple tile — refracted light, kept very subtle. */
|
caustic ripple tile — refracted light, kept very subtle. */
|
||||||
[data-theme="ocean"][data-mantine-color-scheme="light"] body::before {
|
[data-theme="ocean"][data-mantine-color-scheme="light"] .flxn-theme-overlay {
|
||||||
background-color: #eaf6f6;
|
background-color: #eaf6f6;
|
||||||
background-image:
|
background-image:
|
||||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'%3E%3Cg fill='none' stroke='%23ffffff' stroke-width='1.4'%3E%3Cpath d='M0 40 Q 40 10 80 42 T 160 44 T 240 38 T 320 42' stroke-opacity='.4'/%3E%3Cpath d='M0 110 Q 45 82 90 112 T 180 110 T 268 114 T 320 108' stroke-opacity='.3'/%3E%3Cpath d='M0 180 Q 50 152 100 182 T 200 178 T 300 184 T 320 180' stroke-opacity='.35'/%3E%3C/g%3E%3C/svg%3E"),
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'%3E%3Cg fill='none' stroke='%23ffffff' stroke-width='1.4'%3E%3Cpath d='M0 40 Q 40 10 80 42 T 160 44 T 240 38 T 320 42' stroke-opacity='.4'/%3E%3Cpath d='M0 110 Q 45 82 90 112 T 180 110 T 268 114 T 320 108' stroke-opacity='.3'/%3E%3Cpath d='M0 180 Q 50 152 100 182 T 200 178 T 300 184 T 320 180' stroke-opacity='.35'/%3E%3C/g%3E%3C/svg%3E"),
|
||||||
|
|||||||
@@ -414,8 +414,7 @@
|
|||||||
* 3. Full-page decoration: worn parchment edges / torchlight
|
* 3. Full-page decoration: worn parchment edges / torchlight
|
||||||
* ---------------------------------------------------------------- */
|
* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
[data-theme="quest"][data-mantine-color-scheme="light"] body::before {
|
[data-theme="quest"][data-mantine-color-scheme="light"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
@@ -426,8 +425,7 @@
|
|||||||
radial-gradient(55% 38% at 95% 98%, rgba(122, 91, 42, 0.07), transparent 70%);
|
radial-gradient(55% 38% at 95% 98%, rgba(122, 91, 42, 0.07), transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="quest"][data-mantine-color-scheme="dark"] body::before {
|
[data-theme="quest"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
|
|||||||
@@ -151,8 +151,7 @@
|
|||||||
/* 3. Paper grain / chalk dust — one fixed overlay, feTurbulence */
|
/* 3. Paper grain / chalk dust — one fixed overlay, feTurbulence */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
[data-theme="sketch"] body::before {
|
[data-theme="sketch"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
@@ -163,7 +162,7 @@
|
|||||||
opacity: 0.05;
|
opacity: 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="sketch"][data-mantine-color-scheme="dark"] body::before {
|
[data-theme="sketch"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
|
||||||
mix-blend-mode: screen;
|
mix-blend-mode: screen;
|
||||||
opacity: 0.07;
|
opacity: 0.07;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,8 +575,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 4. Decoration: fixed scanline overlay + faint RGB aperture mask */
|
/* 4. Decoration: fixed scanline overlay + faint RGB aperture mask */
|
||||||
[data-theme="terminal"] body::before {
|
[data-theme="terminal"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
@@ -589,7 +588,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
[data-theme="terminal"] body::before {
|
[data-theme="terminal"] .flxn-theme-overlay {
|
||||||
animation: terminal-phosphor-drift 9s ease-in-out infinite;
|
animation: terminal-phosphor-drift 9s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes terminal-phosphor-drift {
|
@keyframes terminal-phosphor-drift {
|
||||||
|
|||||||
@@ -150,8 +150,7 @@
|
|||||||
|
|
||||||
/* ---------- 3. Decoration: static perspective floor-grid hint ---------- */
|
/* ---------- 3. Decoration: static perspective floor-grid hint ---------- */
|
||||||
|
|
||||||
[data-theme="wireframe"] body::before {
|
[data-theme="wireframe"] .flxn-theme-overlay {
|
||||||
content: "";
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: -30vw;
|
left: -30vw;
|
||||||
right: -30vw;
|
right: -30vw;
|
||||||
|
|||||||
+46
-32
@@ -5,7 +5,7 @@ import {
|
|||||||
} from "@tanstack/react-start";
|
} from "@tanstack/react-start";
|
||||||
import { getRequest } from "@tanstack/react-start/server";
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import { isAdminRole } from "@/features/core/utils/roles";
|
import { isAdminRole, isDeveloperRole } from "@/features/core/utils/roles";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
const logger = new Logger("Middleware");
|
const logger = new Logger("Middleware");
|
||||||
@@ -70,41 +70,55 @@ export const superTokensFunctionMiddleware = createMiddleware({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const superTokensAdminFunctionMiddleware = createMiddleware({
|
const createRoleFunctionMiddleware = (
|
||||||
type: "function",
|
hasRole: (roles?: string[] | null) => boolean,
|
||||||
}).server(async ({ next, serverFnMeta }) => {
|
label: string
|
||||||
const request = getRequest();
|
) =>
|
||||||
|
createMiddleware({
|
||||||
|
type: "function",
|
||||||
|
}).server(async ({ next, serverFnMeta }) => {
|
||||||
|
const request = getRequest();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await getSessionContext(request, { isServerFunction: true });
|
const context = await getSessionContext(request, { isServerFunction: true });
|
||||||
|
|
||||||
if (isAdminRole(context.roles)) {
|
if (hasRole(context.roles)) {
|
||||||
return next({ context });
|
return next({ context });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(`Unauthorized user in ${label} function.`, {
|
||||||
|
userAuthId: context.userAuthId,
|
||||||
|
roles: context.roles,
|
||||||
|
});
|
||||||
|
const { recordDeniedServerFn } = await import("./activities");
|
||||||
|
recordDeniedServerFn(request, context.metadata, serverFnMeta);
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === "SESSION_REFRESH_REQUIRED") {
|
||||||
|
throw new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "SESSION_REFRESH_REQUIRED",
|
||||||
|
message: "Session needs to be refreshed"
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
logger.error("Unauthorized user in admin function.", {
|
export const superTokensAdminFunctionMiddleware = createRoleFunctionMiddleware(
|
||||||
userAuthId: context.userAuthId,
|
isAdminRole,
|
||||||
roles: context.roles,
|
"admin"
|
||||||
});
|
);
|
||||||
const { recordDeniedServerFn } = await import("./activities");
|
|
||||||
recordDeniedServerFn(request, context.metadata, serverFnMeta);
|
export const superTokensDeveloperFunctionMiddleware = createRoleFunctionMiddleware(
|
||||||
throw new Error("Unauthorized");
|
isDeveloperRole,
|
||||||
} catch (error: any) {
|
"developer"
|
||||||
if (error.message === "SESSION_REFRESH_REQUIRED") {
|
);
|
||||||
throw new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "SESSION_REFRESH_REQUIRED",
|
|
||||||
message: "Session needs to be refreshed"
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setUserMetadata = createServerFn({ method: "POST" })
|
export const setUserMetadata = createServerFn({ method: "POST" })
|
||||||
.validator(
|
.validator(
|
||||||
|
|||||||
Reference in New Issue
Block a user