remove slide animation, use pill instead
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user