Patterns de navigation dans Speedcube Master : routing file-based, liens actifs, navigation mobile, breadcrumbs et onglets.
Next.js App Router utilise le systeme de fichiers pour definir les routes. Chaque dossier dans app/ correspond a un segment d'URL, et un fichier page.tsx rend la route accessible.
// Structure des fichiers → Routes generees
src/app/
├── page.tsx // → /
├── (pages)/
│ ├── timer/
│ │ └── page.tsx // → /timer
│ ├── algos/
│ │ ├── page.tsx // → /algos
│ │ └── [id]/
│ │ └── page.tsx // → /algos/:id
│ ├── learning/
│ │ ├── page.tsx // → /learning
│ │ └── [method]/
│ │ └── page.tsx // → /learning/:method
│ ├── dashboard/
│ │ ├── layout.tsx // Layout partage du dashboard
│ │ ├── page.tsx // → /dashboard
│ │ ├── stats/
│ │ │ └── page.tsx // → /dashboard/stats
│ │ └── settings/
│ │ └── page.tsx // → /dashboard/settings
│ └── room/
│ └── [code]/
│ └── page.tsx // → /room/:code
├── api/
│ ├── solves/
│ │ └── route.ts // → /api/solves
│ └── sessions/
│ └── route.ts // → /api/sessionsLes dossiers entre parentheses (pages) sont des groupes de routes. Ils organisent le code sans affecter l'URL. Ils permettent aussi de partager un layout entre certaines pages.
// (pages)/ est un groupe de routes
// Il n'apparait PAS dans l'URL
src/app/(pages)/timer/page.tsx // → /timer (pas /pages/timer)
// Utilisation pour partager un layout
src/app/(pages)/layout.tsx // Layout commun a toutes les pages publiques
src/app/(admin)/layout.tsx // Layout specifique a l'admin
// Le layout du groupe s'applique a toutes ses pages
// (pages)/layout.tsx
export default function PagesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header />
<main className="min-h-screen pt-16 pb-20 md:pb-0">
{children}
</main>
<MobileBottomNav />
<Footer />
</>
);
}Le hook usePathname() permet de determiner la route active et de styler le lien correspondant. C'est un composant client car il utilise un hook React.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface NavLinkProps {
href: string;
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
exact?: boolean; // true = match exact, false = match par prefix
}
export function NavLink({ href, children, icon: Icon, exact = false }: NavLinkProps) {
const pathname = usePathname();
const isActive = exact
? pathname === href
: pathname.startsWith(href);
return (
<Link
href={href}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm",
"transition-colors duration-150",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{Icon && <Icon className={cn("h-4 w-4", isActive && "text-primary")} />}
{children}
</Link>
);
}
// Utilisation dans une sidebar
<nav className="space-y-1">
<NavLink href="/dashboard" icon={Home} exact>
Accueil
</NavLink>
<NavLink href="/dashboard/stats" icon={BarChart}>
Statistiques
</NavLink>
<NavLink href="/dashboard/settings" icon={Settings}>
Parametres
</NavLink>
</nav>Extrait de src/components/bottom-nav.tsx. La BottomNav reelle combine 4 onglets principaux + un menu "Plus" qui ouvre un Sheet (panneau glissant) avec toutes les sections de l'application, organisees par categories. Elle utilise Framer Motion pour les animations d'icones et le composant Sheet de shadcn/ui pour le menu complet.
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Timer, BarChart3, GraduationCap, Menu, Trophy,
TrendingUp, BookOpen, Heart, Globe, Settings,
User, LogOut, Gift, RotateCcw, Users, Eye, Zap, Shield, X,
} from "lucide-react";
import { motion } from "framer-motion";
import { useUser } from "@clerk/nextjs";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/hooks/use-locale";
import { hapticFeedback } from "@/hooks/use-haptic";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
interface NavItem {
href: string;
icon: React.ElementType;
label: string;
badge?: number;
}
export function BottomNav() {
const pathname = usePathname();
const { isSignedIn } = useUser();
const t = useTranslations();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const isActive = (href: string) => {
if (!pathname) return false;
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
// Items principaux (toujours visibles)
const mainItems: NavItem[] = [
{ href: "/timer", icon: Timer, label: t.nav.timer },
{ href: "/algos", icon: Zap, label: t.nav.algorithms },
{ href: "/dashboard", icon: BarChart3, label: t.nav.dashboard },
{ href: "/learning/algorithm-trainer", icon: GraduationCap, label: "Training" },
];
// Items du menu "Plus" organises par section
const moreItems = {
competition: [
{ href: "/challenge", icon: Trophy, label: t.nav.challenge },
{ href: "/leaderboard", icon: TrendingUp, label: t.nav.leaderboard },
{ href: "/wca", icon: Globe, label: t.nav.wca },
],
learning: [
{ href: "/learning/training", icon: BarChart3, label: t.nav.training },
{ href: "/methods", icon: BookOpen, label: t.nav.methods },
{ href: "/learning/blind", icon: Eye, label: t.nav.blind },
],
community: [
...(isSignedIn ? [{ href: "/room", icon: Users, label: t.nav.rooms }] : []),
{ href: "/partner", icon: Heart, label: t.nav.partners },
],
};
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 lg:hidden
bg-background/95 backdrop-blur-md border-t border-border"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex items-center justify-around h-16 px-2">
{mainItems.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => hapticFeedback("light")}
className={cn(
"flex flex-col items-center justify-center gap-0.5",
"px-3 py-2 rounded-lg transition-all duration-200",
"touch-feedback min-w-[64px] min-h-[48px]",
active ? "text-primary" : "text-muted-foreground"
)}
>
<motion.div
animate={{ scale: active ? 1.1 : 1, y: active ? -2 : 0 }}
transition={{ type: "spring", stiffness: 400, damping: 20 }}
>
<item.icon className="h-5 w-5" />
</motion.div>
<span className="text-[10px] font-medium">{item.label}</span>
{active && (
<motion.div
layoutId="bottomNavIndicator"
className="absolute -bottom-0.5 w-1 h-1 rounded-full bg-primary"
/>
)}
</Link>
);
})}
{/* Bouton "Plus" avec Sheet */}
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<SheetTrigger asChild>
<button className={cn(
"flex flex-col items-center gap-0.5 px-3 py-2",
"min-w-[64px] min-h-[48px]",
isMenuOpen ? "text-primary" : "text-muted-foreground"
)}>
<motion.div
animate={{ rotate: isMenuOpen ? 90 : 0 }}
transition={{ type: "spring", stiffness: 400, damping: 20 }}
>
<Menu className="h-5 w-5" />
</motion.div>
<span className="text-[10px] font-medium">Plus</span>
</button>
</SheetTrigger>
<SheetContent
side="bottom"
className="h-[85vh] rounded-t-3xl px-0 pb-safe flex flex-col"
>
<SheetTitle className="sr-only">Menu</SheetTitle>
{/* Header du sheet */}
<div className="flex items-center justify-between px-6 pb-4
border-b border-border shrink-0">
<span className="font-semibold text-lg">Menu</span>
<Button variant="ghost" size="icon" onClick={() => setIsMenuOpen(false)}>
<X className="h-5 w-5" />
</Button>
</div>
{/* Contenu scrollable avec sections */}
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 space-y-6">
{Object.entries(moreItems).map(([sectionName, items]) => (
<div key={sectionName} className="space-y-2">
<h3 className="text-xs uppercase tracking-wide
text-muted-foreground font-semibold px-2">
{sectionName}
</h3>
<div className="grid grid-cols-2 gap-2">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setIsMenuOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl",
"transition-colors touch-feedback min-h-[48px]",
isActive(item.href)
? "bg-primary/10 text-primary"
: "bg-muted/30 hover:bg-muted"
)}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
<span className="text-sm font-medium">{item.label}</span>
</Link>
))}
</div>
</div>
))}
</div>
</SheetContent>
</Sheet>
</div>
</nav>
);
}Extrait de src/app/_components/navbar.tsx. La navbar desktop utilise des DropdownMenu de shadcn/ui pour regrouper les liens par section (Competition, Apprentissage, etc.). Le lien actif est detecte avec usePathname() et le meme pattern startsWith(href).
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Timer, Zap, BarChart3, Trophy, BookOpen, GraduationCap,
TrendingUp, Globe, ChevronDown,
} from "lucide-react";
import { useUser } from "@clerk/nextjs";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/hooks/use-locale";
import {
DropdownMenu, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
export function Navbar() {
const { isSignedIn } = useUser();
const pathname = usePathname();
const t = useTranslations();
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
const isActive = (href: string) => {
if (!pathname) return false;
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
// Navigation principale - elements essentiels
const mainNavItems = [
{ href: "/timer", icon: Timer, label: t.nav.timer },
{ href: "/algos", icon: Zap, label: t.nav.algorithms },
{ href: "/dashboard", icon: BarChart3, label: t.nav.dashboard },
];
return (
<header className="fixed top-0 left-0 right-0 z-50 h-16
border-b border-border bg-background/95 backdrop-blur-md">
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-full
flex items-center justify-between">
{/* Logo */}
<Link href="/" className="font-bold text-lg">
Speedcube Master
</Link>
{/* Navigation desktop (hidden sur mobile) */}
<nav className="hidden lg:flex items-center gap-1">
{/* Liens principaux */}
{mainNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm",
"transition-colors duration-150",
isActive(item.href)
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
{/* Menu deroulant "Plus" avec DropdownMenu */}
<DropdownMenu open={isMoreMenuOpen} onOpenChange={setIsMoreMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"gap-1",
isMoreMenuOpen && "bg-muted"
)}
>
Plus
<ChevronDown className={cn(
"h-3.5 w-3.5 transition-transform",
isMoreMenuOpen && "rotate-180"
)} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Competition</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/challenge" className="flex items-center gap-2">
<Trophy className="h-4 w-4" /> Challenge
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/leaderboard" className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" /> Classement
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Apprentissage</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/learning/training" className="flex items-center gap-2">
<GraduationCap className="h-4 w-4" /> Entrainement
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/methods" className="flex items-center gap-2">
<BookOpen className="h-4 w-4" /> Methodes
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</nav>
</div>
</header>
);
}Sur mobile, la navigation principale est une barre fixe en bas de l'ecran. Elle est masquee sur desktop (lg:hidden) au profit de la navbar desktop. Points cles : safe-area pour les iPhones, backdrop-blur pour la transparence, et tailles minimales de 48px pour le toucher.
// Pattern de base de la bottom nav mobile
<nav
className="fixed bottom-0 left-0 right-0 z-50 lg:hidden
bg-background/95 backdrop-blur-md border-t border-border"
style={{
// Safe area pour les iPhones avec encoche/barre home
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
<div className="flex items-center justify-around h-16 px-2">
{navItems.map(({ href, icon: Icon, label }) => {
const active = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex flex-col items-center gap-0.5",
"px-3 py-2 min-w-[64px] min-h-[48px]", // Taille min tactile
"text-xs transition-colors duration-150",
active
? "text-primary"
: "text-muted-foreground active:text-foreground"
)}
>
<Icon className="h-5 w-5" />
<span>{label}</span>
</Link>
);
})}
</div>
</nav>
// Important : ajouter du padding-bottom au contenu principal
// pour eviter que la bottom nav ne cache le contenu
<main className="pb-20 lg:pb-0">
{children}
</main>Les breadcrumbs sont utilises dans les pages profondes pour aider l'utilisateur a se reperer. Ils affichent le chemin hierarchique vers la page courante.
import Link from "next/link";
import { ChevronRight } from "lucide-react";
interface BreadcrumbItem {
label: string;
href?: string; // Pas de href = page courante
}
function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
return (
<nav aria-label="Fil d'Ariane" className="flex items-center gap-1.5 text-sm">
{items.map((item, index) => (
<div key={index} className="flex items-center gap-1.5">
{index > 0 && (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
{item.href ? (
<Link
href={item.href}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{item.label}
</Link>
) : (
<span className="text-foreground font-medium">
{item.label}
</span>
)}
</div>
))}
</nav>
);
}
// Utilisation
<Breadcrumbs
items={[
{ label: "Algos", href: "/algos" },
{ label: "3x3", href: "/algos?cube=3x3" },
{ label: "OLL #21" },
]}
/>Les onglets sont utilises pour naviguer entre des sous-sections d'une meme page. On utilise le composant Tabs de shadcn/ui pour le rendu local, ou des liens pour la navigation URL.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// Onglets locaux (sans changement d'URL)
function AlgorithmDetail({ algorithm }: { algorithm: Algorithm }) {
return (
<Tabs defaultValue="solution" className="w-full">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="solution">Solution</TabsTrigger>
<TabsTrigger value="triggers">Triggers</TabsTrigger>
<TabsTrigger value="variants">Variantes</TabsTrigger>
</TabsList>
<TabsContent value="solution" className="mt-4">
<AlgorithmSolution algorithm={algorithm} />
</TabsContent>
<TabsContent value="triggers" className="mt-4">
<AlgorithmTriggers algorithm={algorithm} />
</TabsContent>
<TabsContent value="variants" className="mt-4">
<AlgorithmVariants algorithm={algorithm} />
</TabsContent>
</Tabs>
);
}
// Onglets avec URL (chaque onglet = une route)
function DashboardTabs() {
const pathname = usePathname();
const tabs = [
{ label: "Vue d'ensemble", href: "/dashboard" },
{ label: "Statistiques", href: "/dashboard/stats" },
{ label: "Sessions", href: "/dashboard/sessions" },
];
return (
<div className="border-b border-border">
<nav className="flex gap-4 -mb-px overflow-x-auto">
{tabs.map((tab) => {
const isActive = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"px-1 py-3 text-sm whitespace-nowrap border-b-2 transition-colors",
isActive
? "border-primary text-primary font-medium"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
</Link>
);
})}
</nav>
</div>
);
}Extrait de src/app/(pages)/learning/blind/page.tsx. La page d'apprentissage Blind utilise le composant Tabs pour organiser le contenu entre "Systeme", "Entrainement" et "Statistiques". Le composant est entierement cote client car il gere un etat local.
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Eye, BookOpen, Target, Brain, BarChart3, Play,
Edit, CheckCircle, XCircle,
} from "lucide-react";
import Link from "next/link";
export default function BlindLearningPage() {
// ... hooks de donnees ...
return (
<div className="container mx-auto px-4 pt-16 sm:pt-20 pb-8 max-w-6xl">
{/* Stats Cards au-dessus des onglets */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Mon systeme
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold mb-1">
{stats.letterpairsConfigured}/{stats.totalLetterpairs}
</div>
<p className="text-sm text-muted-foreground">paires configurees</p>
<Button variant="outline" size="sm" className="mt-4 w-full" asChild>
<Link href="/learning/blind/letterpairs">
<Edit className="h-4 w-4 mr-2" />
Modifier
</Link>
</Button>
</CardContent>
</Card>
{/* ... 2 autres cartes de stats ... */}
</div>
{/* Navigation par onglets */}
<Tabs defaultValue="system" className="w-full">
<TabsList className="w-full grid grid-cols-3">
<TabsTrigger value="system" className="flex items-center gap-2">
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline">Systeme</span>
</TabsTrigger>
<TabsTrigger value="training" className="flex items-center gap-2">
<Target className="h-4 w-4" />
<span className="hidden sm:inline">Entrainement</span>
</TabsTrigger>
<TabsTrigger value="stats" className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
<span className="hidden sm:inline">Statistiques</span>
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-6 space-y-4">
{/* Contenu systeme de memorisation */}
</TabsContent>
<TabsContent value="training" className="mt-6">
{/* Contenu entrainement */}
</TabsContent>
<TabsContent value="stats" className="mt-6">
{/* Contenu statistiques */}
</TabsContent>
</Tabs>
</div>
);
}HeaderNavigation principale sur desktop avec liens horizontaux + DropdownMenu pour "Plus"Bottom NavNavigation principale sur mobile (lg:hidden), 4 onglets + Sheet "Plus" avec categoriesSidebarNavigation secondaire dans le dashboard (hidden md:flex)TabsSous-navigation dans une page, locale ou par URLBreadcrumbsChemin hierarchique pour les pages profondes