slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 33s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m27s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s

This commit is contained in:
2026-09-10 22:21:16 -07:00
parent 8971748d5c
commit c4a9d47180
20 changed files with 232 additions and 73 deletions
+18
View File
@@ -3,6 +3,8 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import RoutePending from "../components/route-pending";
import { headerChange, navDirection } from "@/features/core/utils/nav-direction";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
export function getRouter() {
@@ -30,6 +32,15 @@ export function getRouter() {
},
defaultPreload: "intent",
defaultPreloadStaleTime: 60_000,
defaultPendingMs: 100,
defaultPendingMinMs: 0,
defaultViewTransition: {
types: ({ fromLocation, toLocation }) => {
const direction = navDirection(fromLocation, toLocation);
return direction === "none" ? false : [direction];
},
},
defaultPendingComponent: RoutePending,
defaultErrorComponent: DefaultCatchBoundary,
});
@@ -38,6 +49,13 @@ export function getRouter() {
queryClient
})
if (typeof document !== "undefined") {
router.subscribe("onBeforeNavigate", ({ fromLocation, toLocation }) => {
document.documentElement.dataset.navDirection = navDirection(fromLocation, toLocation);
document.documentElement.dataset.headerVt = headerChange(fromLocation, toLocation);
});
}
return router;
}
+5 -7
View File
@@ -1,7 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
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 { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
@@ -10,16 +11,13 @@ export const Route = createFileRoute("/_authed/")({
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home />
</Suspense>,
beforeLoad: async ({ context }) => {
const queryClient = context.queryClient;
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
return { tournament }
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, tournamentQueries.current());
},
loader: ({ context }) => ({
withPadding: false,
header: {
title: context.tournament.name || "FLXN"
title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN"
}
}),
pendingComponent: () => <UpcomingTournamentSkeleton />
+5 -19
View File
@@ -14,26 +14,12 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => {
beforeLoad: ({ params, context }) => {
const { queryClient } = context;
await Promise.all([
prefetchServerQuery(
queryClient,
playerQueries.details(params.playerId)
),
prefetchServerQuery(
queryClient,
playerQueries.matches(params.playerId)
),
prefetchServerQuery(
queryClient,
playerQueries.stats(params.playerId)
),
prefetchServerQuery(
queryClient,
badgeQueries.playerBadges(params.playerId)
),
]);
prefetchServerQuery(queryClient, playerQueries.details(params.playerId));
prefetchServerQuery(queryClient, playerQueries.matches(params.playerId));
prefetchServerQuery(queryClient, playerQueries.stats(params.playerId));
prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId));
},
loader: ({ params, context }) => ({
header: {
+2 -2
View File
@@ -12,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => {
beforeLoad: ({ params, context }) => {
const { queryClient } = context;
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
},
loader: ({ params }) => ({
header: {
+17
View File
@@ -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;
+7 -2
View File
@@ -6,7 +6,7 @@ import {
ReactNode,
useRef,
useCallback,
useMemo,
startTransition,
} from "react";
import { useRouter } from "@tanstack/react-router";
@@ -48,6 +48,11 @@ function SwipeableTabs({
}, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [mountAll, setMountAll] = useState(false);
useEffect(() => {
const timeoutId = setTimeout(() => startTransition(() => setMountAll(true)), 400);
return () => clearTimeout(timeoutId);
}, []);
const [stuck, setStuck] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
@@ -225,7 +230,7 @@ function SwipeableTabs({
{tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
{tab.content}
{(mountAll || index === activeTab) ? tab.content : null}
</Box>
</Carousel.Slide>
))}
@@ -49,6 +49,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
width={size}
height={size}
loading="lazy"
decoding="async"
onError={() => setImageError(true)}
style={{
objectFit: 'contain',
+1
View File
@@ -60,6 +60,7 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
alignItems: 'center',
justifyContent: 'center',
paddingTop: 'env(safe-area-inset-top, 0px)',
viewTransitionName: 'app-header',
}}
>
{ withBackButton && <BackButton /> }
+18 -10
View File
@@ -1,23 +1,22 @@
import { AppShell, Box } from '@mantine/core';
import { PropsWithChildren } from 'react';
import { useLocation } from '@tanstack/react-router';
import Header from './header';
import Navbar from './navbar';
import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page';
import './route-transition.css';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header, withPadding, fullWidth } = useRouterConfig();
const viewport = useVisualViewportSize();
const location = useLocation();
return (
<AppShell
id='app-shell'
layout='alt'
// must snap: view transitions capture AppShell's collapse mid-animation
transitionDuration={0}
header={{
// Old installs froze black-translucent at add-to-home-screen time, so
// the webview can extend under the status bar; inset is 0 elsewhere.
@@ -48,18 +47,27 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative'
h='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 }}
maw='100dvw'
style={{ overflow: 'hidden' }}
>
<Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}>
<Box key={location.pathname} className="flxn-route-enter">
{/* pane box must be opaque and route-invariant or slides drift */}
<Box
style={{
viewTransitionName: 'page',
backgroundColor: 'var(--mantine-color-body)',
marginTop: 'calc(-1 * var(--app-shell-header-offset, 0px))',
paddingTop: 'var(--app-shell-header-offset, 0px)',
height: 'calc(100% + var(--app-shell-header-offset, 0px))',
}}
>
<Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}>
{children}
</Box>
</Page>
</Pullable>
</Page>
</Pullable>
</Box>
</AppShell.Main>
<Navbar />
<AppShell.Aside withBorder />
@@ -11,11 +11,6 @@
-webkit-tap-highlight-color: transparent;
}
.navLinkBox svg,
.navLinkBox p {
transition: color 150ms ease-out;
}
@media (prefers-reduced-motion: no-preference) {
.navLinkBox {
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
@@ -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;
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { ParsedLocation } from "@tanstack/react-router";
export type NavDirection = "forward" | "back" | "none";
const tabRank = (pathname: string): number | null => {
if (pathname === "/") return 0;
if (pathname.startsWith("/stats")) return 1;
if (pathname.startsWith("/tournaments")) return 2;
if (pathname.startsWith("/profile") || pathname.startsWith("/settings")) return 3;
if (pathname.startsWith("/admin")) return 4;
return null;
};
const depth = (pathname: string) => pathname.split("/").filter(Boolean).length;
// Keep in sync with route loaders that set header.collapsed
const headerHidden = (pathname: string) => {
const segs = pathname.split("/").filter(Boolean);
return segs.length === 2 && ["profile", "teams", "tournaments"].includes(segs[0]);
};
export function headerChange(
from: ParsedLocation | undefined,
to: ParsedLocation
): "exit" | "enter" | "none" {
if (!from) return "none";
const fromHidden = headerHidden(from.pathname);
const toHidden = headerHidden(to.pathname);
if (fromHidden === toHidden) return "none";
return toHidden ? "exit" : "enter";
}
export function navDirection(
from: ParsedLocation | undefined,
to: ParsedLocation
): NavDirection {
if (!from || from.pathname === to.pathname) return "none";
const fromRank = tabRank(from.pathname);
const toRank = tabRank(to.pathname);
if (fromRank !== null && toRank !== null && fromRank !== toRank) {
return toRank > fromRank ? "forward" : "back";
}
const fromIndex = from.state.__TSR_index;
const toIndex = to.state.__TSR_index;
if (Number.isFinite(fromIndex) && Number.isFinite(toIndex) && fromIndex !== toIndex) {
return toIndex > fromIndex ? "forward" : "back";
}
return depth(to.pathname) >= depth(from.pathname) ? "forward" : "back";
}
+10
View File
@@ -1,6 +1,16 @@
import { QueryClient, QueryKey } from "@tanstack/react-query";
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>(
queryClient: QueryClient,
query: {
+1 -1
View File
@@ -2091,7 +2091,7 @@ msgstr "Vorheriger Titel"
msgid "Profile"
msgstr "Profil"
#: src/features/badges/components/badge-showcase.tsx:301
#: src/features/badges/components/badge-showcase.tsx:302
msgid "Progress "
msgstr "Fortschritt "
+1 -1
View File
@@ -2091,7 +2091,7 @@ msgstr "Previous track"
msgid "Profile"
msgstr "Profile"
#: src/features/badges/components/badge-showcase.tsx:301
#: src/features/badges/components/badge-showcase.tsx:302
msgid "Progress "
msgstr "Progress "
+1 -1
View File
@@ -2091,7 +2091,7 @@ msgstr "Pista anterior"
msgid "Profile"
msgstr "Perfil"
#: src/features/badges/components/badge-showcase.tsx:301
#: src/features/badges/components/badge-showcase.tsx:302
msgid "Progress "
msgstr "Progreso "
+1 -1
View File
@@ -2091,7 +2091,7 @@ msgstr "前のトラック"
msgid "Profile"
msgstr "プロフィール"
#: src/features/badges/components/badge-showcase.tsx:301
#: src/features/badges/components/badge-showcase.tsx:302
msgid "Progress "
msgstr "進行状況 "