Development #26
@@ -4,7 +4,6 @@ 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() {
|
||||
@@ -34,12 +33,6 @@ export function getRouter() {
|
||||
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,
|
||||
});
|
||||
@@ -49,13 +42,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
+1
-108
@@ -50,120 +50,13 @@ textarea,
|
||||
transform: scale(0.90) translateY(-4px);
|
||||
}
|
||||
|
||||
/* keep the page tappable mid-transition (rapid tab switching) */
|
||||
::view-transition {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* theme overlays must be this element, not body::before, or they blink off
|
||||
during transitions */
|
||||
/* themes paint their full-page texture through this element */
|
||||
.flxn-theme-overlay {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
view-transition-name: theme-overlay;
|
||||
}
|
||||
|
||||
/* pinned states, not 0ms durations: Safari skips zero-duration VT animations
|
||||
and blends both snapshots */
|
||||
::view-transition-old(root),
|
||||
::view-transition-old(app-header),
|
||||
::view-transition-old(theme-overlay) {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
::view-transition-new(root),
|
||||
::view-transition-new(app-header),
|
||||
::view-transition-new(theme-overlay) {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-image-pair(root),
|
||||
::view-transition-image-pair(app-header),
|
||||
::view-transition-image-pair(theme-overlay),
|
||||
::view-transition-image-pair(page) {
|
||||
isolation: auto;
|
||||
}
|
||||
|
||||
::view-transition-old(page),
|
||||
::view-transition-new(page) {
|
||||
animation-duration: 180ms;
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
html[data-nav-direction="forward"]::view-transition-old(page) {
|
||||
animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-left;
|
||||
}
|
||||
html[data-nav-direction="forward"]::view-transition-new(page) {
|
||||
animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right;
|
||||
}
|
||||
html[data-nav-direction="back"]::view-transition-old(page) {
|
||||
animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-right;
|
||||
}
|
||||
html[data-nav-direction="back"]::view-transition-new(page) {
|
||||
animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left;
|
||||
}
|
||||
|
||||
html[data-nav-direction="forward"][data-header-vt="exit"]::view-transition-old(app-header) {
|
||||
animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-left;
|
||||
opacity: 1;
|
||||
}
|
||||
html[data-nav-direction="back"][data-header-vt="exit"]::view-transition-old(app-header) {
|
||||
animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-right;
|
||||
opacity: 1;
|
||||
}
|
||||
html[data-nav-direction="forward"][data-header-vt="enter"]::view-transition-new(app-header) {
|
||||
animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right;
|
||||
}
|
||||
html[data-nav-direction="back"][data-header-vt="enter"]::view-transition-new(app-header) {
|
||||
animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left;
|
||||
}
|
||||
|
||||
@keyframes flxn-page-out-left {
|
||||
to { transform: translateX(-100%); }
|
||||
}
|
||||
@keyframes flxn-page-in-right {
|
||||
from { transform: translateX(100%); }
|
||||
}
|
||||
@keyframes flxn-page-out-right {
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
@keyframes flxn-page-in-left {
|
||||
from { transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
@keyframes flxn-vt-fade-out {
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes flxn-vt-fade-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*),
|
||||
::view-transition-image-pair(*) {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
::view-transition-old(*) {
|
||||
animation: 120ms ease both flxn-vt-fade-out !important;
|
||||
z-index: auto !important;
|
||||
}
|
||||
|
||||
::view-transition-new(*) {
|
||||
animation: 120ms ease both flxn-vt-fade-in !important;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-old(app-header),
|
||||
::view-transition-old(theme-overlay),
|
||||
::view-transition-new(root),
|
||||
::view-transition-new(app-header),
|
||||
::view-transition-new(theme-overlay) {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.app,
|
||||
[data-drawer-level] {
|
||||
transition: none;
|
||||
|
||||
@@ -60,7 +60,6 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
viewTransitionName: 'app-header',
|
||||
}}
|
||||
>
|
||||
{ withBackButton && <BackButton /> }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppShell, Box } from '@mantine/core';
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import Header from './header';
|
||||
import Navbar from './navbar';
|
||||
@@ -15,7 +15,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
<AppShell
|
||||
id='app-shell'
|
||||
layout='alt'
|
||||
// must snap: view transitions capture AppShell's collapse mid-animation
|
||||
// 0 = header collapse is instant; Mantine's default animates main's padding
|
||||
transitionDuration={0}
|
||||
header={{
|
||||
// Old installs froze black-translucent at add-to-home-screen time, so
|
||||
@@ -52,21 +52,11 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
maw='100dvw'
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{/* pane box must be route-invariant or slides drift */}
|
||||
<Box
|
||||
style={{
|
||||
viewTransitionName: 'page',
|
||||
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}
|
||||
</Page>
|
||||
</Pullable>
|
||||
</Box>
|
||||
<Pullable>
|
||||
<Page noPadding={!withPadding} fullWidth={fullWidth}>
|
||||
{children}
|
||||
</Page>
|
||||
</Pullable>
|
||||
</AppShell.Main>
|
||||
<Navbar />
|
||||
<AppShell.Aside withBorder />
|
||||
|
||||
@@ -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
|
||||
component={Link}
|
||||
to={href}
|
||||
className={styles.navLinkBox}
|
||||
className={`flxn-nav-link ${styles.navLinkBox}`}
|
||||
p={{ base: 0, sm: 8 }}
|
||||
aria-label={label}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-active={isActive || undefined}
|
||||
>
|
||||
<Flex
|
||||
direction={{ base: "column", md: "row" }}
|
||||
align="center"
|
||||
gap={{ base: 0, md: "xs" }}
|
||||
>
|
||||
<Icon
|
||||
weight={isActive ? "fill" : "regular"}
|
||||
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}
|
||||
>
|
||||
<Icon weight={isActive ? "fill" : "regular"} size={28} />
|
||||
<Text visibleFrom="md" ta="center" size="md" fw={isActive ? 800 : 500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
@@ -5,11 +5,13 @@ import { useAuth } from "@/contexts/auth-context";
|
||||
import { useLinks } from "../hooks/use-links";
|
||||
import { memo } from "react";
|
||||
import { useIsPWA } from "@/hooks/use-is-pwa";
|
||||
import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
|
||||
|
||||
const Navbar = () => {
|
||||
const { user, roles } = useAuth()
|
||||
const isMobile = useIsMobile();
|
||||
const isPWA = useIsPWA();
|
||||
const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
|
||||
|
||||
const links = useLinks(user?.id, roles);
|
||||
|
||||
@@ -17,9 +19,16 @@ const Navbar = () => {
|
||||
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
|
||||
: '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 (
|
||||
<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 }}>
|
||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||
<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 ref={containerRef} pos='relative' gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||
{indicator}
|
||||
{links.map((link) => (
|
||||
<NavLink key={link.href} {...link} />
|
||||
))}
|
||||
@@ -27,9 +36,10 @@ const Navbar = () => {
|
||||
</Paper>
|
||||
)
|
||||
|
||||
return <AppShell.Navbar p="xs" role='navigation'>
|
||||
return <AppShell.Navbar className="flxn-nav" p="xs" role='navigation'>
|
||||
<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) => (
|
||||
<NavLink key={link.href} {...link} />
|
||||
))}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
explicit [data-mantine-color-scheme] blocks. */
|
||||
|
||||
: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 */
|
||||
--flxn-gold: gold;
|
||||
--flxn-silver: var(--mantine-color-gray-5);
|
||||
|
||||
@@ -67,3 +67,61 @@
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,6 @@
|
||||
opacity: 0.07;
|
||||
}
|
||||
|
||||
[data-theme="sketch"]::view-transition-new(theme-overlay) {
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
[data-theme="sketch"][data-mantine-color-scheme="dark"]::view-transition-new(theme-overlay) {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 4. Surfaces — every card its own doodle */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user