social overhaul

This commit is contained in:
yohlo
2026-07-22 15:47:34 -07:00
parent 9db3fa697d
commit 86cda294f9
45 changed files with 3026 additions and 430 deletions
+63
View File
@@ -29,6 +29,9 @@ import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resum
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
import { Route as ApiPushUnsubscribeRouteImport } from './routes/api/push/unsubscribe'
import { Route as ApiPushTestRouteImport } from './routes/api/push/test'
import { Route as ApiPushSubscribeRouteImport } from './routes/api/push/subscribe'
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
@@ -149,6 +152,21 @@ const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
path: '/api/spotify/callback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPushUnsubscribeRoute = ApiPushUnsubscribeRouteImport.update({
id: '/api/push/unsubscribe',
path: '/api/push/unsubscribe',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPushTestRoute = ApiPushTestRouteImport.update({
id: '/api/push/test',
path: '/api/push/test',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPushSubscribeRoute = ApiPushSubscribeRouteImport.update({
id: '/api/push/subscribe',
path: '/api/push/subscribe',
getParentRoute: () => rootRouteImport,
} as any)
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
@@ -275,6 +293,9 @@ export interface FileRoutesByFullPath {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/push/subscribe': typeof ApiPushSubscribeRoute
'/api/push/test': typeof ApiPushTestRoute
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -314,6 +335,9 @@ export interface FileRoutesByTo {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/push/subscribe': typeof ApiPushSubscribeRoute
'/api/push/test': typeof ApiPushTestRoute
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -356,6 +380,9 @@ export interface FileRoutesById {
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/push/subscribe': typeof ApiPushSubscribeRoute
'/api/push/test': typeof ApiPushTestRoute
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -398,6 +425,9 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -437,6 +467,9 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -478,6 +511,9 @@ export interface FileRouteTypes {
| '/_authed/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
@@ -509,6 +545,9 @@ export interface RootRouteChildren {
ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
ApiPushSubscribeRoute: typeof ApiPushSubscribeRoute
ApiPushTestRoute: typeof ApiPushTestRoute
ApiPushUnsubscribeRoute: typeof ApiPushUnsubscribeRoute
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
@@ -662,6 +701,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/push/unsubscribe': {
id: '/api/push/unsubscribe'
path: '/api/push/unsubscribe'
fullPath: '/api/push/unsubscribe'
preLoaderRoute: typeof ApiPushUnsubscribeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/push/test': {
id: '/api/push/test'
path: '/api/push/test'
fullPath: '/api/push/test'
preLoaderRoute: typeof ApiPushTestRouteImport
parentRoute: typeof rootRouteImport
}
'/api/push/subscribe': {
id: '/api/push/subscribe'
path: '/api/push/subscribe'
fullPath: '/api/push/subscribe'
preLoaderRoute: typeof ApiPushSubscribeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
@@ -874,6 +934,9 @@ const rootRouteChildren: RootRouteChildren = {
ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute,
ApiPushSubscribeRoute: ApiPushSubscribeRoute,
ApiPushTestRoute: ApiPushTestRoute,
ApiPushUnsubscribeRoute: ApiPushUnsubscribeRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
+3 -3
View File
@@ -44,10 +44,10 @@ export const Route = createRootRouteWithContext<{
content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
},
{ name: 'description', content: 'Amicus meus madidus' },
{ name: 'description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
{ property: 'og:title', content: 'FLXN' },
{ property: 'og:description', content: 'Amicus meus madidus' },
{ property: 'og:description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
{ property: 'og:url', content: 'https://flexxon.app' },
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'FLXN' },
@@ -58,7 +58,7 @@ export const Route = createRootRouteWithContext<{
{ property: 'og:locale', content: 'en_US' },
{ name: 'twitter:card', content: 'summary' },
{ name: 'twitter:title', content: 'FLXN' },
{ name: 'twitter:description', content: 'Amicus meus madidus' },
{ name: 'twitter:description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
{ name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
@@ -97,6 +97,16 @@ function RouteComponent() {
) || false;
}, [tournament.matches]);
const nextUpMatchId = useMemo(() => {
const ready = (tournament.matches ?? [])
.filter(
(match) =>
match.status === "ready" && match.home && match.away && !match.bye
)
.sort((a, b) => a.order - b.order);
return ready[0]?.id;
}, [tournament.matches]);
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
@@ -146,11 +156,12 @@ function RouteComponent() {
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
nextUpMatchId={nextUpMatchId}
/>
<Divider />
<div>
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
</div>
</Stack>
) : hasGroupStage ? (
@@ -162,9 +173,10 @@ function RouteComponent() {
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
nextUpMatchId={nextUpMatchId}
/>
) : (
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
)
) : (
tournament.regional === true ? (
+2
View File
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { Box, Title, Stack } from "@mantine/core";
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
import { NotificationsSection } from "@/features/settings/components/notifications-section";
import { SignOutIcon } from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
@@ -32,6 +33,7 @@ function RouteComponent() {
<ColorSchemePicker />
</Stack>
</Box>
<NotificationsSection />
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
</>
);
+52
View File
@@ -0,0 +1,52 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
export const Route = createFileRoute("/api/push/subscribe")({
server: {
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const player = (context as any).player;
if (!player) return json({ error: "No player for session" }, 401);
let payload: any;
try {
payload = await request.json();
} catch {
return json({ error: "Invalid JSON" }, 400);
}
const sub = payload?.subscription ?? payload;
const endpoint: string | undefined = sub?.endpoint;
const p256dh: string | undefined = sub?.keys?.p256dh;
const auth: string | undefined = sub?.keys?.auth;
if (!endpoint || !p256dh || !auth) {
return json({ error: "Invalid subscription" }, 400);
}
try {
await pbAdmin.upsertPushSubscription({
player: player.id,
endpoint,
p256dh,
auth,
user_agent: request.headers.get("user-agent") ?? undefined,
});
return json({ ok: true });
} catch (error) {
logger.error("Push | subscribe failed", error);
return json({ error: "Failed to store subscription" }, 500);
}
},
},
},
});
+40
View File
@@ -0,0 +1,40 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { sendPushToPlayer } from "@/lib/push";
import { isPushConfigured } from "@/lib/config";
import { logger } from "@/lib/logger";
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
export const Route = createFileRoute("/api/push/test")({
server: {
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ context }) => {
const player = (context as any).player;
if (!player) return json({ error: "No player for session" }, 401);
if (!isPushConfigured()) {
return json({ error: "Push is not configured on the server" }, 503);
}
try {
const result = await sendPushToPlayer(player.id, {
title: "Flexxon",
body: "Test notification — push is working on this device.",
url: "/settings",
tag: "flexxon-test",
});
return json({ ok: true, ...result });
} catch (error) {
logger.error("Push | test send failed", error);
return json({ error: "Failed to send test notification" }, 500);
}
},
},
},
});
+41
View File
@@ -0,0 +1,41 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
export const Route = createFileRoute("/api/push/unsubscribe")({
server: {
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const player = (context as any).player;
if (!player) return json({ error: "No player for session" }, 401);
let payload: any;
try {
payload = await request.json();
} catch {
return json({ error: "Invalid JSON" }, 400);
}
const endpoint: string | undefined =
payload?.endpoint ?? payload?.subscription?.endpoint;
if (!endpoint) return json({ error: "Missing endpoint" }, 400);
try {
await pbAdmin.deletePushSubscriptionByEndpoint(endpoint);
return json({ ok: true });
} catch (error) {
logger.error("Push | unsubscribe failed", error);
return json({ error: "Failed to remove subscription" }, 500);
}
},
},
},
});