Merge branch 'development' into player-picker-single-sele

# Conflicts:
#	src/locales/de/messages.po
#	src/locales/en/messages.po
#	src/locales/es/messages.po
#	src/locales/ja/messages.po
This commit is contained in:
2026-08-23 17:59:55 -07:00
81 changed files with 13127 additions and 286 deletions
@@ -208,8 +208,8 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
style={{
width: '100px',
height: '100px',
borderRadius: '12px',
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
borderRadius: 'var(--flxn-badge-radius)',
background: 'var(--flxn-showcase-bg)',
border: '2px solid var(--mantine-primary-color-5)',
position: 'absolute',
top: `${(i + 1) * 4}px`,
@@ -226,8 +226,8 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
style={{
width: '100px',
height: '100px',
borderRadius: '12px',
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
borderRadius: 'var(--flxn-badge-radius)',
background: 'var(--flxn-showcase-bg)',
border: display.earned
? '2px solid var(--mantine-primary-color-6)'
: '2px dashed var(--mantine-primary-color-4)',
@@ -9,7 +9,7 @@ export function BracketPending() {
p={0}
style={{
overflow: "hidden",
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundImage: `radial-gradient(circle, var(--flxn-dotgrid-color) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
minHeight: "70dvh",
@@ -89,7 +89,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
h={`calc(${height})`}
className={styles["bracket-container"]}
style={{
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundImage: `radial-gradient(circle, var(--flxn-dotgrid-color) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
}}
@@ -269,7 +269,11 @@ export const MatchCard: React.FC<MatchCardProps> = ({
w={showToolbar || showEditButton ? 200 : 220}
withBorder
pos="relative"
className={canTap ? styles["tappable-card"] : undefined}
className={
canTap
? `flxn-bracket-card ${styles["tappable-card"]}`
: "flxn-bracket-card"
}
onClick={canTap ? () => onTap!(match) : undefined}
role={canTap ? "button" : undefined}
tabIndex={canTap ? 0 : undefined}
@@ -83,10 +83,10 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
size={14}
weight="fill"
style={{
color: 'gold',
color: 'var(--flxn-gold)',
marginLeft: '2px',
marginTop: '-1px',
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
filter: 'drop-shadow(0 1px 1px var(--flxn-pop-shadow))',
flexShrink: 0
}}
/>
@@ -52,6 +52,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
py="md"
withBorder
radius="md"
className="flxn-match-card"
style={{ position: "relative", zIndex: 2 }}
>
<Stack gap="sm">
@@ -208,16 +209,12 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
<Paper
px="md"
pb={4}
className="flxn-reaction-row"
style={{
position: "relative",
zIndex: 1,
marginTop: -6,
paddingTop: 8,
backgroundColor: "var(--mantine-color-gray-light)",
borderTop: "none",
borderRadius:
"0 0 var(--mantine-radius-md) var(--mantine-radius-md)",
border: "1px solid var(--mantine-color-default-border)",
}}
>
<Suspense>
@@ -130,7 +130,7 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
{leader && (
<Group justify="center" gap="xs">
<CrownIcon size={16} weight="fill" color="gold" />
<CrownIcon size={16} weight="fill" color="var(--flxn-gold)" />
<Text size="xs" c="dimmed">
<Trans>{leader.name} leads the series</Trans>
</Text>
@@ -158,7 +158,7 @@ const PlayerHeadToHeadContent = ({
{leader && (
<Group justify="center" gap="xs">
<CrownIcon size={16} weight="fill" color="gold" />
<CrownIcon size={16} weight="fill" color="var(--flxn-gold)" />
<Text size="xs" c="dimmed">
<Trans>{leader} leads the series</Trans>
</Text>
@@ -1,4 +1,4 @@
import { Box, Stack, Text, Divider, Group, Button, Anchor } from "@mantine/core";
import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
import { Suspense, useState, useDeferredValue } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
@@ -57,9 +57,14 @@ const Profile = ({ id }: ProfileProps) => {
<Stack px="md">
<Group justify="space-between" align="center">
<Text size="md" fw={700}><Trans>Badges</Trans></Text>
<Anchor component={Link} to="/badges" size="sm" fw={500}>
<Button
component={Link}
to="/badges"
variant="subtle"
size="compact-sm"
>
<Trans>View all badges</Trans>
</Anchor>
</Button>
</Group>
<Suspense fallback={<BadgeShowcaseSkeleton />}>
<BadgeShowcase playerId={id} />
@@ -244,7 +244,7 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
weight="fill"
color={
entry.championCorrect && isComplete
? "gold"
? "var(--flxn-gold)"
: "var(--mantine-color-dimmed)"
}
/>
@@ -79,8 +79,8 @@ const TeamChip = ({
size={14}
weight="fill"
style={{
color: "gold",
filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.3))",
color: "var(--flxn-gold)",
filter: "drop-shadow(0 1px 1px var(--flxn-pop-shadow))",
flexShrink: 0,
}}
/>
@@ -0,0 +1,111 @@
import {
Box,
SimpleGrid,
Stack,
Text,
UnstyledButton,
useComputedColorScheme,
} from "@mantine/core";
import { CaretRightIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import { updateUserTheme } from "@/features/settings/server";
import { useAuth } from "@/contexts/auth-context";
import { DEFAULT_THEME_ID, THEME_LIST, getTheme } from "@/themes/registry";
import ThemePreview from "./theme-preview";
const ThemePicker = () => {
const { metadata, user, set } = useAuth();
const { t } = useLingui();
const sheet = useSheet();
const scheme = useComputedColorScheme("dark");
const currentId = metadata.theme || DEFAULT_THEME_ID;
const current = getTheme(currentId);
const handleSelect = async (id: string) => {
if (!user || id === currentId) return;
const previous = metadata;
set({ metadata: { ...metadata, theme: id } });
try {
await updateUserTheme({ data: id });
} catch {
set({ metadata: previous });
}
};
return (
<Box>
<Text fw={500} size="sm" mb="xs">
<Trans>Theme</Trans>
</Text>
<UnstyledButton
onClick={sheet.open}
w="100%"
style={{
display: "flex",
alignItems: "center",
gap: "var(--mantine-spacing-sm)",
padding: "var(--mantine-spacing-xs)",
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Box w="3.25rem" style={{ flexShrink: 0 }}>
<ThemePreview
manifest={current}
scheme={scheme}
height="2.25rem"
scale={0.5}
/>
</Box>
<Text size="sm" fw={500} style={{ flex: 1, textAlign: "left" }}>
{t(current.label)}
</Text>
<CaretRightIcon size={16} color="var(--mantine-color-dimmed)" />
</UnstyledButton>
<Sheet title={t`Theme`} {...sheet.props}>
<SimpleGrid cols={3} spacing="sm" py="sm">
{[...THEME_LIST]
.sort(
(a, b) =>
(a.order ?? 100) - (b.order ?? 100) ||
t(a.label).localeCompare(t(b.label))
)
.map((manifest) => {
const isSelected = manifest.id === currentId;
return (
<UnstyledButton
key={manifest.id}
onClick={() => handleSelect(manifest.id)}
>
<Stack gap={6} align="center">
<Box
w="100%"
p="0.125rem"
bd={
isSelected
? "0.125rem solid var(--mantine-color-bright)"
: "0.125rem solid transparent"
}
style={{
borderRadius: "calc(var(--mantine-radius-md) + 0.25rem)",
}}
>
<ThemePreview manifest={manifest} scheme={scheme} />
</Box>
<Text size="xs" fw={isSelected ? 700 : 400} ta="center">
{t(manifest.label)}
</Text>
</Stack>
</UnstyledButton>
);
})}
</SimpleGrid>
</Sheet>
</Box>
);
};
export default ThemePicker;
@@ -0,0 +1,179 @@
import {
DEFAULT_THEME,
convertCssVariables,
defaultCssVariablesResolver,
mergeMantineTheme,
mergeThemeOverrides,
} from "@mantine/core";
import { useEffect, useMemo, useState } from "react";
import { useLingui } from "@lingui/react/macro";
import { useAuth } from "@/contexts/auth-context";
import { baseTheme } from "@/lib/mantine/base-theme";
import type { ThemeManifest } from "@/themes/types";
/* Each preview is a same-origin srcdoc iframe carrying the theme's own
data-theme/-scheme, the app's stylesheets, the theme's font links, and its
Mantine variables — 1:1 with the active theme, with nothing bleeding in. */
const buildMantineVarsCss = (manifest: ThemeManifest, accent: string) => {
const merged = mergeMantineTheme(
DEFAULT_THEME,
mergeThemeOverrides(baseTheme, manifest.mantine ?? {}, {
primaryColor: accent,
})
);
return convertCssVariables(defaultCssVariablesResolver(merged), ":root");
};
const collectAppCss = () =>
Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
.filter(
(el) =>
!el.hasAttribute("data-mantine-styles") &&
!el.hasAttribute("data-flxn-theme-font")
)
.map((el) =>
el.tagName === "LINK"
? el.outerHTML
: `<style>${el.textContent ?? ""}</style>`
)
.join("\n");
const PREVIEW_LOCAL_CSS = `
html, body { margin: 0; height: 100%; overflow: hidden; }
body { display: flex; }
.pv-card {
flex: 1;
margin: 0.375rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
background-color: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-default);
box-shadow: var(--mantine-shadow-sm);
}
.pv-heading {
font-family: var(--mantine-font-family-headings);
color: var(--mantine-color-text);
font-size: 0.875rem;
font-weight: 700;
line-height: 1;
}
.pv-row { display: flex; align-items: center; gap: 4px; }
.pv-chip {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: var(--mantine-radius-default);
background-color: var(--mantine-primary-color-filled);
}
.pv-bars { display: flex; flex-direction: column; gap: 3px; flex: 1; }
.pv-bar {
display: block;
height: 4px;
border-radius: 2px;
background-color: var(--mantine-color-dimmed);
}
`;
const buildSrcdoc = (
manifest: ThemeManifest,
scheme: "light" | "dark",
accent: string,
appCss: string
) => {
const fontLinks = (manifest.fontLinks ?? [])
.map((href) => `<link rel="stylesheet" href="${href}">`)
.join("");
return `<!doctype html>
<html data-theme="${manifest.id}" data-mantine-color-scheme="${scheme}">
<head>
<meta charset="utf-8">
<style>html { color-scheme: ${scheme}; }</style>
<style>${buildMantineVarsCss(manifest, accent)}</style>
${appCss}
${fontLinks}
<style>${PREVIEW_LOCAL_CSS}</style>
</head>
<body>
<div class="pv-card">
<span class="pv-heading">Ag</span>
<div class="pv-row">
<span class="pv-chip"></span>
<span class="pv-bars">
<span class="pv-bar" style="opacity: 0.7"></span>
<span class="pv-bar" style="opacity: 0.4; width: 60%"></span>
</span>
</div>
</div>
</body>
</html>`;
};
interface ThemePreviewProps {
manifest: ThemeManifest;
scheme: "light" | "dark";
height?: string;
/** Renders the document at height/scale and scales it down visually. */
scale?: number;
}
const ThemePreview = ({
manifest,
scheme,
height = "4.5rem",
scale = 1,
}: ThemePreviewProps) => {
const { metadata } = useAuth();
const { t } = useLingui();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const previewScheme =
manifest.colorSchemes === "both" ? scheme : manifest.colorSchemes;
const accent = metadata.accentColor || "blue";
const srcdoc = useMemo(
() =>
mounted
? buildSrcdoc(manifest, previewScheme, accent, collectAppCss())
: null,
[mounted, manifest, previewScheme, accent]
);
const frameStyle: React.CSSProperties = {
width: "100%",
height,
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-md)",
overflow: "hidden",
};
if (!srcdoc) return <div style={frameStyle} />;
return (
<div style={frameStyle}>
<iframe
srcDoc={srcdoc}
title={t(manifest.label)}
sandbox=""
scrolling="no"
aria-hidden
tabIndex={-1}
style={{
display: "block",
border: 0,
width: `${100 / scale}%`,
height: `calc(${height} / ${scale})`,
transform: `scale(${scale})`,
transformOrigin: "top left",
pointerEvents: "none",
}}
/>
</div>
);
};
export default ThemePreview;
+45
View File
@@ -2,12 +2,28 @@ import { createServerFn } from "@tanstack/react-start";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
// Whitelists: these values flow into Mantine's primaryColor / the
// data-mantine-color-scheme attribute — an unknown value crash-loops or
// unstyles the account's UI on every load.
const ACCENT_COLORS = [
"blue",
"red",
"green",
"yellow",
"grape",
"orange",
"pink",
"lime",
];
const COLOR_SCHEMES = ["light", "dark", "auto"];
export const updateUserColorScheme = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
if (!COLOR_SCHEMES.includes(data)) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
@@ -51,12 +67,41 @@ export const updateUserLocale = createServerFn({ method: "POST" })
};
});
export const updateUserTheme = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
const { THEME_IDS } = await import("@/themes/registry");
if (!THEME_IDS.includes(data)) {
throw new Error("Unknown theme");
}
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
);
await updateUserMetadataFields(userAuthId, {
theme: data,
});
return {
metadata: {
...metadata,
theme: data,
},
};
});
export const updateUserAccentColor = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
if (!ACCENT_COLORS.includes(data)) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
+9 -18
View File
@@ -14,26 +14,20 @@ type PodiumTier = "first" | "second" | "third";
const tierStyles: Record<PodiumTier, React.CSSProperties> = {
first: {
background:
"light-dark(color-mix(in srgb, var(--mantine-color-yellow-4) 10%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-yellow-8) 16%, var(--mantine-color-dark-6)))",
border:
"1px solid light-dark(color-mix(in srgb, var(--mantine-color-yellow-6) 45%, var(--mantine-color-gray-3)), color-mix(in srgb, var(--mantine-color-yellow-6) 35%, var(--mantine-color-dark-4)))",
background: "var(--flxn-podium-1-bg)",
border: "1px solid var(--flxn-podium-1-border)",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-md)",
},
second: {
background:
"light-dark(color-mix(in srgb, var(--mantine-color-gray-5) 10%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-gray-5) 10%, var(--mantine-color-dark-6)))",
border:
"1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
background: "var(--flxn-podium-2-bg)",
border: "1px solid var(--flxn-podium-2-border)",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-xs)",
},
third: {
background:
"light-dark(color-mix(in srgb, var(--mantine-color-orange-5) 8%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-orange-9) 14%, var(--mantine-color-dark-6)))",
border:
"1px solid light-dark(color-mix(in srgb, var(--mantine-color-orange-6) 35%, var(--mantine-color-gray-3)), color-mix(in srgb, var(--mantine-color-orange-7) 30%, var(--mantine-color-dark-4)))",
background: "var(--flxn-podium-3-bg)",
border: "1px solid var(--flxn-podium-3-border)",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-xs)",
},
@@ -54,8 +48,7 @@ const tierConfig: Record<
first: {
color: "yellow",
label: msg`Champions`,
labelColor:
"light-dark(var(--mantine-color-yellow-8), var(--mantine-color-yellow-4))",
labelColor: "var(--flxn-podium-1-label)",
icon: <CrownIcon size={24} />,
iconSize: "xl",
padding: "md",
@@ -64,8 +57,7 @@ const tierConfig: Record<
second: {
color: "gray",
label: msg`2nd place`,
labelColor:
"light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
labelColor: "var(--flxn-podium-2-label)",
icon: <MedalIcon size={20} />,
iconSize: "lg",
padding: "xs",
@@ -74,8 +66,7 @@ const tierConfig: Record<
third: {
color: "orange",
label: msg`3rd place`,
labelColor:
"light-dark(var(--mantine-color-orange-8), var(--mantine-color-orange-4))",
labelColor: "var(--flxn-podium-3-label)",
icon: <MedalIcon size={18} />,
iconSize: "lg",
padding: "xs",
@@ -320,7 +320,7 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
</Text>
<Box
style={{
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundImage: `radial-gradient(circle, var(--flxn-dotgrid-color) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
padding: "1rem",
@@ -11,6 +11,9 @@
height: 6px;
border-radius: 3px;
background-color: var(--mantine-color-default-border);
/* Mantine ships box-shadow: var(--mantine-shadow-sm) on indicators;
themes with hard offset shadow tokens render it as a doubled dot. */
box-shadow: none;
}
.indicator.indicator[data-active] {
@@ -39,8 +39,12 @@ interface NavTileProps {
/** A single square-ish nav tile with icon-over-label and press feedback. */
export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => (
<UnstyledButton className={classes.tile} onClick={onClick} disabled={disabled}>
<span className={classes.icon}>
<UnstyledButton
className={`${classes.tile} flxn-tile`}
onClick={onClick}
disabled={disabled}
>
<span className={`${classes.icon} flxn-tile-icon`}>
<Icon size={22} weight="bold" />
</span>
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>