veza/apps/web/src/app/App.tsx
senke d829e228a8 feat(ui): profile page premium polish + keyboard shortcuts panel
Profile page:
- Hero: gradient upgrade, animated shimmer sweep, pulsing glow orb, bottom fade
- Header card: avatar ring glow, stats with icons (data-driven), tabular-nums
- Tabs: stagger animation on grid items, tab trigger transitions
- Skeleton: consistent with loaded state styling
- Page entry animation (fade-in)

Keyboard shortcuts panel (Discord-style):
- New KeyboardShortcutsPanel component with framer-motion animations
- Groups: General, Playback, Navigation
- Styled kbd badges with semantic tokens
- ARIA: role=dialog, aria-modal, aria-label
- Replaces old KeyboardShortcutsHelp component
- Fix: ? key handler no longer blocked by !e.shiftKey guard

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:15:50 +01:00

191 lines
6.7 KiB
TypeScript

import { useEffect, useState } from 'react';
import '@/styles/premium-utilities.css';
import '@/styles/visual-enhancements.css';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@/features/auth/store/authStore';
import { useUIStore } from '@/stores/ui';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ToastProvider } from '@/components/feedback/ToastProvider';
import { AstralBackground } from '@/components/ui/AstralBackground';
import { OfflineIndicator } from '@/components/OfflineIndicator';
import { AppRouter } from '@/router';
import { csrfService } from '@/services/csrf';
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
import { KeyboardShortcutsPanel } from '@/components/ui/KeyboardShortcutsPanel';
import { useStateHydration } from '@/utils/stateHydration';
import { useQueryInvalidation } from '@/hooks/useQueryInvalidation';
import { setupReactQuerySync } from '@/utils/reactQuerySync';
import { logger } from '@/utils/logger';
import { AudioProvider } from '@/context/AudioContext';
export function App() {
const { refreshUser } = useAuthStore();
const { theme, setTheme, language, setLanguage } = useUIStore();
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
// P1.2: Auth initialization state to prevent race condition
const [isAuthReady, setIsAuthReady] = useState(false);
const queryClient = useQueryClient();
// FE-COMP-022: Enable global keyboard shortcuts
useGlobalKeyboardShortcuts({
enabled: true,
onHelpOpen: () => setShowKeyboardHelp(true),
});
// FE-STATE-003: Hydrate state from server on app load
useStateHydration({
hydrateAuth: true,
hydrateLibrary: false, // Can be enabled if needed
hydrateChat: false, // Can be enabled if needed
requireAuth: false, // Hydrate auth even if not authenticated (to check status)
});
// FE-STATE-004: Listen for query invalidation events
useQueryInvalidation();
// Action 2.3.1.2: Initialize React Query cache synchronization across tabs
useEffect(() => {
const cleanup = setupReactQuerySync(queryClient, {
enabled: true,
channelName: 'veza-react-query-sync',
});
return cleanup;
}, [queryClient]);
// Initialiser l'application
useEffect(() => {
// CRITIQUE FIX #18: refreshUser est maintenant appelé par useStateHydration
// Ne pas appeler refreshUser ici pour éviter les appels multiples
// useStateHydration gère déjà l'hydratation de l'état d'authentification
// Ce useEffect ne fait plus qu'initialiser les autres aspects de l'app
// Récupérer le token CSRF si l'utilisateur est déjà authentifié
// (refreshUser() est asynchrone, donc on vérifie après un court délai)
const checkAndFetchCSRF = async () => {
// Attendre un peu pour que refreshUser() se termine
await new Promise((resolve) => setTimeout(resolve, 100));
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
csrfService.refreshToken().catch((error) => {
logger.warn('Failed to fetch CSRF token on app init', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
});
}
};
checkAndFetchCSRF();
// Appliquer le thème au chargement (le store persist le fait déjà, mais on s'assure qu'il est appliqué)
// Forcer dark mode par défaut si pas encore défini
if (!theme || theme === 'system') {
const root = document.documentElement;
if (
!root.classList.contains('dark') &&
!root.classList.contains('light')
) {
setTheme('dark');
} else {
setTheme(theme);
}
} else {
setTheme(theme);
}
// Synchroniser la langue avec i18n au chargement
if (typeof window !== 'undefined' && window.i18n) {
const currentLang = window.i18n.language || language;
if (currentLang !== language) {
window.i18n.changeLanguage(language);
} else if (language !== currentLang) {
setLanguage(currentLang as 'en' | 'fr');
}
}
}, [setTheme, theme, language, setLanguage]);
// P1.2: Initialize auth state before rendering app
// With httpOnly cookies we cannot read tokens in JS; always call refreshUser()
// so getMe() is used to verify auth (cookies sent automatically).
useEffect(() => {
const initAuth = async () => {
try {
await refreshUser();
} catch (error) {
logger.error('[App] Auth initialization failed', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
} finally {
setIsAuthReady(true);
}
};
initAuth();
}, [refreshUser]);
// Écouter les changements de préférence système pour le mode 'system'
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const root = document.documentElement;
if (e.matches) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
// Écouter les changements
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
} else {
// Fallback pour les navigateurs plus anciens
mediaQuery.addListener(handleChange);
}
return () => {
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', handleChange);
} else {
mediaQuery.removeListener(handleChange);
}
};
}, [theme]);
// P1.2: Show loading screen while auth is initializing
if (!isAuthReady) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Chargement...</p>
</div>
</div>
);
}
return (
<ErrorBoundary>
<ToastProvider>
<AudioProvider>
<AstralBackground />
{/* Offline/Online Status Indicator */}
<OfflineIndicator />
<AppRouter />
{/* PWA Install Banner - Disabled for now as it is too intrusive */}
{/* <PWAInstallBanner /> */}
{/* Keyboard Shortcuts Panel (Discord-style overlay) */}
<KeyboardShortcutsPanel
isOpen={showKeyboardHelp}
onClose={() => setShowKeyboardHelp(false)}
/>
</AudioProvider>
</ToastProvider>
</ErrorBoundary>
);
}