Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 76 additions & 58 deletions client/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ import { cn } from '@/lib/utils';
const LANGS = ['EN', 'PT', 'ES'] as const;
type Lang = (typeof LANGS)[number];

interface NavLinkProps {
to: string;
label: string;
active: boolean;
}

function NavLink({ to, label, active }: NavLinkProps) {
return (
<Link
to={to}
className={cn(
'relative h-8 px-2 sm:px-3 flex items-center text-sm font-medium rounded-md transition-colors whitespace-nowrap',
active
? 'text-foreground bg-muted'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
)}
>
{label}
{active && (
<span className="absolute bottom-0 left-2 right-2 h-0.5 bg-primary rounded-full" />
)}
</Link>
);
}

export function Navbar() {
const { isAuthenticated, user, clearAuth } = useAuthStore();
const { theme, toggle } = useThemeStore();
Expand All @@ -20,73 +45,58 @@ export function Navbar() {
const activeLang = inst.language.toUpperCase().slice(0, 2) as Lang;

const handleLogout = async () => {
try { await api.post('/auth/logout'); } finally { clearAuth(); navigate('/'); }
try {
await api.post('/auth/logout');
} finally {
clearAuth();
navigate('/');
}
};

const switchLang = (lang: Lang) => {
i18n.changeLanguage(lang.toLowerCase());
localStorage.setItem('chesskernel-lang', lang.toLowerCase());
};

const isActive = (to: string) => location.pathname === to || location.pathname.startsWith(to + '/');
const cycleLang = () => {
const next = LANGS[(LANGS.indexOf(activeLang) + 1) % LANGS.length];
switchLang(next);
};

const isActive = (to: string) =>
location.pathname === to || location.pathname.startsWith(to + '/');

return (
<nav className="sticky top-0 z-50 border-b border-border bg-card">
{/*
3-column CSS grid with FIXED right column width (260px).
Nav text can change length freely without ever shifting the right section.
Layout rules that keep the bar stable across languages:
- left and right blocks are pinned by justify-between, so link text
growing never pushes the controls around
- auth buttons reserve the width of their longest translation via
ch-based minimums, so switching language never resizes them
- every control has a fixed height and shrink-0
*/}
<div
className="max-w-7xl mx-auto px-4 sm:px-6 h-14"
style={{ display: 'grid', gridTemplateColumns: '1fr auto 260px', alignItems: 'center', gap: '0' }}
>
{/* Left: logo + nav links */}
<div className="flex items-center gap-0.5">
<div className="max-w-7xl mx-auto px-3 sm:px-6 h-14 flex items-center justify-between gap-2">
{/* Left: logo + primary nav */}
<div className="flex items-center gap-0.5 min-w-0">
<Link
to="/"
className="flex items-center gap-1 font-black text-sm text-primary tracking-tight mr-4 shrink-0"
>
♔ <span className="hidden sm:inline">ChessKernel</span>
</Link>

<Link
to="/play"
className={cn(
'relative h-8 px-3 flex items-center text-sm font-medium rounded-md transition-colors whitespace-nowrap',
isActive('/play')
? 'text-foreground bg-muted'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
)}
className="flex items-center gap-1 font-black text-sm text-primary tracking-tight mr-2 sm:mr-4 shrink-0"
>
{t('nav.play')}
{isActive('/play') && (
<span className="absolute bottom-0 left-2 right-2 h-0.5 bg-primary rounded-full" />
)}
♔ <span className="hidden md:inline">ChessKernel</span>
</Link>

<Link
<NavLink to="/play" label={t('nav.play')} active={isActive('/play')} />
<NavLink
to="/leaderboard"
className={cn(
'relative h-8 px-3 flex items-center text-sm font-medium rounded-md transition-colors whitespace-nowrap',
isActive('/leaderboard')
? 'text-foreground bg-muted'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
)}
>
{t('nav.leaderboard')}
{isActive('/leaderboard') && (
<span className="absolute bottom-0 left-2 right-2 h-0.5 bg-primary rounded-full" />
)}
</Link>
label={t('nav.leaderboard')}
active={isActive('/leaderboard')}
/>
</div>

{/* Center: spacer (flex-1 equivalent in grid) */}
<div />

{/* Right: fixed 260px — controls never shift */}
<div className="flex items-center justify-end gap-1.5">
{/* Language switcher — always 88px */}
<div className="flex items-center bg-muted rounded-lg p-0.5" style={{ width: 88 }}>
{/* Right: controls, all fixed-size */}
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
{/* Language: segmented control on sm+, single cycle button on mobile */}
<div className="hidden sm:flex items-center bg-muted rounded-lg p-0.5 w-[88px] shrink-0">
{LANGS.map((lang) => (
<button
key={lang}
Expand All @@ -102,31 +112,37 @@ export function Navbar() {
</button>
))}
</div>
<button
onClick={cycleLang}
aria-label={t('nav.language')}
title={t('nav.language')}
className="sm:hidden w-9 h-8 flex items-center justify-center rounded-md bg-muted text-[11px] font-bold text-foreground shrink-0"
>
{activeLang}
</button>

{/* Theme toggle — always 32px */}
<button
onClick={toggle}
aria-label="Toggle theme"
aria-label={t('nav.theme')}
title={t('nav.theme')}
className="w-8 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
>
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
</button>

<div className="w-px h-4 bg-border mx-0.5 shrink-0" />
<div className="hidden sm:block w-px h-4 bg-border mx-0.5 shrink-0" />

{/* Auth — always right-aligned inside fixed 260px column */}
{isAuthenticated ? (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 shrink-0">
<Link
to={`/user/${user?.username}`}
className="flex items-center gap-1.5 h-8 px-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors max-w-[100px]"
className="flex items-center gap-1.5 h-8 px-1.5 sm:px-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors max-w-[110px]"
>
<div className="w-6 h-6 rounded-full bg-primary/15 text-primary flex items-center justify-center text-xs font-bold shrink-0">
{user?.username?.[0]?.toUpperCase()}
</div>
<span className="truncate hidden sm:inline">{user?.username}</span>
</Link>
{/* Icon-only logout — zero width change across languages */}
<button
onClick={handleLogout}
title={t('nav.logout')}
Expand All @@ -137,16 +153,18 @@ export function Navbar() {
</button>
</div>
) : (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 shrink-0">
{/* min-w reserves the longest translation ("Iniciar sesión" /
"Registrarse"), so language switches never resize these */}
<Link
to="/login"
className="h-8 px-3 flex items-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors whitespace-nowrap"
className="hidden sm:flex h-8 px-2 min-w-[13ch] items-center justify-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors whitespace-nowrap"
>
{t('nav.login')}
</Link>
<Link
to="/register"
className="h-8 px-3 flex items-center bg-primary text-primary-foreground text-sm font-bold rounded-md hover:bg-primary/90 transition-colors whitespace-nowrap"
className="h-8 px-2.5 sm:px-3 sm:min-w-[11ch] flex items-center justify-center bg-primary text-primary-foreground text-sm font-bold rounded-md hover:bg-primary/90 transition-colors whitespace-nowrap"
>
{t('nav.signUp')}
</Link>
Expand Down
15 changes: 15 additions & 0 deletions client/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,19 @@ i18n.use(initReactI18next).init({
interpolation: { escapeValue: false },
});

/** Full BCP 47 tags for the html lang attribute. */
const HTML_LANG: Record<string, string> = { en: 'en', pt: 'pt-BR', es: 'es' };

/** Keeps tab title, meta description, and html lang in the active language. */
function syncDocumentLanguage(language: string): void {
document.title = i18n.t('meta.title');
document
.querySelector('meta[name="description"]')
?.setAttribute('content', i18n.t('meta.description'));
document.documentElement.lang = HTML_LANG[language] ?? language;
}

syncDocumentLanguage(lng);
i18n.on('languageChanged', syncDocumentLanguage);

export default i18n;
33 changes: 27 additions & 6 deletions client/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"leaderboard": "Leaderboard",
"login": "Log In",
"signUp": "Sign Up",
"logout": "Log Out"
"logout": "Log Out",
"language": "Change language",
"theme": "Toggle theme"
},
"home": {
"tagline": "Open Source · Free Forever",
Expand All @@ -15,11 +17,26 @@
"featuresTitle": "Everything you need to play chess",
"featuresSubtitle": "A full chess experience with no subscriptions, no ads, no tracking.",
"features": {
"realtime": { "title": "Real-time Play", "desc": "Live games with instant moves. No delays, no lag." },
"bots": { "title": "Play vs Stockfish", "desc": "Challenge the world's strongest engine at any difficulty." },
"analysis": { "title": "Move Analysis", "desc": "Automatic post-game review with move classifications." },
"ladder": { "title": "Leaderboards", "desc": "Separate ratings for bullet, blitz, rapid, and classical." },
"multilang": { "title": "Multilingual", "desc": "Full support for English, Portuguese, and Spanish." }
"realtime": {
"title": "Real-time Play",
"desc": "Live games with instant moves. No delays, no lag."
},
"bots": {
"title": "Play vs Stockfish",
"desc": "Challenge the world's strongest engine at any difficulty."
},
"analysis": {
"title": "Move Analysis",
"desc": "Automatic post-game review with move classifications."
},
"ladder": {
"title": "Leaderboards",
"desc": "Separate ratings for bullet, blitz, rapid, and classical."
},
"multilang": {
"title": "Multilingual",
"desc": "Full support for English, Portuguese, and Spanish."
}
}
},
"play": {
Expand Down Expand Up @@ -129,5 +146,9 @@
"theme": {
"light": "Light",
"dark": "Dark"
},
"meta": {
"title": "ChessKernel: free, open-source chess platform",
"description": "Play chess online on your own terms: real-time multiplayer, Stockfish analysis, and Glicko-2 ratings. Free, open source, self-hostable, no ads or subscriptions."
}
}
33 changes: 27 additions & 6 deletions client/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"leaderboard": "Clasificación",
"login": "Iniciar sesión",
"signUp": "Registrarse",
"logout": "Cerrar sesión"
"logout": "Cerrar sesión",
"language": "Cambiar idioma",
"theme": "Cambiar tema"
},
"home": {
"tagline": "Código Abierto · Gratuito Para Siempre",
Expand All @@ -15,11 +17,26 @@
"featuresTitle": "Todo lo que necesitas para jugar ajedrez",
"featuresSubtitle": "Una experiencia completa sin suscripciones, sin anuncios, sin rastreo.",
"features": {
"realtime": { "title": "Juego en Vivo", "desc": "Partidas en directo con movimientos instantáneos." },
"bots": { "title": "vs Stockfish", "desc": "Desafía al mejor motor de ajedrez en cualquier nivel." },
"analysis": { "title": "Análisis de Jugadas", "desc": "Revisión automática con clasificación de jugadas." },
"ladder": { "title": "Clasificación", "desc": "Ratings separados para bullet, blitz, rapid y clásico." },
"multilang": { "title": "Multilingüe", "desc": "Soporte completo para inglés, portugués y español." }
"realtime": {
"title": "Juego en Vivo",
"desc": "Partidas en directo con movimientos instantáneos."
},
"bots": {
"title": "vs Stockfish",
"desc": "Desafía al mejor motor de ajedrez en cualquier nivel."
},
"analysis": {
"title": "Análisis de Jugadas",
"desc": "Revisión automática con clasificación de jugadas."
},
"ladder": {
"title": "Clasificación",
"desc": "Ratings separados para bullet, blitz, rapid y clásico."
},
"multilang": {
"title": "Multilingüe",
"desc": "Soporte completo para inglés, portugués y español."
}
}
},
"play": {
Expand Down Expand Up @@ -129,5 +146,9 @@
"theme": {
"light": "Claro",
"dark": "Oscuro"
},
"meta": {
"title": "ChessKernel: plataforma de ajedrez gratuita y de código abierto",
"description": "Juega al ajedrez online a tu manera: multijugador en tiempo real, análisis con Stockfish y puntuaciones Glicko-2. Gratis, de código abierto y autoalojable, sin anuncios ni suscripciones."
}
}
33 changes: 27 additions & 6 deletions client/src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"leaderboard": "Placar",
"login": "Entrar",
"signUp": "Cadastrar",
"logout": "Sair"
"logout": "Sair",
"language": "Mudar idioma",
"theme": "Alternar tema"
},
"home": {
"tagline": "Código Aberto · Gratuito Para Sempre",
Expand All @@ -15,11 +17,26 @@
"featuresTitle": "Tudo que você precisa para jogar xadrez",
"featuresSubtitle": "Uma experiência completa sem assinaturas, sem anúncios, sem rastreamento.",
"features": {
"realtime": { "title": "Jogo em Tempo Real", "desc": "Partidas ao vivo com lances instantâneos." },
"bots": { "title": "vs Stockfish", "desc": "Desafie o melhor motor de xadrez em qualquer nível." },
"analysis": { "title": "Análise de Lances", "desc": "Revisão automática com classificação de lances." },
"ladder": { "title": "Classificação", "desc": "Ratings separados para bullet, blitz, rapid e clássico." },
"multilang": { "title": "Multilíngue", "desc": "Suporte completo para inglês, português e espanhol." }
"realtime": {
"title": "Jogo em Tempo Real",
"desc": "Partidas ao vivo com lances instantâneos."
},
"bots": {
"title": "vs Stockfish",
"desc": "Desafie o melhor motor de xadrez em qualquer nível."
},
"analysis": {
"title": "Análise de Lances",
"desc": "Revisão automática com classificação de lances."
},
"ladder": {
"title": "Classificação",
"desc": "Ratings separados para bullet, blitz, rapid e clássico."
},
"multilang": {
"title": "Multilíngue",
"desc": "Suporte completo para inglês, português e espanhol."
}
}
},
"play": {
Expand Down Expand Up @@ -129,5 +146,9 @@
"theme": {
"light": "Claro",
"dark": "Escuro"
},
"meta": {
"title": "ChessKernel: plataforma de xadrez gratuita e open source",
"description": "Jogue xadrez online do seu jeito: multiplayer em tempo real, análise com Stockfish e ratings Glicko-2. Gratuito, open source e auto-hospedável, sem anúncios ou assinaturas."
}
}
Loading