swipeable tabs using mantine carousel
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mantine/carousel": "^8.2.4",
|
||||
"@mantine/charts": "^8.2.4",
|
||||
"@mantine/core": "^8.2.4",
|
||||
"@mantine/dates": "^8.2.4",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"drizzle-orm": "^0.44.4",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"ioredis": "^5.7.0",
|
||||
"pg": "^8.16.3",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/carousel/styles.css';
|
||||
import {
|
||||
HeadContent,
|
||||
Navigate,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||
header: {
|
||||
collapsed: 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: {
|
||||
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 { Player } from "@/features/players/types";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
|
||||
interface ProfileProps {
|
||||
player: Player;
|
||||
}
|
||||
|
||||
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 <>
|
||||
<Header player={player} />
|
||||
<Box m='sm' mt='lg'>
|
||||
<Text size='xl' fw={600}>Teams</Text>
|
||||
<TeamList teams={player.teams ?? []} />
|
||||
<SwipeableTabs
|
||||
tabs={tabs}
|
||||
defaultTab={0}
|
||||
onTabChange={(index, tab) => {
|
||||
console.log(`Switched to ${tab.label} tab`);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>;
|
||||
};
|
||||
|
||||
@@ -47,7 +47,6 @@ export const superTokensRequestMiddleware = createMiddleware({ type: 'request' }
|
||||
|
||||
if (!session.context.userAuthId) {
|
||||
logger.error('Unauthenticated user in API call.', session.context)
|
||||
throw new Error('Unauthenticated')
|
||||
}
|
||||
|
||||
const context = {
|
||||
@@ -66,7 +65,6 @@ export const superTokensFunctionMiddleware = createMiddleware({ type: 'function'
|
||||
|
||||
if (!session.context.userAuthId) {
|
||||
logger.error('Unauthenticated user in server function.', session.context)
|
||||
throw new Error('Unauthenticated')
|
||||
}
|
||||
|
||||
const context = {
|
||||
|
||||
Reference in New Issue
Block a user