social overhaul
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user