swipeable tabs using mantine carousel
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"@mantine/carousel": "^8.2.4",
|
||||||
"@mantine/charts": "^8.2.4",
|
"@mantine/charts": "^8.2.4",
|
||||||
"@mantine/core": "^8.2.4",
|
"@mantine/core": "^8.2.4",
|
||||||
"@mantine/dates": "^8.2.4",
|
"@mantine/dates": "^8.2.4",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
"drizzle-orm": "^0.44.4",
|
"drizzle-orm": "^0.44.4",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
|
import '@mantine/carousel/styles.css';
|
||||||
import {
|
import {
|
||||||
HeadContent,
|
HeadContent,
|
||||||
Navigate,
|
Navigate,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
|||||||
header: {
|
header: {
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
|
settingsLink: context.auth.roles?.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
|
||||||
},
|
},
|
||||||
refresh: {
|
refresh: {
|
||||||
toRefresh: tournamentQueries.details(params.tournamentId).queryKey,
|
toRefresh: tournamentQueries.details(params.tournamentId).queryKey,
|
||||||
|
|||||||
127
src/components/swipeable-tabs.tsx
Normal file
127
src/components/swipeable-tabs.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
|
||||||
|
import { Carousel } from "@mantine/carousel";
|
||||||
|
import { useState, useEffect, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface TabItem {
|
||||||
|
label: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SwipeableTabsProps {
|
||||||
|
tabs: TabItem[];
|
||||||
|
defaultTab?: number;
|
||||||
|
onTabChange?: (index: number, tab: TabItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||||
|
const [embla, setEmbla] = useState<any>(null);
|
||||||
|
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!embla) return;
|
||||||
|
|
||||||
|
const onSelect = () => {
|
||||||
|
const newIndex = embla.selectedScrollSnap();
|
||||||
|
setActiveTab(newIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
embla.on("select", onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
embla.off("select", onSelect);
|
||||||
|
};
|
||||||
|
}, [embla]);
|
||||||
|
|
||||||
|
const handleTabChange = (index: number) => {
|
||||||
|
if (index !== activeTab && index >= 0 && index < tabs.length) {
|
||||||
|
setActiveTab(index);
|
||||||
|
embla?.scrollTo(index);
|
||||||
|
onTabChange?.(index, tabs[index]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setControlRef = (index: number) => (node: HTMLSpanElement | null) => {
|
||||||
|
controlsRefs[index] = node;
|
||||||
|
setControlsRefs(controlsRefs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
ref={setRootRef}
|
||||||
|
pos="relative"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
marginBottom: 'var(--mantine-spacing-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FloatingIndicator
|
||||||
|
target={controlsRefs[activeTab]}
|
||||||
|
parent={rootRef}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderBottom: '2px solid var(--mantine-primary-color-filled)',
|
||||||
|
paddingInline: '0.5rem'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<UnstyledButton
|
||||||
|
key={`${tab.label}-${index}`}
|
||||||
|
onClick={() => handleTabChange(index)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: activeTab === index
|
||||||
|
? 'var(--mantine-color-blue-6)'
|
||||||
|
: 'var(--mantine-color-text)',
|
||||||
|
fontWeight: activeTab === index ? 600 : 400,
|
||||||
|
transition: 'color 200ms ease, font-weight 200ms ease',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 0,
|
||||||
|
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
component="span"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
paddingInline: '1rem',
|
||||||
|
paddingBottom: '0.25rem'
|
||||||
|
}}
|
||||||
|
ref={setControlRef(index)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Carousel
|
||||||
|
getEmblaApi={setEmbla}
|
||||||
|
withControls={false}
|
||||||
|
withIndicators={false}
|
||||||
|
slideSize="100%"
|
||||||
|
initialSlide={activeTab}
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<Carousel.Slide key={`${tab.label}-content-${index}`}>
|
||||||
|
<Box>
|
||||||
|
{tab.content}
|
||||||
|
</Box>
|
||||||
|
</Carousel.Slide>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SwipeableTabs;
|
||||||
@@ -3,18 +3,38 @@ import Header from "./header";
|
|||||||
import { testEvent } from "@/utils/test-event";
|
import { testEvent } from "@/utils/test-event";
|
||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
player: Player;
|
player: Player;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Profile = ({ player }: ProfileProps) => {
|
const Profile = ({ player }: ProfileProps) => {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
content: <Text p="md">Panel 1 content</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Teams",
|
||||||
|
content: <Text p="md">Panel 2 content</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Stats",
|
||||||
|
content: <Text p="md">Panel 3 content</Text>
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Header player={player} />
|
<Header player={player} />
|
||||||
<Box m='sm' mt='lg'>
|
<Box m='sm' mt='lg'>
|
||||||
<Text size='xl' fw={600}>Teams</Text>
|
<SwipeableTabs
|
||||||
<TeamList teams={player.teams ?? []} />
|
tabs={tabs}
|
||||||
|
defaultTab={0}
|
||||||
|
onTabChange={(index, tab) => {
|
||||||
|
console.log(`Switched to ${tab.label} tab`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>;
|
</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export const superTokensRequestMiddleware = createMiddleware({ type: 'request' }
|
|||||||
|
|
||||||
if (!session.context.userAuthId) {
|
if (!session.context.userAuthId) {
|
||||||
logger.error('Unauthenticated user in API call.', session.context)
|
logger.error('Unauthenticated user in API call.', session.context)
|
||||||
throw new Error('Unauthenticated')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
@@ -66,7 +65,6 @@ export const superTokensFunctionMiddleware = createMiddleware({ type: 'function'
|
|||||||
|
|
||||||
if (!session.context.userAuthId) {
|
if (!session.context.userAuthId) {
|
||||||
logger.error('Unauthenticated user in server function.', session.context)
|
logger.error('Unauthenticated user in server function.', session.context)
|
||||||
throw new Error('Unauthenticated')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
|
|||||||
Reference in New Issue
Block a user