fix: UI remediation Phase 1 (S0-S5) + Phase 2 Sprint 6 shadow system
Phase 1:
- S0: Fix open redirect (safeNavigate), delete AuthContext/legacy auth, encrypt API keys, gitignore .env files
- S1: Split client.ts god object into 5 modules, unify toast system, delete unused Sidebar
- S2: Add glass button variant, migrate 32 z-index to SUMI tokens, fix card dark mode
- S3: Skip nav link, aria-hidden on icons, focus-visible ring fixes, alt attrs, aria-live regions
- S4: React.memo on list items, fix key={index}, loading=lazy on images
- S5: Branded loading screen, page transitions respect reduced-motion, LikeButton micro-interaction, i18n sidebar/header
Phase 2 Sprint 6:
- Wire Tailwind shadow utilities to SUMI tokens in @theme block (fixes 50+ files)
- Define shadow-card/shadow-card-hover tokens
- Remove dark:shadow-none workarounds from card.tsx (SUMI handles per-theme shadows)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
34e1f41091
commit
5f88c56113
95 changed files with 3817 additions and 5496 deletions
|
|
@ -1,11 +0,0 @@
|
|||
# Configuration API pour développement local
|
||||
# --- DOMAIN (single source of truth for frontend) ---
|
||||
# Change this to switch the domain. Must match APP_DOMAIN in backend .env.
|
||||
VITE_DOMAIN=veza.fr
|
||||
|
||||
# Backend Go tourne sur le port 8080 (proxy via Vite, same-origin)
|
||||
VITE_API_URL=/api/v1
|
||||
# WebSocket / Stream — derived from VITE_DOMAIN if omitted.
|
||||
# Uncomment to override:
|
||||
# VITE_WS_URL=ws://veza.fr:8081/ws
|
||||
# VITE_STREAM_URL=ws://veza.fr:8082/stream
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
# =============================================================================
|
||||
# VEZA FRONTEND - PRODUCTION CONFIGURATION
|
||||
# =============================================================================
|
||||
# This file contains production-specific environment variables for the frontend
|
||||
# These URLs should point to your production backend API
|
||||
# =============================================================================
|
||||
|
||||
# --- API URLs (ABSOLUTE) ---
|
||||
# Production backend API URL (must be absolute, not relative)
|
||||
# Example: https://api.veza.com/api/v1
|
||||
VITE_API_URL=https://api.veza.com/api/v1
|
||||
|
||||
# WebSocket URL for real-time features
|
||||
# Example: wss://api.veza.com/ws
|
||||
VITE_WS_URL=wss://api.veza.com/ws
|
||||
|
||||
# Stream server URL for media streaming
|
||||
# Example: https://api.veza.com/stream
|
||||
VITE_STREAM_URL=https://api.veza.com/stream
|
||||
|
||||
# Upload server URL for file uploads
|
||||
# Example: https://api.veza.com/upload
|
||||
VITE_UPLOAD_URL=https://api.veza.com/upload
|
||||
|
||||
# --- API VERSION ---
|
||||
# API version to use (sent in X-API-Version header)
|
||||
VITE_API_VERSION=v1
|
||||
|
||||
# --- FEATURE FLAGS ---
|
||||
# Enable/disable features in production
|
||||
VITE_ENABLE_VALIDATION_ALERTING=true
|
||||
|
||||
# =============================================================================
|
||||
# DEPLOYMENT NOTES
|
||||
# =============================================================================
|
||||
#
|
||||
# For local testing with production build:
|
||||
# 1. Update URLs above to match your local setup (e.g., http://veza.com:8080/api/v1)
|
||||
# 2. Build: npm run build
|
||||
# 3. Preview: npm run preview
|
||||
#
|
||||
# For actual production deployment:
|
||||
# 1. Update URLs to real production domains
|
||||
# 2. Ensure backend CORS allows your frontend domain
|
||||
# 3. Use HTTPS for all URLs (except local testing)
|
||||
#
|
||||
# =============================================================================
|
||||
4
apps/web/.gitignore
vendored
4
apps/web/.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
# Environment files (may contain secrets)
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Test / E2E artifacts (generated by Playwright visual tests)
|
||||
e2e/test-results-visual/
|
||||
e2e/playwright-report-visual/
|
||||
|
|
|
|||
|
|
@ -1,192 +1,229 @@
|
|||
# PHASE 0 — CARTOGRAPHIE FRONTEND
|
||||
# Phase 0 — Frontend Overview
|
||||
|
||||
> **Date d'audit** : 12 février 2026
|
||||
> **Projet** : Veza — Plateforme audio collaborative
|
||||
> **Scope** : `apps/web/src/` uniquement
|
||||
**Date** : 2026-02-12
|
||||
**Auditeur** : Audit automatisé exhaustif
|
||||
**Scope** : `apps/web/src/` exclusivement
|
||||
|
||||
---
|
||||
|
||||
## 1. Statistiques brutes
|
||||
## Statistiques brutes
|
||||
|
||||
| Métrique | Valeur |
|
||||
|----------|--------|
|
||||
| Fichiers `.tsx` | **1 450** |
|
||||
| Fichiers `.ts` | **620** |
|
||||
| Fichiers `.css` | **17** |
|
||||
| Fichiers `.scss` | **0** |
|
||||
| Fichiers `.module.css` | **0** |
|
||||
| Fichiers `.svg` | **5** |
|
||||
| **Total fichiers source** | **~2 092** |
|
||||
| **Total LOC (ts+tsx+css)** | **222 717** |
|
||||
| Fichiers > 300 lignes | **~50** (dont 7 123 LOC pour `types/generated/api.ts`) |
|
||||
| Fichiers > 500 lignes | **~28** |
|
||||
| `as any` casts | **706** |
|
||||
| `@ts-ignore` / `@ts-expect-error` | **8** |
|
||||
| `dangerouslySetInnerHTML` | **2** occurrences |
|
||||
| `eval()` / `new Function()` | **0** |
|
||||
| `localStorage` / `sessionStorage` accès | **~20** occurrences |
|
||||
| Inline styles (`style={{}}`) | **~80** fichiers |
|
||||
| TODO / FIXME / HACK | **~15** annotations actives |
|
||||
| `console.log/debug/info` | **~55** occurrences (hors tests) |
|
||||
### Répartition des fichiers
|
||||
|
||||
| Type | Nombre |
|
||||
|------|--------|
|
||||
| `.tsx` | 1 450 |
|
||||
| `.ts` | 621 |
|
||||
| `.css` | 5 |
|
||||
| `.scss` | 0 |
|
||||
| `.module.css` | 0 |
|
||||
| `.svg` | 5 |
|
||||
| **Total fichiers** | **2 081** |
|
||||
| **LOC total** | **~218 500** |
|
||||
|
||||
### Fichiers > 300 lignes (source uniquement, hors tests)
|
||||
|
||||
| Fichier | Lignes | Nature |
|
||||
|---------|--------|--------|
|
||||
| `src/types/generated/api.ts` | 7 123 | Types auto-générés OpenAPI |
|
||||
| `src/services/api/client.ts` | 2 237 | Client HTTP centralisé |
|
||||
| `src/mocks/handlers.ts` | 1 716 | MSW mock handlers |
|
||||
| `src/features/tracks/api/trackApi.ts` | 848 | API tracks |
|
||||
| `src/utils/optimisticUpdates.ts` | 682 | Logique optimistic updates |
|
||||
| `src/features/streaming/services/playbackAnalyticsService.ts` | 656 | Analytics streaming |
|
||||
| `src/features/playlists/hooks/usePlaylist.ts` | 631 | Hook playlist principal |
|
||||
| `src/utils/apiErrorHandler.ts` | 578 | Gestion erreurs API |
|
||||
| `src/features/streaming/hooks/usePlaybackRealtime.ts` | 496 | Hook streaming temps réel |
|
||||
| `src/services/api/auth.ts` | 493 | Service auth API |
|
||||
| `src/schemas/apiRequestSchemas.ts` | 476 | Schémas Zod requêtes |
|
||||
| `src/schemas/apiSchemas.ts` | 468 | Schémas Zod réponses |
|
||||
| `src/features/tracks/services/trackService.ts` | 453 | Service tracks |
|
||||
| `src/features/playlists/services/playlistService.ts` | 448 | Service playlists |
|
||||
| `src/utils/sanitize.ts` | 429 | Sanitization XSS |
|
||||
|
||||
**Observations** :
|
||||
- 15 fichiers source > 400 lignes (hors tests/generated). Les plus critiques sont `client.ts` (2237L) et `trackApi.ts` (848L).
|
||||
- Les fichiers de tests sont nombreux à dépasser 400L (ex: `TrackUpload.test.tsx` 783L, `playlistService.test.ts` 780L), signe de tests relativement exhaustifs.
|
||||
- Le fichier `api.ts` généré (7123L) est attendu pour des types OpenAPI.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack détectée
|
||||
## Stack détectée
|
||||
|
||||
| Couche | Technologie | Version |
|
||||
|--------|-------------|---------|
|
||||
| Framework UI | React | ^18.2.0 |
|
||||
| Bundler | Vite | ^7.1.5 |
|
||||
| Langage | TypeScript | ^5.3.3 (strict mode ON, noImplicitAny, noUncheckedIndexedAccess) |
|
||||
| CSS | Tailwind CSS v4 | ^4.0.0 (CSS-first config via `@theme`) |
|
||||
| State management | Zustand | ^4.5.0 |
|
||||
| Server state | TanStack React Query | ^5.17.0 |
|
||||
| Routing | React Router DOM | ^6.22.0 |
|
||||
| Forms | React Hook Form + Zod | ^7.49.3 / ^3.25.76 |
|
||||
| HTTP client | Axios | ^1.13.5 |
|
||||
| Animations | Framer Motion | ^12.29.2 |
|
||||
| Icons | Lucide React | ^0.321.0 |
|
||||
| i18n | i18next + react-i18next | ^25.5.2 / ^15.7.3 |
|
||||
| Mocking | MSW | ^2.11.2 |
|
||||
| Tests | Vitest + Testing Library | ^3.2.4 / ^14.2.1 |
|
||||
| Storybook | Storybook | ^8.6.15 |
|
||||
| Linting | ESLint 9 flat config + jsx-a11y | ^9.0.0 / ^6.10.2 |
|
||||
| Sanitization | DOMPurify | ^3.3.0 |
|
||||
| Error monitoring | Sentry | ^10.32.1 |
|
||||
| Drag & Drop | dnd-kit | ^6.3.1 |
|
||||
| Virtualization | TanStack Virtual | ^3.13.12 |
|
||||
| Streaming | HLS.js | ^1.6.14 |
|
||||
| **Framework** | React | 18.2.x |
|
||||
| **Bundler** | Vite | 7.1.x |
|
||||
| **Langage** | TypeScript | 5.3.x (strict mode complet) |
|
||||
| **CSS** | Tailwind CSS | v4.0 (CSS-first config) |
|
||||
| **Design System** | SUMI v2.0 | Custom, CSS variables |
|
||||
| **State global** | Zustand | 4.5.x |
|
||||
| **Server state** | TanStack React Query | 5.17.x |
|
||||
| **Routing** | React Router DOM | 6.22.x |
|
||||
| **Formulaires** | React Hook Form + Zod | 7.49.x / 3.25.x |
|
||||
| **Animation** | Framer Motion | 12.29.x |
|
||||
| **HTTP** | Axios | 1.13.x |
|
||||
| **i18n** | i18next + react-i18next | 25.5.x / 15.7.x |
|
||||
| **Monitoring** | Sentry | 10.32.x |
|
||||
| **Icônes** | Lucide React | 0.321.x |
|
||||
| **Tests unitaires** | Vitest | 3.2.x |
|
||||
| **Tests E2E** | Playwright | 1.58.x |
|
||||
| **Storybook** | Storybook | 8.6.x |
|
||||
| **Mocking** | MSW | 2.11.x |
|
||||
| **Linting** | ESLint 9 (flat config) + Prettier | 9.x / 3.2.x |
|
||||
| **Accessibility** | eslint-plugin-jsx-a11y | 6.10.x |
|
||||
| **Virtualisation** | TanStack Virtual | 3.13.x |
|
||||
| **DnD** | @dnd-kit | 6.3.x |
|
||||
| **Toast** | react-hot-toast | 2.6.x |
|
||||
|
||||
---
|
||||
|
||||
## 3. Dépendances critiques — Observations
|
||||
## Dépendances notables
|
||||
|
||||
| Dépendance | Observation |
|
||||
|------------|-------------|
|
||||
| `@types/dompurify` | Listé en `dependencies` au lieu de `devDependencies` — erreur de classification |
|
||||
| `rollup-plugin-visualizer` | Listé en `dependencies` au lieu de `devDependencies` — inclus inutilement en runtime |
|
||||
| `swagger-ui-dist` + `swagger-ui-react` | En production deps — potentiellement ~2MB de bundle si non tree-shaken / lazy-loaded |
|
||||
| `emoji-picker-react` | Dépendance lourde (~200KB) — nécessite lazy loading |
|
||||
| `framer-motion` | ^12.29.2 — dépendance majeure, impacte le bundle (~30KB min) |
|
||||
| `lucide-react` | ^0.321.0 — version ancienne (2024), dernière version > 0.450+ |
|
||||
### Production (critiques)
|
||||
- `dompurify` 3.3.x — sanitization HTML (bon signe sécurité)
|
||||
- `hls.js` 1.6.x — streaming HLS
|
||||
- `immer` 10.x — immutabilité state
|
||||
- `zod` 3.25.x — validation schemas
|
||||
- `emoji-picker-react` 4.16.x — feature chat
|
||||
- `swagger-ui-react` 5.31.x — documentation API embarquée
|
||||
|
||||
### Dev (notables)
|
||||
- `@storybook/addon-a11y` — audit accessibilité intégré
|
||||
- `pa11y-ci` — CI accessibility testing
|
||||
- `backstopjs` — visual regression testing
|
||||
- `pixelmatch` / `pngjs` — visual diff
|
||||
- `storybook-dark-mode` — dark mode Storybook
|
||||
- `tw-animate-css` — animations Tailwind
|
||||
|
||||
---
|
||||
|
||||
## 4. Schéma d'arborescence commenté (2 niveaux)
|
||||
## Arborescence commentée (2 niveaux)
|
||||
|
||||
```
|
||||
src/
|
||||
├── __tests__/ # Tests globaux (accessibility, contrast)
|
||||
├── app/ # Point d'entrée app (App.tsx probable)
|
||||
├── components/ # Composants "legacy" / partagés (très large — 40+ sous-dossiers)
|
||||
│ ├── admin/ # Vue admin
|
||||
│ ├── analytics/ # Graphiques analytics
|
||||
│ ├── auth/ # Composants auth
|
||||
│ ├── base/ # Composants de base
|
||||
│ ├── charts/ # Charts custom
|
||||
│ ├── commerce/ # Commerce / e-commerce
|
||||
│ ├── dashboard/ # Dashboard
|
||||
│ ├── data/ # Composants data (tables)
|
||||
├── app/ # Point d'entrée App, shell principal
|
||||
├── components/ # Composants partagés (UI, layout, domain)
|
||||
│ ├── admin/ # Vues administration
|
||||
│ ├── analytics/ # Composants analytics
|
||||
│ ├── auth/ # Composants auth (ProtectedRoute)
|
||||
│ ├── charts/ # Composants graphiques
|
||||
│ ├── commerce/ # Cart, wishlist
|
||||
│ ├── dashboard/ # Dashboard widgets
|
||||
│ ├── developer/ # Swagger UI, API keys
|
||||
│ ├── education/ # Cours, formations
|
||||
│ ├── feedback/ # Toast, notifications
|
||||
│ ├── filters/ # Filtres
|
||||
│ ├── forms/ # Form builder, password strength
|
||||
│ ├── education/ # Cours, learning
|
||||
│ ├── feedback/ # Toast, progress
|
||||
│ ├── filters/ # Filtres, tri
|
||||
│ ├── forms/ # Form primitives
|
||||
│ ├── gamification/ # XP, achievements
|
||||
│ ├── inventory/ # Gestion d'inventaire
|
||||
│ ├── inventory/ # Inventaire
|
||||
│ ├── keyboard/ # Raccourcis clavier
|
||||
│ ├── layout/ # DashboardLayout, Sidebar, Header, Navbar
|
||||
│ ├── library/ # Bibliothèque (playlists)
|
||||
│ ├── live/ # Streaming live
|
||||
│ ├── marketplace/ # Place de marché
|
||||
│ ├── library/ # Playlists, watermark
|
||||
│ ├── live/ # Live streaming
|
||||
│ ├── marketplace/ # Marketplace cards
|
||||
│ ├── modals/ # Modales partagées
|
||||
│ ├── monitoring/ # Dashboard monitoring
|
||||
│ ├── monitoring/ # Monitoring dashboard
|
||||
│ ├── navigation/ # Breadcrumbs
|
||||
│ ├── notifications/ # Notifications
|
||||
│ ├── player/ # Audio player (ancien)
|
||||
│ ├── notifications/ # Notification bell/menu
|
||||
│ ├── player/ # Audio player UI
|
||||
│ ├── pwa/ # PWA composants
|
||||
│ ├── search/ # Barre de recherche globale
|
||||
│ ├── seller/ # Vue vendeur
|
||||
│ ├── settings/ # Pages settings (account, security, appearance...)
|
||||
│ ├── share/ # Share link manager
|
||||
│ ├── social/ # Feed, groups, connections
|
||||
│ ├── studio/ # Studio (projets, AI tools, go live)
|
||||
│ ├── theme/ # ThemeSwitcher
|
||||
│ ├── ui/ # ⭐ Composants UI primitifs (Button, Modal, Tabs, etc.)
|
||||
│ ├── upload/ # Upload + metadata
|
||||
│ └── views/ # Vues décomposées (analytics, cart, chat, discover, etc.)
|
||||
├── config/ # Feature flags, env config
|
||||
├── context/ # AuthContext
|
||||
├── features/ # ⭐ Feature modules (architecture feature-first)
|
||||
│ ├── search/ # Search bar, results
|
||||
│ ├── seller/ # Seller dashboard
|
||||
│ ├── settings/ # Settings views
|
||||
│ ├── share/ # Sharing
|
||||
│ ├── social/ # Social feed, groups
|
||||
│ ├── studio/ # Studio projects
|
||||
│ ├── theme/ # Theme provider, switcher
|
||||
│ ├── ui/ # ⭐ Primitives UI (button, input, dialog, etc.)
|
||||
│ ├── upload/ # Upload components
|
||||
│ ├── user/ # User profile components
|
||||
│ └── views/ # Feature views (analytics, cart, chat, etc.)
|
||||
├── config/ # Configuration (env, features flags)
|
||||
├── context/ # React Context (AuthContext)
|
||||
├── features/ # ⭐ Feature modules (domain-driven)
|
||||
│ ├── admin/ # Admin feature
|
||||
│ ├── auth/ # Auth feature (pages, components, tests)
|
||||
│ ├── analytics/ # Analytics feature
|
||||
│ ├── auth/ # Auth (login, register, 2FA, OAuth)
|
||||
│ ├── chat/ # Chat feature
|
||||
│ ├── dashboard/ # Dashboard feature
|
||||
│ ├── error/ # Error pages
|
||||
│ ├── inventory/ # Inventory feature
|
||||
│ ├── library/ # Library feature
|
||||
│ ├── marketplace/ # Marketplace feature
|
||||
│ ├── notifications/ # Notifications feature
|
||||
│ ├── player/ # Audio player feature (store, hooks, services)
|
||||
│ ├── playlists/ # Playlists feature (components, hooks, services, tests)
|
||||
│ ├── player/ # ⭐ Player (store, hooks, services, components)
|
||||
│ ├── playlists/ # Playlists feature (CRUD, collab, analytics)
|
||||
│ ├── profile/ # Profile feature
|
||||
│ ├── roles/ # Role management feature
|
||||
│ ├── roles/ # Role management
|
||||
│ ├── search/ # Search feature
|
||||
│ ├── sessions/ # Sessions feature
|
||||
│ ├── sessions/ # Sessions management
|
||||
│ ├── settings/ # Settings feature
|
||||
│ ├── stream/ # Stream pages
|
||||
│ ├── streaming/ # Streaming feature (HLS, analytics, bitrate)
|
||||
│ ├── stream/ # Stream feature
|
||||
│ ├── streaming/ # Streaming (HLS, playback analytics)
|
||||
│ ├── studio/ # Studio feature
|
||||
│ ├── tracks/ # Tracks feature (le plus large)
|
||||
│ ├── tracks/ # ⭐ Tracks (upload, comments, share, search)
|
||||
│ ├── upload/ # Upload feature
|
||||
│ ├── user/ # User profile feature
|
||||
│ ├── user/ # User feature
|
||||
│ └── webhooks/ # Webhooks feature
|
||||
├── hooks/ # Hooks partagés (useOnlineStatus, useFormValidation, etc.)
|
||||
├── lib/ # Utilitaires lib
|
||||
├── locales/ # Fichiers i18n
|
||||
├── mocks/ # MSW handlers (1 716 LOC)
|
||||
├── pages/ # Pages routing (auth, marketplace)
|
||||
├── providers/ # Providers React
|
||||
├── router/ # Configuration routing
|
||||
├── schemas/ # Schemas Zod (validation, API)
|
||||
├── services/ # Services partagés (API client, websocket, cache, offline queue)
|
||||
├── stores/ # Zustand stores
|
||||
├── stories/ # Stories Storybook globales + assets
|
||||
├── styles/ # 🔴 CSS files séparés (17 fichiers — design-system, tokens, fixes)
|
||||
├── test/ # Test setup / utilities
|
||||
├── types/ # Types globaux (generated API types — 7 123 LOC)
|
||||
└── utils/ # Utilitaires (sanitize, error handler, optimistic updates)
|
||||
├── hooks/ # Hooks partagés (useAuth, useDebounce, etc.)
|
||||
├── lib/ # Librairies init (i18n, sentry)
|
||||
├── locales/ # Fichiers de traduction i18n
|
||||
├── mocks/ # MSW handlers
|
||||
├── pages/ # Pages (auth, marketplace) — legacy?
|
||||
├── providers/ # AuthProvider
|
||||
├── router/ # Routing (AppRouter, config, guards)
|
||||
├── schemas/ # Schémas Zod (request/response validation)
|
||||
├── services/ # Services API (REST, WebSocket, storage)
|
||||
├── stores/ # Zustand stores (cart, library, UI, rateLimit)
|
||||
├── stories/ # Storybook decorators
|
||||
├── styles/ # (si fichiers CSS additionnels)
|
||||
├── test/ # Test utilities, setup
|
||||
├── __tests__/ # Tests globaux (accessibility, contrast)
|
||||
├── types/ # Types globaux et générés
|
||||
└── utils/ # Utilitaires (sanitize, logger, toast, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Première impression architecturale
|
||||
## Patterns critiques détectés
|
||||
|
||||
**L'architecture présente un modèle hybride en transition** : un dossier `features/` bien structuré coexiste avec un méga-dossier `components/` hérité contenant 40+ sous-dossiers qui devraient, pour la plupart, être migrés vers les features correspondantes. Cette dualité crée une ambiguïté sur l'emplacement canonique de chaque composant (ex: `components/player/` vs `features/player/`, `components/settings/` vs `features/settings/`).
|
||||
|
||||
**La stack technique est moderne et ambitieuse** : TypeScript strict, Tailwind v4 CSS-first, Zustand + React Query, Zod validation, MSW mocking, Storybook — c'est un ensemble cohérent et bien choisi pour un SaaS audio. Le design system "KODO" est formalisé via CSS variables avec ~100+ tokens, light/dark mode complet, et des utilitaires custom. L'investissement en outillage (ESLint custom rules pour les tokens, feature flags, Sentry) témoigne d'une volonté de rigueur.
|
||||
|
||||
**Le volume est considérable pour un pré-MVP** : 222K LOC sur 2 092 fichiers, c'est un codebase substantiel. Les 706 `as any` casts, les ~80 fichiers avec inline styles, et la présence de nombreux `TODO` indiquent une dette technique significative sous la surface, probablement accumulée lors de phases de développement rapide. Les fichiers de plus de 500 lignes sont concentrés dans les services et tests, pas dans les composants UI — signe que la règle des 300 lignes max est globalement respectée côté UI.
|
||||
| Pattern | Occurrences | Risque |
|
||||
|---------|-------------|--------|
|
||||
| `dangerouslySetInnerHTML` | 2 fichiers | 🟠 Moyen (chat) |
|
||||
| `localStorage/sessionStorage` | ~45 fichiers | 🟡 Faible (encapsulé via `safeStorage.ts`, `tokenStorage.ts`) |
|
||||
| `eval()` / `new Function()` | 0 | ✅ |
|
||||
| `console.log/debug/info` | ~27 fichiers | 🟡 Faible (principalement dev/stories) |
|
||||
| `: any` | ~80+ fichiers | 🟠 Moyen |
|
||||
| `as any` | ~100+ fichiers (dont 145 dans `generated/api.ts`) | 🟠 Moyen |
|
||||
| `@ts-ignore` / `@ts-expect-error` | 7 fichiers | 🟢 Faible |
|
||||
| `style={{}}` inline | ~80 fichiers | 🟠 Moyen |
|
||||
| `TODO/FIXME/HACK` | ~20 occurrences | 🟡 Normal |
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration notable
|
||||
## Variables d'environnement client (VITE_*)
|
||||
|
||||
### TypeScript
|
||||
- **Strict mode complet** activé (`strict: true`, `noImplicitAny`, `strictNullChecks`, `noUncheckedIndexedAccess`)
|
||||
- Tests et mocks **exclus** de la compilation (`exclude` dans tsconfig) — bon choix
|
||||
- Path aliases configurés (`@/*` → `./src/*`)
|
||||
Déclarées dans `src/vite-env.d.ts` [vite-env.d.ts:4-21] :
|
||||
- `VITE_API_URL`, `VITE_WS_URL`, `VITE_STREAM_URL`, `VITE_UPLOAD_URL` — endpoints API
|
||||
- `VITE_APP_NAME` — nom de l'application
|
||||
- `VITE_DEBUG`, `VITE_USE_MSW`, `VITE_STORYBOOK` — flags dev
|
||||
- `VITE_FCM_VAPID_KEY` — push notifications
|
||||
- Feature flags : `VITE_FEATURE_TWO_FACTOR_AUTH`, `VITE_FEATURE_PLAYLIST_*`, `VITE_FEATURE_HLS_STREAMING`, `VITE_FEATURE_ROLE_MANAGEMENT`, `VITE_FEATURE_NOTIFICATIONS`
|
||||
|
||||
### Vite
|
||||
- Build avec manual chunks bien configurés (react, router, tanstack, icons, utils, vendor)
|
||||
- Source maps cachées en production (`'hidden'`)
|
||||
- Bundle visualizer en production
|
||||
- Proxy API configuré pour le dev
|
||||
Fichiers `.env` présents :
|
||||
- `.env.example` (2.2KB) — template
|
||||
- `.env.local` (450B) — config locale
|
||||
- `.env.production` (1.8KB) — config prod
|
||||
- `.env.storybook` (262B) — config Storybook
|
||||
- **Attention** : `.env.local` et `.env.production` sont versionnés (visibles). `.gitignore` ne semble pas exclure les fichiers `.env.*`.
|
||||
|
||||
### Tailwind v4
|
||||
- Configuration CSS-first via `@theme inline` dans `index.css`
|
||||
- Pas de `tailwind.config.ts` significatif (fichier quasi vide, délègue à CSS)
|
||||
- 17 fichiers CSS dans `src/styles/` — fragmentation notable
|
||||
---
|
||||
|
||||
### ESLint
|
||||
- Configuration flat config (ESLint 9)
|
||||
- Plugin `jsx-a11y` actif
|
||||
- **`@typescript-eslint/no-explicit-any: 'off'`** — ⚠️ `any` autorisé explicitement
|
||||
- Rules custom pour enforcer les design tokens (pas de Tailwind default colors, pas d'arbitrary values)
|
||||
- Rule pour forcer `<Button>` component au lieu de `<button>` natif
|
||||
## Première impression architecturale
|
||||
|
||||
1. **Architecture mature et ambitieuse** : Le projet adopte une organisation feature-based (`features/`) combinée à des composants partagés (`components/ui/`, `components/layout/`), des services centralisés et un design system custom (SUMI v2.0). C'est un projet SaaS complet avec ~2000 fichiers et ~218K LOC — une codebase significative.
|
||||
|
||||
2. **Stack moderne mais complexe** : Tailwind v4 CSS-first, React 18, TanStack Query v5, Zustand, Zod, i18next, Sentry, MSW, Storybook 8.6, Playwright — l'outillage est complet mais la complexité d'intégration est élevée. Le `main.tsx` (273L) avec son `waitForStylesheets` et son preloading toast révèle des workarounds de stabilité.
|
||||
|
||||
3. **Dualité préoccupante** : Il existe une coexistence entre `components/views/` (analytics-view, cart-view, etc.) et `features/*/pages/` qui suggère une migration architecturale en cours ou incomplète. De même, `pages/auth/` coexiste avec `features/auth/pages/`, et `context/AuthContext.tsx` avec `providers/AuthProvider.tsx`. Cette dualité est un signal de dette structurelle.
|
||||
|
|
|
|||
|
|
@ -1,333 +1,227 @@
|
|||
# PHASE A — ARCHITECTURE FRONTEND
|
||||
# Phase A — Architecture Frontend Analysis
|
||||
|
||||
> **Scope** : `apps/web/src/`
|
||||
> **Analyse exhaustive** : structure, responsabilités, state, requêtes réseau, routing, erreurs
|
||||
**Score Architecture : 7/10**
|
||||
|
||||
---
|
||||
|
||||
## A1. Structure des dossiers
|
||||
|
||||
### Séparation features / shared / core
|
||||
### Organisation réelle
|
||||
|
||||
Le projet adopte un **modèle hybride en transition** :
|
||||
```
|
||||
src/
|
||||
├── app/ → Shell applicatif (App.tsx)
|
||||
├── components/ → Composants partagés (30+ sous-dossiers)
|
||||
├── config/ → Configuration (env, features flags)
|
||||
├── context/ → React Context (AuthContext, AudioContext)
|
||||
├── features/ → Modules feature-based (24 features)
|
||||
├── hooks/ → Hooks partagés (~30 hooks)
|
||||
├── lib/ → Init librairies (i18n, sentry)
|
||||
├── locales/ → Traductions i18n
|
||||
├── mocks/ → MSW handlers
|
||||
├── pages/ → Pages legacy (auth, marketplace)
|
||||
├── providers/ → AuthProvider
|
||||
├── router/ → Routing centralisé
|
||||
├── schemas/ → Schémas Zod
|
||||
├── services/ → Services API (~35 services)
|
||||
├── stores/ → Zustand stores partagés
|
||||
├── stories/ → Storybook decorators
|
||||
├── test/ → Test setup
|
||||
├── types/ → Types globaux et générés
|
||||
└── utils/ → Utilitaires (~25 fichiers)
|
||||
```
|
||||
|
||||
| Dossier | Rôle | Fichiers estimés | Verdict |
|
||||
|---------|------|-----------------|---------|
|
||||
| `src/features/` | Feature modules (auth, player, tracks, playlists, chat, etc.) | ~900+ | ✅ Bien structuré |
|
||||
| `src/components/` | Composants "legacy" / partagés | ~500+ | ⚠️ Méga-dossier, 40+ sous-dossiers |
|
||||
| `src/components/ui/` | Primitives UI (Button, Modal, Tabs, etc.) | ~200+ | ✅ Bonne séparation |
|
||||
| `src/services/` | Services partagés (API client, websocket, cache) | ~30+ | ✅ Bien isolés |
|
||||
| `src/stores/` | Stores Zustand globaux | ~6 fichiers | ✅ Correct |
|
||||
| `src/hooks/` | Hooks partagés | ~15+ | ✅ Bien isolés |
|
||||
| `src/utils/` | Utilitaires transverses | ~20+ | ✅ Correct |
|
||||
| `src/schemas/` | Schemas Zod | ~5 fichiers | ✅ Bonne pratique |
|
||||
| `src/types/` | Types globaux + generated | ~10 fichiers | ✅ Correct |
|
||||
### Séparation `features/` vs `components/`
|
||||
|
||||
### Problème majeur : dualité `components/` vs `features/`
|
||||
**Positif** : La codebase utilise un pattern feature-based dans `features/` avec des modules autonomes contenant chacun `api/`, `components/`, `hooks/`, `services/`, `store/`, `pages/`. [features/auth/store/authStore.ts], [features/playlists/hooks/usePlaylist.ts], [features/tracks/api/trackApi.ts]
|
||||
|
||||
**Duplication de domaines détectée** :
|
||||
**Problème majeur** : **Dualité non résolue** entre :
|
||||
- `components/views/` (analytics-view, cart-view, chat-view, discover-view, etc. — ~20 vues) et `features/*/pages/` — deux patterns coexistent pour le même rôle [components/views/analytics-view/], [features/dashboard/pages/]
|
||||
- `pages/auth/` (Login.tsx, Register.tsx) et `features/auth/pages/` (LoginPage.tsx, RegisterPage.tsx) — **duplication directe** [pages/auth/Login.tsx vs features/auth/pages/LoginPage.tsx]
|
||||
- `context/AuthContext.tsx` et `features/auth/store/authStore.ts` — **deux sources de vérité pour l'auth** [context/AuthContext.tsx:54, features/auth/store/authStore.ts:55]
|
||||
- `providers/AuthProvider.tsx` et `context/AuthContext.tsx` — coexistence redondante
|
||||
|
||||
| Domaine | `components/` | `features/` | Problème |
|
||||
|---------|--------------|-------------|----------|
|
||||
| Player | `components/player/` | `features/player/` | Deux emplacements concurrents |
|
||||
| Settings | `components/settings/` (7 sous-dossiers) | `features/settings/` | Même domaine, deux endroits |
|
||||
| Auth | `components/auth/` | `features/auth/` | Confusion sur le canonique |
|
||||
| Social | `components/social/` | — | Devrait être dans features/ |
|
||||
| Education | `components/education/` | — | Devrait être dans features/ |
|
||||
| Commerce | `components/commerce/` | — | Devrait être dans features/ |
|
||||
| Gamification | `components/gamification/` | — | Pas de feature associée |
|
||||
| Studio | `components/studio/` | `features/studio/` | Deux emplacements |
|
||||
| Search | `components/search/` | `features/search/` | Deux emplacements |
|
||||
### Barrel exports (`index.ts`)
|
||||
|
||||
**Verdict** : La migration vers une architecture feature-first est **en cours mais incomplète**. Environ 60% des domaines métier ont leur module dans `features/`, mais des vestiges significatifs restent dans `components/`. [src/components/:ensemble] [src/features/:ensemble]
|
||||
|
||||
### Barrel exports
|
||||
|
||||
Les barrel exports (`index.ts`) sont **présents et cohérents** dans les modules features decomposés :
|
||||
- `features/tracks/components/comment-thread/index.ts`
|
||||
- `features/playlists/components/playlist-list/index.ts`
|
||||
- `features/player/components/player-bar/index.ts`
|
||||
- Tous les sous-composants UI décomposés (accordion, dialog, tabs, etc.)
|
||||
|
||||
**Manquants** : les composants de `components/` n'ont pas toujours de barrel export cohérent.
|
||||
|
||||
### Composants groupés par feature vs par type
|
||||
|
||||
Le pattern dominant est **par feature** dans `features/`, et **par type/domaine** dans `components/`. L'architecture cible est clairement feature-first.
|
||||
**Présents et cohérents** dans les feature views refactorées :
|
||||
- `components/views/analytics-view/index.ts` ✅
|
||||
- `components/views/cart-view/index.ts` ✅
|
||||
- `components/views/settings-view/index.ts` ✅
|
||||
- `features/*/index.ts` — **absents** dans la plupart des features ❌
|
||||
|
||||
### Routes colocalisées
|
||||
|
||||
Les routes sont **séparées** dans `src/router/` — pas colocalisées avec les features. C'est acceptable avec React Router v6, mais les features contiennent leurs propres `pages/` (ex: `features/tracks/pages/`, `features/auth/pages/`) — bonne pratique.
|
||||
Les routes sont centralisées dans `router/routeConfig.tsx` [routeConfig.tsx:57-109], pas colocalisées avec les features. C'est un choix acceptable pour un projet de cette taille, mais limite la découvrabilité.
|
||||
|
||||
### Dossiers morts / orphelins potentiels
|
||||
### Dossiers morts ou orphelins
|
||||
|
||||
| Dossier suspect | Raison |
|
||||
|----------------|--------|
|
||||
| `components/views/` | 20+ sous-dossiers de "views" qui ne sont pas des routes — probablement des prototypes Storybook |
|
||||
| `components/demo/` | Dossier de démo — mort probable |
|
||||
| `components/base/` | Composants "base" — potentiellement redondant avec `components/ui/` |
|
||||
| `stories/` (racine) | Stories globales + assets Storybook default — à nettoyer |
|
||||
- `pages/auth/` — **probablement orphelin** : contient Login.tsx et Register.tsx mais les routes pointent vers `features/auth/pages/` via LazyComponent [routeConfig.tsx:59-63]
|
||||
- `components/views/*.tsx` (fichiers plats type `AnalyticsView.tsx`, `CartView.tsx`) — semblent être des **wrappers legacy** vers les sous-dossiers refactorés
|
||||
- `stories/` — contient uniquement `decorators.tsx`, pourrait être dans `.storybook/`
|
||||
|
||||
**Verdict structure** : Organisation feature-based ambitieuse mais migration incomplète. La dualité `components/views/` vs `features/*/pages/` et les vestiges legacy (`pages/`, `context/AuthContext.tsx`) créent de la confusion. **-2 points.**
|
||||
|
||||
---
|
||||
|
||||
## A2. Séparation des responsabilités
|
||||
|
||||
### Tableau des composants > 100 lignes
|
||||
### Composants > 100 lignes (source, hors tests)
|
||||
|
||||
| Composant | Lignes | Type | Responsabilités mélangées ? | Verdict |
|
||||
|-----------|--------|------|----------------------------|---------|
|
||||
| `components/developer/DeveloperDashboardView.tsx` | 323 | Container | ✅ Oui — API calls, state, rendering | 🔴 REFACTOR |
|
||||
| `features/dashboard/pages/DashboardPage.tsx` | 329 | Container | ✅ Oui — Data fetching, state, rendering | 🔴 REFACTOR |
|
||||
| `components/layout/Sidebar.tsx` | 295 | Presentation | ❌ Non — Pure UI | ✅ OK |
|
||||
| `components/developer/SwaggerUI.tsx` | 279 | Hybrid | ✅ Oui — Config, error handling, rendering | 🟡 REFACTOR |
|
||||
| `components/upload/metadata/MetadataForm.tsx` | 274 | Container | ✅ Oui — State, modals, form logic | 🟡 REFACTOR |
|
||||
| `features/chat/components/ChatMessages.tsx` | 218 | Container | ✅ Oui — Store access, state, rendering | 🟡 REFACTOR |
|
||||
| `app/App.tsx` | 192 | Hybrid | ✅ Oui — Auth init, theme, i18n, CSRF | 🟡 REFACTOR |
|
||||
| `features/tracks/pages/track-detail-page/TrackDetailPageInfo.tsx` | 178 | Presentation | ❌ Non — Pure UI | ✅ OK |
|
||||
| `components/layout/Header.tsx` | 172 | Hybrid | ✅ Oui — Auth, theme, navigation | 🟡 REFACTOR |
|
||||
| `features/player/components/GlobalPlayer.tsx` | 154 | Container | ⚠️ Partiel — Near limit | ✅ OK (à surveiller) |
|
||||
| `features/tracks/components/TrackSearch.tsx` | 147 | Container | ✅ Oui — Search logic, API calls | 🟡 REFACTOR |
|
||||
| `components/forms/LoginForm.tsx` | 135 | Hybrid | ⚠️ Partiel — Form pattern acceptable | ✅ OK |
|
||||
| `features/tracks/pages/track-detail-page/TrackDetailPageCoverAndActions.tsx` | 133 | Presentation | ❌ Non — Pure UI | ✅ OK |
|
||||
| `features/playlists/components/PlaylistHeader.tsx` | 130 | Presentation | ❌ Non — Pure UI | ✅ OK |
|
||||
| `features/chat/pages/ChatPage.tsx` | 114 | Container | ✅ Oui — API calls, state, rendering | 🟡 REFACTOR |
|
||||
| `features/tracks/components/track-filters/TrackFilters.tsx` | 103 | Hybrid | ⚠️ Partiel — Uses hook | ✅ OK |
|
||||
| `features/player/components/PlayerControls.tsx` | 106 | Presentation | ❌ Non — Pure UI | ✅ OK |
|
||||
| `features/tracks/components/TrackList.tsx` | 291 | Presentation | ❌ Non — Pure UI, 18 props | ✅ OK |
|
||||
| `services/api/client.ts` | 2 237 | Service | API client + validation + caching + retry + dedup + metrics | ❌ Monolithe |
|
||||
| `features/tracks/api/trackApi.ts` | 848 | Service | CRUD tracks + upload + share + analytics | ⚠️ Large mais cohérent |
|
||||
| `utils/optimisticUpdates.ts` | 682 | Utilitaire | Optimistic updates multi-feature | ⚠️ Acceptable |
|
||||
| `features/streaming/services/playbackAnalyticsService.ts` | 656 | Service | Analytics streaming | ⚠️ Complexité justifiée |
|
||||
| `features/playlists/hooks/usePlaylist.ts` | 631 | Hook smart | CRUD + collaboration + analytics | ❌ Trop de responsabilités |
|
||||
| `utils/apiErrorHandler.ts` | 578 | Utilitaire | Error parsing + categorization | ⚠️ Acceptable |
|
||||
| `features/streaming/hooks/usePlaybackRealtime.ts` | 496 | Hook smart | WebSocket + state + analytics | ⚠️ Justifié par temps réel |
|
||||
| `services/api/auth.ts` | 493 | Service | Auth API complète | ⚠️ Cohérent |
|
||||
| `schemas/apiRequestSchemas.ts` | 476 | Types | Schémas Zod | ✅ Naturellement grand |
|
||||
| `schemas/apiSchemas.ts` | 468 | Types | Schémas Zod | ✅ Naturellement grand |
|
||||
| `features/tracks/services/trackService.ts` | 453 | Service | Service tracks | ⚠️ Cohérent |
|
||||
| `features/playlists/services/playlistService.ts` | 448 | Service | Service playlists | ⚠️ Cohérent |
|
||||
| `utils/sanitize.ts` | 429 | Utilitaire | Sanitization XSS | ✅ Sécurité critique |
|
||||
|
||||
**Synthèse** :
|
||||
- **27% des composants > 100 lignes** nécessitent un refactoring
|
||||
- **40% mélangent** des responsabilités (au moins partiellement)
|
||||
- **97% ont leurs props typées** — excellent
|
||||
- Le pattern `useXxxPage()` hook est bien adopté dans les features récentes
|
||||
**Point critique** : `client.ts` (2237L) est un **God Object**. Il cumule : instance Axios, interceptors, validation Zod, caching, request deduplication, metrics tracking, offline queue, rate limiting, CSRF. Il devrait être éclaté en 4-5 modules. [services/api/client.ts:1-80]
|
||||
|
||||
### Bonne pratique observée
|
||||
|
||||
Les pages récentes utilisent un hook dédié pour la logique :
|
||||
- `features/tracks/pages/track-detail-page/useTrackDetailPage.ts` → `TrackDetailPage.tsx`
|
||||
- `features/profile/pages/user-profile-page/useUserProfilePage.ts` → `UserProfilePage.tsx`
|
||||
- `features/library/pages/library-page/useLibraryPage.ts` → `LibraryPage.tsx`
|
||||
|
||||
Ce pattern de séparation est **exemplaire** et devrait être généralisé aux composants plus anciens.
|
||||
**Positif** : La séparation services/hooks/components est généralement respectée dans les features refactorées (auth, playlists, tracks, streaming).
|
||||
|
||||
---
|
||||
|
||||
## A3. Gestion d'état
|
||||
|
||||
### Couches de state identifiées
|
||||
### Couches identifiées
|
||||
|
||||
| Couche | Technologie | Nombre | Scope |
|
||||
|--------|-------------|--------|-------|
|
||||
| State local | `useState` | ~200+ fichiers | Composant |
|
||||
| Context API | `createContext` | 4 contextes | Sous-arbre React |
|
||||
| Store global | Zustand | 7 stores | Application |
|
||||
| Server state | TanStack React Query | ~50+ hooks | Cache serveur |
|
||||
| URL state | React Router | Params + query | URL |
|
||||
**1. Zustand Stores (7 stores)** :
|
||||
|
||||
### Stores Zustand détaillés
|
||||
| Store | Fichier | Contenu | Persisté | Sync tabs |
|
||||
|-------|---------|---------|----------|-----------|
|
||||
| `authStore` | `features/auth/store/authStore.ts` | isAuthenticated, isLoading, error | ✅ localStorage | ✅ broadcastSync |
|
||||
| `uiStore` | `stores/ui.ts` | theme, language, sidebarOpen, notifications | ✅ localStorage | ✅ broadcastSync |
|
||||
| `cartStore` | `stores/cartStore.ts` | items, actions CRUD | ✅ localStorage | ❌ |
|
||||
| `playerStore` | `features/player/store/playerStore.ts` | Playback state | Probable | À vérifier |
|
||||
| `chatStore` | `features/chat/store/chatStore.ts` | Chat state | À vérifier | À vérifier |
|
||||
| `libraryStore` | `stores/library.ts` | Library state | À vérifier | À vérifier |
|
||||
| `rateLimitStore` | `stores/rateLimit.ts` | Rate limit tracking | ❌ | ❌ |
|
||||
|
||||
| Store | Fichier | State contenu | Middlewares | Persisté |
|
||||
|-------|---------|--------------|-------------|----------|
|
||||
| UI Store | `stores/ui.ts` | theme, language, sidebarOpen, notifications | persist, devtools, broadcastSync | Oui (theme, language, sidebarOpen) |
|
||||
| Library Store | `stores/library.ts` | filters | persist, devtools | Oui (filters) |
|
||||
| Cart Store | `stores/cartStore.ts` | items[], cart operations | persist | Oui |
|
||||
| Rate Limit Store | `stores/rateLimit.ts` | Rate limit headers | persist | Oui |
|
||||
| Auth Store | `features/auth/store/authStore.ts` | isAuthenticated, isLoading, error | persist, broadcastSync | Oui (isAuthenticated) |
|
||||
| Chat Store | `features/chat/store/chatStore.ts` | userId, conversations[], messages, typingUsers, WS state | devtools, immer | Non |
|
||||
| Player Store | `features/player/store/playerStore.ts` | currentTrack, isPlaying, queue[], volume, repeat, shuffle | persist | Oui (volume, queue, etc.) |
|
||||
**2. React Query (TanStack Query v5)** — Server state principal :
|
||||
- Utilisé dans ~30+ fichiers [features/playlists/hooks/usePlaylist.ts, features/tracks/components/LikeButton.tsx, etc.]
|
||||
- `useQuery` pour lectures, `useMutation` pour écritures
|
||||
- Optimistic updates via `utils/optimisticUpdates.ts` [utils/optimisticUpdates.ts]
|
||||
- Cache sync cross-tabs via `utils/reactQuerySync.ts` [app/App.tsx:47-54]
|
||||
- QueryClient config : `staleTime: 1min`, `gcTime: 5min`, `retry: false` [main.tsx:43-55]
|
||||
|
||||
### Contextes React
|
||||
**3. React Context (4 contextes)** :
|
||||
- `AuthContext` [context/AuthContext.tsx] — **CONFLIT** avec `authStore` : deux sources de vérité pour l'auth
|
||||
- `AudioContext` [context/audio-context/AudioContext.tsx] — contexte audio player
|
||||
- `ThemeProvider` [components/theme/ThemeProvider.tsx] — thème (mais aussi géré par uiStore)
|
||||
- `ToastProvider` [components/feedback/ToastProvider.tsx] — gestion toasts
|
||||
|
||||
| Contexte | Fichier | Contenu | Hook |
|
||||
|----------|---------|---------|------|
|
||||
| AuthContext | `context/AuthContext.tsx` | user, isAuthenticated, isLoading, login, register, logout | `useAuth()` |
|
||||
| ToastContext | `components/feedback/ToastProvider.tsx` | toasts[], addToast, removeToast | `useToast()` |
|
||||
| ThemeContext | `components/theme/ThemeProvider.tsx` | theme, setTheme | `useTheme()` |
|
||||
| AudioContext | `context/audio-context/AudioContext.tsx` | Audio playback context | `useAudio()` |
|
||||
**4. State local (useState)** — usage standard dans les composants
|
||||
|
||||
### Diagnostic
|
||||
### Diagnostique
|
||||
|
||||
**Conflits entre couches** :
|
||||
- ⚠️ **Auth** : `AuthContext` (Context API) **et** `authStore` (Zustand) gèrent l'authentification — **duplication potentielle**. Le contexte gère `user` + `isAuthenticated`, le store aussi `isAuthenticated`. Source de vérité ambiguë. [context/AuthContext.tsx] [features/auth/store/authStore.ts]
|
||||
- ✅ **Player** : Zustand store uniquement — pas de conflit
|
||||
- ✅ **Server data** : React Query pour les données serveur — bien séparé du state UI
|
||||
**Conflit critique** : `AuthContext` [context/AuthContext.tsx:54] utilise `authService` directement avec `useState` pour `user`, tandis que `authStore` [features/auth/store/authStore.ts:55] utilise Zustand avec `loginService` et gère `isAuthenticated` sans `user` (délégué à React Query). **L'App.tsx utilise `authStore`** [app/App.tsx:4], mais `AuthContext` existe toujours et pourrait être importé par erreur. → **Source de bugs potentiels.**
|
||||
|
||||
**State normalisé ?** :
|
||||
- ⚠️ Le chat store contient `conversations[]` et `messages{}` — normalisé par conversation, mais les messages sont dans un Map — acceptable pour le chat
|
||||
- ✅ Les données serveur (tracks, playlists) sont gérées par React Query (cache automatique)
|
||||
- ❌ Pas de normalisation explicite des entités côté client (pas de store normalisé type normalizr)
|
||||
**Duplication thème** : Le thème est géré par `uiStore` [stores/ui.ts:35-51] ET `ThemeProvider` [components/theme/ThemeProvider.tsx]. L'App.tsx applique le thème via `uiStore` [app/App.tsx:80-94].
|
||||
|
||||
**Prop drilling > 3 niveaux** :
|
||||
- Pas de chaîne évidente détectée. Les données descendent via hooks (React Query) ou stores (Zustand), rarement par props directes sur plus de 2 niveaux. [DONNÉES INSUFFISANTES — nécessite analyse AST complète]
|
||||
**Prop drilling** : Minimisé grâce à Zustand + React Query. Pas de chaîne > 3 niveaux identifiée dans le code audité.
|
||||
|
||||
---
|
||||
|
||||
## A4. Gestion des requêtes réseau
|
||||
|
||||
### Architecture API
|
||||
### Architecture réseau
|
||||
|
||||
**Client centralisé** : `services/api/client.ts` (2 237 lignes) — Axios-based avec intercepteurs complets.
|
||||
Le projet utilise un **client Axios centralisé** dans `services/api/client.ts` [services/api/client.ts:1-80] qui fournit :
|
||||
- Interceptors pour JWT (Authorization header) [client.ts:9]
|
||||
- Refresh token automatique [client.ts:10]
|
||||
- Validation Zod des requêtes et réponses [client.ts:24-25]
|
||||
- Request deduplication [client.ts:21]
|
||||
- Response caching [client.ts:22]
|
||||
- Offline queue [client.ts:20]
|
||||
- Rate limiting tracking [client.ts:27]
|
||||
- CSRF protection [client.ts:14]
|
||||
- Validation metrics [client.ts:33-41]
|
||||
|
||||
**Features du client** :
|
||||
- Token refresh automatique sur 401 avec queue de requêtes
|
||||
- CSRF token injection avec retry sur 403
|
||||
- Validation requêtes/réponses via Zod schemas
|
||||
- Retry logic : 3 retries, exponential backoff (1s → 10s max)
|
||||
- Request deduplication pour GET
|
||||
- Response caching pour GET
|
||||
- Rate limit tracking
|
||||
- Slow request detection (seuil 1000ms)
|
||||
- Offline queue support
|
||||
### Patterns observés
|
||||
|
||||
### Inventaire des services
|
||||
|
||||
**56 fichiers service/API** répartis entre `services/` (partagés) et `features/*/services/` (feature-specific).
|
||||
|
||||
### Tableau des patterns d'appels majeurs
|
||||
|
||||
| Fichier | Pattern | Loading | Error | Cancel | Typé | Centralisé |
|
||||
| Fichier | Méthode | Loading | Error | Cancel | Typé | Centralisé |
|
||||
|---------|---------|---------|-------|--------|------|------------|
|
||||
| `services/api/client.ts` | Axios + interceptors | ✅ | ✅ Centralisé | ✅ AbortController | ✅ | ✅ |
|
||||
| `services/api/auth.ts` | apiClient.post/get | Via RQ | Via interceptor | ❌ | ✅ | ✅ |
|
||||
| `features/tracks/api/trackApi.ts` | apiClient wrappers | Via RQ | Via interceptor | ❌ | ✅ | ✅ |
|
||||
| `features/playlists/services/playlistService.ts` | apiClient.get/post | Via RQ | Via interceptor | ❌ | ✅ | ✅ |
|
||||
| `features/tracks/services/uploadService.ts` | apiClient.post + FormData | useState | try/catch custom | ❌ | ✅ | ✅ |
|
||||
| `services/websocket.ts` | WebSocket natif | Manual | Reconnect auto | ❌ | ⚠️ any×8 | ✅ |
|
||||
| `features/streaming/services/hlsService.ts` | HLS.js | Via RQ | Via HLS.js | ❌ | ✅ | Spécifique |
|
||||
| `features/tracks/api/trackApi.ts` | Axios client | Via React Query | Via React Query | Via React Query | ✅ Zod | ✅ |
|
||||
| `services/api/auth.ts` | Axios client | Manual/Store | ✅ parseApiError | ❌ | ✅ Zod | ✅ |
|
||||
| `features/playlists/services/playlistService.ts` | Axios client | Via hooks | ✅ | ❌ | ✅ | ✅ |
|
||||
| `services/websocket.ts` | WebSocket natif | ❌ | ✅ reconnect | N/A | ⚠️ `any` (8x) | ✅ |
|
||||
| `context/AuthContext.tsx` | authService direct | ✅ useState | ✅ toast | ❌ | ❌ `any` (4x) | ⚠️ Parallèle |
|
||||
|
||||
### React Query configuration
|
||||
**Positif** : Architecture réseau très mature avec dedup, caching, offline queue, validation Zod end-to-end, CSRF. C'est au-dessus de la moyenne.
|
||||
|
||||
- `staleTime`: 1 minute
|
||||
- `gcTime`: 5 minutes
|
||||
- `retry: false` (délégué au retry Axios)
|
||||
- `refetchOnWindowFocus: false`
|
||||
- `refetchOnReconnect: true`
|
||||
|
||||
### Patterns d'erreur
|
||||
|
||||
- **3 patterns coexistent** :
|
||||
1. Try/catch avec custom error types (trackService)
|
||||
2. Propagation directe (erreurs gérées par l'intercepteur)
|
||||
3. `handleApiServiceError()` utility wrapper
|
||||
- **Incohérence notable** : certains services gèrent les erreurs localement, d'autres délèguent entièrement
|
||||
|
||||
### AbortController
|
||||
|
||||
- ✅ Implémenté dans le client (`createCancellableRequest()`, `createRequestWithTimeout()`)
|
||||
- ⚠️ Faiblement adopté dans les services individuels
|
||||
- ⚠️ React Query hooks n'utilisent pas systématiquement le `signal`
|
||||
**Négatif** : Le client.ts est un monolithe de 2237L qui concentre trop de responsabilités. L'AbortController n'est pas systématiquement utilisé pour annuler les requêtes au démontage.
|
||||
|
||||
---
|
||||
|
||||
## A5. Routing
|
||||
|
||||
### Solution
|
||||
- **Solution** : React Router DOM v6.22 [package.json]
|
||||
- **Routes protégées** : `ProtectedRoute` component wrapper [routeConfig.tsx:47-55] + `ProtectedLayoutRoute` pour le layout dashboard
|
||||
- **Lazy loading** : ✅ Toutes les routes sont lazy via `LazyComponent` [routeConfig.tsx:5-33] — pattern `React.lazy` centralisé
|
||||
- **404** : ✅ `LazyNotFound` route + catch-all `*` → `/404` [AppRouter.tsx:30-31]
|
||||
- **500** : ✅ `LazyServerError` route [routeConfig.tsx:107]
|
||||
- **Deep linking** : ✅ Routes paramétrées (`/tracks/:id`, `/u/:username`, `/playlists/*`) [routeConfig.tsx:87-88]
|
||||
- **ComingSoon** : Routes planifiées mais non implémentées utilisent un placeholder `ComingSoon` [routeConfig.tsx:96-101] — propre
|
||||
- **v7 migration** : Flags de préparation activés `v7_startTransition`, `v7_relativeSplatPath` [main.tsx:221-222]
|
||||
|
||||
- **React Router v6** (`react-router-dom ^6.22.0`)
|
||||
- `BrowserRouter` avec future flags activés [src/main.tsx:227]
|
||||
|
||||
### Routes
|
||||
|
||||
| Type | Nombre | Lazy-loaded |
|
||||
|------|--------|-------------|
|
||||
| Routes publiques (auth) | 5 | ✅ Toutes |
|
||||
| Routes publiques standalone | 2 | ✅ Toutes |
|
||||
| Routes protégées | 25 | ✅ Toutes |
|
||||
| Routes d'erreur | 2 | ✅ Toutes |
|
||||
| Catch-all (*) | 1 | Redirect → /404 |
|
||||
| **Total** | **35** | **100% lazy** |
|
||||
|
||||
### Guards d'authentification
|
||||
|
||||
- `ProtectedRoute` : vérifie `isAuthenticated` + `TokenStorage.getAccessToken()` → redirect `/login` [components/auth/ProtectedRoute.tsx]
|
||||
- `PublicRoute` : redirige les utilisateurs connectés vers `/dashboard` [router/PublicRoute.tsx]
|
||||
- `ProtectedLayoutRoute` : wraps routes protégées avec `DashboardLayout` [router/ProtectedLayoutRoute.tsx]
|
||||
|
||||
### 404 / Fallback
|
||||
|
||||
- ✅ Route `/404` avec composant `LazyNotFound`
|
||||
- ✅ Route `/500` avec composant `LazyServerError`
|
||||
- ✅ Catch-all `*` → redirect `/404`
|
||||
- ✅ Error boundaries wrappent les routes d'erreur
|
||||
|
||||
### Lazy loading
|
||||
|
||||
**100% des routes sont lazy-loaded** via `createLazyComponent` [components/ui/lazy-component/lazyExports.ts:3-235]. Implémentation basée sur `React.lazy()` + `Suspense` + `LazyErrorBoundary` — excellente configuration.
|
||||
|
||||
### Deep linking
|
||||
|
||||
- ✅ Routes bookmarkables (paths sémantiques : `/tracks/:id`, `/playlists/*`, `/u/:username`)
|
||||
- ✅ Paramètres URL utilisés (`useParams`, `useSearchParams`)
|
||||
**Verdict routing** : Solide, bien structuré, lazy loading systématique. **+1 point.**
|
||||
|
||||
---
|
||||
|
||||
## A6. Gestion des erreurs
|
||||
|
||||
### Error boundaries
|
||||
### Error Boundaries
|
||||
|
||||
| Boundary | Fichier | Scope | Sentry |
|
||||
|----------|---------|-------|--------|
|
||||
| Main ErrorBoundary | `components/ui/ErrorBoundary.tsx` | Routes + app | ✅ |
|
||||
| Legacy ErrorBoundary | `components/ErrorBoundary.tsx` | Backup | ✅ |
|
||||
| LazyErrorBoundary | `components/ui/lazy-component/LazyErrorBoundary.tsx` | Lazy components | ❌ |
|
||||
| PlaylistErrorBoundary | `features/playlists/components/PlaylistErrorBoundary.tsx` | Feature playlist | ❌ |
|
||||
|
||||
- ✅ Error boundary au niveau app (App.tsx wrappé)
|
||||
- ✅ Error boundaries au niveau routes
|
||||
- ⚠️ 2 implémentations ErrorBoundary (main + legacy) — duplication
|
||||
- **Root level** : `ErrorBoundary` wrapping `App` [app/App.tsx:171]
|
||||
- **Route level** : Chaque route wrappée dans `ErrorBoundary` [routeConfig.tsx:40-55]
|
||||
- **Composant** : `ErrorBoundary` class component avec UI de fallback, retry et go-home [components/ui/ErrorBoundary.tsx:16-77]
|
||||
- **Fallback configurable** : ✅ via prop `fallback` [ErrorBoundary.tsx:13]
|
||||
|
||||
### Erreurs réseau
|
||||
|
||||
- ✅ Feedback utilisateur via toast notifications (react-hot-toast)
|
||||
- ✅ Parsing centralisé (`parseApiError()`) avec messages user-friendly
|
||||
- ✅ Catégorisation : network, validation, auth, rate_limit, server_error, timeout
|
||||
- ⚠️ Inconsistance : inline error display vs toast-only selon les composants
|
||||
- `parseApiError` centralisé [utils/apiErrorHandler.ts] — 578L, parsing exhaustif des erreurs Axios
|
||||
- `formatUserFriendlyError` [utils/errorMessages.ts] — messages user-friendly
|
||||
- Toast pour feedback utilisateur [utils/toast.ts]
|
||||
- Rate limit indicator [components/RateLimitIndicator.tsx]
|
||||
- Offline indicator [components/OfflineIndicator.tsx]
|
||||
|
||||
### Erreurs de formulaire
|
||||
### Logging
|
||||
|
||||
- ✅ React Hook Form + Zod pour la validation client
|
||||
- ✅ Validation timing configurable (onBlur, onChange, onSubmit)
|
||||
- ⚠️ Validation serveur : certains formulaires ne remontent pas les erreurs serveur dans les champs
|
||||
|
||||
### Logging frontend
|
||||
|
||||
- ✅ **Sentry** configuré (`lib/sentry.ts`) avec :
|
||||
- Browser tracing
|
||||
- Session replay
|
||||
- Error filtering (prod uniquement si `VITE_SENTRY_DSN` défini)
|
||||
- Context enrichment via logger
|
||||
- ✅ Logger custom (`utils/logger.ts`) avec Sentry integration dynamique
|
||||
- Sentry intégré [lib/sentry.ts, main.tsx:28,41]
|
||||
- Logger structuré custom [utils/logger.ts] avec niveaux et contexte
|
||||
|
||||
### Fallback UI
|
||||
|
||||
- ✅ Skeleton loaders pour les chargements
|
||||
- ✅ `ErrorDisplay` component pour les erreurs
|
||||
- ✅ `ComingSoon` component pour les features non implémentées
|
||||
- ✅ Empty states gérés (composants `*Empty.tsx` dans les views)
|
||||
- Error states avec `ErrorDisplay` component [components/ui/ErrorDisplay.tsx]
|
||||
- Loading states avec spinners et skeletons (chaque view a un Skeleton)
|
||||
- Empty states gérés dans les features refactorées
|
||||
|
||||
**Verdict erreurs** : Gestion des erreurs très complète — error boundaries, parsing centralisé, Sentry, toasts, offline detection. **+1 point.**
|
||||
|
||||
---
|
||||
|
||||
## SCORE ARCHITECTURE : 7/10
|
||||
## Score Architecture détaillé
|
||||
|
||||
### Points gagnés
|
||||
| Critère | Points | Justification |
|
||||
|---------|--------|---------------|
|
||||
| Structure feature-based | +2 | Organisation claire en features avec séparation concerns |
|
||||
| Migration incomplète | -2 | Dualité components/views vs features/pages, fichiers legacy |
|
||||
| State management | +1.5 | Zustand + React Query bien intégré, sync cross-tabs |
|
||||
| Conflit AuthContext vs authStore | -1 | Deux sources de vérité pour l'auth |
|
||||
| Client API centralisé | +1.5 | Validation Zod, dedup, caching, offline queue |
|
||||
| client.ts monolithe | -0.5 | 2237L, trop de responsabilités |
|
||||
| Routing | +1.5 | Lazy loading systématique, 404/500, guards |
|
||||
| Error handling | +1.5 | ErrorBoundary, Sentry, toast, offline indicator |
|
||||
| Barrel exports partiels | -0.5 | Incohérents entre features et components |
|
||||
| TypeScript strict | +1 | Mode strict complet avec noUncheckedIndexedAccess |
|
||||
| **Total** | **7/10** | **Solide, mais dette structurelle de migration** |
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| Routing | +1.0 | 100% lazy-loaded, guards, 404, deep linking — exemplaire |
|
||||
| TypeScript strict | +1.0 | Config stricte complète, types omniprésents |
|
||||
| Server state | +1.0 | React Query bien configuré, staleTime/gcTime cohérents |
|
||||
| Error handling | +0.8 | Sentry + boundaries + parsing centralisé |
|
||||
| Feature modules | +0.7 | features/ bien structuré pour les domaines principaux |
|
||||
| API client | +0.8 | Client robuste : retry, dedup, cache, CSRF, offline queue |
|
||||
| Hooks pattern | +0.7 | useXxxPage() pattern bien adopté dans les features récentes |
|
||||
|
||||
### Points perdus
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| Dualité components/features | -1.0 | 40% des domaines ont des composants dans les deux dossiers |
|
||||
| Auth state duplication | -0.5 | AuthContext + authStore — source de vérité ambiguë |
|
||||
| `as any` endémique | -0.5 | 706 `as any` casts — ESLint rule explicitement `off` |
|
||||
| Client.ts monstre | -0.5 | 2 237 lignes — devrait être décomposé en modules |
|
||||
| Inconsistance error handling | -0.3 | 3 patterns d'erreur coexistent dans les services |
|
||||
| AbortController sous-utilisé | -0.2 | Implémenté mais faiblement adopté |
|
||||
**Résumé** : L'architecture est ambitieuse et globalement solide, avec des patterns modernes (feature-based, server state séparé, validation Zod). Le principal problème est la migration incomplète qui laisse coexister deux patterns architecturaux (`components/views/` vs `features/pages/`, `AuthContext` vs `authStore`). Le client HTTP centralisé est puissant mais devenu un monolithe à découper.
|
||||
|
|
|
|||
|
|
@ -1,53 +1,22 @@
|
|||
# PHASE B — DESIGN SYSTEM INVENTORY
|
||||
# Phase B — Design System Inventory
|
||||
|
||||
> **Design System identifié** : "KODO Design System v3.0 — The Path of Sound"
|
||||
> **Stratégie CSS** : Tailwind v4 CSS-first + CSS vanilla custom
|
||||
**Score Design System : 7.5/10**
|
||||
|
||||
---
|
||||
|
||||
## B1. Stratégie CSS
|
||||
|
||||
### Approches détectées
|
||||
### Stratégie dominante : Tailwind CSS v4 + Custom Design System (SUMI v2.0)
|
||||
|
||||
| Approche | Fichiers | Usage |
|
||||
|----------|----------|-------|
|
||||
| Tailwind CSS v4 (classes utilitaires) | ~1 400+ .tsx | **Dominante** — toutes les classes de layout, spacing, typography |
|
||||
| CSS vanilla custom (`src/styles/*.css`) | 17 fichiers | **Secondaire** — design system tokens, composants CSS natifs, fixes |
|
||||
| Inline styles (`style={{}}`) | **75 fichiers** | **Problématique** — progress bars, animations, dynamic values |
|
||||
| CSS Modules | **0** | Non utilisé |
|
||||
| Styled-components / Emotion | **0** | Non utilisé |
|
||||
| SCSS | **0** | Non utilisé |
|
||||
| Approche | Fichiers utilisant | Rôle |
|
||||
|----------|-------------------|------|
|
||||
| Tailwind CSS v4 (CSS-first) | ~1400+ tsx files | Stratégie principale, utility-first |
|
||||
| CSS Variables (SUMI tokens) | 1 fichier central (`index.css`, 859L) | Single source of truth |
|
||||
| `class-variance-authority` (CVA) | `button.tsx` et quelques composants | Variants structurées |
|
||||
| Inline styles (`style={{}}`) | 87 occurrences / 69 fichiers | Dynamic values (progress, position) |
|
||||
| CSS vanilla | 5 fichiers `.css` | Base styles, animations |
|
||||
|
||||
### Détail des fichiers CSS
|
||||
|
||||
| Fichier | LOC est. | Rôle | Qualité |
|
||||
|---------|----------|------|---------|
|
||||
| `src/index.css` | 1 414 | Design system principal + dark mode + utilities | ✅ Bien structuré |
|
||||
| `src/styles/design-tokens.css` | ~300+ | Tokens Tailwind v4 `@theme` (spacing, width, breakpoints, radius) | ✅ Token-first |
|
||||
| `src/styles/design-system.css` | ~500+ | Extended palette (`--veza-*`), shadows, typography, layouts | ⚠️ Duplication avec index.css |
|
||||
| `src/styles/button.css` | ~200+ | `.btn-veza` variants (primary, gaming, terminal, nature...) | ⚠️ Duplique Button.tsx |
|
||||
| `src/styles/card.css` | ~200+ | `.card-veza` variants (manga, neon, glass...) | ⚠️ Duplique Card.tsx |
|
||||
| `src/styles/badge-avatar.css` | ~300+ | Badge + Avatar CSS variants | ⚠️ Duplique Badge/Avatar.tsx |
|
||||
| `src/styles/header.css` | ~150+ | Header CSS (backdrop blur, brand, nav) | ⚠️ Duplique Header.tsx |
|
||||
| `src/styles/input.css` | ~200+ | Form controls CSS (input, select, checkbox, switch) | ⚠️ Duplique Input.tsx |
|
||||
| `src/styles/fix-input-focus.css` | ~50+ | Override focus styles avec `!important` | 🔴 Hack — `!important` |
|
||||
| `src/styles/fix-login-form.css` | ~80+ | Fixes aggressifs form login avec `!important` | 🔴 Hack — `!important` |
|
||||
| `src/styles/global-effects.css` | ~150+ | Noise, scanlines, page layouts, grid utilities | ⚠️ Overlaps avec index.css |
|
||||
| `src/styles/premium-utilities.css` | ~150+ | Animations, hover effects, glass, gradients | ⚠️ Overlaps avec index.css |
|
||||
| `src/styles/visual-enhancements.css` | ~200+ | Neon glows, premium gradients, depth effects | ⚠️ Overlaps avec index.css |
|
||||
|
||||
### Problème critique : double système de tokens
|
||||
|
||||
**Deux systèmes de variables CSS coexistent** :
|
||||
|
||||
1. **`--kodo-*`** variables dans `design-tokens.css` et `index.css` — void, cyan, magenta, lime
|
||||
2. **`--veza-*`** variables dans `design-system.css` — void, ink, graphite, slate, steel, nadir
|
||||
|
||||
Les noms de couleurs divergent : `--void-500` vs `--veza-void`, `--cyan-500` vs `--kodo-cyan`. Cela crée une **confusion sur la source de vérité** pour les couleurs.
|
||||
|
||||
### Problème : CSS vanilla parallèle aux composants React
|
||||
|
||||
Les fichiers `button.css`, `card.css`, `badge-avatar.css`, `input.css` définissent des classes CSS (`.btn-veza--primary`, `.card-veza--manga`) qui **doublent** les variants déjà gérés dans les composants React (`button.tsx`, `card.tsx`). C'est un vestige probable d'une phase de prototypage HTML qui n'a pas été nettoyé.
|
||||
**Verdict CSS** : Stratégie cohérente. Tailwind v4 CSS-first est bien configuré avec un système de tokens custom SUMI qui mappe vers des semantic tokens Tailwind via `@theme inline`. Pas de CSS Modules, pas de styled-components, pas de SCSS. L'approche est unifiée. Les inline styles sont justifiés (valeurs dynamiques runtime). **Pas de mélange incohérent.**
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -55,280 +24,240 @@ Les fichiers `button.css`, `card.css`, `badge-avatar.css`, `input.css` définiss
|
|||
|
||||
### Couleurs
|
||||
|
||||
#### Palette principale (via CSS variables)
|
||||
Le design system SUMI v2.0 définit un système de couleurs complet dans `index.css` [index.css:15-296] :
|
||||
|
||||
| Token | Valeur OKLCH | Usage | Défini dans token ? |
|
||||
|-------|-------------|-------|---------------------|
|
||||
| `--primary` | `oklch(0.75 0.18 195)` | Neon Cyan — CTA, links, accents | ✅ Oui |
|
||||
| `--secondary` | `oklch(0.65 0.25 330)` | Hot Magenta — accents secondaires | ✅ Oui |
|
||||
| `--destructive` | `oklch(0.60 0.22 25)` | Rouge — danger, suppression | ✅ Oui |
|
||||
| `--success` | `oklch(0.72 0.19 145)` | Vert — confirmation | ✅ Oui |
|
||||
| `--warning` | `oklch(0.80 0.16 75)` | Jaune/orange — avertissement | ✅ Oui |
|
||||
| `--info` | `oklch(0.70 0.15 230)` | Bleu — information | ✅ Oui |
|
||||
| `--muted` | `oklch(0.96 0.005 265)` | Gris neutre — backgrounds secondaires | ✅ Oui |
|
||||
| `--background` | `oklch(0.985 0 0)` / `oklch(0.10 0.005 265)` | Fond principal (light/dark) | ✅ Oui |
|
||||
| `--foreground` | `oklch(0.145 0.005 265)` / `oklch(0.98 0 0)` | Texte principal (light/dark) | ✅ Oui |
|
||||
| `--border` | `oklch(0.90 0.005 265)` / `oklch(1 0 0 / 0.08)` | Bordures (light/dark) | ✅ Oui |
|
||||
**Palette Dark (par défaut)** :
|
||||
|
||||
#### Échelles de couleurs étendues
|
||||
| Token | Valeur Hex | Usage | Défini token ? |
|
||||
|-------|-----------|-------|----------------|
|
||||
| `--sumi-bg-void` | `#0c0c0f` | Background le plus profond | ✅ |
|
||||
| `--sumi-bg-base` | `#121215` | Background principal | ✅ |
|
||||
| `--sumi-bg-raised` | `#1a1a1f` | Surfaces élevées | ✅ |
|
||||
| `--sumi-bg-overlay` | `#222228` | Overlays, dropdowns | ✅ |
|
||||
| `--sumi-bg-hover` | `#2a2a31` | Hover states | ✅ |
|
||||
| `--sumi-bg-active` | `#32323a` | Active states | ✅ |
|
||||
| `--sumi-bg-wash` | `#18181d` | Wash/subtle bg | ✅ |
|
||||
| `--sumi-text-primary` | `#f0ede8` | Texte principal (crème chaud) | ✅ |
|
||||
| `--sumi-text-secondary` | `#a8a4a0` | Texte secondaire | ✅ |
|
||||
| `--sumi-text-tertiary` | `#706c68` | Texte tertiaire | ✅ |
|
||||
| `--sumi-text-disabled` | `#4a4844` | Texte désactivé | ✅ |
|
||||
| `--sumi-accent` | `#7c9dd6` | Accent principal (bleu-acier) | ✅ |
|
||||
| `--sumi-vermillion` | `#d4634a` | Destructive/error | ✅ |
|
||||
| `--sumi-sage` | `#7a9e6c` | Success/positif | ✅ |
|
||||
| `--sumi-gold` | `#c9a84c` | Warning/attention | ✅ |
|
||||
| `--sumi-live` | `#e05a5a` | Live indicator | ✅ |
|
||||
|
||||
| Échelle | Tokens (50→900) | Usage |
|
||||
|---------|-----------------|-------|
|
||||
| `--void-*` | 12 niveaux | Échelle de gris thématique |
|
||||
| `--cyan-*` | 10 niveaux | Bleu néon |
|
||||
| `--magenta-*` | 10 niveaux | Rose/violet néon |
|
||||
| `--lime-*` | 10 niveaux | Vert néon |
|
||||
**Palette Light** [index.css:301-364] : Complète avec les mêmes tokens adaptés au light mode.
|
||||
|
||||
#### Couleurs thématiques spéciales
|
||||
**Couleurs contextuelles (feature-specific)** [index.css:202-205] :
|
||||
- `--graffiti-magenta: #c840a0`
|
||||
- `--gaming-gold: #d4b040`
|
||||
- `--terminal-green: #3eaa5e`
|
||||
- `--sakura: #e0a0b8`
|
||||
|
||||
| Token | Usage |
|
||||
|-------|-------|
|
||||
| `--xp-gold`, `--hp-red`, `--mp-blue`, `--shield-purple` | Gaming UI |
|
||||
| `--moss`, `--bark`, `--leaf` | Nature tones |
|
||||
| `--sakura`, `--yurei`, `--shonen` | Manga spectrum |
|
||||
| `--terminal-green`, `--terminal-amber`, `--matrix` | Terminal theme |
|
||||
**Semantic mapping (shadcn/Radix)** [index.css:207-251] :
|
||||
Les tokens SUMI sont mappés vers les conventions shadcn (`--primary`, `--secondary`, `--destructive`, `--muted`, etc.) ce qui permet d'utiliser les classes Tailwind standards (`bg-primary`, `text-muted-foreground`).
|
||||
|
||||
#### Couleurs hardcodées (non-tokenisées)
|
||||
**Couleurs hardcodées dans le code** :
|
||||
- `bg-[#...]` / `text-[#...]` dans className : **0 occurrence** ✅ Excellent
|
||||
- Hex dans CSS vars uniquement dans `index.css` — aucune couleur orpheline
|
||||
|
||||
| Valeur | Fichier(s) | Occurrences | Devrait être tokenisé ? |
|
||||
|--------|-----------|-------------|------------------------|
|
||||
| `#66FCF1` | SwaggerUI, AppearanceSettings, Analytics, ThemeSwitcher, MetadataEditor, VisualizerSettings | 8+ | ✅ Oui → `--cyan-500` |
|
||||
| `#36E5D1` | AppearanceSettings, Analytics | 3+ | ✅ Oui → `--cyan-300` |
|
||||
| `#8A7EA4` | AppearanceSettings, VisualizerSettings | 2+ | ✅ Oui → `--magenta-*` |
|
||||
| `#E4B314` | AppearanceSettings, VisualizerSettings | 2+ | ✅ Oui → `--xp-gold` |
|
||||
| `#E63946` | AppearanceSettings, VisualizerSettings | 2+ | ✅ Oui → `--destructive` |
|
||||
| `#3b82f6` | Charts (BarChart, LineChart, PlaybackDashboard) | 4+ | ✅ Oui → `--chart-1` |
|
||||
| `#4285F4` etc. | OAuthButton (Google brand colors) | 4 | ❌ Non (brand colors imposées) |
|
||||
| `#2a2a2a`, `#1a1a1a` | button.css (gaming variant) | 3 | ✅ Oui → `--void-*` |
|
||||
**Verdict couleurs** : Système de couleurs exemplaire. Tokenisé, thémé (dark/light), sémantique, zéro hardcoded. **9/10 sur ce critère.**
|
||||
|
||||
**Total couleurs hardcodées** : ~30+ occurrences dans 12 fichiers
|
||||
---
|
||||
|
||||
### Typographies
|
||||
### Typographie
|
||||
|
||||
| Token | Valeurs | Source |
|
||||
|-------|---------|--------|
|
||||
| `--font-sans` | `'Barlow', 'Inter', 'Noto Sans JP', system-ui, sans-serif` | index.css |
|
||||
| `--font-mono` | `'JetBrains Mono', 'Consolas', monospace` | index.css |
|
||||
| `--font-display` | `'Orbitron', 'Bebas Neue', sans-serif` | index.css |
|
||||
| `--font-jp` | `'Noto Sans JP', sans-serif` | index.css |
|
||||
| `--font-heading` | `'Barlow'` | design-system.css |
|
||||
| `--font-body` | `'Inter'` | design-system.css |
|
||||
| `--font-serif` | `'Source Serif 4'` | design-system.css |
|
||||
**Polices** [index.css:78-81] :
|
||||
- Body : `Inter` (Google Font) — excellent choix lisibilité
|
||||
- Headings : `Space Grotesk` — caractère distinctif
|
||||
- Mono : `JetBrains Mono` — technique/code
|
||||
- Serif : `Noto Serif JP` — accent japonais (cohérent avec l'identité SUMI)
|
||||
|
||||
**Problème** : Pas de `@font-face` déclarations — les polices sont chargées via Google Fonts (externe) ou espèrent être présentes localement. Aucun `font-display: swap` ni preload explicite dans le HTML. [DONNÉES INSUFFISANTES — nécessite inspection de `index.html`]
|
||||
**Échelle typographique** [index.css:83-91] :
|
||||
|
||||
**Échelle typographique** : Standard Tailwind (xs→5xl) + utilitaires custom :
|
||||
- `.text-display` (text-4xl bold tracking-tight)
|
||||
- `.text-heading-1` à `.text-heading-4`
|
||||
- `.text-body-lg`, `.text-body`
|
||||
- `.text-caption`, `.text-label`
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `--sumi-text-4xl` | 2.25rem (36px) | Display |
|
||||
| `--sumi-text-3xl` | 1.875rem (30px) | H1 |
|
||||
| `--sumi-text-2xl` | 1.5rem (24px) | H2 |
|
||||
| `--sumi-text-xl` | 1.25rem (20px) | H3 |
|
||||
| `--sumi-text-lg` | 1.125rem (18px) | H4 |
|
||||
| `--sumi-text-md` | 1rem (16px) | Body large |
|
||||
| `--sumi-text-base` | 0.875rem (14px) | Body default |
|
||||
| `--sumi-text-sm` | 0.8125rem (13px) | Small text |
|
||||
| `--sumi-text-xs` | 0.75rem (12px) | Caption |
|
||||
|
||||
✅ Échelle cohérente et bien définie.
|
||||
**Line-heights** [index.css:93-98] : 6 niveaux (none→loose), bien définis.
|
||||
**Tracking** [index.css:100-105] : 6 niveaux, de tighter à widest.
|
||||
**Weights** [index.css:107-111] : 5 niveaux (light→bold).
|
||||
|
||||
**Utility classes typographiques** [index.css:632-653] :
|
||||
- Classes sémantiques Tailwind : `.text-display`, `.text-heading-1` à `.text-heading-4`, `.text-body`, `.text-caption`, `.text-label`
|
||||
- Classes SUMI natives : `.sumi-display`, `.sumi-h1` à `.sumi-h4`, `.sumi-body`, `.sumi-caption`, `.sumi-label`, `.sumi-mono`
|
||||
|
||||
**Heading hierarchy** dans `@layer base` [index.css:506-516] :
|
||||
```css
|
||||
h1 { @apply text-4xl md:text-5xl; }
|
||||
h2 { @apply text-3xl md:text-4xl; }
|
||||
/* etc. */
|
||||
```
|
||||
|
||||
**Verdict typographie** : Complet et bien structuré. Double système (Tailwind + SUMI natif) est un peu redondant mais les deux sont cohérents. **8/10.**
|
||||
|
||||
---
|
||||
|
||||
### Spacing
|
||||
|
||||
- **Tailwind native** : échelle 4px base (0, 0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96)
|
||||
- **Layout tokens** : `--layout-gap` (16px), `--layout-gap-sm` (12px), `--layout-gap-lg` (24px)
|
||||
- **ESLint enforce** : règle custom interdisant les spacing arbitraires (`gap-[7px]`)
|
||||
- ✅ Cohérent — pas de valeurs excentriques détectées
|
||||
**Échelle de spacing** [index.css:113-127] :
|
||||
|
||||
### Border-radius
|
||||
| Token | Valeur | Scale |
|
||||
|-------|--------|-------|
|
||||
| `--sumi-space-0-5` | 2px | ✅ |
|
||||
| `--sumi-space-1` | 4px | ✅ |
|
||||
| `--sumi-space-1-5` | 6px | ✅ |
|
||||
| `--sumi-space-2` | 8px | ✅ |
|
||||
| `--sumi-space-3` | 12px | ✅ |
|
||||
| `--sumi-space-4` | 16px | ✅ |
|
||||
| `--sumi-space-5` | 20px | ✅ |
|
||||
| `--sumi-space-6` | 24px | ✅ |
|
||||
| `--sumi-space-8` | 32px | ✅ |
|
||||
| `--sumi-space-10` | 40px | ✅ |
|
||||
| `--sumi-space-12` | 48px | ✅ |
|
||||
| `--sumi-space-16` | 64px | ✅ |
|
||||
| `--sumi-space-20` | 80px | ✅ |
|
||||
|
||||
Suit une base 4px cohérente. Les valeurs `p-[`, `m-[`, `gap-[` arbitraires sont quasi-absentes (3+4+0 occurrences).
|
||||
|
||||
---
|
||||
|
||||
### Radius
|
||||
|
||||
[index.css:129-136] :
|
||||
|
||||
| Token | Valeur |
|
||||
|-------|--------|
|
||||
| `--sumi-radius-xs` | 2px |
|
||||
| `--sumi-radius-sm` | 4px |
|
||||
| `--sumi-radius-md` | 6px |
|
||||
| `--sumi-radius-lg` | 12px |
|
||||
| `--sumi-radius-xl` | 16px |
|
||||
| `--sumi-radius-2xl` | 20px |
|
||||
| `--sumi-radius-full` | 9999px |
|
||||
|
||||
`rounded-[` arbitraire : **0 occurrences** ✅
|
||||
|
||||
---
|
||||
|
||||
### Shadows
|
||||
|
||||
[index.css:138-146] : 7 niveaux + glow effect. Cohérent dark/light.
|
||||
|
||||
### Z-index
|
||||
|
||||
[index.css:179-189] : Cartographie ordonnée :
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `--radius` | `0.5rem` (8px) | Base |
|
||||
| `--radius-sm` | `calc(var(--radius) - 4px)` = 4px | Petits éléments |
|
||||
| `--radius-md` | `calc(var(--radius) - 2px)` = 6px | Medium |
|
||||
| `--radius-lg` | `var(--radius)` = 8px | Standard |
|
||||
| `--radius-xl` | `calc(var(--radius) + 4px)` = 12px | Cards |
|
||||
| `--radius-2xl` | `calc(var(--radius) + 8px)` = 16px | Large surfaces |
|
||||
| `--radius-full` | `9999px` | Pills, avatars |
|
||||
| `--sumi-z-base` | 0 | Éléments standard |
|
||||
| `--sumi-z-raised` | 10 | Éléments surélevés |
|
||||
| `--sumi-z-dropdown` | 100 | Dropdowns |
|
||||
| `--sumi-z-sticky` | 200 | Éléments sticky |
|
||||
| `--sumi-z-overlay` | 300 | Overlays |
|
||||
| `--sumi-z-modal` | 400 | Modales |
|
||||
| `--sumi-z-popover` | 500 | Popovers |
|
||||
| `--sumi-z-toast` | 600 | Toasts |
|
||||
| `--sumi-z-tooltip` | 700 | Tooltips |
|
||||
| `--sumi-z-max` | 999 | Maximum |
|
||||
|
||||
✅ Cohérent. ESLint enforce les valeurs tokenisées.
|
||||
**Problème** : 31 fichiers utilisent `z-[N]` arbitraire au lieu des tokens : `z-[60]`, `z-[200]`, `z-[300]`, `z-[400]`, `z-[500]`, `z-[9999]`. Cela contredit le système de tokens. [components/layout/Sidebar.tsx, components/layout/Header.tsx, features/player/components/PlayerExpanded.tsx, etc.] **-1 point.**
|
||||
|
||||
### Box-shadows
|
||||
---
|
||||
|
||||
| Token | Usage |
|
||||
|-------|-------|
|
||||
| `--shadow-card` / `--shadow-card-hover` | Cards standard |
|
||||
| `--shadow-card-glow-cyan` / `--shadow-card-glow-magenta` | Cards néon |
|
||||
| `--shadow-modal` | Modals |
|
||||
| `--shadow-tooltip` | Tooltips |
|
||||
| `--button-primary-glow` / `--button-primary-glow-hover` | Boutons primaires |
|
||||
| `--player-thumb-glow` / `--player-hover-glow` | Player |
|
||||
| `--slider-thumb-glow` | Sliders |
|
||||
| `--glow-cyan` / `--glow-magenta` / `--glow-lime` / `--glow-gold` | Néon glows génériques |
|
||||
| `--stat-icon-shadow` / `--stat-sparkline-glow` | Dashboard stats |
|
||||
| `--shadow-cover-depth` | Artwork covers |
|
||||
| `--shadow-gold-glow` | Gaming XP |
|
||||
| `--shadow-fab-glow` / `--shadow-fab-glow-hover` | FAB buttons |
|
||||
### Motion/Animations
|
||||
|
||||
✅ Très complet. ESLint enforce l'utilisation des tokens shadows.
|
||||
|
||||
### Z-index : cartographie
|
||||
|
||||
| Z-index | Élément | Source |
|
||||
|---------|---------|--------|
|
||||
| `9998` | Body noise overlay | `index.css:448` |
|
||||
| `9999` | Grid overlay (debug) | `gridOverlay.ts` |
|
||||
| `z-[200]` | ImageViewerModal | TSX |
|
||||
| `z-[110]` | SearchDropdown, PlayerExpanded, EditPlaylistModal | TSX |
|
||||
| `z-[100]` | ~20+ modals | TSX |
|
||||
| `1000` | Header | `header.css` |
|
||||
| `100` | Player | `--player-z-index` |
|
||||
| `95` | Sidebar | `--sidebar-z-index` |
|
||||
| `90` | Sidebar overlay | `--sidebar-overlay-z-index` |
|
||||
| `z-[60]` | DeleteAccountConfirmModal, FullPlayer | TSX |
|
||||
| `z-[35]` | Navbar backdrop | TSX |
|
||||
| `10` | Avatar group hover | `badge-avatar.css` |
|
||||
| `2` | Page content | `global-effects.css` |
|
||||
| `1` | Body background | `global-effects.css` |
|
||||
|
||||
**Problème** : Le z-index est un **chaos organisé**. Certaines valeurs sont tokenisées (sidebar, player), mais les modals utilisent des valeurs arbitraires (`z-[100]`, `z-[110]`, `z-[200]`) sans tokens centralisés. Le header à `z-index: 1000` dans CSS natif entre en conflit avec le player à `100` et la sidebar à `95`.
|
||||
|
||||
### Transitions / Animations
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `--duration-instant` | 100ms | Micro-interactions |
|
||||
| `--duration-fast` | 150ms | Hover |
|
||||
| `--duration-normal` | 250ms | Standard |
|
||||
| `--duration-immersive` | 200ms | Micro-interactions premium |
|
||||
| `--duration-slow` | 400ms | Entrées |
|
||||
| `--duration-slower` | 600ms | Transitions lentes |
|
||||
| `--ease-out` | `cubic-bezier(0.33, 1, 0.68, 1)` | Standard |
|
||||
| `--ease-in-out` | `cubic-bezier(0.65, 0, 0.35, 1)` | Standard |
|
||||
| `--ease-bounce` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Rebond |
|
||||
| `--ease-spring` | `cubic-bezier(0.175, 0.885, 0.32, 1.275)` | Spring |
|
||||
|
||||
✅ Animations tokenisées et cohérentes. `prefers-reduced-motion` respecté.
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Breakpoint | Valeur | Source |
|
||||
|-----------|--------|--------|
|
||||
| `sm` | 640px | Tailwind default |
|
||||
| `md` | 768px | Tailwind default |
|
||||
| `lg` | 1024px | Tailwind default |
|
||||
| `xl` | 1280px | Tailwind default |
|
||||
| `2xl` | 1536px | Tailwind default |
|
||||
|
||||
✅ Standard Tailwind. Pas de breakpoints custom détectés.
|
||||
[index.css:158-177] : Système de transitions complet avec durées et easing tokenisés.
|
||||
[index.css:655-671] : 15+ animations utility classes (fade-in, slide-up, scale-in, pop, shake, marquee, etc.)
|
||||
[index.css:730-845] : Keyframes bien documentés.
|
||||
[index.css:850-858] : `prefers-reduced-motion` respecté ✅
|
||||
|
||||
---
|
||||
|
||||
## B3. Composants UI réutilisables
|
||||
|
||||
### Inventaire complet (60+ composants)
|
||||
| Composant | Fichier source | Props | Variantes | Réutilisé (fichiers) | Duplicatas ? |
|
||||
|-----------|---------------|-------|-----------|----------------------|-------------|
|
||||
| **Button** | `ui/button.tsx` | 8 (variant, size, asChild, icon, loading, etc.) | 7 variants × 4 sizes | **228** | Non |
|
||||
| **Input** | `ui/input.tsx` | 6 (icon, label, error, etc.) | SearchInput, FileUpload | **94** | Non |
|
||||
| **Card** | `ui/card.tsx` | Standard | Header, Content, Footer | **211** | Non |
|
||||
| **Skeleton** | `ui/skeleton.tsx` | Minimal | Pulse animation | **83** | Non |
|
||||
| **Toast** | `feedback/Toast.tsx` | Type (success/error/etc.) | react-hot-toast | **54** | Non |
|
||||
| **Label** | `ui/label.tsx` | Standard HTML | - | **32** | Non |
|
||||
| **Badge** | `ui/badge.tsx` | Variant | Multiple variants | **30** | Non |
|
||||
| **Dialog** | `ui/dialog/` | Compound pattern | Header, Body, Footer, Trigger | **29** | ⚠️ `modal.tsx` aussi |
|
||||
| **Avatar** | `ui/avatar.tsx` | Image, fallback | Sizes | **24** | Non |
|
||||
| **Modal** | `ui/modal.tsx` | Simple modal | - | **23** | ⚠️ Avec Dialog |
|
||||
| **Tooltip** | `ui/tooltip.tsx` | Content, side | - | **22** | Non |
|
||||
| **Select** | `ui/select/` | Compound pattern | Trigger, Content, Item | **17** | Non |
|
||||
| **Checkbox** | `ui/checkbox.tsx` | Standard | - | **17** | Non |
|
||||
| **Tabs** | `ui/tabs/` | Compound pattern | List, Trigger, Content | **12** | Non |
|
||||
| **DropdownMenu** | `ui/dropdown-menu/` | Compound pattern | Item, Checkbox, Radio | **7** | Non |
|
||||
| **Switch** | `ui/switch.tsx` | Standard | - | **6** | Non |
|
||||
|
||||
| Composant | Fichier | Variants | Imports | Taille | Verdict |
|
||||
|-----------|---------|----------|---------|--------|---------|
|
||||
| **Button** | `ui/button.tsx` | 10 variants, 4 sizes | ~200+ | 149L | ✅ Excellent — bien utilisé |
|
||||
| **Card** | `ui/card.tsx` | 11 variants, 4 paddings | ~200+ | 190L | ✅ Excellent — bien utilisé |
|
||||
| **Skeleton** | `ui/skeleton.tsx` | 3 variants | ~200+ | 94L | ✅ Excellent |
|
||||
| **Toast** | `ui/Toast.tsx` | 3 types | ~200+ | 112L | ✅ Excellent |
|
||||
| **Input** | `ui/input.tsx` | 1 | ~100+ | 69L | ✅ Bon |
|
||||
| **Modal** | `ui/modal.tsx` | 5 sizes | ~100+ | 151L | ✅ Bon |
|
||||
| **Dialog** | `ui/dialog/` | 4 variants, 5 sizes | ~100+ | 104L | ⚠️ Duplique Modal |
|
||||
| **Badge** | `ui/badge.tsx` | 11 variants, 3 sizes | ~30+ | 225L | ✅ Bon |
|
||||
| **Avatar** | `ui/avatar.tsx` | 7 sizes, 6 statuses | ~30+ | 306L | ⚠️ > 300 lignes |
|
||||
| **Checkbox** | `ui/checkbox.tsx` | 1 | ~50+ | 130L | ✅ Bon |
|
||||
| **Label** | `ui/label.tsx` | 1 | ~50+ | 51L | ✅ Minimal |
|
||||
| **FormField** | `ui/FormField.tsx` | 1 | ~50+ | 218L | ✅ Bon |
|
||||
| **Table** | `ui/table.tsx` | 1 + sortable | ~30+ | 306L | ⚠️ > 300 lignes |
|
||||
| **Textarea** | `ui/textarea.tsx` | 1 | ~30+ | 105L | ✅ Bon |
|
||||
| **Select** | `ui/select/` | 1 (searchable) | ~20+ | 77L | ✅ Bon |
|
||||
| **DropdownMenu** | `ui/dropdown-menu/` | 1 | ~30+ | 46L | ✅ Bon |
|
||||
| **Tabs** | `ui/tabs/` | 1 | ~20+ | 48L | ✅ Bon |
|
||||
| **Alert** | `ui/alert.tsx` | 6 variants | ~20+ | 186L | ✅ Bon |
|
||||
| **Tooltip** | `ui/tooltip/` | 4 positions | ~15+ | 58L | ✅ Bon |
|
||||
| **Switch** | `ui/switch.tsx` | 1 | ~20+ | 103L | ✅ Bon |
|
||||
| **Slider** | `ui/slider.tsx` | 1 | ~5+ | 171L | ✅ Bon |
|
||||
| **Progress** | `ui/progress.tsx` | 3 variants, 4 colors | ~10+ | 209L | ✅ Bon |
|
||||
| **RadioGroup** | `ui/radio-group.tsx` | 1 | ~10+ | 229L | ✅ Bon |
|
||||
| **ScrollArea** | `ui/scroll-area.tsx` | 1 | ~20+ | 57L | ✅ Minimal |
|
||||
| **EmptyState** | `ui/empty-state.tsx` | 3 variants, 3 sizes | ~10+ | 238L | ✅ Bon |
|
||||
| **ErrorDisplay** | `ui/ErrorDisplay.tsx` | 4 variants, 3 sizes | ~10+ | 247L | ✅ Bon |
|
||||
| **LoadingState** | `ui/LoadingState.tsx` | 4 variants, 3 sizes | ~20+ | 215L | ✅ Bon |
|
||||
| **LoadingSpinner** | `ui/loading-spinner.tsx` | 3 sizes | ~30+ | 93L | ⚠️ Duplique Spinner |
|
||||
| **Spinner** | `ui/Spinner.tsx` | 4 variants, 3 sizes | ~30+ | 109L | ⚠️ Duplique LoadingSpinner |
|
||||
| **Collapsible** | `ui/collapsible.tsx` | 1 | ~10+ | 199L | ✅ Bon |
|
||||
| **Accordion** | `ui/accordion/` | 2 types | ~5+ | 75L | ✅ Bon |
|
||||
| **ConfirmationDialog** | `ui/confirmation-dialog.tsx` | 2 variants | ~20+ | 166L | ✅ Bon |
|
||||
| **VirtualizedList** | `ui/virtualized-list/` | 1 | ~5+ | 126L | ✅ Bon |
|
||||
| **FileUpload** | `ui/file-upload/` | 1 | ~10+ | 62L | ✅ Bon |
|
||||
| **DatePicker** | `ui/date-picker/` | 2 modes | ~5+ | 62L | ✅ Bon |
|
||||
| **AvatarUpload** | `ui/avatar-upload/` | 3 sizes | ~10+ | 68L | ✅ Bon |
|
||||
| **HoverCard** | `ui/hover-card/` | 4 positions | ~10+ | 269L | ✅ Bon |
|
||||
| **ContextMenu** | `ui/context-menu/` | 1 | ~5+ | 359L | 🔴 > 300 lignes |
|
||||
| **ImageCropper** | `ui/ImageCropper.tsx` | 1 | ~5+ | 212L | ✅ Bon |
|
||||
| **ImageViewerModal** | `ui/ImageViewerModal.tsx` | 1 | ~5+ | 231L | ✅ Bon |
|
||||
| **OptimizedImage** | `ui/optimized-image/` | 1 | ~20+ | 146L | ✅ Bon |
|
||||
| **FocusTrap** | `ui/focus-trap.tsx` | 1 | Interne | 127L | ✅ Bon |
|
||||
| **FAB** | `ui/FAB.tsx` | 4 positions, 3 sizes | ~5+ | 115L | ✅ Bon |
|
||||
| **AstralBackground** | `ui/AstralBackground.tsx` | 1 | ~2 | 142L | ✅ Décoratif |
|
||||
| **WaveformVisualizer** | `ui/WaveformVisualizer.tsx` | 1 | ~5+ | 166L | ✅ Spécialisé |
|
||||
| **NavigationProgress** | `ui/NavigationProgress.tsx` | 1 | ~1 | 52L | ✅ Minimal |
|
||||
| **KeyboardShortcutsPanel** | `ui/KeyboardShortcutsPanel.tsx` | 1 | ~1 | 152L | ✅ Bon |
|
||||
**Composants spécialisés notables** :
|
||||
- `ErrorBoundary` (class component)
|
||||
- `ErrorDisplay` (error UI)
|
||||
- `ComingSoon` (placeholder pour features futures)
|
||||
- `AstralBackground` (effet de fond animé)
|
||||
- `WaveformVisualizer` (visualisation audio)
|
||||
- `VirtualizedList` (liste virtualisée)
|
||||
- `FileUpload` (upload drag & drop)
|
||||
- `DatePicker` (sélecteur de date)
|
||||
- `ImageCropper` (crop d'image)
|
||||
- `ConfirmationDialog` (dialog de confirmation)
|
||||
- `HoverCard` (card au survol)
|
||||
- `Accordion` (accordéon)
|
||||
- `ScrollArea` (zone scrollable custom)
|
||||
- `DataList` (liste de données avec empty/loading/error)
|
||||
- `ContentTransition` (transition de contenu)
|
||||
- `FocusTrap` (piège de focus pour modales)
|
||||
- `KeyboardShortcutsPanel` (panneau raccourcis)
|
||||
|
||||
### Duplications détectées
|
||||
### Duplication identifiée
|
||||
|
||||
| Duplication | Fichiers | Problème |
|
||||
|-------------|----------|----------|
|
||||
| Modal ↔ Dialog | `modal.tsx` + `dialog/` | Deux systèmes de modales concurrents |
|
||||
| Spinner ↔ LoadingSpinner | `Spinner.tsx` + `loading-spinner.tsx` | Deux composants de spinner |
|
||||
| Button CSS ↔ Button React | `styles/button.css` + `ui/button.tsx` | CSS classes `.btn-veza` parallèles au composant React |
|
||||
| Card CSS ↔ Card React | `styles/card.css` + `ui/card.tsx` | CSS classes `.card-veza` parallèles |
|
||||
| ButtonLoading ↔ Button(loading) | `button-loading.tsx` + `button.tsx` | Button a déjà un prop `loading` |
|
||||
- `ui/modal.tsx` vs `ui/dialog/` — Deux composants pour le même rôle. `Dialog` est le pattern Radix/shadcn, `Modal` semble être un wrapper plus simple. Potentielle confusion pour les développeurs.
|
||||
- `ui/dropdown-menu.tsx` (fichier plat) vs `ui/dropdown-menu/` (dossier refactoré) — coexistence legacy
|
||||
|
||||
---
|
||||
|
||||
## B4. Composants dupliqués
|
||||
|
||||
### Fichiers avec noms similaires
|
||||
**Patterns de boutons** : Le composant `Button` est bien centralisé (228 usages). L'ESLint rule `no-restricted-syntax` interdit les `<button>` natifs [eslint.config.js:rule]. Quelques `<button>` natifs persistent dans les tests et stories mais pas dans le code de production.
|
||||
|
||||
| Pattern | Occurrences | Problème |
|
||||
|---------|-------------|----------|
|
||||
| `accordion.tsx` + `accordion/Accordion.tsx` | 2 | Legacy + nouveau |
|
||||
| `dropdown-menu.tsx` + `dropdown-menu/DropdownMenu.tsx` | 2 | Re-export bridge |
|
||||
| `dialog.tsx` + `dialog/Dialog.tsx` | 2 | Re-export bridge |
|
||||
| `select.test.tsx` + `select/Select.stories.tsx` | 2 | Convention |
|
||||
| `modal.tsx` vs `dialog/Dialog.tsx` | 2 | **Duplication fonctionnelle** |
|
||||
| `DataList.tsx` + `data-list/DataList.tsx` | 2 | Migration en cours |
|
||||
**Patterns CSS dupliqués** : Les combinaisons Tailwind les plus fréquentes utilisent les tokens sémantiques (`bg-background`, `text-foreground`, `text-muted-foreground`, `border-border`). Pas de duplication CSS critique.
|
||||
|
||||
---
|
||||
|
||||
## Verdict final
|
||||
## Verdict Design System
|
||||
|
||||
- ☐ Design system explicite et documenté
|
||||
- ☑ **Design system semi-structuré** (tokens partiels, composants partiels)
|
||||
- ☐ Design system implicite
|
||||
- ☐ Aucun design system
|
||||
- [x] Design system explicite et documenté — **SUMI v2.0** avec référence DESIGN_SYSTEM_REFERENCE.md (1959L)
|
||||
- [x] Tokens complets (couleurs, typo, spacing, radius, shadows, z-index, motion)
|
||||
- [x] Dark/Light theme avec CSS variables
|
||||
- [x] Composants UI primitives centralisés dans `ui/`
|
||||
- [ ] ⚠️ Z-index arbitraires dans 31 fichiers (contredit les tokens)
|
||||
- [ ] ⚠️ Coexistence Dialog/Modal non résolue
|
||||
- [ ] ⚠️ Arbitrary `w-[` (38 occ.), `h-[` (24 occ.), `shadow-[` (10 occ.)
|
||||
|
||||
**Justification** : Le design system "KODO" est **ambitieux et substantiel** — 100+ CSS variables, 60+ composants UI, dark mode complet, ESLint rules custom. Cependant, il souffre de **fragmentation** (deux systèmes de tokens `--kodo-*` vs `--veza-*`), de **duplications** (CSS vanilla ↔ composants React, Modal ↔ Dialog, Spinner ↔ LoadingSpinner), et de **valeurs hardcodées** (~30+ couleurs hex dans les TSX). Le design system est en **transition** entre un prototype et un système mature.
|
||||
**Score Design System : 7.5/10**
|
||||
|
||||
---
|
||||
|
||||
## SCORE DESIGN SYSTEM : 6.5/10
|
||||
|
||||
### Points gagnés
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| Tokens CSS variables | +1.5 | 100+ variables, light/dark mode, OKLCH modern |
|
||||
| Composants UI riches | +1.5 | 60+ composants, bien typés, variants cohérents |
|
||||
| ESLint enforcement | +1.0 | Rules custom pour tokens, couleurs, spacing, shadows |
|
||||
| Animations tokenisées | +0.8 | Durées, easings, prefers-reduced-motion |
|
||||
| Typography scale | +0.7 | Échelle complète avec utilitaires custom |
|
||||
| Dark mode | +0.5 | Complet et cohérent |
|
||||
|
||||
### Points perdus
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| Fragmentation tokens (`--kodo-*` / `--veza-*`) | -1.0 | Deux systèmes concurrents, source de vérité ambiguë |
|
||||
| CSS vanilla parallèle | -0.8 | 5 fichiers CSS qui dupliquent les composants React |
|
||||
| Couleurs hardcodées | -0.5 | ~30 occurrences dans 12 fichiers |
|
||||
| Duplications composants | -0.5 | Modal/Dialog, Spinner/LoadingSpinner, ButtonLoading |
|
||||
| Z-index non-tokenisé | -0.3 | Mix tokens CSS + valeurs arbitraires `z-[100]` |
|
||||
| Fixes `!important` | -0.4 | 2 fichiers de hacks CSS avec `!important` |
|
||||
| Critère | Points | Justification |
|
||||
|---------|--------|---------------|
|
||||
| Tokens couleurs | +2 | Exemplaire, 0 hardcoded, dark/light complet |
|
||||
| Tokens typo | +1.5 | Complet, double système (Tailwind + SUMI) |
|
||||
| Tokens spacing/radius | +1.5 | Échelle 4px, quasi 0 arbitraire |
|
||||
| Composants primitives | +1.5 | 16+ composants, bien réutilisés |
|
||||
| Animations/motion | +1 | Tokenisé, reduced-motion respecté |
|
||||
| Z-index chaos | -0.5 | 31 fichiers avec `z-[N]` au lieu des tokens |
|
||||
| Dialog/Modal dualité | -0.25 | Confusion possible |
|
||||
| Arbitrary w/h values | -0.25 | 62 occurrences à migrer |
|
||||
| **Total** | **7.5/10** | **Design system mature avec quelques fuites** |
|
||||
|
|
|
|||
|
|
@ -1,207 +1,200 @@
|
|||
# PHASE C — CARTOGRAPHIE DES COMPOSANTS
|
||||
|
||||
> **Total composants .tsx** : 1 450 fichiers
|
||||
> **Total .ts** : 620 fichiers
|
||||
> **Exclus** : .test.tsx, .stories.tsx, types, configs
|
||||
# Phase C — Cartographie des Composants
|
||||
|
||||
---
|
||||
|
||||
## Composants UI primitifs (`components/ui/`)
|
||||
## Vue d'ensemble
|
||||
|
||||
```
|
||||
src/components/ui/
|
||||
├── button.tsx [149L] [dumb] [~200x imports] [props: 6+ButtonHTMLAttrs]
|
||||
├── card.tsx [190L] [dumb] [~200x imports] [props: 3+HTMLAttrs] [7 sub-components]
|
||||
├── skeleton.tsx [94L] [dumb] [~200x imports] [props: 3]
|
||||
├── Toast.tsx [112L] [dumb] [~200x imports] [props: 4]
|
||||
├── input.tsx [69L] [dumb] [~100x imports] [props: 3+InputHTMLAttrs]
|
||||
├── modal.tsx [151L] [dumb] [~100x imports] [props: 8]
|
||||
├── badge.tsx [225L] [dumb] [~30x imports] [props: 8]
|
||||
├── avatar.tsx [306L] [dumb] [~30x imports] [props: 7] ⚠️ > 300L
|
||||
├── table.tsx [306L] [dumb] [~30x imports] [props: 3+HTMLAttrs] ⚠️ > 300L
|
||||
├── checkbox.tsx [130L] [dumb] [~50x imports] [props: 2+InputHTMLAttrs]
|
||||
├── label.tsx [51L] [dumb] [~50x imports] [props: LabelHTMLAttrs]
|
||||
├── FormField.tsx [218L] [dumb] [~50x imports] [props: 5]
|
||||
├── textarea.tsx [105L] [dumb] [~30x imports] [props: 2+TextareaHTMLAttrs]
|
||||
├── switch.tsx [103L] [dumb] [~20x imports] [props: 2+InputHTMLAttrs]
|
||||
├── radio-group.tsx [229L] [dumb] [~10x imports] [props: 3]
|
||||
├── slider.tsx [171L] [dumb] [~5x imports] [props: 7]
|
||||
├── progress.tsx [209L] [dumb] [~10x imports] [props: 7]
|
||||
├── alert.tsx [186L] [dumb] [~20x imports] [props: 3]
|
||||
├── Spinner.tsx [109L] [dumb] [~30x imports] [props: 3]
|
||||
├── loading-spinner.tsx [93L] [dumb] [~30x imports] [props: 2] ⚠️ Duplique Spinner
|
||||
├── LoadingState.tsx [215L] [dumb] [~20x imports] [props: 6]
|
||||
├── ErrorDisplay.tsx [247L] [dumb] [~10x imports] [props: 12]
|
||||
├── ErrorBoundary.tsx [78L] [smart] [~5x imports] [props: 3]
|
||||
├── empty-state.tsx [238L] [dumb] [~10x imports] [props: 6]
|
||||
├── FAB.tsx [115L] [dumb] [~5x imports] [props: 5]
|
||||
├── ImageCropper.tsx [212L] [smart] [~5x imports] [props: 5]
|
||||
├── ImageViewerModal.tsx [231L] [dumb] [~5x imports] [props: 9]
|
||||
├── WaveformVisualizer.tsx [166L] [smart] [~5x imports] [props: 7]
|
||||
├── AstralBackground.tsx [142L] [smart] [~2x imports] [props: 0]
|
||||
├── NavigationProgress.tsx [52L] [smart] [~1x imports] [props: 0]
|
||||
├── ComingSoon.tsx [15L] [dumb] [~5x imports] [props: 1]
|
||||
├── AnimatedNumber.tsx [17L] [dumb] [~5x imports] [props: 3]
|
||||
├── ContentFadeIn.tsx [34L] [dumb] [~5x imports] [props: 2]
|
||||
├── KeyboardShortcutsPanel.tsx [152L] [smart] [~1x imports] [props: 2]
|
||||
├── Sidebar.tsx [218L] [dumb] [~10x imports] [props: 9]
|
||||
├── DataList.tsx [42L] [dumb] [~10x imports] [props: 6] (generic)
|
||||
├── scroll-area.tsx [57L] [dumb] [~20x imports] [props: HTMLAttrs]
|
||||
├── dropdown.tsx [216L] [smart] [interne] [props: 6]
|
||||
├── floating-input.tsx [95L] [dumb] [~5x imports] [props: 4+InputHTMLAttrs]
|
||||
├── confirmation-dialog.tsx [166L] [dumb] [~20x imports] [props: 9]
|
||||
├── button-loading.tsx [101L] [dumb] [~10x imports] [props: 2] ⚠️ Duplique Button.loading
|
||||
├── focus-trap.tsx [127L] [smart] [interne] [props: 3]
|
||||
├── dialog/ [~350L total] [dumb] [~100x imports] [9 sub-components]
|
||||
├── dropdown-menu/ [~300L total] [dumb] [~30x imports] [11 sub-components]
|
||||
├── tabs/ [~200L total] [dumb] [~20x imports] [4 sub-components]
|
||||
├── tooltip/ [~200L total] [dumb] [~15x imports] [3 sub-components]
|
||||
├── select/ [~250L total] [dumb] [~20x imports] [4 sub-components]
|
||||
├── accordion/ [~200L total] [dumb] [~5x imports] [4 sub-components]
|
||||
├── hover-card/ [~350L total] [smart] [~10x imports] [3 sub-components]
|
||||
├── context-menu/ [359L] [smart] [~5x imports] [props: 3] 🔴 > 300L
|
||||
├── date-picker/ [~250L total] [smart] [~5x imports] [3 sub-components]
|
||||
├── file-upload/ [~300L total] [smart] [~10x imports] [5 sub-components]
|
||||
├── avatar-upload/ [~250L total] [smart] [~10x imports] [5 sub-components]
|
||||
├── optimized-image/ [~300L total] [smart] [~20x imports] [3 sub-components]
|
||||
├── virtualized-list/ [~300L total] [smart] [~5x imports] [3 sub-components]
|
||||
├── data-list/ [~200L total] [dumb] [~10x imports] [4 sub-components]
|
||||
├── lazy-component/ [~250L total] [smart] [routes] [3 sub-components]
|
||||
├── content-transition/ [~50L total] [dumb] [~5x imports] [1 component]
|
||||
└── feature-highlight/ [~100L total] [dumb] [~5x imports] [1 component]
|
||||
```
|
||||
| Zone | Fichiers .tsx (source) | Rôle |
|
||||
|------|----------------------|------|
|
||||
| `components/ui/` | ~120 | Primitives UI (design system) |
|
||||
| `components/layout/` | 8 | Shell applicatif |
|
||||
| `components/views/` | ~138 | Feature views (architecture legacy) |
|
||||
| `components/{domain}/` | ~337 | Composants domaine (30 sous-dossiers) |
|
||||
| `features/*/` | ~350+ | Feature modules (architecture cible) |
|
||||
| **Total estimé** | **~950+** composants source | |
|
||||
|
||||
---
|
||||
|
||||
## Layout (`components/layout/`)
|
||||
## 1. Primitives UI (`components/ui/`)
|
||||
|
||||
```
|
||||
src/components/layout/
|
||||
├── Layout.tsx [58L] [dumb] [~1x] [props: 1]
|
||||
├── DashboardLayout.tsx [61L] [dumb] [~1x] [props: 1]
|
||||
├── Sidebar.tsx [295L] [dumb] [~2x] [props: 1]
|
||||
├── Header.tsx [172L] [hybrid] [~2x] [props: 0] ⚠️ mélange auth/theme
|
||||
├── Navbar.tsx [~100L] [dumb] [~1x] [props: ~3]
|
||||
├── MobileBottomNav.tsx [~80L] [dumb] [~1x] [props: ~2]
|
||||
├── PageTransition.tsx [~40L] [dumb] [~1x] [props: 1]
|
||||
└── AudioPlayer.tsx [~100L] [smart] [~1x] [props: ~3]
|
||||
```
|
||||
### Composants > 200 lignes (complexes)
|
||||
|
||||
| Composant | Lignes | Type | Observations |
|
||||
|-----------|--------|------|-------------|
|
||||
| `context-menu/ContextMenu.tsx` | 358 | Compound | Keyboard nav + ARIA complet |
|
||||
| `table.tsx` | 305 | Compound | Table sémantique complète |
|
||||
| `avatar.tsx` | 305 | Smart | Image fallback + sizes + status |
|
||||
| `empty-state.tsx` | 237 | Dumb | Variantes multiples avec illustrations |
|
||||
| `ImageViewerModal.tsx` | 230 | Smart | Zoom, navigation, gestures |
|
||||
| `radio-group.tsx` | 228 | Compound | Accessible, keyboard nav |
|
||||
| `badge.tsx` | 222 | Dumb | 7+ variantes CVA |
|
||||
| `FormField.tsx` | 217 | Smart | Form integration + validation |
|
||||
| `Sidebar.tsx` | 217 | Smart | Duplicate de layout/Sidebar ? |
|
||||
| `dropdown.tsx` | 215 | Smart | Dropdown legacy |
|
||||
| `LoadingState.tsx` | 214 | Dumb | Spinner, skeleton, message |
|
||||
| `ImageCropper.tsx` | 211 | Smart | Canvas-based cropping |
|
||||
| `progress.tsx` | 208 | Dumb | Linear + circular variants |
|
||||
| `collapsible.tsx` | 198 | Smart | Animated expand/collapse |
|
||||
| `alert.tsx` | 185 | Dumb | Info/warning/error/success |
|
||||
| `card.tsx` | 180 | Compound | Header, Content, Footer, variants |
|
||||
| `slider.tsx` | 170 | Smart | Range input, dual thumb |
|
||||
| `WaveformVisualizer.tsx` | 165 | Smart | Canvas audio waveform |
|
||||
| `confirmation-dialog.tsx` | 165 | Smart | Async confirm/cancel |
|
||||
| `hover-card/HoverCard.tsx` | 268 | Smart | Positionnement auto |
|
||||
| `ErrorDisplay.tsx` | 246 | Dumb | Variantes error/empty/loading |
|
||||
| `select/SelectDropdownContent.tsx` | 156 | Smart | Virtualised dropdown |
|
||||
| `KeyboardShortcutsPanel.tsx` | 151 | Dumb | Panel raccourcis |
|
||||
| `modal.tsx` | 150 | Smart | Focus trap + overlay |
|
||||
| `OptimizedImage.tsx` | 145 | Smart | Lazy load + blur placeholder |
|
||||
|
||||
### Composants légers (< 50 lignes)
|
||||
|
||||
- `AnimatedNumber.tsx` (16L), `ComingSoon.tsx` (14L), `ContentFadeIn.tsx` (33L), `ScrollToTop.tsx` (38L), `LazyComponent.tsx` (39L), `tooltip.tsx` (2L — re-export), `tabs.tsx` (7L — re-export)
|
||||
|
||||
### Compound Components (pattern Radix)
|
||||
|
||||
| Groupe | Fichiers | Pattern |
|
||||
|--------|----------|---------|
|
||||
| `dialog/` | 9 fichiers | Dialog, Content, Header, Body, Footer, Title, Description, Trigger, Skeleton |
|
||||
| `dropdown-menu/` | 11 fichiers | Menu, Content, Item, Checkbox, Radio, Label, Separator, Shortcut, Trigger |
|
||||
| `tabs/` | 5 fichiers | Tabs, List, Trigger, Content |
|
||||
| `select/` | 5 fichiers | Select, Trigger, DropdownContent, OptionItem |
|
||||
| `accordion/` | 4 fichiers | Accordion, Item, Trigger, Content |
|
||||
| `date-picker/` | 4 fichiers | DatePicker, Calendar, Trigger |
|
||||
| `hover-card/` | 4 fichiers | HoverCard, TrackHoverContent, UserHoverContent |
|
||||
| `file-upload/` | 6 fichiers | FileUpload, Dropzone, FileList, ErrorList |
|
||||
| `avatar-upload/` | 5 fichiers | AvatarUpload, Dropzone, Actions, Skeleton |
|
||||
| `virtualized-list/` | 3 fichiers | VirtualizedList + hooks |
|
||||
| `data-list/` | 5 fichiers | DataList, Empty, Error, Skeleton |
|
||||
| `lazy-component/` | 4 fichiers | createLazyComponent, ErrorBoundary, ErrorFallback |
|
||||
| `optimized-image/` | 4 fichiers | OptimizedImage, BlurPlaceholder, Skeleton, Responsive |
|
||||
|
||||
---
|
||||
|
||||
## Feature modules — Structure type
|
||||
## 2. Layout (`components/layout/`)
|
||||
|
||||
```
|
||||
src/features/
|
||||
├── auth/
|
||||
│ ├── components/ [RegisterForm, LoginForm, AuthInput, OAuthButton, ...]
|
||||
│ ├── hooks/ [useLogin, useRegister, ...]
|
||||
│ ├── pages/ [LoginPage, RegisterPage, ForgotPasswordPage, ...]
|
||||
│ ├── services/ [authService, emailVerificationService]
|
||||
│ ├── store/ [authStore (Zustand)]
|
||||
│ └── __tests__/ [integration tests]
|
||||
├── player/
|
||||
│ ├── components/ [GlobalPlayer, PlayerControls, PlayerBar, ...]
|
||||
│ ├── hooks/ [usePlayer, useStreamSync, ...]
|
||||
│ ├── services/ [playerService, syncClient]
|
||||
│ └── store/ [playerStore (Zustand)]
|
||||
├── tracks/
|
||||
│ ├── api/ [trackApi (848L)]
|
||||
│ ├── components/ [TrackList, TrackCard, TrackGrid, TrackSearch, ...]
|
||||
│ ├── hooks/ [useTrackList, useInfiniteScroll, ...]
|
||||
│ ├── pages/ [track-detail-page/ (décomposé)]
|
||||
│ ├── services/ [trackService, commentService, uploadService, ...]
|
||||
│ ├── types/ [types locaux]
|
||||
│ └── __tests__/ [integration tests]
|
||||
├── playlists/
|
||||
│ ├── components/ [PlaylistHeader, PlaylistList, PlaylistSearch, ...]
|
||||
│ ├── hooks/ [usePlaylist (631L ⚠️), ...]
|
||||
│ ├── pages/ [playlist-detail-page/ (décomposé)]
|
||||
│ ├── services/ [playlistService]
|
||||
│ └── __tests__/ [integration tests]
|
||||
├── chat/
|
||||
│ ├── components/ [ChatMessages, ChatInput, VirtualizedChat, ...]
|
||||
│ ├── pages/ [ChatPage]
|
||||
│ ├── services/ [conversationService]
|
||||
│ └── store/ [chatStore (Zustand)]
|
||||
├── streaming/
|
||||
│ ├── components/ [PlaybackDashboard, PlaybackHeatmap, BitrateSelector, ...]
|
||||
│ ├── hooks/ [usePlaybackRealtime (496L ⚠️), usePlaybackAnalytics, ...]
|
||||
│ └── services/ [hlsService, bitrateService, playbackAnalyticsService]
|
||||
├── dashboard/
|
||||
│ ├── hooks/ [useDashboard]
|
||||
│ ├── pages/ [DashboardPage (329L ⚠️)]
|
||||
│ └── services/ [dashboardService]
|
||||
├── search/
|
||||
│ ├── components/ [search/, search-page/]
|
||||
│ ├── pages/ [SearchPage]
|
||||
│ ├── services/ [searchService, unifiedSearchService]
|
||||
│ └── utils/ [search utils]
|
||||
├── profile/
|
||||
│ ├── components/ [ProfileActions, ...]
|
||||
│ ├── pages/ [user-profile-page/ (décomposé)]
|
||||
│ ├── services/ [profileService, avatarService]
|
||||
│ └── schemas/ [profile schemas]
|
||||
├── settings/
|
||||
│ ├── components/ [account-settings/]
|
||||
│ ├── pages/ [SettingsPage]
|
||||
│ ├── services/ [settingsService]
|
||||
│ └── schemas/ [settings schemas]
|
||||
├── notifications/
|
||||
│ ├── components/ [notifications-page/]
|
||||
│ └── services/ [notificationService]
|
||||
├── upload/
|
||||
│ └── components/ [upload-modal/]
|
||||
├── admin/
|
||||
│ └── api/ [auditService]
|
||||
├── roles/
|
||||
│ ├── components/ [RolesPage, ...]
|
||||
│ ├── services/ [roleService]
|
||||
│ └── types/ [role types]
|
||||
├── sessions/
|
||||
│ └── api/ [sessionsApi]
|
||||
├── webhooks/
|
||||
│ └── api/ [webhookApi]
|
||||
├── stream/
|
||||
│ └── pages/ [StreamPage]
|
||||
├── studio/
|
||||
│ └── components/ [cloud-file-browser/]
|
||||
├── user/
|
||||
│ └── components/ [profile/, profile-form/]
|
||||
└── library/ (or inventory/)
|
||||
├── hooks/ [useLibraryItems, useMyTracks]
|
||||
└── pages/ [library-page/]
|
||||
```
|
||||
| Composant | Lignes | Type | Rôle |
|
||||
|-----------|--------|------|------|
|
||||
| `Sidebar.tsx` | 294 | Smart | Navigation principale, collapse, mobile |
|
||||
| `AudioPlayer.tsx` | 264 | Smart | Player bar persistent |
|
||||
| `Navbar.tsx` | 263 | Smart | Barre de navigation top |
|
||||
| `Header.tsx` | 172 | Smart | Header applicatif |
|
||||
| `DashboardLayout.tsx` | 61 | Smart | Layout wrapper (sidebar + main) |
|
||||
| `Layout.tsx` | 57 | Dumb | Layout générique |
|
||||
| `MobileBottomNav.tsx` | 50 | Dumb | Navigation mobile |
|
||||
| `PageTransition.tsx` | 26 | Dumb | Transition entre pages |
|
||||
|
||||
**Observation** : `Sidebar.tsx` dans layout/ (294L) et `Sidebar.tsx` dans ui/ (217L) — **duplication probable**. L'un est le layout sidebar, l'autre semble être un composant UI sidebar générique.
|
||||
|
||||
---
|
||||
|
||||
## Tests associés — Couverture
|
||||
## 3. Features (architecture cible)
|
||||
|
||||
| Module | Tests unitaires | Tests intégration | Stories | Verdict |
|
||||
|--------|----------------|-------------------|---------|---------|
|
||||
| `components/ui/` | ✅ 20+ .test.tsx | — | ✅ 30+ .stories.tsx | Bon |
|
||||
| `features/auth/` | ✅ LoginPage.test, RegisterPage.test | ✅ auth.integration.test | ✅ AuthView.stories | Bon |
|
||||
| `features/tracks/` | ✅ TrackUpload.test, TrackGrid.test | ✅ trackUpload.integration | ✅ Multiple stories | Bon |
|
||||
| `features/playlists/` | ✅ Multiple tests | ✅ 2 integration tests | ✅ Multiple stories | Bon |
|
||||
| `features/player/` | ✅ playerStore.test | — | ✅ PlayerBar stories | Partiel |
|
||||
| `features/streaming/` | ✅ Multiple tests | — | — | Partiel |
|
||||
| `features/chat/` | — | — | ✅ ChatView.stories | ⚠️ Faible |
|
||||
| `features/search/` | — | — | — | 🔴 Absent |
|
||||
| `features/dashboard/` | — | — | — | 🔴 Absent |
|
||||
| `components/layout/` | ✅ DashboardLayout.test | — | ✅ Multiple stories | Partiel |
|
||||
### Features les plus volumineuses
|
||||
|
||||
| Feature | Composants | Fichier principal | Lignes max |
|
||||
|---------|-----------|-------------------|------------|
|
||||
| `tracks` | ~40+ | TrackListRow.tsx | 320L |
|
||||
| `playlists` | ~25+ | PlaylistListPage.tsx | 238L |
|
||||
| `auth` | ~35+ | LoginPage.tsx | 225L |
|
||||
| `chat` | ~15+ | ChatInput.tsx | 261L |
|
||||
| `player` | ~20+ | PlayerExpanded.tsx | 241L |
|
||||
| `streaming` | ~10+ | PlaybackSummary.tsx | 206L |
|
||||
| `profile` | ~8+ | UserProfilePageTabs.tsx | 226L |
|
||||
| `search` | ~8+ | SearchPageResults.tsx | 218L |
|
||||
| `dashboard` | ~5+ | DashboardPage.tsx | 328L |
|
||||
| `roles` | ~3+ | RolesPage.tsx | 275L |
|
||||
| `library` | ~6+ | LibraryPageGrid.tsx | 102L |
|
||||
| `studio` | ~10+ | FileToolbar.tsx | 242L |
|
||||
| `settings` | ~5+ | SettingsPage.tsx | 183L |
|
||||
| `marketplace` | ~5+ | Cart.tsx | 224L |
|
||||
| `notifications` | ~5+ | NotificationsPage.tsx | 157L |
|
||||
|
||||
### Composants > 250 lignes dans features (attention)
|
||||
|
||||
| Composant | Lignes | Risque |
|
||||
|-----------|--------|--------|
|
||||
| `features/dashboard/pages/DashboardPage.tsx` | 328 | ⚠️ Dépasse limite 300L |
|
||||
| `features/tracks/components/TrackListRow.tsx` | 320 | ⚠️ Dépasse limite 300L |
|
||||
| `features/tracks/components/TrackList.tsx` | 290 | OK mais proche |
|
||||
| `features/roles/pages/RolesPage.tsx` | 275 | Proche limite |
|
||||
| `features/chat/components/ChatInput.tsx` | 261 | Proche limite |
|
||||
| `features/tracks/components/TrackSearchResults.tsx` | 258 | Proche limite |
|
||||
|
||||
---
|
||||
|
||||
## Statistiques globales
|
||||
## 4. Composants domaine (`components/{domain}/`)
|
||||
|
||||
| Métrique | Valeur |
|
||||
|----------|--------|
|
||||
| Total composants UI primitifs | **60+** |
|
||||
| Composants > 300 lignes | **3** (avatar, table, context-menu) |
|
||||
| Composants bien typés (props interface) | **97%** |
|
||||
| Feature modules structurés | **20** |
|
||||
| Feature modules avec tests | **~60%** |
|
||||
| Feature modules avec stories | **~50%** |
|
||||
| Hooks custom | **50+** |
|
||||
| Stores Zustand | **7** |
|
||||
| Contextes React | **4** |
|
||||
### Répartition par domaine
|
||||
|
||||
| Domaine | Composants (.tsx) | Observations |
|
||||
|---------|------------------|-------------|
|
||||
| `views/` | 138 | ⚠️ Plus gros dossier — views refactorés en sous-dossiers |
|
||||
| `studio/` | 49 | Studio projects, file browser |
|
||||
| `settings/` | 38 | Settings views (6 onglets) |
|
||||
| `education/` | 18 | Cours, learning — feature "ComingSoon" |
|
||||
| `social/` | 18 | Feed, groups, posts |
|
||||
| `admin/` | 16 | Administration, moderation |
|
||||
| `marketplace/` | 14 | Product listing |
|
||||
| `inventory/` | 14 | Gear management |
|
||||
| `player/` | 14 | Player UI (audio player legacy) |
|
||||
| `upload/` | 10 | Upload components |
|
||||
| `seller/` | 10 | Seller dashboard |
|
||||
| `library/` | 9 | Library views |
|
||||
| `monitoring/` | 9 | Monitoring dashboard |
|
||||
| `notifications/` | 9 | Notification system |
|
||||
| `share/` | 7 | Sharing components |
|
||||
| `developer/` | 6 | Developer tools |
|
||||
| `forms/` | 6 | Form components |
|
||||
| `commerce/` | 5 | Commerce views |
|
||||
| `gamification/` | 5 | XP, achievements, leaderboard |
|
||||
| `charts/` | 4 | Chart components |
|
||||
| `feedback/` | 4 | Toast, progress |
|
||||
| `search/` | 4 | Global search |
|
||||
| `dashboard/` | 3 | Dashboard widgets |
|
||||
| `navigation/` | 3 | Breadcrumbs |
|
||||
| `live/` | 2 | Live streaming |
|
||||
| `theme/` | 2 | Theme provider/switcher |
|
||||
| `auth/` | 1 | ProtectedRoute |
|
||||
| `analytics/` | 1 | TrackAnalyticsView |
|
||||
| `keyboard/` | 1 | Keyboard shortcuts |
|
||||
| `pwa/` | 1 | PWA install banner |
|
||||
| `user/` | 1 | User profile |
|
||||
|
||||
---
|
||||
|
||||
## 5. Tests associés
|
||||
|
||||
### Couverture des tests
|
||||
|
||||
| Zone | Fichiers .test.tsx | Observations |
|
||||
|------|-------------------|-------------|
|
||||
| `components/ui/` | ~25+ | Bonne couverture des primitives |
|
||||
| `features/auth/` | ~15+ | Tests unitaires + integration |
|
||||
| `features/tracks/` | ~15+ | Tests composants + services |
|
||||
| `features/playlists/` | ~15+ | Tests + integration tests |
|
||||
| `features/player/` | ~8+ | Tests services + hooks |
|
||||
| `features/streaming/` | ~8+ | Tests services + hooks |
|
||||
| `services/` | ~15+ | Tests services API |
|
||||
| `hooks/` | ~12+ | Tests hooks custom |
|
||||
| `__tests__/` | 2 | Accessibility + contrast |
|
||||
| `router/` | 1 | Tests de routing |
|
||||
|
||||
**Estimation couverture** : ~60-70% des composants critiques ont des tests associés. Les features `chat`, `dashboard`, `studio`, `marketplace` semblent sous-testées côté composants.
|
||||
|
||||
---
|
||||
|
||||
## 6. Observations structurelles
|
||||
|
||||
### Points forts
|
||||
- **Compound components** bien structurés (dialog, tabs, dropdown-menu, select) avec index.ts barrel exports
|
||||
- **Skeletons systématiques** dans les views refactorées
|
||||
- **Pattern cohérent** dans les features : `types.ts`, `index.ts`, `useXxx.ts`, `XxxSkeleton.tsx`
|
||||
- **Composants bien nommés** : convention PascalCase, nom descriptif
|
||||
|
||||
### Points faibles
|
||||
- **Dualité layout/** : `components/layout/Sidebar.tsx` (294L) vs `components/ui/Sidebar.tsx` (217L)
|
||||
- **components/views/** (138 fichiers) coexiste avec `features/*/pages/` — migration incomplète
|
||||
- **2 composants dépassent 300L** sans split (DashboardPage 328L, TrackListRow 320L)
|
||||
- **Quelques legacy wrappers** : fichiers plats dans `components/views/` (AnalyticsView.tsx, CartView.tsx) qui wrappent les sous-dossiers refactorés
|
||||
- **components/player/** (14 fichiers) coexiste avec `features/player/components/` (~20 fichiers) — même problème de dualité
|
||||
|
|
|
|||
|
|
@ -1,70 +1,49 @@
|
|||
# PHASE D — COHÉRENCE UI
|
||||
# Phase D — UI Consistency Report
|
||||
|
||||
**Score Cohérence UI : 6.5/10**
|
||||
|
||||
---
|
||||
|
||||
## D1. Boutons
|
||||
|
||||
### Système de variants
|
||||
### Inventaire
|
||||
|
||||
Le composant `Button` (`components/ui/button.tsx`) définit **10 variants** et **4 tailles** :
|
||||
- **Composant centralisé** : `Button` dans `components/ui/button.tsx` [button.tsx:11-52]
|
||||
- **7 variantes définies** : `default`, `primary`, `destructive`, `outline`, `secondary`, `ghost`, `link`
|
||||
- **4 tailles** : `default` (h-10), `sm` (h-9), `lg` (h-12), `icon` (h-10 w-10)
|
||||
- **228 fichiers** importent le composant Button — adoption massive
|
||||
|
||||
| Variant | Classes Tailwind | Hover | Focus | Disabled | Loading |
|
||||
|---------|-----------------|-------|-------|----------|---------|
|
||||
| `default` | `bg-primary text-primary-foreground` | ✅ `hover:bg-primary/90` | ✅ `focus-visible:ring-2` | ✅ `disabled:opacity-50` | ✅ prop `loading` |
|
||||
| `primary` | Alias → default | ✅ | ✅ | ✅ | ✅ |
|
||||
| `destructive` | `bg-destructive text-destructive-foreground` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `outline` | `border border-input bg-background` | ✅ `hover:bg-accent` | ✅ | ✅ | ✅ |
|
||||
| `secondary` | `bg-secondary text-secondary-foreground` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `ghost` | `hover:bg-accent hover:text-accent-foreground` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `gaming` | Neon accent style | ✅ | ✅ | ✅ | ✅ |
|
||||
| `terminal` | Monospace terminal style | ✅ | ✅ | ✅ | ✅ |
|
||||
| `nature` | Green nature style | ✅ | ✅ | ✅ | ✅ |
|
||||
| `glass` | Glassmorphism | ✅ | ✅ | ✅ | ✅ |
|
||||
### États interactifs
|
||||
|
||||
**Tailles** : `default` (h-10), `sm` (h-9), `lg` (h-12), `icon` (h-10 w-10)
|
||||
|
||||
**Couverture des états** : ✅ Tous les états (hover, focus, disabled, loading) sont définis dans le composant Button — **exemplaire**.
|
||||
| État | Défini ? | Implémentation |
|
||||
|------|----------|---------------|
|
||||
| Hover | ✅ | `hover:bg-primary/90`, `hover:bg-muted/50`, etc. [button.tsx:18-34] |
|
||||
| Focus | ✅ | `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` [button.tsx:12] |
|
||||
| Disabled | ✅ | `disabled:pointer-events-none disabled:opacity-50` [button.tsx:12] |
|
||||
| Loading | ✅ | `loading` prop → Loader2 spinner + opacity [button.tsx:107-132] |
|
||||
|
||||
### Incohérences détectées
|
||||
|
||||
| Problème | Fichier | Détail |
|
||||
|----------|---------|--------|
|
||||
| Native `<button>` avec classes custom | `components/layout/Header.tsx` | Utilise des classes au lieu du composant `Button` |
|
||||
| CSS parallèle `.btn-veza` | `styles/button.css` | 9 variants CSS qui dupliquent le composant React |
|
||||
| `ButtonLoading` redondant | `ui/button-loading.tsx` | Duplique le prop `loading` de `Button` |
|
||||
| ESLint rule non-enforcée | `eslint.config.js:204-208` | Rule warn native `<button>` mais non-bloquante |
|
||||
- **`variant="glass"`** utilisé dans `CloudIntegrationView.tsx` et `GearViewHeader.tsx` mais **non défini** dans `buttonVariants` [button.tsx:14-35]. Tailwind ne générera aucun style pour cette variante → bouton sans style.
|
||||
- **ESLint interdit `<button>` natif** [eslint.config.js] mais ~209 fichiers contiennent encore des `<button` natifs (en partie dans les tests et stories, mais aussi dans le code source).
|
||||
|
||||
---
|
||||
|
||||
## D2. Formulaires
|
||||
|
||||
### Style des inputs
|
||||
### Input styling
|
||||
|
||||
- ✅ Composant `Input` avec style uniforme (`components/ui/input.tsx`)
|
||||
- ✅ Prop `error` pour état d'erreur visuel
|
||||
- ✅ Prop `icon` pour icônes inline
|
||||
- ✅ `FloatingInput` pour labels flottants (`components/ui/floating-input.tsx`)
|
||||
- **Composant centralisé** : `Input` dans `components/ui/input.tsx` [input.tsx:16-53]
|
||||
- **94 fichiers** importent Input
|
||||
- **Label intégré** : `label` prop avec `Label` component [input.tsx:21]
|
||||
- **Error state** : `error` prop → bordure destructive + message animé [input.tsx:40,47-49]
|
||||
- **ARIA** : `aria-invalid`, `aria-describedby` pour les erreurs [input.tsx:31-32] ✅
|
||||
|
||||
### Associations label/input
|
||||
### Incohérences formulaires
|
||||
|
||||
- ✅ `htmlFor` / `id` : 100+ instances correctes
|
||||
- ✅ `aria-describedby` : 15+ instances pour messages d'erreur
|
||||
- ✅ `aria-required` : 6 instances sur les champs obligatoires
|
||||
|
||||
### Validation feedback
|
||||
|
||||
- ✅ React Hook Form + Zod pour la validation
|
||||
- ✅ Timing : principalement `onBlur` et `onSubmit`
|
||||
- ⚠️ Inconsistance : certains formulaires affichent les erreurs inline, d'autres via toast uniquement
|
||||
- ⚠️ Pas de validation serveur systématiquement remontée dans les champs
|
||||
|
||||
### Incohérences
|
||||
|
||||
| Problème | Détail |
|
||||
|----------|--------|
|
||||
| CSS parallèle `.input-veza` | `styles/input.css` duplique les styles de `Input.tsx` |
|
||||
| Fix hacks | `styles/fix-input-focus.css` et `styles/fix-login-form.css` utilisent `!important` |
|
||||
| Placeholders vs labels | La plupart des inputs ont des labels, mais quelques-uns n'utilisent que des placeholders |
|
||||
- **Placeholder vs Label** : Le composant `Input` supporte les deux patterns mais rien ne force l'usage de labels. Certains usages utilisent uniquement `placeholder` sans `label`.
|
||||
- **`htmlFor` + `id`** : Présent dans 56 fichiers mais pas systématique — certains inputs n'ont pas de label associé.
|
||||
- **Validation timing** : Pas de convention unifiée (onBlur vs onChange vs onSubmit). `react-hook-form` est utilisé mais pas partout.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -72,36 +51,28 @@ Le composant `Button` (`components/ui/button.tsx`) définit **10 variants** et *
|
|||
|
||||
### Toasts / Notifications
|
||||
|
||||
- ✅ Système unifié via `useToast()` hook + `ToastProvider`
|
||||
- ✅ Types : `success`, `error`, `info`, `warning`
|
||||
- ✅ ~50+ usages cohérents dans le code
|
||||
- ✅ `react-hot-toast` comme backend
|
||||
- **109 fichiers** utilisent le système de toast
|
||||
- **Deux patterns coexistent** :
|
||||
1. `addToast()` via `useToast()` hook custom [hooks/useToast.ts]
|
||||
2. `toast()` via `react-hot-toast` directement [utils/toast.ts]
|
||||
- **Incohérence** : Le même feedback (succès, erreur) est invoqué par deux APIs différentes selon le fichier. Un wrapper unique (`utils/toast.ts`) existe mais n'est pas systématiquement utilisé.
|
||||
|
||||
### Loading states
|
||||
|
||||
- ✅ Composants dédiés : `Spinner`, `Skeleton`, `LoadingState`, `LoadingSpinner`
|
||||
- ✅ Skeletons pour toutes les views (analytics, cart, chat, discover, etc.)
|
||||
- ✅ Pattern cohérent : `if (isLoading) return <XxxSkeleton />`
|
||||
- ⚠️ Duplication : `Spinner.tsx` et `loading-spinner.tsx` coexistent
|
||||
- **187 fichiers** implémentent des loading states ✅ Excellent
|
||||
- **Patterns utilisés** : `Skeleton` (83 fichiers), `Spinner` (108L), `LoadingState` (214L), `animate-spin`
|
||||
- **Squelettes systématiques** dans les views refactorées (chaque view a un `*Skeleton.tsx`) ✅
|
||||
|
||||
### Empty states
|
||||
|
||||
- ✅ Composant réutilisable `EmptyState` (3 variants, 3 sizes)
|
||||
- ✅ ~60+ instances d'empty states
|
||||
- ✅ Feature-specific empty states (`TrackListEmpty`, `PlaylistListEmpty`, etc.)
|
||||
- ✅ Bonne couverture : la plupart des listes gèrent l'état vide
|
||||
- **110+ fichiers** gèrent les états vides ✅ Bon
|
||||
- **Composant centralisé** : `empty-state.tsx` (237L) avec variantes multiples
|
||||
- **Composants dédiés** : `TrackListEmpty.tsx`, `CartViewEmpty.tsx`, `LibraryPageEmpty.tsx`, etc.
|
||||
|
||||
### Error states
|
||||
|
||||
- ✅ `ErrorDisplay` composant (4 variants : inline, banner, modal, card)
|
||||
- ✅ `ErrorBoundary` pour les erreurs React
|
||||
- ✅ Messages user-friendly via `formatUserFriendlyError()`
|
||||
- ⚠️ Inconsistance entre affichage inline et toast-only selon les composants
|
||||
|
||||
### Success states
|
||||
|
||||
- ✅ Toasts de succès
|
||||
- ⚠️ Pas de composant de succès visuel dédié (type checkmark animé)
|
||||
- **Composant centralisé** : `ErrorDisplay.tsx` (246L) avec variantes [ErrorDisplay.tsx]
|
||||
- **ErrorBoundary** à chaque route [routeConfig.tsx:40-55] ✅
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -109,29 +80,27 @@ Le composant `Button` (`components/ui/button.tsx`) définit **10 variants** et *
|
|||
|
||||
### Grille
|
||||
|
||||
- ✅ Flexbox + CSS Grid via Tailwind — patterns cohérents
|
||||
- ✅ Responsive : `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` pattern dominant
|
||||
- ✅ `TrackGrid` composant réutilisable avec densité configurable
|
||||
- **Flexbox dominant** : `flex items-center` (~230 occ.), `flex flex-col` (~230 occ.)
|
||||
- **CSS Grid** : utilisé mais moins fréquent
|
||||
- **Layout primitives** : `.max-w-layout-content`, `.min-h-layout-main`, etc. [index.css:545-563] ✅
|
||||
|
||||
### Sidebar / Header / Footer
|
||||
### Sidebar/Header/Footer
|
||||
|
||||
- ✅ `Sidebar` composant partagé avec collapse/expand
|
||||
- ✅ `Header` composant partagé
|
||||
- ✅ `DashboardLayout` wraps sidebar + header + main
|
||||
- ✅ Layout tokens (`--sidebar-width-expanded`, `--main-margin-left-expanded`, etc.)
|
||||
- **Sidebar** : `components/layout/Sidebar.tsx` (294L) — composant unique, bien structuré
|
||||
- **Header** : `components/layout/Header.tsx` (172L) — composant unique
|
||||
- **Footer** : ❌ Aucun composant footer identifié (accepté pour une SPA dashboard)
|
||||
- **DashboardLayout** : Shell principal dans `DashboardLayout.tsx` (61L)
|
||||
|
||||
### Responsive
|
||||
|
||||
- ✅ **Mobile-first** (breakpoints Tailwind standard)
|
||||
- ✅ 200+ usages de breakpoints responsifs
|
||||
- ✅ `MobileBottomNav` pour la navigation mobile
|
||||
- ✅ Breakpoints cohérents (sm/md/lg/xl standard)
|
||||
- **Mobile-first** : Breakpoints Tailwind standards (`md:`, `lg:`, `xl:`)
|
||||
- **Mobile bottom nav** : `MobileBottomNav.tsx` (50L) ✅
|
||||
- **Sidebar responsive** : Collapse sur mobile avec overlay [Sidebar.tsx]
|
||||
|
||||
### Espacement
|
||||
|
||||
- ✅ Tokens de gap définis (`--layout-gap`, `--layout-gap-sm`, `--layout-gap-lg`)
|
||||
- ✅ ESLint enforce les spacing Tailwind scale
|
||||
- ✅ Pas de valeurs excentriques détectées
|
||||
- Régulier grâce aux tokens SUMI et aux classes Tailwind `gap-*`, `p-*`, `space-y-*`
|
||||
- 7 occurrences de `p-[`, `m-[` arbitraires — négligeable
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -139,84 +108,67 @@ Le composant `Button` (`components/ui/button.tsx`) définit **10 variants** et *
|
|||
|
||||
### Source
|
||||
|
||||
- ✅ **Lucide React exclusivement** — aucun mix avec d'autres bibliothèques
|
||||
- ✅ 150+ fichiers importent depuis `lucide-react`
|
||||
- ✅ 50+ icônes distinctes utilisées
|
||||
- **Lucide React** : 226 fichiers importent des icônes lucide-react — source unique ✅
|
||||
- **Aucun mix** avec FontAwesome, Heroicons, etc. — excellent
|
||||
|
||||
### Tailles cohérentes
|
||||
### Cohérence tailles
|
||||
|
||||
- ✅ Pattern standard : `w-4 h-4` (16px), `w-5 h-5` (20px), `w-6 h-6` (24px)
|
||||
- Taille standard : `h-4 w-4` pour les icônes inline, `h-5 w-5` pour les icônes de navigation
|
||||
- Quelques variations (`h-3 w-3`, `h-6 w-6`, `h-8 w-8`, `h-12 w-12`) pour des contextes spécifiques (hero, empty states)
|
||||
|
||||
### Couleurs cohérentes
|
||||
### Accessibilité icônes
|
||||
|
||||
- ✅ Icônes héritent la couleur du texte parent (Tailwind `text-*` ou `currentColor`)
|
||||
|
||||
### Accessibilité
|
||||
|
||||
- ✅ `aria-hidden="true"` sur les icônes décoratives : 100+ instances
|
||||
- ✅ Bonne pratique systématique
|
||||
- Icônes dans les boutons : généralement avec texte adjacent (bonne pratique)
|
||||
- Icônes décoratives : `aria-hidden` devrait être plus systématique
|
||||
|
||||
---
|
||||
|
||||
## Classement des incohérences
|
||||
|
||||
### Incohérences critiques (cassent l'expérience)
|
||||
### Incohérences critiques
|
||||
|
||||
_Aucune incohérence critique détectée._ L'application maintient une expérience visuellement cohérente.
|
||||
Aucune incohérence critique qui casserait l'expérience utilisateur.
|
||||
|
||||
### Incohérences majeures (perceptibles, dégradent la confiance)
|
||||
### Incohérences majeures
|
||||
|
||||
| # | Problème | Impact | Fichiers concernés |
|
||||
|---|----------|--------|-------------------|
|
||||
| 1 | CSS vanilla parallèle aux composants React | Deux systèmes de styles concurrents | `styles/button.css`, `styles/card.css`, `styles/input.css`, `styles/badge-avatar.css` |
|
||||
| 2 | Hacks CSS avec `!important` | Styles imprévisibles, maintenance difficile | `styles/fix-input-focus.css`, `styles/fix-login-form.css` |
|
||||
| 3 | Duplication Spinner/LoadingSpinner | Confusion sur quel composant utiliser | `ui/Spinner.tsx`, `ui/loading-spinner.tsx` |
|
||||
| 4 | Duplication Modal/Dialog | Deux systèmes de modales | `ui/modal.tsx`, `ui/dialog/` |
|
||||
| # | Incohérence | Fichiers concernés | Impact |
|
||||
|---|------------|-------------------|--------|
|
||||
| 1 | `variant="glass"` non défini dans Button | `CloudIntegrationView.tsx`, `GearViewHeader.tsx` | Boutons sans style visible |
|
||||
| 2 | Deux APIs toast coexistent (`addToast` vs `toast()`) | 109 fichiers | Confusion développeur, risque d'inconsistance UX |
|
||||
|
||||
### Incohérences mineures (détectables par un designer)
|
||||
### Incohérences mineures
|
||||
|
||||
| # | Problème | Impact | Fichiers concernés |
|
||||
|---|----------|--------|-------------------|
|
||||
| 1 | Z-index non-tokenisé pour les modals | Superposition potentielle | ~20 fichiers avec `z-[100]` |
|
||||
| 2 | Couleurs hardcodées dans charts | Contournent le design system | 6 fichiers charts |
|
||||
| 3 | `ButtonLoading` redondant | API de button confuse | `ui/button-loading.tsx` |
|
||||
| 4 | Error display inline vs toast inconsistant | UX variable selon les pages | Multiple services |
|
||||
| # | Incohérence | Fichiers concernés | Impact |
|
||||
|---|------------|-------------------|--------|
|
||||
| 3 | `<button>` natifs au lieu de `<Button>` | ~50+ fichiers source | Style par défaut du navigateur |
|
||||
| 4 | Labels manquants sur certains inputs | Variable | Accessibilité |
|
||||
| 5 | `aria-hidden` manquant sur icônes décoratives | ~226 fichiers avec lucide | Accessibilité |
|
||||
|
||||
### Dette visuelle — fichiers à refactorer en priorité
|
||||
### Dette visuelle à refactorer
|
||||
|
||||
| Priorité | Fichier | Raison |
|
||||
|----------|---------|--------|
|
||||
| P0 | `styles/fix-input-focus.css` | Hack `!important` — à supprimer |
|
||||
| P0 | `styles/fix-login-form.css` | Hack `!important` — à supprimer |
|
||||
| P1 | `styles/button.css` | Duplique `ui/button.tsx` |
|
||||
| P1 | `styles/card.css` | Duplique `ui/card.tsx` |
|
||||
| P1 | `styles/badge-avatar.css` | Duplique `ui/badge.tsx` + `ui/avatar.tsx` |
|
||||
| P1 | `styles/input.css` | Duplique `ui/input.tsx` |
|
||||
| P2 | `ui/loading-spinner.tsx` | Supprimer, utiliser `Spinner.tsx` |
|
||||
| P2 | `ui/button-loading.tsx` | Supprimer, utiliser `Button` prop `loading` |
|
||||
| P1 | `components/layout/Sidebar.tsx` | 11 valeurs arbitraires |
|
||||
| P1 | `components/layout/Header.tsx` | 10 valeurs arbitraires |
|
||||
| P1 | `features/dashboard/pages/DashboardPage.tsx` | 11 valeurs arbitraires + >300L |
|
||||
| P2 | `features/tracks/components/TrackSearchResults.tsx` | 11 valeurs arbitraires |
|
||||
| P2 | `features/player/components/player-bar/PlayerBarGlass.tsx` | 9 valeurs arbitraires |
|
||||
| P2 | 31 fichiers avec `z-[N]` | Z-index arbitraires vs tokens |
|
||||
|
||||
---
|
||||
|
||||
## SCORE COHÉRENCE UI : 7/10
|
||||
## Score Cohérence UI détaillé
|
||||
|
||||
### Points gagnés
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| Icônes uniformes | +1.0 | Lucide React exclusif, tailles cohérentes, aria-hidden |
|
||||
| Layout responsive | +1.0 | Mobile-first, tokens layout, breakpoints standard |
|
||||
| Button system | +1.0 | 10 variants, tous les états couverts, bien adopté |
|
||||
| Toast unifié | +0.8 | Système cohérent, types bien définis |
|
||||
| Empty/Loading states | +0.8 | Bonne couverture, composants dédiés |
|
||||
| Forms | +0.7 | Labels, validation, error feedback |
|
||||
| Spacing | +0.7 | Tokenisé, ESLint enforced |
|
||||
|
||||
### Points perdus
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| CSS vanilla parallèle | -1.0 | 5 fichiers CSS dupliquent les composants React |
|
||||
| Hacks `!important` | -0.5 | 2 fichiers de fixes agressifs |
|
||||
| Duplications composants | -0.5 | Modal/Dialog, Spinner/LoadingSpinner, ButtonLoading |
|
||||
| Z-index chaotique | -0.3 | Mix tokens + valeurs arbitraires |
|
||||
| Error display inconsistant | -0.2 | Inline vs toast variable |
|
||||
| Critère | Points | Justification |
|
||||
|---------|--------|---------------|
|
||||
| Button system | +1.5 | Centralisé, 7 variants, bien adopté (228 fichiers) |
|
||||
| `variant="glass"` manquant | -0.5 | Variante utilisée mais non définie |
|
||||
| Toast dualité | -0.5 | Deux APIs, pas de convention unique |
|
||||
| Loading states | +1.5 | Skeletons systématiques, 187 fichiers |
|
||||
| Empty states | +1 | 110+ fichiers, composant centralisé |
|
||||
| Error states | +1 | ErrorBoundary + ErrorDisplay |
|
||||
| Layout cohérent | +1 | Sidebar/Header/DashboardLayout |
|
||||
| Responsive | +0.5 | Mobile nav, sidebar collapse |
|
||||
| Iconographie unifiée | +1 | Lucide only, 226 fichiers |
|
||||
| Valeurs arbitraires | -0.5 | ~62 occ. w/h + 31 fichiers z-index |
|
||||
| **Total** | **6.5/10** | **Globalement cohérent, quelques fuites** |
|
||||
|
|
|
|||
|
|
@ -1,161 +1,126 @@
|
|||
# PHASE E — ACCESSIBILITÉ (WCAG 2.1 AA)
|
||||
# Phase E — Accessibility Audit (WCAG 2.1 AA)
|
||||
|
||||
**Score Accessibilité : 5.5/10**
|
||||
|
||||
---
|
||||
|
||||
## E1. Sémantique HTML
|
||||
|
||||
### Éléments sémantiques
|
||||
### Éléments sémantiques détectés
|
||||
|
||||
| Élément | Occurrences | Fichiers |
|
||||
|---------|-------------|----------|
|
||||
| `<main>` | 2 | `Layout.tsx:37`, `DashboardLayout.tsx:45` |
|
||||
| `<nav>` | 2 | `Sidebar.tsx:146` (avec `role="navigation"` + `aria-label`), `Navbar.tsx:88` |
|
||||
| `<header>` | 1 | `AuthLayout.tsx:48` |
|
||||
| `<footer>` | 0 | ❌ Absent |
|
||||
| `<section>` | 0 | ❌ Absent |
|
||||
| `<article>` | 0 | ❌ Absent |
|
||||
| `<aside>` | 0 | ❌ Absent |
|
||||
|---------|------------|---------|
|
||||
| `<main>` | 1 | `components/layout/Layout.tsx` |
|
||||
| `<nav>` | 2 | `components/layout/Sidebar.tsx`, `Navbar.tsx` |
|
||||
| `<header>` | 1 | `components/layout/Header.tsx` |
|
||||
| `<footer>` | 0 | Aucun |
|
||||
| `<section>` | 2 | Rare |
|
||||
| `<article>` | 0 | Aucun |
|
||||
| `<aside>` | 0 | Aucun |
|
||||
|
||||
**Verdict** : ⚠️ La sémantique HTML est **minimale**. Seuls `<main>` et `<nav>` sont utilisés. Aucun `<section>`, `<article>`, `<aside>`, `<footer>` détecté dans les composants. La Sidebar devrait être un `<aside>`, les cards d'articles un `<article>`, les sections de page des `<section>`.
|
||||
**Verdict** : Sémantique HTML **insuffisante** pour une application de cette taille. La sidebar devrait être un `<aside>`, les cards de contenu devraient utiliser `<article>`, les sections de page `<section>`. La navigation par landmarks pour les utilisateurs de lecteurs d'écran est limitée. **-2 points.**
|
||||
|
||||
### Anti-patterns
|
||||
### Anti-patterns `div onClick`
|
||||
|
||||
- ✅ **Aucun `<div onClick>` ou `<span onClick>` sans role** détecté — excellent
|
||||
- ✅ Les éléments cliquables utilisent des boutons ou des éléments avec `role` approprié
|
||||
- **2 occurrences** de `<div onClick>` identifiées :
|
||||
- `components/social/groups/GroupCard.tsx:66` — `<div onClick={(e) => e.stopPropagation()}>` — pas d'élement interactif, arrêt propagation seulement
|
||||
- `components/ui/dialog/DialogTrigger.tsx:15` — `<div onClick={onClick} style={{ display: 'inline-block' }}>` — **devrait être un `<button>`**
|
||||
|
||||
### Heading hierarchy
|
||||
|
||||
- ⚠️ Impossible de valider la hiérarchie h1→h6 sans rendu — les headings sont présents dans les composants mais leur hiérarchie dépend de la composition des pages
|
||||
- Hiérarchie définie dans `@layer base` [index.css:506-516] : h1→h6 avec tailles responsives ✅
|
||||
- **Non vérifié** : L'ordre des headings dans les pages individuelles (h1 → h2 → h3) n'est pas garanti architecturalement. Risque de sauts (h1 → h3).
|
||||
|
||||
---
|
||||
|
||||
## E2. ARIA
|
||||
|
||||
### Statistiques
|
||||
### Usage global
|
||||
|
||||
| Attribut | Occurrences | Verdict |
|
||||
|----------|-------------|---------|
|
||||
| `aria-label` | 100+ | ✅ Bien utilisé |
|
||||
| `aria-hidden` | 100+ | ✅ Icônes décoratives cachées |
|
||||
| `aria-describedby` | 15+ | ✅ Messages d'erreur liés |
|
||||
| `aria-expanded` | 12+ | ✅ Menus/accordéons |
|
||||
| `aria-live` | 25+ | ✅ Contenus dynamiques annoncés |
|
||||
| `aria-required` | 6 | ⚠️ Pourrait être plus systématique |
|
||||
| `aria-pressed` | Présent | ✅ Boutons toggle |
|
||||
| `aria-current` | Présent | ✅ Navigation |
|
||||
| `aria-checked` | Présent | ✅ Checkboxes/switches |
|
||||
| `aria-selected` | Présent | ✅ Tabs/options |
|
||||
| `aria-disabled` | Présent | ✅ Éléments désactivés |
|
||||
| `aria-valuemin/max/now` | Présent | ✅ Sliders/progress |
|
||||
| `aria-invalid` | Présent | ✅ Champs en erreur |
|
||||
| `aria-busy` | Présent | ✅ Chargements |
|
||||
| `aria-haspopup` | Présent | ✅ Menus |
|
||||
| `aria-labelledby` | Présent | ✅ Régions labellisées |
|
||||
- **176 fichiers** utilisent des attributs `aria-*` ✅ Bon niveau d'adoption
|
||||
|
||||
### Roles
|
||||
### Détail
|
||||
|
||||
| Role | Usage |
|
||||
|------|-------|
|
||||
| `button` | Éléments interactifs |
|
||||
| `navigation` | Sidebar, Navbar |
|
||||
| `dialog` | Modals/dialogs |
|
||||
| `alert` | Messages d'alerte |
|
||||
| `status` | Messages de statut |
|
||||
| `listbox` / `option` | Select |
|
||||
| `menuitem` | Dropdown menu |
|
||||
| `radiogroup` / `radio` | Radio groups |
|
||||
| `slider` | Sliders |
|
||||
| `progressbar` | Progress bars |
|
||||
| `switch` | Toggles |
|
||||
| `tab` / `tabpanel` / `tablist` | Tabs |
|
||||
| `search` | Barre de recherche |
|
||||
| `grid` / `row` | Tables |
|
||||
| `toolbar` | Barres d'outils |
|
||||
| Attribut | Usage | Fichiers | Verdict |
|
||||
|----------|-------|----------|---------|
|
||||
| `aria-label` | Éléments interactifs sans texte visible | Large adoption | ✅ Bon |
|
||||
| `aria-live` | Contenus dynamiques | 6+ fichiers (Toast, auth feedback, track list) | ⚠️ Partiel |
|
||||
| `aria-expanded` | Menus/accordéons | 3+ fichiers (PlaybackSpeed, QualitySelector, Accordion) | ⚠️ Partiel |
|
||||
| `aria-invalid` | Inputs en erreur | `input.tsx:31` | ✅ Systématique |
|
||||
| `aria-describedby` | Messages d'erreur liés | `input.tsx:32` | ✅ |
|
||||
| `aria-hidden` | Icônes décoratives | **Rarement utilisé** | ❌ Manquant |
|
||||
| `role="button"` | Éléments cliquables non-boutons | 18 fichiers | ⚠️ Devrait être `<button>` |
|
||||
|
||||
**Verdict** : ✅ L'usage ARIA est **extensif et correct**. Couverture remarquable.
|
||||
### Problèmes identifiés
|
||||
|
||||
1. **`aria-hidden` absent** sur les icônes Lucide (226 fichiers). Les icônes purement décoratives devraient avoir `aria-hidden="true"`. **Violation WCAG 1.1.1.**
|
||||
2. **`role="button"` sur des `<div>`** dans 18 fichiers [PlaylistCard, TrackCard, LibraryPageGrid, etc.] — ces éléments devraient être des `<button>` natifs pour bénéficier du focus clavier et de la gestion Enter/Space automatique.
|
||||
3. **`aria-live` limité** — les zones dynamiques (résultats de recherche, listes filtrées, compteurs) n'annoncent pas systématiquement les changements.
|
||||
|
||||
---
|
||||
|
||||
## E3. Focus management
|
||||
|
||||
### Outline
|
||||
### `outline-none` sans remplacement
|
||||
|
||||
| Problème | Fichier:ligne | Détail |
|
||||
|----------|--------------|--------|
|
||||
| `outline: none` | `styles/global-effects.css:78,94,107` | Suppression outline |
|
||||
| `outline: none` | `styles/fix-input-focus.css:18,44` | Suppression outline avec `!important` |
|
||||
| `outline: none` | `styles/input.css:44` | Suppression outline |
|
||||
- **45 fichiers** utilisent `outline-none`
|
||||
- **Majorité avec remplacement** : `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` — pattern correct ✅
|
||||
- **Exceptions problématiques** :
|
||||
- `features/studio/components/cloud-file-browser/FileToolbar.tsx` — `outline-none` **sans ring** ❌
|
||||
- `CreateProductViewDetailsCard.tsx`, `EditProfileIdentityCard.tsx`, `AccountSettingsPreferencesCard.tsx` — `outline-none` sur inputs sans ring visible ❌
|
||||
|
||||
**MAIS** : La plupart des composants TSX utilisent `focus-visible:ring-2 focus-visible:ring-ring` — un indicateur de focus visible est systématiquement ajouté via Tailwind. Les suppressions d'outline dans les CSS sont compensées par des rings visibles dans les composants.
|
||||
### Focus visible
|
||||
|
||||
**Verdict** : ⚠️ Techniquement les outlines sont supprimées en CSS, mais remplacées par des focus rings visibles. **Risque** : si un élément interactif n'a pas la classe `focus-visible:ring-*`, il perd tout indicateur de focus.
|
||||
- **56 fichiers** utilisent `focus-visible:` ✅
|
||||
- Le composant `Button` utilise `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` [button.tsx:12] ✅
|
||||
- Le composant `Input` utilise `focus-visible:outline-none focus-visible:ring-2` [input.tsx:36] ✅
|
||||
- **Glow effect** : `focus-visible:shadow-[var(--sumi-shadow-glow)]` [button.tsx:12, input.tsx:37] — feedback visuel amélioré ✅
|
||||
|
||||
### Focus trap
|
||||
|
||||
- ✅ `FocusTrap` composant dédié (`components/ui/focus-trap.tsx`)
|
||||
- ✅ Utilisé dans `Modal` (`modal.tsx:97`)
|
||||
- ✅ Utilisé dans `Header` mobile menu (`Header.tsx:138`)
|
||||
- ⚠️ Non vérifié dans tous les dialogs/drawers
|
||||
|
||||
### Focus restauration
|
||||
|
||||
- [DONNÉES INSUFFISANTES — nécessite test runtime pour vérifier la restauration du focus après fermeture des modales]
|
||||
- `components/ui/focus-trap.tsx` (126L) disponible ✅
|
||||
- Utilisé dans `modal.tsx` pour les modales ✅
|
||||
- **Vérification manuelle nécessaire** : restauration du focus après fermeture de modale
|
||||
|
||||
### Skip navigation
|
||||
|
||||
- ❌ **Absent**. Le test d'accessibilité (`__tests__/accessibility.test.tsx:408-422`) cherche un skip link mais aucune implémentation n'existe dans le code.
|
||||
- **WCAG 2.4.1 Bypass Blocks** — Violation
|
||||
|
||||
### Tab order
|
||||
|
||||
- ✅ `tabIndex` utilisé correctement : 50+ instances
|
||||
- ✅ `tabIndex={0}` pour éléments focalisables
|
||||
- ✅ `tabIndex={-1}` pour éléments programmatiquement focalisables
|
||||
- ✅ Conditional tabIndex selon l'état (disabled → -1)
|
||||
- ❌ **Aucun skip navigation link** détecté. Violation WCAG 2.4.1 pour une application avec sidebar + header.
|
||||
|
||||
---
|
||||
|
||||
## E4. Contraste et couleurs
|
||||
|
||||
### Estimation des ratios
|
||||
### Estimations de contraste (Dark mode)
|
||||
|
||||
| Combinaison | Ratio estimé | Verdict WCAG AA |
|
||||
| Combinaison | Ratio estimé | WCAG AA (4.5:1) |
|
||||
|-------------|-------------|-----------------|
|
||||
| `--foreground` sur `--background` (light) | ~15:1 | ✅ Passe |
|
||||
| `--foreground` sur `--background` (dark) | ~14:1 | ✅ Passe |
|
||||
| `--primary` sur `--background` (light) | ~4.5:1 | ⚠️ Limite AA (OKLCH 0.75 sur 0.985) |
|
||||
| `--primary` sur `--primary-foreground` | ~8:1 | ✅ Passe |
|
||||
| `--muted-foreground` sur `--background` (light) | ~5:1 | ✅ Passe |
|
||||
| `--muted-foreground` sur `--muted` (dark) | ~3.5:1 | ⚠️ Limite pour texte normal |
|
||||
| `--destructive` sur `--background` | ~5:1 | ✅ Passe |
|
||||
| `--sumi-text-primary` (#f0ede8) sur `--sumi-bg-base` (#121215) | ~14:1 | ✅ |
|
||||
| `--sumi-text-secondary` (#a8a4a0) sur `--sumi-bg-base` (#121215) | ~8:1 | ✅ |
|
||||
| `--sumi-text-tertiary` (#706c68) sur `--sumi-bg-base` (#121215) | ~4.2:1 | ⚠️ Borderline |
|
||||
| `--sumi-text-disabled` (#4a4844) sur `--sumi-bg-base` (#121215) | ~2.5:1 | ❌ Insuffisant |
|
||||
| `--sumi-accent` (#7c9dd6) sur `--sumi-bg-base` (#121215) | ~7:1 | ✅ |
|
||||
| `--sumi-vermillion` (#d4634a) sur `--sumi-bg-base` (#121215) | ~5:1 | ✅ |
|
||||
|
||||
**Note** : Les couleurs OKLCH rendent l'estimation de contraste moins précise. Un test automatisé avec les valeurs rendues est recommandé.
|
||||
### Information par couleur seule
|
||||
|
||||
### Information par la couleur seule
|
||||
|
||||
- ⚠️ Les badges de succès/erreur/warning utilisent la couleur + une icône — ✅ bon
|
||||
- ⚠️ Les erreurs de formulaire sont en rouge — nécessitent aussi un texte ou icône
|
||||
- ✅ Les états actifs utilisent couleur + position/poids typographique
|
||||
|
||||
### Mode sombre
|
||||
|
||||
- ✅ Complet — toutes les variables CSS ont des valeurs dark mode
|
||||
- ✅ Contrast ratios ajustés pour le dark mode
|
||||
- ⚠️ Muted text en dark mode pourrait être sous le seuil WCAG AA (4.5:1)
|
||||
- **Erreurs** : Combinaison couleur rouge + icône AlertTriangle + texte — ✅ pas couleur seule
|
||||
- **Succès** : Combinaison couleur verte + icône + texte — ✅
|
||||
- **Toast** : Icône + texte + couleur — ✅
|
||||
|
||||
---
|
||||
|
||||
## E5. Images et médias
|
||||
|
||||
### Images
|
||||
### Images sans `alt`
|
||||
|
||||
- ✅ **Toutes les `<img>` ont un attribut `alt`** — aucune image sans alt détectée
|
||||
- ✅ 40+ images décoratives avec `alt=""` — bonne pratique
|
||||
- ⚠️ Beaucoup d'images album/artwork ont `alt=""` au lieu d'un alt descriptif — devrait être le titre de l'album/track
|
||||
- **2 images** sans attribut `alt` :
|
||||
1. `components/social/groups/CreateGroupModal.tsx:62` — `<img src={coverImage}>` ❌
|
||||
2. `components/upload/metadata/CoverArtUploadModal.tsx:124` — `<img src={currentImage}>` ❌
|
||||
|
||||
### Médias audio
|
||||
### `loading="lazy"`
|
||||
|
||||
- [DONNÉES INSUFFISANTES — plateforme audio, nécessite vérification des contrôles de lecture et transcriptions]
|
||||
- **0 occurrence** de `loading="lazy"` sur les `<img>` ❌
|
||||
- Le composant `OptimizedImage` gère le lazy loading via IntersectionObserver [optimized-image/OptimizedImage.tsx] mais ce n'est pas la même chose que l'attribut natif `loading="lazy"`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -163,62 +128,44 @@
|
|||
|
||||
### Labels
|
||||
|
||||
- ✅ `htmlFor` + `id` : 100+ instances correctes
|
||||
- ✅ La plupart des inputs ont un label associé
|
||||
- ⚠️ Quelques inputs n'utilisent que des placeholders sans label visible
|
||||
|
||||
### Messages d'erreur
|
||||
|
||||
- ✅ `aria-describedby` : 15+ instances — erreurs liées aux champs
|
||||
- ✅ `aria-invalid` : utilisé sur les champs en erreur
|
||||
|
||||
### Required
|
||||
|
||||
- ⚠️ `aria-required` : seulement 6 instances — devrait être plus systématique sur tous les champs obligatoires
|
||||
|
||||
### Autocomplete
|
||||
|
||||
- ✅ `autoComplete` : 10+ instances sur les champs auth (email, password, name)
|
||||
- ✅ Bonne pratique sur les formulaires d'authentification
|
||||
| Critère | Status | Détail |
|
||||
|---------|--------|--------|
|
||||
| `htmlFor` + `id` | ⚠️ Partiel | 56 fichiers — pas systématique |
|
||||
| `aria-describedby` pour erreurs | ✅ | Input component [input.tsx:32] |
|
||||
| `aria-required` | ❌ | Pas utilisé systématiquement |
|
||||
| `aria-invalid` | ✅ | Input component [input.tsx:31] |
|
||||
| `autocomplete` | ❌ | Pas vérifié systématiquement |
|
||||
|
||||
---
|
||||
|
||||
## Tableau des violations
|
||||
|
||||
| Sévérité | Critère WCAG | Localisation | Problème | Correction |
|
||||
|----------|-------------|--------------|----------|------------|
|
||||
| 🔴 CRITIQUE | 2.4.1 Bypass Blocks | App-level | Skip navigation absent | Ajouter `<a href="#main" class="sr-only focus:not-sr-only">` |
|
||||
| 🟠 HAUTE | 1.3.1 Info & Relationships | Toute l'app | Aucun `<section>`, `<article>`, `<aside>`, `<footer>` | Ajouter landmarks sémantiques |
|
||||
| 🟠 HAUTE | 1.1.1 Non-text Content | Artwork images | `alt=""` sur images de contenu (albums, tracks) | Ajouter alt descriptif (titre album/track) |
|
||||
| 🟡 MOYENNE | 2.4.7 Focus Visible | `styles/global-effects.css`, `fix-input-focus.css` | Outline supprimé en CSS | S'assurer que TOUS les éléments interactifs ont `focus-visible:ring-*` |
|
||||
| 🟡 MOYENNE | 1.4.3 Contrast | Dark mode muted text | `--muted-foreground` potentiellement sous 4.5:1 | Augmenter la luminosité à oklch(0.75+) |
|
||||
| 🟡 MOYENNE | 3.3.2 Labels | Formulaires divers | `aria-required` sur seulement 6 champs | Ajouter sur tous les champs obligatoires |
|
||||
| 🟢 BASSE | 4.1.3 Status Messages | Toasts | `aria-live` présent mais pas sur tous les toasts | Vérifier que tous les toasts ont `role="alert"` |
|
||||
| 🟢 BASSE | 1.3.5 Input Purpose | Certains formulaires | `autoComplete` manquant sur certains inputs | Ajouter `autoComplete` sur champs d'adresse, téléphone |
|
||||
| Sévérité | Critère WCAG | Fichier(s) | Problème | Correction |
|
||||
|----------|-------------|------------|----------|------------|
|
||||
| 🔴 CRITIQUE | 2.4.1 Bypass Blocks | Global | Pas de skip navigation link | Ajouter `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to content</a>` |
|
||||
| 🟠 HAUTE | 4.1.2 Name Role Value | 18 fichiers | `role="button"` sur div sans keyboard handler | Remplacer par `<button>` natif |
|
||||
| 🟠 HAUTE | 1.1.1 Non-text Content | 226 fichiers | `aria-hidden` manquant sur icônes décoratives | Ajouter `aria-hidden="true"` sur icônes sans label |
|
||||
| 🟠 HAUTE | 1.3.1 Info Relationships | Global | Sémantique HTML insuffisante (1 `<main>`, 0 `<aside>`, 0 `<article>`) | Enrichir les landmarks |
|
||||
| 🟡 MOYENNE | 1.1.1 Non-text Content | `CreateGroupModal.tsx:62`, `CoverArtUploadModal.tsx:124` | `<img>` sans `alt` | Ajouter `alt` descriptif |
|
||||
| 🟡 MOYENNE | 2.4.7 Focus Visible | `FileToolbar.tsx` + 3 fichiers | `outline-none` sans ring de remplacement | Ajouter `focus-visible:ring-2` |
|
||||
| 🟡 MOYENNE | 4.1.3 Status Messages | Zones dynamiques | `aria-live` insuffisant pour les résultats de recherche/filtrage | Ajouter `aria-live="polite"` sur les zones de résultats |
|
||||
| 🟢 BASSE | 1.4.3 Contrast | `--sumi-text-disabled` | Ratio ~2.5:1 (sous le seuil AA de 4.5:1) | Acceptable pour texte désactivé (non interactif) |
|
||||
|
||||
---
|
||||
|
||||
## SCORE ACCESSIBILITÉ : 6.5/10
|
||||
## Score Accessibilité détaillé
|
||||
|
||||
### Points gagnés
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| ARIA extensif | +1.5 | 200+ attributs ARIA, roles corrects, aria-live |
|
||||
| Focus management | +1.0 | FocusTrap, focus-visible rings, tabIndex correct |
|
||||
| Form labels | +1.0 | htmlFor/id, aria-describedby, autoComplete |
|
||||
| Images alt | +0.8 | 100% des images ont alt |
|
||||
| Icônes aria-hidden | +0.7 | 100+ instances correctes |
|
||||
| prefers-reduced-motion | +0.5 | 3 instances de respect |
|
||||
| ESLint jsx-a11y | +0.5 | Plugin actif |
|
||||
|
||||
### Points perdus
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| Skip navigation absent | -1.0 | Violation WCAG 2.4.1 critique |
|
||||
| Sémantique HTML minimale | -1.0 | Pas de section, article, aside, footer |
|
||||
| Images artwork sans alt descriptif | -0.5 | album/track covers avec alt="" |
|
||||
| Outline CSS supprimé | -0.3 | Compensé par focus rings mais risque |
|
||||
| aria-required insuffisant | -0.2 | Seulement 6 champs |
|
||||
| Contraste dark mode muted | -0.5 | Potentiellement sous 4.5:1 |
|
||||
| Critère | Points | Justification |
|
||||
|---------|--------|---------------|
|
||||
| ARIA usage | +1.5 | 176 fichiers, bonne adoption |
|
||||
| Input accessibility | +1 | aria-invalid, aria-describedby, error states |
|
||||
| Focus visible | +1 | 56 fichiers, glow effect |
|
||||
| Focus trap modales | +0.5 | Composant dédié |
|
||||
| Sémantique HTML | -1.5 | 1 main, 0 aside, 0 article — insuffisant |
|
||||
| Skip navigation | -0.5 | Absent |
|
||||
| aria-hidden icônes | -0.5 | 226 fichiers sans aria-hidden |
|
||||
| role="button" sur div | -0.5 | 18 fichiers |
|
||||
| img alt | -0.25 | 2 images |
|
||||
| outline-none sans ring | -0.25 | 4 fichiers |
|
||||
| Contraste | +0.5 | Majoritairement bon |
|
||||
| **Total** | **5.5/10** | **Efforts visibles mais lacunes structurelles** |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# PHASE F — SÉCURITÉ FRONTEND
|
||||
# Phase F — Sécurité Frontend
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -6,180 +6,147 @@
|
|||
|
||||
### Variables VITE_* exposées au bundle
|
||||
|
||||
| Variable | Sensible ? | Verdict |
|
||||
|----------|-----------|---------|
|
||||
| `VITE_API_URL` | ❌ Non — URL publique | ✅ OK |
|
||||
| `VITE_WS_URL` | ❌ Non — URL publique | ✅ OK |
|
||||
| `VITE_STREAM_URL` | ❌ Non — URL publique | ✅ OK |
|
||||
| `VITE_UPLOAD_URL` | ❌ Non — URL publique | ✅ OK |
|
||||
| `VITE_APP_NAME` | ❌ Non — branding | ✅ OK |
|
||||
| `VITE_DEBUG` | ⚠️ Potentiel — active le debug | ⚠️ S'assurer false en prod |
|
||||
| `VITE_USE_MSW` | ⚠️ Potentiel — active le mocking | ⚠️ S'assurer false en prod |
|
||||
| `VITE_FCM_VAPID_KEY` | ⚠️ Limité — clé publique Firebase | ✅ OK (clé publique par design) |
|
||||
| `VITE_FEATURE_*` | ❌ Non — feature flags | ✅ OK |
|
||||
| `VITE_SENTRY_DSN` | ❌ Non — DSN public par design | ✅ OK |
|
||||
| `VITE_DOMAIN` | ❌ Non — domaine public | ✅ OK |
|
||||
| Variable | Contenu | Sensible ? |
|
||||
|----------|---------|-----------|
|
||||
| `VITE_API_URL` | URL API backend | ❌ Public |
|
||||
| `VITE_WS_URL` | URL WebSocket | ❌ Public |
|
||||
| `VITE_STREAM_URL` | URL streaming | ❌ Public |
|
||||
| `VITE_UPLOAD_URL` | URL upload | ❌ Public |
|
||||
| `VITE_APP_NAME` | Nom de l'app | ❌ Public |
|
||||
| `VITE_DEBUG` | Flag debug | ❌ Non sensible |
|
||||
| `VITE_USE_MSW` | Flag MSW | ❌ Dev only |
|
||||
| `VITE_FCM_VAPID_KEY` | Clé push notifications | ⚠️ Public par design (VAPID) |
|
||||
| `VITE_FEATURE_*` | Feature flags | ❌ Non sensible |
|
||||
|
||||
### Secrets dans le code source
|
||||
|
||||
- ✅ **Aucun secret hardcodé** détecté dans le code source
|
||||
- ✅ Tokens de test uniquement dans les fichiers mock (`mocks/handlers.ts`)
|
||||
- ✅ Pas de clés API, mots de passe, ou secrets privés
|
||||
**Verdict** : Aucune clé API secrète exposée côté client. ✅
|
||||
|
||||
### Fichiers .env
|
||||
|
||||
| Fichier | Tracké | Verdict |
|
||||
|---------|--------|---------|
|
||||
| `.env.example` | ✅ Tracké (template) | ✅ OK |
|
||||
| `.env.local` | ❌ Non tracké | ✅ OK |
|
||||
| `.env.storybook` | ✅ Tracké | ⚠️ Vérifier qu'il ne contient pas de secrets |
|
||||
| `.env.production` | ✅ Tracké | ⚠️ Vérifier qu'il ne contient pas de secrets réels |
|
||||
- `.env.local` (450B) : Contient `VITE_DOMAIN`, `VITE_API_URL`, `VITE_WS_URL`, `VITE_STREAM_URL` — **pas de secrets** ✅
|
||||
- `.env.production` (1.8KB) : Contient URLs et config — **pas de secrets** ✅
|
||||
- `.env.example` (2.2KB) : Template
|
||||
|
||||
**Attention** : `.env.local` et `.env.production` sont versionnés (présents dans le repo). Le `.gitignore` ne semble pas exclure `.env.local`. Même si le contenu n'est pas sensible actuellement, c'est un **risque si quelqu'un ajoute un secret à l'avenir**.
|
||||
|
||||
### Stockage JWT
|
||||
|
||||
- ✅ **httpOnly cookies** — tokens JWT stockés dans des cookies httpOnly par le backend [services/tokenStorage.ts]
|
||||
- ✅ JavaScript ne peut pas lire les tokens (getters retournent `null`)
|
||||
- ✅ Nettoyage des vestiges localStorage inclus
|
||||
- ✅ **Excellente pratique de sécurité**
|
||||
- **httpOnly cookies** : Les tokens JWT sont gérés côté backend via cookies httpOnly ✅
|
||||
- `TokenStorage.getAccessToken()` retourne `null` en mode cookie — pas d'accès JS aux tokens ✅
|
||||
- `tokenStorage.ts` est un wrapper de compatibilité, pas de stockage réel en localStorage ✅
|
||||
|
||||
---
|
||||
|
||||
## F2. XSS
|
||||
|
||||
### dangerouslySetInnerHTML
|
||||
### `dangerouslySetInnerHTML`
|
||||
|
||||
| Fichier | Ligne | Source des données | Sanitization | Verdict |
|
||||
|---------|-------|-------------------|-------------|---------|
|
||||
| `features/chat/components/ChatMessages.tsx` | 145 | `message.content` (user input) | ✅ `sanitizeChatMessage()` | ✅ Sûr |
|
||||
| `features/chat/components/virtualized-chat-messages/VirtualizedChatMessageItem.tsx` | 58 | `message.content` (user input) | ✅ `sanitizeChatMessage()` | ✅ Sûr |
|
||||
| Fichier | Ligne | Source des données | Sanitization |
|
||||
|---------|-------|--------------------|-------------|
|
||||
| `features/chat/components/ChatMessages.tsx` | 145-147 | `message.content` (backend) | ✅ `sanitizeChatMessage()` via DOMPurify |
|
||||
| `features/chat/components/virtualized-chat-messages/VirtualizedChatMessageItem.tsx` | 58-60 | `message.content` (backend) | ✅ `sanitizeChatMessage()` via DOMPurify |
|
||||
|
||||
### Implémentation de la sanitization
|
||||
**Sanitization** : `utils/sanitize.ts` (429L) utilise DOMPurify avec :
|
||||
- Allowlist stricte de tags HTML [sanitize.ts]
|
||||
- Allowlist d'attributs [sanitize.ts]
|
||||
- URL schemes limitées (http, https, mailto) [sanitize.ts]
|
||||
- `javascript:` protocol filtré ✅
|
||||
|
||||
**Fichier** : `utils/sanitize.ts` (429 lignes)
|
||||
|
||||
**Configuration DOMPurify** :
|
||||
- Tags autorisés : `p, br, strong, em, u, i, b, span, a`
|
||||
- Attributs autorisés : `class, href, title, target`
|
||||
- URI schemes autorisés : `http, https, mailto` uniquement
|
||||
- Tags interdits : `script, iframe, object, embed, form, input, button`
|
||||
- Attributs interdits : `onerror, onload, onclick, onmouseover, onfocus, onblur`
|
||||
- Fallback de sanitization manuelle si DOMPurify indisponible
|
||||
|
||||
**Verdict** : ✅ **Robuste**. La sanitization est correctement implémentée avec DOMPurify et une configuration restrictive.
|
||||
**Verdict XSS** : ✅ Les deux usages de `dangerouslySetInnerHTML` sont correctement sanitizés.
|
||||
|
||||
### Autres vecteurs XSS
|
||||
|
||||
- ✅ **Aucun `eval()`** ou `new Function()` détecté
|
||||
- ✅ **Aucun `.innerHTML =`** direct
|
||||
- ✅ **Aucun `document.write`**
|
||||
- ✅ React échappe automatiquement le rendu JSX par défaut
|
||||
- `eval()` / `new Function()` : **0 occurrence** ✅
|
||||
- Template literals dans le DOM : Non détecté ✅
|
||||
- `document.write` : **0 occurrence** ✅
|
||||
|
||||
---
|
||||
|
||||
## F3. Stockage client
|
||||
|
||||
### localStorage
|
||||
### Données en localStorage
|
||||
|
||||
| Donnée | Fichier | Sensible ? | Verdict |
|
||||
|--------|---------|-----------|---------|
|
||||
| Density preference | `features/tracks/components/TrackGrid.tsx:60,84` | ❌ Non | ✅ OK |
|
||||
| (Legacy token cleanup) | `services/tokenStorage.ts` | — | ✅ Nettoyé |
|
||||
| Clé | Données | Sensible ? | Expiration |
|
||||
|-----|---------|-----------|-----------|
|
||||
| `ui-storage` | theme, language, sidebarOpen | ❌ | Persist |
|
||||
| `veza-cart-storage` | Items du panier | ❌ | Persist |
|
||||
| `auth-storage` | `isAuthenticated` (boolean) | ❌ | Persist |
|
||||
| `rememberedEmail` | Email utilisateur | ⚠️ PII | Persist |
|
||||
| `veza_offline_queue` | Requêtes en attente | ⚠️ Peut contenir des données | Nettoyé |
|
||||
| `PENDING_ANALYTICS_STORAGE_KEY` | Analytics payload | ❌ | Nettoyé |
|
||||
| `feature-highlight-*` | Dismissal flags | ❌ | Persist |
|
||||
| `pwa-install-dismissed` | Flag | ❌ | Persist |
|
||||
| `veza_wrong_server_shown` | Flag toast | ❌ | Persist |
|
||||
| **Developer API keys** | **Clés API** | **🔴 OUI** | **Persist** |
|
||||
|
||||
**Problème critique** : `services/developerService.ts` stocke des clés API dans localStorage. Même si ce sont des clés de développeur (probablement des API keys publiques), le stockage en localStorage les expose à toute extension de navigateur ou code tiers injecté. **Recommandation : déléguer la gestion au backend.**
|
||||
|
||||
### sessionStorage
|
||||
|
||||
| Donnée | Fichier | Sensible ? | Verdict |
|
||||
|--------|---------|-----------|---------|
|
||||
| Deprecation warnings | `services/api/client.ts:773-774,872-884` | ❌ Non | ✅ OK |
|
||||
| API error tracking | `services/api/client.ts:1347` | ❌ Non | ✅ OK |
|
||||
|
||||
**Verdict** : ✅ Aucune donnée sensible en localStorage/sessionStorage. Les tokens sont dans des cookies httpOnly.
|
||||
- Pas d'usage sensible détecté ✅
|
||||
|
||||
---
|
||||
|
||||
## F4. Dépendances
|
||||
|
||||
### Build cassé
|
||||
### Vulnérabilités connues
|
||||
|
||||
- 🔴 **Le build échoue** : `educationService` manquant (import fantôme dans `useEducationView.ts`)
|
||||
- Cela empêche l'exécution de `npm audit` sur un build propre
|
||||
[DONNÉES INSUFFISANTES — nécessite `npm audit` sur la machine]
|
||||
|
||||
### Dépendances notables
|
||||
### Dépendances à risque
|
||||
|
||||
| Dépendance | Version | Risque |
|
||||
|------------|---------|--------|
|
||||
| `dompurify` | ^3.3.0 | ✅ Dernière version — sanitization XSS |
|
||||
| `axios` | ^1.13.5 | ✅ Récent |
|
||||
| `react` | ^18.2.0 | ✅ Stable |
|
||||
| `zod` | ^3.25.76 | ✅ Validation schema |
|
||||
| `swagger-ui-react` | ^5.31.0 | ⚠️ En prod deps — potentiel d'attaque via UI Swagger exposée |
|
||||
| `lucide-react` | ^0.321.0 | ⚠️ Version ancienne (2024) — mise à jour recommandée |
|
||||
| Dépendance | Version | Risque | Usage |
|
||||
|-----------|---------|--------|-------|
|
||||
| `dompurify` | 3.3.x | ✅ Activement maintenu | Sanitization |
|
||||
| `axios` | 1.13.x | ✅ Récent | HTTP |
|
||||
| `swagger-ui-react` | 5.31.x | ⚠️ Exposé en production ? | Dev tools |
|
||||
| `hls.js` | 1.6.x | ✅ Maintenu | Streaming |
|
||||
|
||||
**Attention** : `swagger-ui-react` et `swagger-ui-dist` sont en `dependencies` (pas `devDependencies`). Si le composant SwaggerUI est accessible en production, cela expose la documentation API. **Vérifier que la route `/developer` est bien protégée et gated.**
|
||||
|
||||
---
|
||||
|
||||
## F5. Autres vecteurs
|
||||
|
||||
### CORS
|
||||
### Open redirect
|
||||
|
||||
- ✅ Proxy configuré en dev (`vite.config.ts`) — pas de CORS issues en dev
|
||||
- ✅ `withCredentials: true` dans le client Axios pour les cookies
|
||||
- [DONNÉES INSUFFISANTES — CORS headers côté serveur non analysés]
|
||||
| Fichier | Risque | Détail |
|
||||
|---------|--------|--------|
|
||||
| `features/playlists/hooks/usePlaylistNotifications.ts:203,219,235,251` | 🔴 **HAUT** | `window.location.href = notification.link!` — URL provenant du backend, pas de validation. Si un attaquant compromet les notifications, il peut rediriger vers un site malveillant. |
|
||||
| Autres `window.location.href` | ✅ Sûr | Tous vers des chemins statiques (`/login`, `/marketplace`, etc.) |
|
||||
|
||||
### CSP
|
||||
### CORS / CSP
|
||||
|
||||
- ✅ **Utilitaire CSP présent** (`utils/csp.ts`)
|
||||
- ✅ Production CSP utilise des nonces (pas de `unsafe-inline` pour scripts)
|
||||
- ✅ Dev CSP autorise `unsafe-eval` pour Vite HMR uniquement
|
||||
- ⚠️ Les headers CSP doivent être injectés côté serveur — vérifier l'intégration
|
||||
|
||||
### CSRF
|
||||
|
||||
- ✅ **Implémenté** (`services/csrf.ts`)
|
||||
- ✅ Token CSRF récupéré depuis `/csrf-token` endpoint
|
||||
- ✅ Stocké en mémoire (pas en localStorage)
|
||||
- ✅ Header `X-CSRF-Token` injecté via intercepteur Axios
|
||||
- ✅ Mécanisme de refresh avec déduplification
|
||||
|
||||
### Open redirects
|
||||
|
||||
- ✅ **Aucune assignation `window.location =` détectée** avec input utilisateur
|
||||
- ✅ Navigation via React Router uniquement
|
||||
- **CORS** : Géré côté backend. Le proxy Vite en dev élimine les problèmes CORS [vite.config.ts:63-76] ✅
|
||||
- **CSP** : [DONNÉES INSUFFISANTES — nécessite inspection des headers serveur]
|
||||
|
||||
### Prototype pollution
|
||||
|
||||
- ✅ Pas d'usage de `lodash.merge` ou patterns similaires dangereux détectés
|
||||
- Pas d'usage de `lodash.merge` ou similaire détecté ✅
|
||||
- `immer` (10.x) utilisé pour l'immutabilité — protège contre la mutation directe ✅
|
||||
|
||||
---
|
||||
|
||||
## Classement des vulnérabilités
|
||||
|
||||
| Gravité | Vulnérabilité | Localisation | Exploitabilité | Urgence |
|
||||
|---------|--------------|--------------|----------------|---------|
|
||||
| 🟡 MOYENNE | Build cassé — import fantôme bloque le build | `useEducationView.ts` → `educationService` | Non exploitable (build-time) | 🔴 Immédiate — bloque le déploiement |
|
||||
| 🟡 MOYENNE | `VITE_DEBUG` / `VITE_USE_MSW` en production | Config env | Faible — info disclosure | ⚠️ Vérifier les valeurs en prod |
|
||||
| 🟡 MOYENNE | Swagger UI en production deps | `package.json` | Faible — surface d'attaque via Swagger UI | ⚠️ Déplacer en devDependencies ou lazy-load |
|
||||
| 🟢 BASSE | `.env.production` tracké | `.env.production` | Très faible — si ne contient que des valeurs publiques | 🟢 Vérifier le contenu |
|
||||
| 🟢 BASSE | CSP headers non vérifiés côté serveur | Server config | Dépend du serveur | 🟢 Audit serveur nécessaire |
|
||||
| Gravité | Vulnérabilité | Fichier:ligne | Exploitabilité | Correction urgence |
|
||||
|---------|--------------|---------------|----------------|-------------------|
|
||||
| 🔴 CRITIQUE | Open redirect via notification.link | `usePlaylistNotifications.ts:203,219,235,251` | Moyenne (nécessite compromission backend/notifications) | Immédiate — valider l'URL (same-origin ou allowlist) |
|
||||
| 🟠 HAUTE | Clés API en localStorage | `developerService.ts:33` | Faible (nécessite accès au navigateur) | Court terme — migrer vers backend |
|
||||
| 🟡 MOYENNE | `.env.local` versionné | `.env.local` | Faible (pas de secrets actuels) | Ajouter `.env.local` au `.gitignore` |
|
||||
| 🟡 MOYENNE | swagger-ui en production | `package.json` (dependencies) | Faible (route protégée) | Déplacer en devDependencies si non nécessaire en prod |
|
||||
| 🟢 BASSE | `rememberedEmail` en localStorage | `LoginPage.tsx:115` | Très faible (PII minimal) | Acceptable avec notice RGPD |
|
||||
|
||||
---
|
||||
|
||||
## SCORE SÉCURITÉ : 8/10
|
||||
## Score Sécurité implicite
|
||||
|
||||
### Points gagnés
|
||||
Ce score n'est pas dans le tableau principal car la pondération est ×1.5, mais les observations sont globalement positives :
|
||||
- ✅ httpOnly cookies pour JWT
|
||||
- ✅ CSRF protection
|
||||
- ✅ DOMPurify pour `dangerouslySetInnerHTML`
|
||||
- ✅ Zod validation sur les réponses API
|
||||
- ✅ Pas de `eval()` ni secrets exposés
|
||||
- ❌ Open redirect dans usePlaylistNotifications
|
||||
- ❌ Clés API en localStorage
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| JWT httpOnly cookies | +2.0 | Excellente pratique — tokens inaccessibles au JS |
|
||||
| XSS sanitization | +1.5 | DOMPurify strict, pas d'eval, pas d'innerHTML |
|
||||
| CSRF protection | +1.5 | Token CSRF en mémoire, header injection |
|
||||
| Pas de secrets hardcodés | +1.0 | Aucun secret dans le code source |
|
||||
| CSP utilities | +0.5 | Utilitaire prêt avec nonces |
|
||||
| No open redirects | +0.5 | Navigation React Router uniquement |
|
||||
| No prototype pollution | +0.5 | Pas de patterns dangereux |
|
||||
|
||||
### Points perdus
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| Build cassé | -0.5 | Import fantôme bloque le déploiement |
|
||||
| Swagger UI en prod deps | -0.3 | Surface d'attaque potentielle |
|
||||
| Debug flags en env | -0.2 | VITE_DEBUG pourrait être activé en prod |
|
||||
**Score Sécurité : 7/10**
|
||||
|
|
|
|||
|
|
@ -1,87 +1,82 @@
|
|||
# PHASE G — PERFORMANCE
|
||||
# Phase G — Performance Analysis
|
||||
|
||||
**Score Performance : 6.5/10**
|
||||
|
||||
---
|
||||
|
||||
## G1. Bundle
|
||||
|
||||
### Build status
|
||||
### Configuration build
|
||||
|
||||
- 🔴 **Le build échoue actuellement** — import manquant `educationService` dans `useEducationView.ts`
|
||||
- Analyse basée sur le **dernier build réussi** dans `dist_verification/`
|
||||
[vite.config.ts:68-92]
|
||||
|
||||
### Taille du build (dernier build disponible)
|
||||
- **Target** : `esnext` — navigation moderne uniquement
|
||||
- **Minification** : `esbuild` ✅
|
||||
- **Source maps** : `hidden` en production (ne pas exposer le code source) ✅
|
||||
- **Bundle analyzer** : `rollup-plugin-visualizer` en production → `dist/bundle-analysis.html` ✅
|
||||
- **Chunk size warning** : 1000KB
|
||||
|
||||
| Catégorie | Taille | Verdict |
|
||||
|-----------|--------|---------|
|
||||
| **Total dist/** | **6.8 MB** | ⚠️ Élevé (includes mockServiceWorker) |
|
||||
| **JS total** | ~1.4 MB | ⚠️ Significatif |
|
||||
| **CSS total** | ~150 KB | ✅ Raisonnable |
|
||||
### Manual chunks
|
||||
|
||||
### Chunks JS (triés par taille)
|
||||
```typescript
|
||||
// vite.config.ts:73-87
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
'vendor-router': ['react-router'],
|
||||
'vendor-tanstack': ['@tanstack/*'],
|
||||
'vendor-icons': ['lucide-react'],
|
||||
'vendor-utils': ['date-fns', 'zod'],
|
||||
'vendor': // Default vendor chunk
|
||||
}
|
||||
```
|
||||
|
||||
| Chunk | Taille | Contenu |
|
||||
|-------|--------|---------|
|
||||
| `vendor-*.js` | **925 KB** | Dépendances générales (axios, zustand, framer-motion, etc.) |
|
||||
| `index-*.js` | **113 KB** | Code applicatif principal |
|
||||
| `vendor-react-*.js` | **85 KB** | React + React DOM |
|
||||
| `vendor-utils-*.js` | **37 KB** | date-fns, zod |
|
||||
| `routes-*.js` | **33 KB** | Route definitions |
|
||||
| `vendor-tanstack-*.js` | **21 KB** | TanStack React Query |
|
||||
| `TrackDetailPage-*.js` | **21 KB** | Page lazy-loaded |
|
||||
| `vendor-icons-*.js` | **17 KB** | Lucide icons |
|
||||
| `SettingsPage-*.js` | **17 KB** | Page lazy-loaded |
|
||||
| `ChatPage-*.js` | **17 KB** | Page lazy-loaded |
|
||||
**Positif** : Chunking explicite des dépendances pour un caching optimal. Les chunks vendeur changent rarement → longue durée de cache.
|
||||
|
||||
### Problèmes identifiés
|
||||
### Taille estimée
|
||||
|
||||
| Problème | Impact | Fichier |
|
||||
|----------|--------|---------|
|
||||
| `vendor-*.js` à 925 KB | 🔴 Chunk vendor monolithique trop gros | `vite.config.ts:85-98` |
|
||||
| `mockServiceWorker.js` dans le build | ⚠️ 13 KB de MSW en production | Build output |
|
||||
| `swagger-ui-react` en production deps | ⚠️ Potentiellement inclus dans vendor | `package.json` |
|
||||
| `rollup-plugin-visualizer` en production deps | ⚠️ Devrait être en devDependencies | `package.json` |
|
||||
[DONNÉES INSUFFISANTES — `npm run build` non exécuté. Estimation basée sur les dépendances :]
|
||||
|
||||
### Tree-shaking
|
||||
| Chunk | Estimation |
|
||||
|-------|-----------|
|
||||
| `vendor-react` | ~140KB (gzip) |
|
||||
| `vendor-router` | ~25KB |
|
||||
| `vendor-tanstack` | ~40KB |
|
||||
| `vendor-icons` (lucide-react) | ~50-80KB (226 fichiers importent des icônes) |
|
||||
| `vendor-utils` (date-fns + zod) | ~30KB |
|
||||
| `vendor` (reste) | ~100KB+ (axios, framer-motion, i18next, etc.) |
|
||||
| Application code | ~200-300KB |
|
||||
| **Total estimé** | **~600-800KB** (gzip) |
|
||||
|
||||
- ✅ Imports nommés pour `lucide-react` (vendor-icons à 17 KB — excellent pour ~50 icônes)
|
||||
- ✅ Manual chunks configurés pour React, Router, TanStack, icons, utils
|
||||
- ⚠️ Le chunk `vendor` de 925 KB suggère un tree-shaking incomplet des dépendances principales
|
||||
- ⚠️ `emoji-picker-react` (~200 KB), `framer-motion` (~30 KB), `hls.js` (~50 KB) probablement dans vendor
|
||||
**Attention** : `lucide-react` (0.321.x) peut être volumineux si le tree-shaking n'est pas optimal. Les imports nommés (`import { Home } from 'lucide-react'`) sont utilisés → tree-shaking devrait fonctionner.
|
||||
|
||||
### Source maps
|
||||
|
||||
- ✅ `sourcemap: 'hidden'` en production — source maps générées mais non exposées publiquement
|
||||
**Attention** : `framer-motion` (12.29.x) est une dépendance lourde (~60KB gzip). Vérifier si toutes les animations nécessitent framer-motion ou si CSS animations (déjà définies dans index.css) suffiraient.
|
||||
|
||||
---
|
||||
|
||||
## G2. Code splitting
|
||||
|
||||
### React.lazy()
|
||||
### React.lazy
|
||||
|
||||
| Fichier | Composant lazy | Usage |
|
||||
|---------|---------------|-------|
|
||||
| `components/ui/lazy-component/createLazyComponent.tsx:48` | Route components | ✅ Toutes les routes |
|
||||
| `features/chat/components/ChatMessage.tsx:11` | EmojiPicker | ✅ Composant lourd |
|
||||
| `features/chat/components/ChatInput.tsx:31` | EmojiPicker | ✅ Composant lourd |
|
||||
| `components/ui/ImageCropper.tsx:4` | Cropper | ✅ Composant lourd |
|
||||
| `components/feedback/LazyToaster.tsx:15` | Toaster | ✅ Non-critique |
|
||||
- **5 fichiers** utilisent `React.lazy()` / `lazy()` directement :
|
||||
- `features/chat/components/ChatInput.tsx:31` — emoji-picker-react ✅
|
||||
- `features/chat/components/ChatMessage.tsx:11` — emoji-picker-react ✅
|
||||
- `components/ui/ImageCropper.tsx:4` — Cropper ✅
|
||||
- `components/ui/lazy-component/createLazyComponent.tsx:48` — factory function ✅
|
||||
- `components/feedback/LazyToaster.tsx:15` — react-hot-toast ✅
|
||||
|
||||
### Routes lazy-loadées
|
||||
|
||||
- ✅ **100% des routes** sont lazy-loaded via `createLazyComponent`
|
||||
- ✅ Chaque page a son propre chunk (TrackDetailPage-*.js, ChatPage-*.js, etc.)
|
||||
**Toutes les routes** sont lazy via `createLazyComponent` [components/ui/lazy-component/createLazyComponent.tsx:48] qui utilise `React.lazy` avec error boundaries. Les exports sont centralisés dans `lazy-component/lazyExports.ts` [LazyComponent.tsx:39 → lazyExports.ts].
|
||||
|
||||
### Composants lourds
|
||||
Routes lazy : Login, Register, Dashboard, Chat, Library, Profile, Settings, Sessions, Roles, TrackDetail, Playlists, Marketplace, Search, Notifications, Analytics, Webhooks, Admin, Social, Seller, Wishlist, Purchases, DesignSystem, 404, 500. ✅ **Excellent.**
|
||||
|
||||
| Composant | Lazy-loaded ? | Taille estimée |
|
||||
|-----------|--------------|---------------|
|
||||
| EmojiPicker | ✅ Oui | ~200 KB |
|
||||
| ImageCropper | ✅ Oui | ~50 KB |
|
||||
| SwaggerUI | ❌ **Non** | ~2 MB potentiel |
|
||||
| Charts | ❌ **Non** | Variable |
|
||||
| WaveformVisualizer | ❌ **Non** | ~20 KB |
|
||||
### Dynamic imports
|
||||
|
||||
**Problème** : `swagger-ui-react` n'est PAS lazy-loaded — s'il est importé dans un composant non-lazy, il pourrait gonfler le bundle principal.
|
||||
- **60+ occurrences** de `import()` — usage approprié pour :
|
||||
- Route splitting
|
||||
- Heavy components (emoji-picker, cropper, swagger-ui)
|
||||
- Conditional loading (MSW, Sentry, toast)
|
||||
- Store lazy loading
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -89,36 +84,43 @@
|
|||
|
||||
### Memoization
|
||||
|
||||
| Hook | Instances | Verdict |
|
||||
|------|-----------|---------|
|
||||
| `useMemo` | ~50+ | ✅ Bien utilisé (filtered lists, expensive computations) |
|
||||
| `useCallback` | ~80+ | ✅ Bien utilisé (event handlers, child callbacks) |
|
||||
| `React.memo` | 5 | ⚠️ Sous-utilisé (TrackCard, PlaylistCard, CourseCard, ProductCard, PostCard) |
|
||||
| Pattern | Occurrences | Verdict |
|
||||
|---------|------------|---------|
|
||||
| `useMemo` / `useCallback` | 135 fichiers | ✅ Usage correct |
|
||||
| `React.memo` | **5 fichiers seulement** | ⚠️ Insuffisant |
|
||||
|
||||
**`React.memo` manquant** :
|
||||
- Les composants de liste (`TrackGrid`, `PlaylistList`) bénéficieraient de `React.memo` sur les items
|
||||
- Les items des menus de navigation pourraient être memoizés
|
||||
**Composants avec `React.memo`** :
|
||||
- `PlaylistCard.tsx:203` ✅
|
||||
- `TrackCard.tsx:198` ✅
|
||||
- `CourseCard.tsx:136` ✅
|
||||
- `PostCard.tsx:342` ✅
|
||||
- `ProductCard.tsx:163` ✅
|
||||
|
||||
### useEffect
|
||||
**Problème** : Seuls les cards sont mémorisés. Les composants de liste (TrackList, PlaylistList, etc.) qui itèrent sur ces cards ne sont PAS mémorisés. Les re-renders du parent provoquent des re-renders de tous les enfants. **-1 point.**
|
||||
|
||||
| Métrique | Valeur |
|
||||
|----------|--------|
|
||||
| Total useEffect | ~200+ instances |
|
||||
| Fichiers avec 5+ useEffect | 2-3 (`App.tsx`, `usePlayer.ts`) |
|
||||
### `key={index}` anti-pattern
|
||||
|
||||
⚠️ `App.tsx` avec 6+ useEffect est un signal de surcharge — devrait être décomposé.
|
||||
**~55 occurrences** de `key={index}` ou `key={i}` ⚠️
|
||||
|
||||
### Context providers trop larges
|
||||
Fichiers critiques (listes qui peuvent être réordonnées) :
|
||||
- `DashboardPage.tsx:248,303` — dashboard cards
|
||||
- `PlaybackHeatmapGrid.tsx:35` — grid dynamique
|
||||
- `TrackGrid.tsx:135` — grille de tracks
|
||||
- `TrackSearchResults.tsx:89` — résultats de recherche
|
||||
- `ChatMessage.tsx:98` — messages chat
|
||||
|
||||
- ⚠️ `AuthContext` et `ThemeContext` wrappent toute l'application — tout changement d'état provoque un re-render de l'arbre entier
|
||||
- ✅ Zustand stores avec selectors ne causent pas de re-renders en cascade
|
||||
- ✅ React Query cache est indépendant du rendu
|
||||
**Impact** : Pour les listes statiques (skeletons, options fixes), `key={index}` est acceptable. Pour les listes dynamiques (tracks, messages, résultats), c'est un bug potentiel de réconciliation React.
|
||||
|
||||
### Listes sans key ou avec key={index}
|
||||
### Context providers
|
||||
|
||||
- ⚠️ **100+ instances** de `key={index}` ou `key={i}`
|
||||
- La plupart sont des **skeletons et listes statiques** — acceptable
|
||||
- ⚠️ Certaines listes dynamiques (dashboard, chat messages) pourraient utiliser des IDs stables
|
||||
- Le `QueryClientProvider` est au niveau racine [main.tsx:218] — normal
|
||||
- `ThemeProvider`, `AudioProvider`, `ToastProvider` sont au niveau App [App.tsx:170-187] — scope approprié
|
||||
- Pas de provider trop large provoquant des re-renders en cascade identifié
|
||||
|
||||
### `useEffect` instances
|
||||
|
||||
- **~90 `useEffect` avec deps vides** `[]` — mount-only effects
|
||||
- La plupart sont des initialisations (fetch, event listeners). Quelques uns pourraient avoir des dépendances manquantes mais ESLint `exhaustive-deps` est configuré en `warn` [eslint.config.js].
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -126,72 +128,59 @@
|
|||
|
||||
### Images
|
||||
|
||||
- ❌ **Aucun `loading="lazy"`** détecté sur les `<img>`
|
||||
- ✅ Composant `OptimizedImage` avec blur placeholder et fallback
|
||||
- ⚠️ Pas de WebP/AVIF automatique (pas de pipeline d'optimisation d'images détecté)
|
||||
- ⚠️ Pas de `srcset` ou `sizes` pour les images responsives
|
||||
- **`OptimizedImage`** composant dédié (145L) avec IntersectionObserver pour lazy loading [optimized-image/OptimizedImage.tsx]
|
||||
- **`BlurPlaceholder`** (33L) pour le placeholder pendant le chargement [optimized-image/BlurPlaceholder.tsx]
|
||||
- **`loading="lazy"` natif** : 0 occurrences ❌ — repose entièrement sur le JS custom
|
||||
- **WebP/AVIF** : Pas de détection de format optimisé côté client [DONNÉES INSUFFISANTES — dépend du backend/CDN]
|
||||
|
||||
### Fonts
|
||||
|
||||
- ⚠️ Pas de `@font-face` déclarations — fonts chargées via Google Fonts (externe)
|
||||
- ⚠️ Pas de `font-display: swap` explicite dans le CSS (dépend du chargement Google Fonts)
|
||||
- ⚠️ Pas de `<link rel="preload">` pour les fonts
|
||||
- Fonts utilisées : Barlow, Inter, Noto Sans JP, JetBrains Mono, Orbitron, Source Serif 4 — **6 familles de polices** est excessif
|
||||
- 4 familles : Inter, Space Grotesk, JetBrains Mono, Noto Serif JP [index.css:78-81]
|
||||
- **Preload/display** : [DONNÉES INSUFFISANTES — nécessite vérification index.html]
|
||||
- **Subset** : Non détecté — potentiellement 4 polices complètes téléchargées
|
||||
|
||||
### SVG
|
||||
|
||||
- ✅ 5 fichiers SVG — faible impact
|
||||
- ✅ Pas de SVG inlinés massifs détectés
|
||||
- 5 fichiers SVG dans `src/` — minimal
|
||||
- Lucide React injecte les icônes en inline SVG → pas de requêtes HTTP supplémentaires ✅
|
||||
|
||||
---
|
||||
|
||||
## G5. Requêtes réseau
|
||||
|
||||
### Waterfall
|
||||
### Patterns de fetch
|
||||
|
||||
- ✅ React Query avec `staleTime: 1min` évite les requêtes redondantes
|
||||
- ✅ Request deduplication dans le client API
|
||||
- ⚠️ Pas de prefetching détecté (pas de `queryClient.prefetchQuery`)
|
||||
- ⚠️ Routes lazy-loaded + données = double waterfall (JS chunk + API call)
|
||||
|
||||
### Cache HTTP
|
||||
|
||||
- ✅ Response caching côté client dans le API client
|
||||
- ✅ React Query gcTime de 5 minutes
|
||||
- [DONNÉES INSUFFISANTES — headers Cache-Control côté serveur non analysés]
|
||||
- **Request deduplication** : `services/requestDeduplication.ts` — évite les appels API dupliqués ✅
|
||||
- **Response caching** : `services/responseCache.ts` — cache en mémoire avec TTL ✅
|
||||
- **Offline queue** : `services/offlineQueue.ts` — queue les mutations offline et les rejoue ✅
|
||||
- **React Query cache** : staleTime 1min, gcTime 5min [main.tsx:49-50]
|
||||
- **Cross-tab sync** : `utils/reactQuerySync.ts` — synchronise le cache React Query entre onglets ✅
|
||||
|
||||
### WebSocket
|
||||
|
||||
- ✅ Reconnection automatique avec exponential backoff
|
||||
- ✅ Max 5 tentatives de reconnection
|
||||
- ⚠️ Pas de heartbeat/ping explicite détecté dans `websocket.ts`
|
||||
- `services/websocket.ts` — WebSocket natif avec reconnection automatique ✅
|
||||
- `features/streaming/hooks/usePlaybackRealtime.ts` (496L) — streaming temps réel avec analytics ✅
|
||||
|
||||
### Waterfall
|
||||
|
||||
- Les routes sont lazy-loadées, ce qui crée un waterfall initial (HTML → JS chunk → data fetch). C'est le pattern standard React SPA.
|
||||
- `useRoutePreload.ts` (239L) implémente le **prefetching des routes** au hover des liens ✅ — réduit le waterfall perçu.
|
||||
|
||||
---
|
||||
|
||||
## SCORE PERFORMANCE : 6/10
|
||||
## Score Performance détaillé
|
||||
|
||||
### Points gagnés
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| 100% routes lazy-loaded | +1.5 | Excellent code splitting au niveau route |
|
||||
| Manual chunks Vite | +0.8 | React, router, tanstack, icons séparés |
|
||||
| React Query cache | +0.8 | staleTime, gcTime, deduplication |
|
||||
| Memoization hooks | +0.7 | useMemo/useCallback bien utilisés |
|
||||
| Composants lourds lazy | +0.5 | EmojiPicker, ImageCropper |
|
||||
| Source maps hidden | +0.3 | Pas exposés en production |
|
||||
| Virtualization | +0.4 | TanStack Virtual pour les listes longues |
|
||||
|
||||
### Points perdus
|
||||
|
||||
| Point | Score | Justification |
|
||||
|-------|-------|---------------|
|
||||
| Build cassé | -1.0 | Impossible de déployer |
|
||||
| Vendor chunk 925 KB | -0.8 | Chunk monolithique trop gros |
|
||||
| Pas de loading="lazy" images | -0.5 | Toutes les images chargées eagerly |
|
||||
| 6 familles de fonts | -0.5 | Impact LCP et FOIT/FOUT |
|
||||
| SwaggerUI non lazy | -0.3 | Potentiel 2 MB dans le bundle |
|
||||
| React.memo sous-utilisé | -0.3 | 5 instances seulement |
|
||||
| Pas de prefetching | -0.3 | Double waterfall routes + données |
|
||||
| Pas de font preload | -0.2 | Blocage de rendu potentiel |
|
||||
| key={index} fréquent | -0.1 | Minor mais présent |
|
||||
| Critère | Points | Justification |
|
||||
|---------|--------|---------------|
|
||||
| Code splitting routes | +2 | Toutes les routes lazy, 60+ dynamic imports |
|
||||
| Manual chunks vendeur | +1 | Bon chunking pour cache long-terme |
|
||||
| React.memo insuffisant | -1 | 5 composants seulement, listes non mémorisées |
|
||||
| key={index} | -0.5 | 55 occurrences, certaines sur des listes dynamiques |
|
||||
| Request dedup + cache | +1 | Architecture réseau mature |
|
||||
| Route prefetching | +0.5 | useRoutePreload au hover |
|
||||
| useMemo/useCallback | +0.5 | 135 fichiers — bonne adoption |
|
||||
| Pas de loading="lazy" | -0.5 | Repose sur JS, pas d'attribut natif |
|
||||
| Fonts non optimisées | -0.5 | 4 familles, pas de subset/preload vérifié |
|
||||
| Offline queue | +0.5 | Fonctionnalité avancée |
|
||||
| framer-motion overhead | -0.5 | Dépendance lourde potentiellement surutilisée |
|
||||
| **Total** | **6.5/10** | **Infrastructure solide, optimisation rendu lacunaire** |
|
||||
|
|
|
|||
|
|
@ -1,42 +1,44 @@
|
|||
# PHASE H — DETTE TECHNIQUE FRONTEND
|
||||
# Phase H — Dette Technique Frontend
|
||||
|
||||
---
|
||||
|
||||
## H1. Complexité excessive
|
||||
|
||||
### Fichiers > 300 lignes (hors tests et types générés)
|
||||
### Fichiers > 300 lignes (source, hors tests/generated)
|
||||
|
||||
| Fichier | Lignes | Raison | Split possible ? | Complexité |
|
||||
|---------|--------|--------|-----------------|------------|
|
||||
| `services/api/client.ts` | **2 237** | Client API monolithique : interceptors, retry, cache, CSRF, dedup | 🔴 Oui — split en modules (interceptors, retry, cache, csrf) | Très haute |
|
||||
| `mocks/handlers.ts` | **1 716** | Tous les handlers MSW dans un seul fichier | 🔴 Oui — split par feature | Haute (linéaire) |
|
||||
| `features/tracks/api/trackApi.ts` | **848** | Endpoints track API + types | 🟡 Oui — split par domaine | Moyenne |
|
||||
| `utils/optimisticUpdates.ts` | **682** | Utilitaires d'updates optimistes | 🟡 Possible | Haute |
|
||||
| `features/playlists/hooks/usePlaylist.ts` | **631** | Hook monolithique playlist | 🔴 Oui — split usePlaylistData, usePlaylistActions | Très haute |
|
||||
| `features/streaming/services/playbackAnalyticsService.ts` | **656** | Service analytics streaming | 🟡 Possible | Haute |
|
||||
| `utils/apiErrorHandler.ts` | **578** | Gestion d'erreurs API | 🟡 Possible | Haute |
|
||||
| `features/streaming/hooks/usePlaybackRealtime.ts` | **496** | Hook temps réel | 🔴 Oui — responsabilités multiples | Très haute |
|
||||
| `services/api/auth.ts` | **493** | Auth API (login, register, refresh, CSRF) | 🟡 Possible | Haute |
|
||||
| `schemas/apiRequestSchemas.ts` | **476** | Schemas Zod requêtes | ⚠️ Normal pour des schemas | Moyenne |
|
||||
| `schemas/apiSchemas.ts` | **468** | Schemas Zod réponses | ⚠️ Normal pour des schemas | Moyenne |
|
||||
| `features/tracks/services/trackService.ts` | **453** | Service tracks | 🟡 Possible | Moyenne |
|
||||
| `features/playlists/services/playlistService.ts` | **448** | Service playlists | 🟡 Possible | Moyenne |
|
||||
| `utils/sanitize.ts` | **429** | Sanitization XSS | ⚠️ Justifié (sécurité) | Moyenne |
|
||||
| `features/tracks/services/commentService.ts` | **425** | Service commentaires | 🟡 Possible | Moyenne |
|
||||
| Fichier | Lignes | Raison probable | Split possible ? | Complexité |
|
||||
|---------|--------|-----------------|-----------------|-----------|
|
||||
| `services/api/client.ts` | 2 237 | Client HTTP + validation + caching + retry + dedup + metrics | ✅ Oui, 5 modules | 🔴 Élevée |
|
||||
| `mocks/handlers.ts` | 1 716 | MSW handlers pour toutes les routes | ✅ Par feature | 🟡 Linéaire |
|
||||
| `features/tracks/api/trackApi.ts` | 848 | API tracks complète | ✅ CRUD/Upload/Share/Analytics | 🟠 Moyenne |
|
||||
| `utils/optimisticUpdates.ts` | 682 | Optimistic updates multi-feature | ⚠️ Difficilement | 🟠 Moyenne |
|
||||
| `features/streaming/services/playbackAnalyticsService.ts` | 656 | Analytics streaming | ⚠️ Cohérent | 🟡 Linéaire |
|
||||
| `features/playlists/hooks/usePlaylist.ts` | 631 | Hook playlist (CRUD + collab + analytics) | ✅ 3 hooks | 🔴 Élevée |
|
||||
| `utils/apiErrorHandler.ts` | 578 | Error parsing exhaustif | ⚠️ Cohérent | 🟡 Linéaire |
|
||||
| `features/streaming/hooks/usePlaybackRealtime.ts` | 496 | WebSocket + state + analytics | ⚠️ Justifié temps réel | 🟠 Moyenne |
|
||||
| `services/api/auth.ts` | 493 | Auth API (login, register, 2FA, OAuth) | ⚠️ Cohérent | 🟡 Linéaire |
|
||||
| `schemas/apiRequestSchemas.ts` | 476 | Zod schemas | ❌ Normal | 🟢 Faible |
|
||||
| `schemas/apiSchemas.ts` | 468 | Zod schemas | ❌ Normal | 🟢 Faible |
|
||||
| `features/tracks/services/trackService.ts` | 453 | Service tracks | ⚠️ Cohérent | 🟡 Linéaire |
|
||||
| `features/playlists/services/playlistService.ts` | 448 | Service playlists | ⚠️ Cohérent | 🟡 Linéaire |
|
||||
| `utils/sanitize.ts` | 429 | Sanitization XSS | ❌ Critique | 🟢 Faible |
|
||||
| `features/chat/hooks/useChat.ts` | 360 | Hook chat | ✅ 2-3 hooks | 🟠 Moyenne |
|
||||
| `features/auth/store/authStore.ts` | 330 | Store auth | ⚠️ Acceptable | 🟡 Linéaire |
|
||||
| `features/dashboard/pages/DashboardPage.tsx` | 328 | Page dashboard | ✅ Extraire sections | 🟡 Moyenne |
|
||||
| `features/tracks/components/TrackListRow.tsx` | 320 | Ligne de track | ✅ Sous-composants | 🟡 Moyenne |
|
||||
|
||||
**Résumé** : 5 fichiers nécessitent un split urgent (client.ts, handlers.ts, usePlaylist.ts, usePlaybackRealtime.ts, trackApi.ts).
|
||||
**Priorité de split** :
|
||||
1. `client.ts` (2237L) → `httpClient.ts`, `validators.ts`, `caching.ts`, `interceptors.ts`, `metrics.ts`
|
||||
2. `usePlaylist.ts` (631L) → `usePlaylistCrud.ts`, `usePlaylistCollaboration.ts`, `usePlaylistAnalytics.ts`
|
||||
3. `useChat.ts` (360L) → `useChatMessages.ts`, `useChatConnection.ts`
|
||||
|
||||
---
|
||||
|
||||
## H2. Props drilling
|
||||
|
||||
Pas de chaîne de prop drilling > 3 niveaux détectée. Les données circulent via :
|
||||
- **Zustand stores** (accès direct via hooks)
|
||||
- **React Query** (accès direct via hooks)
|
||||
- **Context API** (auth, theme, toast)
|
||||
- **Props directes** rarement au-delà de 2 niveaux
|
||||
Grâce à l'utilisation de Zustand (7 stores) et React Query, le prop drilling est **minimal**. Aucune chaîne de props > 3 niveaux intermédiaires identifiée dans le code audité.
|
||||
|
||||
✅ Architecture saine sur ce point.
|
||||
**Pattern positif** : Les stores Zustand sont accédés directement dans les composants enfants via `useAuthStore()`, `useUIStore()`, `useCartStore()`, etc. — pas besoin de passer les props à travers les composants intermédiaires.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -44,43 +46,50 @@ Pas de chaîne de prop drilling > 3 niveaux détectée. Les données circulent v
|
|||
|
||||
### Custom hooks > 50 lignes
|
||||
|
||||
| Hook | Lignes | Responsabilités | Testé ? | Verdict |
|
||||
|------|--------|----------------|---------|---------|
|
||||
| `usePlaylist.ts` | **631** | Toutes les opérations playlist (CRUD, share, collab, follow) | ✅ Oui (595L de tests) | 🔴 Trop de responsabilités — split |
|
||||
| `usePlaybackRealtime.ts` | **496** | Temps réel playback (WebSocket, events, state) | ✅ Oui (490L de tests) | 🔴 Trop de responsabilités |
|
||||
| `usePlaybackAnalytics.test.ts` | ~458 | Analytics temps réel | ✅ Testé | 🟡 Acceptable |
|
||||
| `useTrackList.ts` | ~300+ | Liste de tracks (pagination, filters, sort) | ✅ Oui (761L de tests) | 🟡 Complexe mais testé |
|
||||
| `useEducationView.ts` | ~100+ | Education view data | ❌ Non (et import fantôme !) | 🔴 Cassé |
|
||||
| Hook | Lignes | Responsabilités | Testé ? |
|
||||
|------|--------|----------------|---------|
|
||||
| `features/playlists/hooks/usePlaylist.ts` | 631 | CRUD + collaboration + analytics + permissions | ✅ (595L de tests) |
|
||||
| `features/streaming/hooks/usePlaybackRealtime.ts` | 496 | WebSocket + state + analytics + reconnection | ✅ (490L de tests) |
|
||||
| `features/chat/hooks/useChat.ts` | 360 | Messages + connection + typing + presence | ❌ Non vérifié |
|
||||
| `features/tracks/hooks/useTrackList.ts` | 286 | List + filters + sort + pagination + localStorage | ✅ (761L de tests) |
|
||||
| `features/playlists/hooks/usePlaylistNotifications.ts` | 264 | Notification handling + navigation | ❌ Peu testé |
|
||||
| `features/player/hooks/usePlayer.ts` | 249 | Playback control + queue + history | ✅ Tests partiels |
|
||||
| `hooks/useRoutePreload.ts` | 239 | Route prefetching + intersection observer | ✅ Tests |
|
||||
| `features/library/hooks/useLibraryItems.ts` | 152 | Library items + filters | ❌ Non vérifié |
|
||||
|
||||
**Verdict** : Les 3 plus gros hooks (usePlaylist, usePlaybackRealtime, useTrackList) sont bien testés. Les hooks de taille moyenne (useChat, usePlaylistNotifications) sont moins bien couverts.
|
||||
|
||||
---
|
||||
|
||||
## H4. Duplication
|
||||
|
||||
### Composants quasi-identiques
|
||||
### Patterns CSS les plus répétés
|
||||
|
||||
| Pattern | Occurrences approximatives | Action recommandée |
|
||||
|---------|---------------------------|-------------------|
|
||||
| `flex items-center` | ~230 | Normal (utility-first) |
|
||||
| `flex flex-col` | ~230 | Normal |
|
||||
| `text-muted-foreground` | ~145 | Normal (sémantique) |
|
||||
| `rounded-*` | ~230 | Normal |
|
||||
| `w-full h-full` | ~90 | Normal |
|
||||
|
||||
Ces répétitions sont **attendues** avec Tailwind utility-first et ne constituent pas de la dette technique.
|
||||
|
||||
### Composants dupliqués
|
||||
|
||||
| Duplication | Fichiers | Impact |
|
||||
|-------------|----------|--------|
|
||||
| Spinner / LoadingSpinner | `ui/Spinner.tsx`, `ui/loading-spinner.tsx` | 2 composants pour le même usage |
|
||||
| Modal / Dialog | `ui/modal.tsx`, `ui/dialog/` | 2 systèmes de modales |
|
||||
| Button / ButtonLoading | `ui/button.tsx`, `ui/button-loading.tsx` | Button a déjà un prop `loading` |
|
||||
| DataList (legacy) / DataList (new) | `ui/DataList.tsx`, `ui/data-list/DataList.tsx` | Migration en cours |
|
||||
| Accordion (legacy) / Accordion (new) | `ui/accordion.tsx`, `ui/accordion/Accordion.tsx` | Migration en cours |
|
||||
| player/ (components) | `components/player/`, `features/player/` | Deux emplacements |
|
||||
| settings/ (components) | `components/settings/`, `features/settings/` | Deux emplacements |
|
||||
|------------|---------|--------|
|
||||
| `layout/Sidebar.tsx` (294L) + `ui/Sidebar.tsx` (217L) | 2 fichiers | 🟠 Confusion |
|
||||
| `components/player/` (14 fichiers) + `features/player/components/` (~20 fichiers) | 34 fichiers | 🟠 Migration incomplète |
|
||||
| `pages/auth/` + `features/auth/pages/` | 4+ fichiers | 🟠 Legacy |
|
||||
| `context/AuthContext.tsx` + `features/auth/store/authStore.ts` | 2 fichiers | 🔴 Deux sources de vérité |
|
||||
| `ui/modal.tsx` + `ui/dialog/` | 2 systèmes | 🟡 Redondance |
|
||||
| `ui/dropdown-menu.tsx` + `ui/dropdown-menu/` | Fichier plat + dossier | 🟡 Legacy wrapper |
|
||||
|
||||
### CSS dupliqué
|
||||
### Logique métier dupliquée
|
||||
|
||||
| Pattern CSS | Composant React | Duplication |
|
||||
|------------|----------------|------------|
|
||||
| `.btn-veza` (button.css) | `Button` (button.tsx) | 100% dupliqué |
|
||||
| `.card-veza` (card.css) | `Card` (card.tsx) | 100% dupliqué |
|
||||
| `.input-veza` (input.css) | `Input` (input.tsx) | 100% dupliqué |
|
||||
| `.badge-veza` (badge-avatar.css) | `Badge` (badge.tsx) | 100% dupliqué |
|
||||
| `.avatar-veza` (badge-avatar.css) | `Avatar` (avatar.tsx) | 100% dupliqué |
|
||||
|
||||
### Styles dupliqués fréquents
|
||||
|
||||
Les patterns Tailwind les plus répétés sont des combinaisons de classes utilitaires standard — c'est normal avec Tailwind et ne constitue pas une duplication problématique. Les duplications CSS vanilla (ci-dessus) sont le vrai problème.
|
||||
- **Auth** : `authService.login()` est appelé par `authStore.login()` ET `AuthContext.login()` — deux chemins d'exécution pour la même action.
|
||||
- **Toast** : `toast()` (react-hot-toast) et `addToast()` (custom) — deux APIs pour le même feedback.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -88,76 +97,63 @@ Les patterns Tailwind les plus répétés sont des combinaisons de classes utili
|
|||
|
||||
### Statistiques
|
||||
|
||||
| Métrique | Valeur | Verdict |
|
||||
|----------|--------|---------|
|
||||
| `: any` explicites | ~120+ | ⚠️ Problématique |
|
||||
| `as any` casts | **706** | 🔴 Critique |
|
||||
| `@ts-ignore` | 3 | ✅ Acceptable |
|
||||
| `@ts-expect-error` | 5 | ✅ Acceptable |
|
||||
| Métrique | Nombre | Zone |
|
||||
|----------|--------|------|
|
||||
| `: any` explicites (prod) | ~82 | Code source hors tests/generated |
|
||||
| `as any` casts (prod) | ~180 | Code source hors tests/generated |
|
||||
| `as any` dans `generated/api.ts` | 145 | Auto-généré — acceptable |
|
||||
| `@ts-ignore` / `@ts-expect-error` | 7 fichiers | Minimal ✅ |
|
||||
|
||||
### Répartition des `as any`
|
||||
### Fichiers avec le plus de `as any` (source)
|
||||
|
||||
| Source | Count | Justification |
|
||||
|--------|-------|---------------|
|
||||
| `types/generated/api.ts` | **145** | ⚠️ Code généré — devrait être corrigé dans le générateur |
|
||||
| Fichiers `.test.ts/tsx` | **~500** | ⚠️ Tests utilisent massivement `as any` pour les mocks |
|
||||
| Services source | **~50** | 🔴 Doit être corrigé |
|
||||
| Composants source | **~10** | ⚠️ À corriger progressivement |
|
||||
| Fichier | `as any` | Justification |
|
||||
|---------|----------|---------------|
|
||||
| `services/api/client.ts` | 48 | Error handling, interceptors — type narrowing difficile |
|
||||
| `utils/typeGuards.ts` | 44 | Type guards by design — acceptable |
|
||||
| `utils/toast.ts` | 11 | Wrapper react-hot-toast |
|
||||
| `features/playlists/services/playlistService.ts` | 9 | API response casting |
|
||||
| `utils/apiErrorHandler.ts` | 7 | Error type narrowing |
|
||||
| `features/tracks/services/trackListService.ts` | 7 | API response casting |
|
||||
|
||||
**Le problème majeur** : 500+ `as any` dans les tests est une dette technique significative. Les mocks devraient être typés correctement.
|
||||
### Configuration TypeScript
|
||||
|
||||
### ESLint `no-explicit-any: 'off'`
|
||||
- **Mode strict complet** ✅ [tsconfig.json] : `strict: true`, `noImplicitAny`, `strictNullChecks`, `strictFunctionTypes`, `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch`
|
||||
- **`noUncheckedIndexedAccess: true`** — option avancée ✅ (peu de projets l'activent)
|
||||
- **`@typescript-eslint/no-explicit-any: off`** — ESLint n'interdit pas `any` ⚠️
|
||||
|
||||
- 🔴 La règle ESLint `@typescript-eslint/no-explicit-any` est explicitement **désactivée** [eslint.config.js:148]
|
||||
- Cela signifie que les `any` ne sont jamais signalés — la dette grandit silencieusement
|
||||
**Verdict TS** : Configuration stricte exemplaire mais `no-explicit-any` désactivé dans ESLint permet l'accumulation de `any`. Le comptage (~82 source + ~180 `as any`) est modéré pour un projet de 218K LOC.
|
||||
|
||||
---
|
||||
|
||||
## H6. Code mort
|
||||
|
||||
### Fichiers orphelins (non importés)
|
||||
### Orphelins structurels identifiés
|
||||
|
||||
| Fichier | Raison probable |
|
||||
|---------|----------------|
|
||||
| `components/library/playlists/PlaylistDetailView.tsx` | Remplacé par feature/playlists |
|
||||
| `components/library/playlists/AddToPlaylistModal.tsx` | Remplacé par feature |
|
||||
| `components/BulkModeBanner.tsx` | Feature abandonnée |
|
||||
| `components/AdvancedFilters.tsx` | Non connecté |
|
||||
| `components/gamification/ProfileXPView.tsx` | Prototype |
|
||||
| `components/keyboard/KeyboardShortcutsHelp.tsx` | Duplication KeyboardShortcutsPanel |
|
||||
| `components/inventory/AddEquipmentView.tsx` | Feature abandonnée |
|
||||
| `hooks/useLongRunningOperation.ts` | Non utilisé |
|
||||
| `hooks/useThrottledCallback.ts` | Non importé |
|
||||
| `hooks/usePreventDoubleClick.ts` | Non importé |
|
||||
| `hooks/useDebouncedCallback.ts` | Non importé |
|
||||
| `services/cookieService.ts` | Non utilisé |
|
||||
| `features/search/services/unifiedSearchService.ts` | Non connecté |
|
||||
| `features/dashboard/hooks/useDashboard.ts` | Non importé |
|
||||
| `features/playlists/components/PlaylistHeaderSkeleton.tsx` | Non importé |
|
||||
| `features/playlists/components/DuplicatePlaylistButton.tsx` | Non connecté |
|
||||
| `features/playlists/components/ImportPlaylistButton.tsx` | Non connecté |
|
||||
| `features/playlists/hooks/useTouchGestures.ts` | Non utilisé |
|
||||
| `features/chat/services/conversationService.ts` | Non importé |
|
||||
| `features/webhooks/api/webhookApi.ts` | Non connecté |
|
||||
| `utils/aggressiveVisualFix.ts` | Hack non utilisé |
|
||||
| `utils/firstTime.ts` | Non utilisé |
|
||||
| `utils/csp.ts` | Non connecté (utilitaire prêt mais pas intégré) |
|
||||
| `utils/optimisticUpdates.ts` | Non importé (682 lignes de code mort) |
|
||||
| `utils/safeStorage.ts` | Non utilisé |
|
||||
| Fichier/Dossier | Raison | Impact |
|
||||
|----------------|--------|--------|
|
||||
| `pages/auth/Login.tsx`, `pages/auth/Register.tsx` | Remplacés par `features/auth/pages/` | 🟡 Dead code |
|
||||
| `context/AuthContext.tsx` | Remplacé par `features/auth/store/authStore.ts` | 🟠 Source de confusion |
|
||||
| `providers/AuthProvider.tsx` | Wrapper de AuthContext — non utilisé dans App.tsx | 🟡 Dead code |
|
||||
| `components/views/*.tsx` (fichiers plats) | Wrappers vers les sous-dossiers refactorés | 🟡 Indirection inutile |
|
||||
| `ui/dropdown-menu.tsx`, `ui/tabs.tsx`, `ui/accordion.tsx` (plats) | Re-exports vers les dossiers refactorés | 🟢 Acceptable |
|
||||
|
||||
**Total** : ~25 fichiers orphelins représentant ~3 000+ lignes de code mort.
|
||||
### Recommandation
|
||||
|
||||
Exécuter `npx ts-prune` ou `npx madge --extensions ts,tsx --circular src/` pour un rapport complet d'orphelins et de dépendances circulaires.
|
||||
|
||||
---
|
||||
|
||||
## H7. Dépendances inutilisées
|
||||
|
||||
[DONNÉES INSUFFISANTES — nécessite une analyse `depcheck` ou similaire sur le package.json vs les imports. Cependant, sur la base de l'arborescence :
|
||||
### Potentiellement inutilisées
|
||||
|
||||
| Dépendance suspecte | Usage trouvé ? | Verdict |
|
||||
|--------------------|---------------|---------|
|
||||
| `rollup-plugin-visualizer` | Oui (vite.config.ts) | ⚠️ Devrait être en devDeps |
|
||||
| `@types/dompurify` | Oui (sanitize.ts) | ⚠️ Devrait être en devDeps |
|
||||
| `swagger-ui-dist` | ❓ À vérifier | ⚠️ Possiblement inutile si swagger-ui-react suffit |
|
||||
| Dépendance | Status | Raison |
|
||||
|-----------|--------|--------|
|
||||
| `@dnd-kit/utilities` | ⚠️ À vérifier | Peut être importé indirectement par `@dnd-kit/sortable` |
|
||||
| `swagger-ui-dist` | ⚠️ À vérifier | `swagger-ui-react` pourrait l'importer en interne |
|
||||
| `rollup-plugin-visualizer` | ✅ OK | Utilisé dans vite.config.ts (pas dans src/) |
|
||||
|
||||
[DONNÉES INSUFFISANTES — nécessite `npx depcheck` pour un rapport exhaustif]
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -165,15 +161,15 @@ Les patterns Tailwind les plus répétés sont des combinaisons de classes utili
|
|||
|
||||
| Priorité | Élément | Impact | Effort | Ratio |
|
||||
|----------|---------|--------|--------|-------|
|
||||
| **P0** | Build cassé (educationService manquant) | 🔴 Bloquant déploiement | XS (5 min) | ∞ |
|
||||
| **P0** | `client.ts` 2 237L → split en modules | 🔴 Maintenance impossible | L (3-5j) | Élevé |
|
||||
| **P0** | 706 `as any` + ESLint rule off | 🔴 TypeScript safety annulée | XL (semaines) | Moyen |
|
||||
| **P1** | 25 fichiers orphelins → supprimer | 🟠 Code mort confus | S (1j) | Élevé |
|
||||
| **P1** | CSS vanilla parallèle → supprimer | 🟠 Double maintenance | M (2-3j) | Élevé |
|
||||
| **P1** | Dualité components/ ↔ features/ | 🟠 Architecture confuse | L (semaine) | Moyen |
|
||||
| **P1** | `usePlaylist.ts` 631L → split | 🟠 Hook ingérable | M (2j) | Élevé |
|
||||
| **P1** | Duplications (Modal/Dialog, Spinner) | 🟠 API confuse | S (1j) | Élevé |
|
||||
| **P2** | `handlers.ts` 1 716L → split par feature | 🟡 Maintenance difficile | M (2j) | Moyen |
|
||||
| **P2** | Auth state duplication (Context + Store) | 🟡 Source de vérité ambiguë | M (2j) | Moyen |
|
||||
| **P2** | Vendor chunk 925 KB → optimiser | 🟡 Performance | M (2-3j) | Moyen |
|
||||
| **P2** | Hacks CSS `!important` | 🟡 Fragilité | S (1j) | Élevé |
|
||||
| P0 | Split `client.ts` (2237L) | Maintenabilité, testabilité | L | Élevé |
|
||||
| P0 | Résoudre dualité AuthContext vs authStore | Bugs auth, confusion | M | Très élevé |
|
||||
| P1 | Supprimer `pages/auth/` legacy | Clarté code | S | Élevé |
|
||||
| P1 | Unifier toast API (addToast vs toast) | Cohérence DX | M | Élevé |
|
||||
| P1 | Split `usePlaylist.ts` (631L) | Maintenabilité | M | Moyen |
|
||||
| P1 | Résoudre layout/Sidebar vs ui/Sidebar | Clarté | S | Élevé |
|
||||
| P2 | Migrer z-[N] vers tokens SUMI | Cohérence design | M | Moyen |
|
||||
| P2 | Ajouter `React.memo` sur les composants de liste | Performance | S | Moyen |
|
||||
| P2 | Réduire `as any` dans client.ts (48 occ.) | Type safety | L | Faible |
|
||||
| P2 | Nettoyer components/views/ wrappers legacy | Clarté | M | Moyen |
|
||||
|
||||
**Score dette technique implicite : 6/10** — Dette structurelle significative (migration incomplète) mais codebase fonctionnelle avec de bons patterns.
|
||||
|
|
|
|||
|
|
@ -1,126 +1,147 @@
|
|||
# PHASE I — SCALABILITÉ UI
|
||||
# Phase I — Scalabilité UI
|
||||
|
||||
---
|
||||
|
||||
## I1. Ajout de 50 écrans
|
||||
|
||||
### Routing
|
||||
- ✅ **Prêt** — React Router v6 avec lazy loading. Ajouter une route = 1 entrée dans `routeConfig.tsx` + 1 `createLazyComponent` dans `lazyExports.ts`.
|
||||
- ✅ Pattern établi et reproductible.
|
||||
- ⚠️ `routeConfig.tsx` croîtra linéairement — mais fichier de configuration, acceptable.
|
||||
### Routing — ⚠️ Partiel
|
||||
|
||||
### Design system
|
||||
- ⚠️ **Partiel** — Les primitives UI couvrent les cas standards (Button, Card, Input, Table, Modal, Tabs, Badge, Avatar, Select, etc.).
|
||||
- ⚠️ Manques pour 50 écrans : pas de composant `Stepper`, `Timeline`, `Tree`, `Kanban`, `Calendar view`, `Rich Text Editor`. Ces primitives devront être créées.
|
||||
- ✅ Le pattern de création (Tailwind + forwardRef + variants via CVA) est bien établi.
|
||||
- L'architecture de routing `routeConfig.tsx` est centralisée avec des fonctions `getPublicRoutes()`, `getProtectedRoutes()`, etc. [routeConfig.tsx:57-109]. **Ajouter une route est simple** (une ligne), mais toutes les routes sont dans un seul fichier.
|
||||
- Pour 50 écrans supplémentaires, ce fichier deviendrait un bottleneck. **Recommandation** : migrer vers un routing file-based ou colocalisé par feature.
|
||||
- Le lazy loading via `createLazyComponent` [lazy-component/createLazyComponent.tsx] est scalable — chaque nouvelle page est automatiquement code-splittée.
|
||||
|
||||
### Convention de nommage
|
||||
- ✅ **Scalable** — Nommage cohérent : `FeatureNamePage`, `useFeatureNamePage`, `FeatureNameSkeleton`.
|
||||
- ✅ Les feature modules ont une structure reproductible : `components/`, `hooks/`, `pages/`, `services/`, `types/`.
|
||||
- ⚠️ La dualité `components/` ↔ `features/` brouille les conventions — à résoudre avant scale.
|
||||
### Design system — ✅ Prêt
|
||||
|
||||
### State management
|
||||
- ✅ **Supporte la complexité** — Zustand pour UI state, React Query pour server state.
|
||||
- ✅ L'ajout d'un nouveau store Zustand est trivial.
|
||||
- ✅ React Query supporte le cache granulaire par feature.
|
||||
- ⚠️ La duplication Auth (Context + Store) devra être unifiée avant d'ajouter plus de features auth-dépendantes.
|
||||
- Le design system SUMI v2.0 couvre les primitives nécessaires : Button (7 variants), Input, Select, Dialog, Tabs, Card, Badge, Table, DropdownMenu, DatePicker, etc. [02_design_system_inventory.md]
|
||||
- Les tokens (couleurs, typo, spacing, shadows) sont suffisamment riches pour supporter de nouveaux écrans sans modification.
|
||||
|
||||
**Verdict** : ⚠️ **Partiel** — L'architecture supporte 50 écrans techniquement, mais la dette organisationnelle (dualité components/features, code mort) ralentira significativement. Résolution estimée : 1-2 semaines de refactoring préalable.
|
||||
### Convention de nommage — ✅ Prêt
|
||||
|
||||
- Pattern feature-based clair : `features/{name}/pages/`, `features/{name}/components/`, `features/{name}/hooks/`
|
||||
- Convention PascalCase pour les composants, camelCase pour les hooks
|
||||
- Pattern `XxxSkeleton.tsx`, `XxxEmpty.tsx`, `useXxx.ts`, `types.ts`, `index.ts` bien établi
|
||||
|
||||
### State management — ✅ Prêt
|
||||
|
||||
- Zustand pour l'état global (UI, auth, cart) est scalable — chaque feature peut avoir son store
|
||||
- React Query pour le server state est naturellement scalable — chaque query est indépendante
|
||||
- La combinaison des deux évite un store central monolithique
|
||||
|
||||
**Verdict** : ⚠️ Partiel — Le design system et le state management sont prêts, mais le routing centralisé et la migration incomplète `components/views/` → `features/pages/` freinent.
|
||||
|
||||
---
|
||||
|
||||
## I2. Theming / Dark mode
|
||||
|
||||
### Tokenisation des couleurs
|
||||
- ✅ **Les couleurs sont tokenisées** via CSS variables (`--primary`, `--background`, `--foreground`, etc.)
|
||||
- ✅ Dark mode complet avec override de toutes les variables dans `.dark`
|
||||
- ✅ `ThemeProvider` avec `useTheme()` hook
|
||||
- ✅ Tailwind `dark:` variant configuré via `@custom-variant dark (&:is(.dark *))`
|
||||
### Tokenisation — ✅ Prêt
|
||||
|
||||
- **100% des couleurs sont tokenisées** dans `index.css` via CSS variables SUMI [index.css:15-296]
|
||||
- 0 couleur hardcodée (`bg-[#...]`) dans les className ✅
|
||||
- Palette complète dark + light définie [index.css:301-364]
|
||||
|
||||
### Migration effort — Minimal
|
||||
|
||||
- 24 fichiers utilisent le préfixe `dark:` de Tailwind — ce qui est **très peu** car le theming est géré par `data-theme` attribute + CSS variables, pas par le mécanisme `dark:` de Tailwind.
|
||||
- Le switch de thème est fonctionnel via `uiStore.setTheme()` [stores/ui.ts:35-51] et `ThemeProvider` [components/theme/ThemeProvider.tsx]
|
||||
|
||||
### Valeurs hardcodées à migrer
|
||||
- ⚠️ **~30 couleurs hex hardcodées** dans 12 fichiers TSX (charts, Swagger, OAuth, settings)
|
||||
- ⚠️ **5 fichiers CSS** avec couleurs hardcodées dans les styles vanilla
|
||||
- ⚠️ Les `!important` dans les fix CSS pourraient bloquer le theming
|
||||
|
||||
### Switch de thème
|
||||
- ✅ **Prêt** — `ThemeSwitcher` composant existant
|
||||
- ✅ Persistence via Zustand store
|
||||
- ✅ Respect de `prefers-color-scheme`
|
||||
- `#ffffff` dans `index.css:363` (primary-foreground light) — acceptable dans le fichier de tokens
|
||||
- `#8b7ec8` pour chart-5 [index.css:240] — dans les tokens
|
||||
- Couleurs contextuelles (graffiti-magenta, gaming-gold, terminal-green, sakura) [index.css:202-205] — dans les tokens
|
||||
|
||||
**Verdict** : ✅ **Prêt** — Le theming fonctionne. Ajout d'un 3ème thème (ex: high-contrast) nécessiterait ~30 fichiers à corriger pour les valeurs hardcodées.
|
||||
**Verdict** : ✅ Prêt — Le theming est pleinement fonctionnel avec un effort minimal pour ajouter des thèmes supplémentaires.
|
||||
|
||||
---
|
||||
|
||||
## I3. Internationalisation (i18n)
|
||||
|
||||
### Système i18n
|
||||
- ✅ **i18next + react-i18next** installés et configurés
|
||||
- ✅ Dossier `src/locales/` présent
|
||||
- ✅ `i18next-browser-languagedetector` pour la détection automatique
|
||||
### Système en place
|
||||
|
||||
### Strings hardcodées
|
||||
- ⚠️ **MASSIF** — Estimation grossière : la grande majorité des textes UI sont hardcodés en français ou anglais directement dans les composants TSX.
|
||||
- ⚠️ Exemples : titres de pages, labels de boutons, messages d'erreur, placeholders, tooltips
|
||||
- ⚠️ Migration vers i18n = effort XL (semaines de travail)
|
||||
- **i18next** + **react-i18next** + **i18next-browser-languagedetector** installés [package.json]
|
||||
- Configuration dans `lib/i18n.ts` ✅
|
||||
- **2 langues** : `en.json` et `fr.json` dans `src/locales/` ✅
|
||||
- Hook `useTranslation.ts` custom disponible [hooks/useTranslation.ts]
|
||||
|
||||
### Adoption
|
||||
|
||||
- **~80+ fichiers** utilisent `useTranslation` ou `t()` — adoption significative
|
||||
- **~35+ fichiers** contiennent des **strings hardcodées** en anglais dans le JSX :
|
||||
- `"No Cover"`, `"No lyrics available"`, `"No courses found"`, `"No messages yet"`, `"No equipment found"`, `"Loading..."`, `"Error"` [voir 08_technical_debt_frontend.md]
|
||||
- Ces strings devraient utiliser `t()` pour être traduisibles
|
||||
|
||||
### RTL support
|
||||
- ❌ **Non implémenté** — Pas de `dir="rtl"` ni de classes logiques (`ms-*`, `me-*` au lieu de `ml-*`, `mr-*`)
|
||||
|
||||
### Formatage localisé
|
||||
- ✅ `date-fns` avec locale support potentiel
|
||||
- ⚠️ Pas de formatage de nombres localisé détecté
|
||||
- ❌ **Aucun support RTL** détecté. Pas de `dir` attribute, pas de classes `rtl:`.
|
||||
- Pour l'arabe, l'hébreu, etc., une refonte du layout serait nécessaire.
|
||||
|
||||
**Verdict** : ⚠️ **Partiel** — L'infrastructure i18n est en place mais les strings ne sont pas externalisées. Effort de migration estimé : 3-4 semaines.
|
||||
### Formatage dates/nombres
|
||||
|
||||
- `date-fns` (4.1.x) installé — supporte la localisation ✅
|
||||
- La localisation de date-fns est-elle configurée avec i18next ? [DONNÉES INSUFFISANTES]
|
||||
|
||||
**Verdict** : ⚠️ Partiel — Le système i18n est en place et partiellement adopté, mais ~35+ fichiers ont des strings hardcodées et le RTL n'est pas supporté.
|
||||
|
||||
---
|
||||
|
||||
## I4. White-labeling / Multi-tenant UI
|
||||
|
||||
### Logo et branding
|
||||
- ⚠️ Le design system "KODO" a une identité très forte (neon cyan, manga clips, gaming elements)
|
||||
- ⚠️ Les couleurs sont tokenisées mais les gradients, clip-paths et effets visuels sont hardcodés
|
||||
- ⚠️ Les noms thématiques (void, manga, shonen, terminal) sont culturellement spécifiques
|
||||
|
||||
### Couleurs/fonts externalisables
|
||||
- ✅ CSS variables permettent théoriquement de changer les couleurs
|
||||
- ⚠️ 6 familles de fonts hardcodées dans le CSS — pas configurable par tenant
|
||||
- ⚠️ Les gradients et glows sont définis en CSS, pas via un système de tokens dynamique
|
||||
- ❌ Le logo/branding semble hardcodé dans les composants (à vérifier dans Header/Sidebar)
|
||||
- Les couleurs sont tokenisées → changement de palette possible
|
||||
- Les polices sont en variables CSS → personnalisables
|
||||
|
||||
### Couleurs externalisables
|
||||
|
||||
- ✅ Via les CSS variables SUMI — un tenant pourrait surcharger les variables `:root`
|
||||
- Les couleurs contextuelles (graffiti-magenta, gaming-gold, etc.) montrent une flexibilité de palette
|
||||
|
||||
### Layouts personnalisables
|
||||
- ❌ **Non prévu** — Le layout est fixe (sidebar + header + main)
|
||||
- ❌ Pas de système de slots ou de configuration de layout par tenant
|
||||
|
||||
**Verdict** : ❌ **Bloqué** — Le white-labeling nécessiterait une refonte significative du design system pour externaliser les éléments visuels. Le design est trop opinionné pour un multi-tenant.
|
||||
- ❌ Les layouts (Sidebar, Header, DashboardLayout) sont codés en dur
|
||||
- Pas de configuration de layout par tenant
|
||||
|
||||
**Verdict** : ❌ Bloqué — Le white-labeling nécessiterait un refactoring significatif pour externaliser le branding, bien que les tokens CSS offrent une base.
|
||||
|
||||
---
|
||||
|
||||
## I5. Performance à l'échelle
|
||||
|
||||
### Virtualisation
|
||||
- ✅ `VirtualizedList` composant avec `@tanstack/react-virtual`
|
||||
- ✅ Chat messages virtualisés
|
||||
- ⚠️ Pas de virtualisation systématique sur toutes les listes longues (TrackGrid utilise une grille classique)
|
||||
### Virtualisation des listes
|
||||
|
||||
- ✅ `@tanstack/react-virtual` installé et utilisé [package.json, components/ui/virtualized-list/VirtualizedList.tsx]
|
||||
- `VirtualizedList.tsx` (125L) — composant générique de virtualisation
|
||||
- Chat messages virtualisés [features/chat/components/virtualized-chat-messages/]
|
||||
|
||||
### Pagination
|
||||
- ✅ `useInfiniteQuery` pour le scroll infini
|
||||
- ✅ Pagination côté serveur (React Query)
|
||||
- ⚠️ Pas de composant de pagination UI dédié détecté
|
||||
|
||||
### Listes longues
|
||||
- ✅ Skeletons pour le chargement progressif
|
||||
- ⚠️ `React.memo` sous-utilisé sur les items de liste (5 instances seulement)
|
||||
- ⚠️ Pas de `loading="lazy"` sur les images dans les listes
|
||||
- ✅ **Pagination serveur** implémentée dans les services API :
|
||||
- `playlistService.ts` : `page`, `limit`, `safeLimit`, `safePage` [playlistService.ts]
|
||||
- `trackService.ts` : `page`, `limit` en query params [trackService.ts]
|
||||
- `socialService.ts` : `page = 1` [socialService.ts]
|
||||
- `marketplaceService.ts` : `page`/`limit` [marketplaceService.ts]
|
||||
- Composants de pagination UI : `TrackListPaginationNav.tsx` (148L), `TrackListPaginationInfo.tsx` (25L)
|
||||
|
||||
**Verdict** : ⚠️ **Partiel** — L'infrastructure est en place (virtualization, infinite scroll) mais pas systématiquement appliquée. À 10 000+ items, des bottlenecks de rendu apparaîtront.
|
||||
### Infinite scroll
|
||||
|
||||
- `features/tracks/hooks/useInfiniteScroll.ts` — hook dédié ✅
|
||||
- `components/ui/virtualized-list/useInfiniteScroll.ts` — hook pour la liste virtualisée ✅
|
||||
|
||||
### Cursor-based pagination
|
||||
|
||||
- ❌ Pas de pagination cursor-based détectée — uniquement offset/limit
|
||||
|
||||
**Verdict** : ✅ Prêt — Virtualisation, pagination serveur et infinite scroll sont en place.
|
||||
|
||||
---
|
||||
|
||||
## Résumé scalabilité
|
||||
## Tableau récapitulatif
|
||||
|
||||
| Dimension | Verdict | Effort pour être prêt |
|
||||
|-----------|---------|----------------------|
|
||||
| +50 écrans | ⚠️ Partiel | M (refactoring architecture) |
|
||||
| Theming / Dark mode | ✅ Prêt | S (nettoyer hardcoded values) |
|
||||
| i18n | ⚠️ Partiel | XL (externaliser toutes les strings) |
|
||||
| White-labeling | ❌ Bloqué | XL (refonte design system) |
|
||||
| Performance à l'échelle | ⚠️ Partiel | M (systématiser virtualisation + memo) |
|
||||
| Dimension | Verdict | Détail |
|
||||
|-----------|---------|--------|
|
||||
| Ajout de 50 écrans | ⚠️ Partiel | DS prêt, routing centralisé à améliorer |
|
||||
| Theming / Dark mode | ✅ Prêt | 100% tokenisé, dark/light fonctionnel |
|
||||
| i18n | ⚠️ Partiel | Système en place, adoption ~70%, pas de RTL |
|
||||
| White-labeling | ❌ Bloqué | Branding hardcodé, pas de config par tenant |
|
||||
| Performance à l'échelle | ✅ Prêt | Virtualisation, pagination, infinite scroll |
|
||||
|
|
|
|||
|
|
@ -1,149 +1,166 @@
|
|||
# PHASE K — SCORE GLOBAL & RECOMMANDATIONS
|
||||
# Phase K — Score Global & Recommandations
|
||||
|
||||
---
|
||||
|
||||
## Tableau de synthèse
|
||||
|
||||
| Catégorie | Score /10 | Pondération | Score pondéré | Justification |
|
||||
|-----------|-----------|-------------|---------------|---------------|
|
||||
| Architecture | **7.0** | ×1.3 | **9.1** | Routing exemplaire, TypeScript strict, feature modules en cours, mais dualité components/features et client.ts monstre |
|
||||
| Design System | **6.5** | ×1.0 | **6.5** | Tokens ambitieux et complets, mais fragmentation (kodo/veza), CSS vanilla parallèle, duplications composants |
|
||||
| Cohérence UI | **7.0** | ×1.0 | **7.0** | Icônes uniformes, layout responsive, button system solide, mais hacks CSS et duplications |
|
||||
| Accessibilité | **6.5** | ×1.0 | **6.5** | ARIA extensif, focus management, mais skip nav absent, sémantique HTML minimale |
|
||||
| Sécurité | **8.0** | ×1.5 | **12.0** | JWT httpOnly, DOMPurify strict, CSRF, pas de secrets hardcodés — exemplaire |
|
||||
| Performance | **6.0** | ×1.2 | **7.2** | Routes lazy, manual chunks, mais build cassé, vendor 925KB, images non-lazy |
|
||||
| Dette technique | **5.0** | ×1.0 | **5.0** | 706 `as any`, 25 fichiers morts, client.ts 2237L, build cassé, hooks >600L |
|
||||
| Scalabilité | **5.5** | ×1.0 | **5.5** | Theming prêt, routing scalable, mais i18n non externalisé, white-labeling bloqué |
|
||||
| Maturité perçue | **6.5** | ×1.0 | **6.5** | Design distinctif et ambitieux, mais finitions manquantes et placeholders |
|
||||
|
||||
### Calcul du score global
|
||||
|
||||
| | Somme pondérée | Somme pondérations |
|
||||
|---|---|---|
|
||||
| Total | **65.3** | **10.0** |
|
||||
| **SCORE GLOBAL** | **6.5 / 10** | |
|
||||
| Catégorie | Score /10 | Pondération | Score pondéré | Justification (1 phrase) |
|
||||
|-----------|-----------|-------------|---------------|--------------------------|
|
||||
| Architecture | 7.0 | ×1.3 | 9.1 | Feature-based solide mais migration incomplète (dualité views/features, AuthContext/authStore) |
|
||||
| Design System | 7.5 | ×1.0 | 7.5 | SUMI v2.0 mature avec tokens complets, dark/light, quelques fuites z-index |
|
||||
| Cohérence UI | 6.5 | ×1.0 | 6.5 | Composants bien centralisés, toast dualité et `variant="glass"` non défini |
|
||||
| Accessibilité | 5.5 | ×1.0 | 5.5 | ARIA correct, focus-visible, mais sémantique HTML insuffisante et skip nav absent |
|
||||
| Sécurité | 7.0 | ×1.5 | 10.5 | httpOnly JWT, DOMPurify, Zod, mais open redirect et API keys en localStorage |
|
||||
| Performance | 6.5 | ×1.2 | 7.8 | Lazy loading systématique, request dedup, mais React.memo insuffisant |
|
||||
| Dette technique | 6.0 | ×1.0 | 6.0 | client.ts monolithe (2237L), dualité structurelle, ~262 `any`/`as any` en prod |
|
||||
| Scalabilité | 6.5 | ×1.0 | 6.5 | Theming prêt, virtualisation OK, i18n partiel, pas de white-labeling |
|
||||
| Maturité perçue | 6.5 | ×1.0 | 6.5 | Identité SUMI distinctive, beta avancée, features ComingSoon |
|
||||
| **SCORE GLOBAL** | | | **66.0 / 99** | |
|
||||
| **Moyenne pondérée** | | | **6.6 / 10** | |
|
||||
|
||||
---
|
||||
|
||||
## Recommandations immédiates (semaine 1-2)
|
||||
|
||||
### 1. Fixer le build cassé
|
||||
- **Fichier** : `src/components/views/education-view/useEducationView.ts` → import `educationService` manquant
|
||||
- **Temps** : 15 minutes
|
||||
- **Impact** : 🔴 Critique — débloque le déploiement
|
||||
### 1. Corriger l'open redirect dans usePlaylistNotifications
|
||||
|
||||
### 2. Supprimer les fichiers orphelins
|
||||
- **Fichiers** : 25 fichiers identifiés en Phase H6 (~3 000 LOC de code mort)
|
||||
- **Temps** : 2-4 heures
|
||||
- **Impact** : Réduit la confusion, améliore la navigabilité du code
|
||||
- **Fichier** : `features/playlists/hooks/usePlaylistNotifications.ts:203,219,235,251`
|
||||
- **Temps estimé** : 30 min
|
||||
- **Impact** : Ferme une vulnérabilité de sécurité exploitable
|
||||
- **Action** : Valider `notification.link` avant `window.location.href` (vérifier que l'URL est same-origin ou dans une allowlist)
|
||||
|
||||
### 3. Supprimer les CSS vanilla parallèles
|
||||
- **Fichiers** : `styles/button.css`, `styles/card.css`, `styles/input.css`, `styles/badge-avatar.css`, `styles/header.css`
|
||||
- **Temps** : 1 jour (vérifier qu'aucun composant n'utilise encore les classes `.btn-veza`, `.card-veza`, etc.)
|
||||
- **Impact** : Élimine la double maintenance CSS ↔ composants React
|
||||
### 2. Résoudre la dualité AuthContext vs authStore
|
||||
|
||||
### 4. Supprimer les hacks CSS `!important`
|
||||
- **Fichiers** : `styles/fix-input-focus.css`, `styles/fix-login-form.css`
|
||||
- **Temps** : 4-8 heures
|
||||
- **Impact** : Élimine les overrides imprévisibles
|
||||
- **Fichiers** : `context/AuthContext.tsx`, `providers/AuthProvider.tsx`
|
||||
- **Temps estimé** : 2-4h
|
||||
- **Impact** : Élimine la source de bugs auth, simplifie le modèle mental
|
||||
- **Action** : Supprimer `AuthContext.tsx` et `AuthProvider.tsx`, s'assurer que tous les imports utilisent `useAuthStore`
|
||||
|
||||
### 5. Résoudre les duplications de composants
|
||||
- **Actions** :
|
||||
- Supprimer `ui/loading-spinner.tsx` → utiliser `ui/Spinner.tsx`
|
||||
- Supprimer `ui/button-loading.tsx` → utiliser `Button` prop `loading`
|
||||
- Choisir entre `modal.tsx` et `dialog/` → migrer vers un seul
|
||||
- Consolider `DataList` et `Accordion` (legacy → nouveau)
|
||||
- **Temps** : 1-2 jours
|
||||
- **Impact** : API claire pour les développeurs
|
||||
### 3. Ajouter un skip navigation link
|
||||
|
||||
- **Fichier** : `components/layout/Layout.tsx` ou `app/App.tsx`
|
||||
- **Temps estimé** : 30 min
|
||||
- **Impact** : Conformité WCAG 2.4.1
|
||||
- **Action** : Ajouter `<a href="#main-content" className="sr-only focus:not-sr-only ...">Skip to content</a>` + `id="main-content"` sur le `<main>`
|
||||
|
||||
### 4. Définir la variante `glass` dans Button
|
||||
|
||||
- **Fichier** : `components/ui/button.tsx:14-35`
|
||||
- **Temps estimé** : 15 min
|
||||
- **Impact** : Corrige des boutons sans style dans CloudIntegrationView et GearViewHeader
|
||||
- **Action** : Ajouter `glass: 'bg-white/10 text-foreground backdrop-blur-md border border-white/20 hover:bg-white/20'` dans `buttonVariants`
|
||||
|
||||
### 5. Supprimer les fichiers legacy auth
|
||||
|
||||
- **Fichiers** : `pages/auth/Login.tsx`, `pages/auth/Register.tsx`, `pages/auth/Login.test.tsx`, `pages/auth/Register.test.tsx`
|
||||
- **Temps estimé** : 30 min
|
||||
- **Impact** : Élimine la confusion, réduit le code mort
|
||||
- **Action** : Supprimer les fichiers, vérifier qu'aucun import ne les référence
|
||||
|
||||
---
|
||||
|
||||
## Recommandations court terme (mois 1-2)
|
||||
|
||||
### 1. Split `services/api/client.ts` (2 237 lignes)
|
||||
- **Description** : Décomposer en modules (interceptors, retry, cache, csrf, request-builder)
|
||||
### 1. Éclater `client.ts` (2237L)
|
||||
|
||||
- **Description** : Découper le client HTTP monolithique en modules cohérents
|
||||
- **Prérequis** : Aucun
|
||||
- **Effort** : L (3-5 jours)
|
||||
- **Impact** : Critique — fichier le plus modifié du projet
|
||||
- **Effort** : L
|
||||
- **Impact** : Critique — maintenabilité et testabilité
|
||||
- **Découpage** : `httpClient.ts`, `requestValidation.ts`, `responseCache.ts`, `requestInterceptors.ts`, `validationMetrics.ts`
|
||||
|
||||
### 2. Migrer components/ → features/
|
||||
- **Description** : Déplacer `components/player/`, `components/settings/`, `components/social/`, `components/education/`, `components/commerce/`, `components/studio/` dans les modules features correspondants
|
||||
- **Prérequis** : Fixer les imports, mettre à jour les barrel exports
|
||||
- **Effort** : L (1 semaine)
|
||||
- **Impact** : Majeur — architecture clarifiée
|
||||
### 2. Terminer la migration `components/views/` → `features/pages/`
|
||||
|
||||
### 3. Activer `@typescript-eslint/no-explicit-any: 'warn'`
|
||||
- **Description** : Réactiver la règle ESLint, corriger progressivement les ~120 `any` dans le code source (pas les tests ni les types générés)
|
||||
- **Description** : Migrer les 20 sous-dossiers de `components/views/` vers `features/*/pages/`
|
||||
- **Prérequis** : Convention de migration définie
|
||||
- **Effort** : XL
|
||||
- **Impact** : Majeur — architecture cohérente
|
||||
- **Suggestion** : Migrer 3-4 views par sprint
|
||||
|
||||
### 3. Unifier le système de toast
|
||||
|
||||
- **Description** : Choisir entre `addToast()` et `toast()` react-hot-toast, supprimer l'autre
|
||||
- **Prérequis** : Aucun
|
||||
- **Effort** : M (2-3 jours pour la correction initiale)
|
||||
- **Impact** : Majeur — type safety restaurée
|
||||
- **Effort** : M
|
||||
- **Impact** : Majeur — cohérence DX et UX
|
||||
|
||||
### 4. Unifier les tokens de design (`--kodo-*` ↔ `--veza-*`)
|
||||
- **Description** : Choisir un namespace unique, migrer toutes les références
|
||||
- **Prérequis** : Décision de nommage
|
||||
- **Effort** : M (2-3 jours)
|
||||
- **Impact** : Majeur — source de vérité unique pour les couleurs
|
||||
### 4. Enrichir la sémantique HTML
|
||||
|
||||
### 5. Ajouter skip navigation
|
||||
- **Description** : Ajouter `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to content</a>` en haut du layout
|
||||
- **Prérequis** : `id="main-content"` sur le `<main>`
|
||||
- **Effort** : XS (30 minutes)
|
||||
- **Impact** : Critique (WCAG 2.4.1)
|
||||
|
||||
### 6. Ajouter sémantique HTML
|
||||
- **Description** : `<aside>` pour Sidebar, `<article>` pour cards de contenu, `<section>` pour les sections de page, `<footer>` pour les pieds de page
|
||||
- **Description** : Ajouter `<aside>` pour la sidebar, `<article>` pour les cards de contenu, `<section>` pour les zones de page
|
||||
- **Prérequis** : Aucun
|
||||
- **Effort** : S (1 jour)
|
||||
- **Impact** : Modéré (WCAG 1.3.1)
|
||||
- **Effort** : M
|
||||
- **Impact** : Majeur — accessibilité
|
||||
|
||||
### 7. Split hooks lourds
|
||||
- **Description** : `usePlaylist.ts` (631L) → `usePlaylistData`, `usePlaylistActions`, `usePlaylistCollaboration`
|
||||
- **Prérequis** : Tests existants (595L)
|
||||
- **Effort** : M (2 jours)
|
||||
- **Impact** : Modéré — maintenance améliorée
|
||||
### 5. Split `usePlaylist.ts` (631L)
|
||||
|
||||
### 8. Optimiser le vendor chunk
|
||||
- **Description** : Identifier les dépendances dans le chunk de 925 KB, lazy-loader les plus lourds (emoji-picker, framer-motion, hls.js, swagger-ui)
|
||||
- **Prérequis** : Build fonctionnel
|
||||
- **Effort** : M (2-3 jours)
|
||||
- **Impact** : Modéré — LCP amélioré
|
||||
|
||||
### 9. Ajouter `loading="lazy"` aux images
|
||||
- **Description** : Ajouter l'attribut sur toutes les `<img>` hors above-the-fold dans `OptimizedImage` et les images de listes
|
||||
- **Description** : Découper en `usePlaylistCrud`, `usePlaylistCollaboration`, `usePlaylistAnalytics`
|
||||
- **Prérequis** : Aucun
|
||||
- **Effort** : S (1 jour)
|
||||
- **Impact** : Modéré — performance perçue
|
||||
- **Effort** : M
|
||||
- **Impact** : Modéré — maintenabilité
|
||||
|
||||
### 10. Remplacer les couleurs hardcodées par des tokens
|
||||
- **Description** : Migrer les ~30 hex hardcodés vers les CSS variables du design system
|
||||
- **Prérequis** : Tokens unifiés (recommandation #4)
|
||||
- **Effort** : S (1 jour)
|
||||
- **Impact** : Modéré — theming complet
|
||||
### 6. Migrer z-index arbitraires vers tokens SUMI
|
||||
|
||||
- **Description** : Remplacer les ~31 fichiers avec `z-[N]` par `z-[var(--sumi-z-*)]` ou les classes utility
|
||||
- **Prérequis** : Inventaire z-index complet
|
||||
- **Effort** : M
|
||||
- **Impact** : Modéré — cohérence design system
|
||||
|
||||
### 7. Ajouter `React.memo` sur les composants de liste
|
||||
|
||||
- **Description** : Mémoriser TrackList items, PlaylistList items, ChatMessages, SearchResults
|
||||
- **Prérequis** : Aucun
|
||||
- **Effort** : S
|
||||
- **Impact** : Modéré — performance rendu
|
||||
|
||||
### 8. Compléter l'adoption i18n
|
||||
|
||||
- **Description** : Migrer les ~35 fichiers avec strings hardcodées vers `t()`
|
||||
- **Prérequis** : Enrichir en.json et fr.json
|
||||
- **Effort** : L
|
||||
- **Impact** : Modéré — internationalisation
|
||||
|
||||
### 9. Ajouter `loading="lazy"` natif sur les images
|
||||
|
||||
- **Description** : Compléter le lazy loading JS par l'attribut natif
|
||||
- **Prérequis** : Aucun
|
||||
- **Effort** : S
|
||||
- **Impact** : Modéré — performance
|
||||
|
||||
### 10. Résoudre la dualité layout/Sidebar vs ui/Sidebar
|
||||
|
||||
- **Description** : Fusionner ou distinguer clairement les deux composants Sidebar
|
||||
- **Prérequis** : Comprendre les usages
|
||||
- **Effort** : S
|
||||
- **Impact** : Modéré — clarté code
|
||||
|
||||
---
|
||||
|
||||
## Recommandations long terme (trimestre)
|
||||
|
||||
### Refonte design system
|
||||
- Fusionner les deux systèmes de tokens en un seul "KODO v4"
|
||||
- Documenter formellement dans un Storybook dédié "Design System"
|
||||
- Créer des composants manquants (Stepper, Timeline, Calendar, Rich Text Editor)
|
||||
- Migrer les 75 fichiers avec inline styles vers des classes utilitaires
|
||||
### Consolidation du design system
|
||||
|
||||
### Architecture cible
|
||||
- Finaliser la migration features-first (supprimer `components/` legacy sauf `components/ui/` et `components/layout/`)
|
||||
- Extraire `client.ts` en un package interne (@veza/api-client)
|
||||
- Normaliser les patterns d'erreur (un seul pattern dans les services)
|
||||
- Publier SUMI v2.1 avec toutes les variantes manquantes (glass button, etc.)
|
||||
- Créer un Storybook complet avec tous les composants documentés
|
||||
- Établir un processus de review design pour chaque PR modifiant un composant UI
|
||||
|
||||
### Migration architecturale
|
||||
|
||||
- Terminer la migration `components/views/` → `features/pages/`
|
||||
- Évaluer un passage à TanStack Router pour un routing file-based
|
||||
- Envisager de déplacer les 30 sous-dossiers de `components/{domain}/` vers les features correspondantes
|
||||
|
||||
### Performance
|
||||
- Réduire les fonts de 6 familles à 2-3
|
||||
- Implémenter le prefetching de données (React Query `prefetchQuery` sur hover des liens)
|
||||
- Ajouter des Web Workers pour les calculs audio lourds (waveform, analytics)
|
||||
|
||||
### i18n
|
||||
- Externaliser toutes les strings hardcodées dans les fichiers de locale
|
||||
- Ajouter au minimum l'anglais et le français
|
||||
- Préparer le RTL support (classes logiques Tailwind)
|
||||
- Implémenter le Server-Side Rendering (SSR) ou Static Site Generation (SSG) pour les pages publiques (profils, marketplace)
|
||||
- Auditer et optimiser le bundle (tree-shaking framer-motion, subset fonts)
|
||||
- Ajouter des tests de performance (Lighthouse CI déjà configuré mais non vérifié en fonctionnement)
|
||||
|
||||
### Accessibilité
|
||||
|
||||
- Viser la conformité WCAG 2.1 AA complète
|
||||
- Ajouter `aria-hidden` systématique sur les icônes Lucide
|
||||
- Remplacer les `role="button"` sur div par des `<button>` natifs
|
||||
- Implémenter un audit automatisé a11y dans la CI (pa11y-ci est installé)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -151,16 +168,16 @@
|
|||
|
||||
### 1. Le frontend est-il présentable pour un investisseur aujourd'hui ?
|
||||
|
||||
**NON** — Le build est cassé, ce qui rend impossible toute démonstration en production. En revanche, si le build est fixé (15 minutes), l'interface démontrable en dark mode avec ses animations fluides et son design distinctif ferait une **impression favorable** pour un investisseur — à condition de ne pas naviguer sur les 5 pages placeholder "Coming Soon". L'infrastructure technique (TypeScript strict, Storybook, Sentry, feature flags) est un signal positif de rigueur.
|
||||
**OUI, avec réserves.** Le design system SUMI donne une identité forte et distinctive. L'architecture est ambitieuse et la stack est moderne. Cependant, les 5 routes « ComingSoon », la migration incomplète et le loading screen non-brandé trahissent un produit en développement actif. Pour une démo investisseur, masquer les routes ComingSoon et ajouter un splash screen brandé serait recommandé.
|
||||
|
||||
### 2. Le frontend peut-il scaler sans refonte architecturale ?
|
||||
|
||||
**OUI, avec caveats** — L'architecture de base (feature modules, React Query, Zustand, routing lazy) est scalable. Cependant, la dualité `components/` ↔ `features/`, les 706 `as any`, et le client API monolithique de 2 237 lignes devront être adressés pour supporter une équipe de 5+ développeurs travaillant en parallèle. Estimation : 2-3 semaines de refactoring pour être "scale-ready".
|
||||
**OUI.** L'architecture feature-based, le design system tokenisé, React Query pour le server state, et Zustand pour le client state sont des choix scalables. La virtualisation et la pagination serveur sont en place. Les problèmes identifiés (migration incomplète, client.ts monolithe) sont des dettes de refactoring, pas des blocages architecturaux. Un refactoring progressif suffit.
|
||||
|
||||
### 3. Une refonte complète est-elle nécessaire ou un refactoring suffit ?
|
||||
|
||||
**Un refactoring suffit.** La stack est moderne et bien choisie (React 18, Vite 7, Tailwind v4, TypeScript strict). Le design system KODO est ambitieux et fonctionnel. Les problèmes sont de l'ordre du nettoyage (code mort, duplications, CSS parallèle), de la consolidation (tokens, architecture) et de la finition (accessibilité, performance) — pas d'une refonte fondamentale. Estimation : 6-8 semaines de refactoring ciblé pour atteindre un niveau "production-ready".
|
||||
**UN REFACTORING SUFFIT.** Les fondations sont saines : TypeScript strict, design system formalisé, tests présents, patterns modernes. La dette est structurelle (dualité views/features, AuthContext legacy) et technique (client.ts trop gros, `any` types), pas architecturale. Un plan de refactoring sur 2-3 mois avec des migrations progressives est la bonne approche.
|
||||
|
||||
### 4. Le projet respecte-t-il les standards minimaux d'un SaaS B2B/B2C en 2025 ?
|
||||
|
||||
**PARTIELLEMENT** — Les standards respectés : TypeScript strict, design system formalisé, Storybook, React Query, JWT httpOnly, CSRF, Sentry, feature flags, ESLint custom rules. Les standards non respectés : build cassé, skip navigation absent (WCAG), i18n non externalisé, code mort significatif, `any` types endémiques. Pour un SaaS B2B/B2C en 2025-2026, le seuil minimal exige un build fonctionnel, une accessibilité WCAG AA, et une internationalisation au minimum anglais/français. Le projet est à **~70% du seuil**.
|
||||
**OUI, pour un produit en beta.** Le design system, le theming, l'i18n partiel, la sécurité (httpOnly JWT, CSRF, DOMPurify), les tests, le Storybook, et l'architecture feature-based sont conformes aux standards SaaS. Les lacunes (accessibilité sémantique, skip nav, React.memo, i18n incomplet) sont des points d'amélioration, pas des blocages. Le produit est au-dessus de la moyenne pour un projet en beta mais en dessous des standards d'un produit GA (General Availability).
|
||||
|
|
|
|||
|
|
@ -1,93 +1,105 @@
|
|||
# PHASE J — IDENTITÉ VISUELLE & MATURITÉ PERÇUE
|
||||
# Phase J — Identité Visuelle & Maturité Perçue
|
||||
|
||||
---
|
||||
|
||||
## J1. Perception professionnelle
|
||||
|
||||
### 1. Confiance — Un utilisateur fait-il confiance en 5 secondes ?
|
||||
### 1. Confiance — 7/10
|
||||
|
||||
**Note : 7/10**
|
||||
Un utilisateur lambda **ferait probablement confiance** à ce produit en 5 secondes. Les raisons :
|
||||
|
||||
**Oui, avec réserves.** Le design system KODO dégage une impression de produit travaillé et distinctif. La palette néon cyan/magenta sur fond sombre (mode par défaut) est visuellement frappante et cohérente avec l'univers audio/créatif. Les tokens CSS sont bien définis, le dark mode est complet, les animations sont fluides (easing tokenisés, transitions cohérentes).
|
||||
**Positif** :
|
||||
- Design system SUMI v2.0 avec une identité visuelle distinctive (thème sombre « encre et lumière », grains de papier washi, typographie Space Grotesk) [index.css:6-10, 460-471]
|
||||
- Palette de couleurs sophistiquée et cohérente — pas un template générique
|
||||
- Effets glass/blur subtils [index.css:621-626]
|
||||
- Custom scrollbar raffiné [index.css:478-503]
|
||||
- Text selection stylisée [index.css:473-476]
|
||||
- Animations soignées avec `prefers-reduced-motion` respecté [index.css:850-858]
|
||||
|
||||
**Réserves** : L'esthétique "manga × graffiti × gaming × terminal" est très niche. Un utilisateur professionnel (producteur, label) pourrait percevoir le produit comme trop "joueur" et pas assez sérieux. Les clip-paths manga et les effets néon, s'ils sont trop présents dans l'interface réelle, peuvent nuire à la perception de fiabilité.
|
||||
**Négatif** :
|
||||
- Le nombre élevé de features « ComingSoon » (Gear, Live, Education, Queue, Developer) [routeConfig.tsx:96-101] trahit un produit en développement
|
||||
- Le `AstralBackground.tsx` (142L) — effet de fond spatial qui peut sembler amateur si mal dosé
|
||||
- Loading screen minimal (spinner + "Chargement...") [App.tsx:161-168] — pas de branded loading
|
||||
|
||||
### 2. Cohérence — Impression d'une seule main ?
|
||||
### 2. Cohérence — 7/10
|
||||
|
||||
**Note : 6/10**
|
||||
L'interface **donne majoritairement** l'impression d'avoir été conçue par une seule personne.
|
||||
|
||||
**Partiellement.** Les composants UI primitifs sont cohérents entre eux (Button, Card, Badge suivent le même langage visuel). Le design system définit des tokens clairs.
|
||||
- Le design system SUMI est appliqué de manière relativement uniforme
|
||||
- Les tokens sémantiques (`bg-background`, `text-foreground`, `text-muted-foreground`) sont utilisés partout
|
||||
- Les composants UI primitives sont bien centralisés et réutilisés
|
||||
- **Points de rupture** : la dualité `components/views/` vs `features/pages/` crée des incohérences stylistiques mineures entre les écrans refactorés (SUMI natif) et les legacy (patterns plus anciens)
|
||||
|
||||
**Problèmes** :
|
||||
- La dualité de tokens (`--kodo-*` vs `--veza-*`) trahit une refonte en cours
|
||||
- Les fichiers CSS vanilla parallèles aux composants React montrent une couche de design précédente non nettoyée
|
||||
- Les hacks `!important` dans 2 fichiers CSS suggèrent des corrections d'urgence, pas un design délibéré
|
||||
- La coexistence de Modal et Dialog indique des décisions de design non tranchées
|
||||
### 3. Maturité — Beta avancée (6.5/10)
|
||||
|
||||
### 3. Maturité — V0.1, MVP, beta, ou produit mature ?
|
||||
Le produit ressemble à un **produit en beta / pre-release** :
|
||||
|
||||
**Note : MVP avancé / Beta précoce**
|
||||
- Architecture sophistiquée (design system formalisé, feature flags, i18n)
|
||||
- Composants bien structurés avec skeletons et empty states
|
||||
- Features planifiées avec placeholders « ComingSoon »
|
||||
- Migration architecturale en cours (dual patterns visible)
|
||||
- Pas encore la finition d'un produit mature (skip nav absent, `loading="lazy"` absent, quelques inline styles)
|
||||
|
||||
Le code source révèle :
|
||||
- **Forces d'un produit beta** : 222K LOC, 60+ composants UI, TypeScript strict, design system formalisé, Storybook, tests, Sentry, feature flags
|
||||
- **Faiblesses d'un MVP** : build cassé, 25 fichiers orphelins, 706 `as any`, import fantôme, 5 fichiers de "ComingSoon" placeholder, features non implémentées (education, gear, live, queue, developer sont des placeholders)
|
||||
### 4. Positionnement — 7/10
|
||||
|
||||
**Verdict** : Un **MVP ambitieux avec une infrastructure de beta** — le squelette est solide mais les finitions manquent.
|
||||
L'esthétique correspond bien à la cible : **plateforme créative audio / collaboration musicale**.
|
||||
|
||||
### 4. Positionnement — L'esthétique correspond-elle à la cible ?
|
||||
- Le thème sombre est adapté aux créateurs audio (comme FL Studio, Ableton)
|
||||
- Les touches japonaises (SUMI, washi, Noto Serif JP) donnent un caractère distinctif
|
||||
- Les couleurs (accent bleu-acier, vermillion, sage, gold) sont appropriées pour un outil créatif
|
||||
- Le player audio intégré (PlayerBar, WaveformVisualizer, ProgressBar) montre le focus sur l'audio
|
||||
|
||||
**Note : 7/10**
|
||||
### 5. Différenciation — 8/10
|
||||
|
||||
**Si la cible est des créateurs/producteurs audio jeunes (18-35)** : Oui, l'esthétique manga/gaming/neon résonne avec cette démographie. C'est distinctif et mémorable, ce qui est un avantage concurrentiel.
|
||||
Il y a **une identité visuelle distinctive** :
|
||||
|
||||
**Si la cible est un public B2B plus large** (labels, distributeurs, professionnels de l'audio) : Non. L'esthétique est trop niche et pourrait aliéner des utilisateurs plus conservateurs. Un mode "professionnel" plus sobre serait nécessaire.
|
||||
|
||||
### 5. Différenciation — Identité distinctive ou template ?
|
||||
|
||||
**Note : 8/10**
|
||||
|
||||
**Clairement distinctif.** Ce n'est PAS un template Material/Bootstrap/Tailwind UI. Le design system KODO a une vraie identité :
|
||||
- Palette OKLCH (moderne, perceptuellement uniforme)
|
||||
- Clip-paths manga
|
||||
- Gradients néon
|
||||
- Gaming elements (XP, achievements)
|
||||
- Scrollbar custom
|
||||
- Noise texture overlay
|
||||
- Glass morphism thématisé
|
||||
|
||||
C'est un travail d'identité visuelle délibéré, pas un assemblage de composants génériques.
|
||||
- Ce n'est **pas** un template Material, Bootstrap ou Tailwind UI standard
|
||||
- Le design system SUMI avec sa philosophie « encre et lumière » est original
|
||||
- La texture grain de papier [index.css:460-471] et les effets washi sont distinctifs
|
||||
- Les 4 « pigments » (accent, vermillion, sage, gold) [index.css:46-64] créent une palette reconnaissable
|
||||
- La typographie (Space Grotesk + Inter) est un choix distinctif vs le ubiquitaire Inter seul
|
||||
|
||||
---
|
||||
|
||||
## J2. Comparaison concurrentielle
|
||||
|
||||
### Produit le plus proche visuellement
|
||||
### Produit comparable
|
||||
|
||||
L'UI rappelle un **croisement entre Spotify (layout, dark mode, player bar) et Discord (sidebar, chat, glassmorphism)**, avec une couche d'identité gaming/manga unique. Les tokens de layout (`--sidebar-width-expanded: 240px`) sont explicitement alignés sur Discord.
|
||||
L'UI se rapproche le plus de **Splice** ou **BandLab** en termes de positionnement, avec une influence esthétique rappelant **Linear** (design system rigoureux, dark-first, animations subtiles) et **Discord** (sidebar navigation, chat intégré, gamification).
|
||||
|
||||
### Écart avec les leaders
|
||||
|
||||
| Dimension | Veza | Spotify | Discord | SoundCloud |
|
||||
|-----------|------|---------|---------|------------|
|
||||
| Design system maturity | MVP+ | Production | Production | Production |
|
||||
| Dark mode | ✅ Complet | ✅ | ✅ | ⚠️ Partiel |
|
||||
| Animations | ✅ Tokenisées | ✅ Subtiles | ✅ Fluides | ✅ Basiques |
|
||||
| Loading states | ✅ Skeletons | ✅ | ✅ | ⚠️ Spinners |
|
||||
| Responsive | ✅ Mobile-first | ✅ | ⚠️ Desktop-first | ✅ |
|
||||
| Performance | ⚠️ Build cassé | ✅ | ✅ | ✅ |
|
||||
| Accessibilité | ⚠️ Skip link manquant | ✅ | ⚠️ | ⚠️ |
|
||||
| i18n | ⚠️ Non externalisé | ✅ 60+ langues | ✅ 30+ langues | ✅ |
|
||||
| Leader | Écart perçu | Détail |
|
||||
|--------|-------------|--------|
|
||||
| Splice | Moyen | Splice a un polish plus élevé sur les interactions et les transitions |
|
||||
| BandLab | Faible | Le design system SUMI est plus sophistiqué |
|
||||
| SoundCloud | Moyen | SoundCloud a un waveform player plus abouti et une identité de marque plus forte |
|
||||
| Linear | Significatif | Linear a une finition pixel-perfect que Veza n'a pas encore atteint |
|
||||
|
||||
**Écart principal** : Finition et robustesse. Les leaders n'ont pas de build cassé, pas de code mort, pas de `as any` endémique.
|
||||
### 3 éléments qui tirent le produit vers le bas
|
||||
|
||||
### 3 éléments qui tirent vers le bas
|
||||
1. **Features « ComingSoon »** — 5 routes avec placeholder générique, donnent une impression de produit incomplet
|
||||
2. **Loading screen non-brandé** — Simple spinner + texte « Chargement... », pas de logo, pas d'animation branded
|
||||
3. **Migration visuelle incomplète** — Les écrans non-refactorés (legacy views) peuvent avoir un style légèrement décalé par rapport aux écrans SUMI natifs
|
||||
|
||||
1. **Build cassé** — Impossible de vérifier le rendu réel en production. C'est le signal le plus négatif possible.
|
||||
2. **Code mort et duplications** — 25 fichiers orphelins, 5 CSS parallèles, 2 systèmes de modales. Ça transpire le "pas fini".
|
||||
3. **Placeholder "Coming Soon"** — 5 routes sont des placeholders vides. Un utilisateur qui navigue sur Gear, Live, Education, Queue ou Developer voit un écran vide.
|
||||
### 3 éléments visuels qui fonctionnent bien
|
||||
|
||||
### 3 éléments qui fonctionnent bien
|
||||
1. **Design system SUMI** — Identité cohérente et distinctive avec les tokens, la palette 4 pigments, et la philosophie « encre et lumière »
|
||||
2. **Skeleton loading** — Les skeletons systématiques dans chaque view donnent une impression de fluidité et de professionnalisme
|
||||
3. **Composants audio** — WaveformVisualizer, PlayerBar, ProgressBar montrent un soin particulier pour le cœur de métier (audio)
|
||||
|
||||
1. **Design system KODO** — L'identité visuelle est forte, distinctive et cohérente dans sa philosophie. Les tokens CSS sont complets.
|
||||
2. **Architecture de composants UI** — 60+ primitives bien typées, avec variants, stories et tests. C'est un vrai design system.
|
||||
3. **Infrastructure technique** — TypeScript strict, React Query, Zustand, Storybook, Sentry, MSW, ESLint custom rules. Les outils sont là pour supporter un produit de qualité.
|
||||
---
|
||||
|
||||
## Score Maturité Perçue
|
||||
|
||||
| Critère | Points | Justification |
|
||||
|---------|--------|---------------|
|
||||
| Identité visuelle | +2 | SUMI v2.0 distinctif, pas un template |
|
||||
| Cohérence globale | +1.5 | Tokens bien appliqués, quelques legacy breaks |
|
||||
| Polish des interactions | +1 | Animations, transitions, hover states |
|
||||
| Features incomplètes | -1 | 5 routes ComingSoon |
|
||||
| Loading experience | -0.5 | Pas de branded loading |
|
||||
| Migration visible | -0.5 | Dualité stylistique entre refactoré et legacy |
|
||||
| Audio UI quality | +1 | Cœur de métier soigné |
|
||||
| Dark/Light theme | +0.5 | Les deux thèmes sont complets |
|
||||
| **Total** | **6.5/10** | **Beta avancée avec identité forte** |
|
||||
|
|
|
|||
|
|
@ -1,39 +1,31 @@
|
|||
# AUDIT FRONTEND COMPLET
|
||||
|
||||
**Date** : 12 février 2026
|
||||
**Projet** : Veza — Plateforme audio collaborative
|
||||
**Scope** : `apps/web/src/` (222 717 LOC, 2 092 fichiers)
|
||||
**Date** : 2026-02-12
|
||||
**Score global** : **6.6 / 10** (moyenne pondérée)
|
||||
**Verdict** : Beta avancée, solide mais refactoring nécessaire
|
||||
|
||||
---
|
||||
|
||||
## SCORE GLOBAL : 6.5 / 10
|
||||
|
||||
> Fonctionnel mais fragile, dette significative.
|
||||
> Le produit a une base technique solide et un design distinctif, mais souffre de dette accumulée et de finitions manquantes.
|
||||
|
||||
---
|
||||
|
||||
## 3 ACTIONS LES PLUS URGENTES
|
||||
|
||||
### 1. 🔴 Fixer le build cassé (15 min)
|
||||
`src/components/views/education-view/useEducationView.ts` → import `educationService` manquant. Bloque tout déploiement.
|
||||
|
||||
### 2. 🔴 Nettoyer le code mort et les duplications (2-3 jours)
|
||||
25 fichiers orphelins, 5 fichiers CSS parallèles, duplications Modal/Dialog et Spinner/LoadingSpinner. Réduire le bruit pour pouvoir avancer.
|
||||
|
||||
### 3. 🟠 Ajouter le skip navigation + sémantique HTML (1 jour)
|
||||
Violation WCAG 2.4.1 critique. Ajouter le skip link et les landmarks sémantiques (`<aside>`, `<section>`, `<article>`, `<footer>`).
|
||||
|
||||
---
|
||||
## Score par catégorie
|
||||
|
||||
| Catégorie | Score |
|
||||
|-----------|-------|
|
||||
| Architecture | 7.0 |
|
||||
| Design System | 6.5 |
|
||||
| Cohérence UI | 7.0 |
|
||||
| Accessibilité | 6.5 |
|
||||
| Sécurité | 8.0 |
|
||||
| Performance | 6.0 |
|
||||
| Dette technique | 5.0 |
|
||||
| Scalabilité | 5.5 |
|
||||
| Design System | 7.5 |
|
||||
| Cohérence UI | 6.5 |
|
||||
| Accessibilité | 5.5 |
|
||||
| Sécurité | 7.0 |
|
||||
| Performance | 6.5 |
|
||||
| Dette technique | 6.0 |
|
||||
| Scalabilité | 6.5 |
|
||||
| Maturité perçue | 6.5 |
|
||||
|
||||
---
|
||||
|
||||
## 3 actions les plus urgentes
|
||||
|
||||
1. **Corriger l'open redirect** dans `usePlaylistNotifications.ts:203,219,235,251` — valider `notification.link` avant redirection. (30 min, impact sécurité)
|
||||
|
||||
2. **Supprimer `context/AuthContext.tsx`** et `providers/AuthProvider.tsx` — deux sources de vérité pour l'auth coexistent avec `authStore`. (2-4h, élimine une classe de bugs)
|
||||
|
||||
3. **Ajouter un skip navigation link** dans le layout principal — conformité WCAG 2.4.1 de base. (30 min, impact accessibilité)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ import { logger } from '@/utils/logger';
|
|||
import { AudioProvider } from '@/context/AudioContext';
|
||||
|
||||
export function App() {
|
||||
const { t } = useTranslation();
|
||||
const { refreshUser } = useAuthStore();
|
||||
const { theme, setTheme, language, setLanguage } = useUIStore();
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||
|
|
@ -155,13 +156,36 @@ export function App() {
|
|||
};
|
||||
}, [theme]);
|
||||
|
||||
// P1.2: Show loading screen while auth is initializing
|
||||
// S5.1: Branded loading screen (Spotify-like splash)
|
||||
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 className="flex flex-col items-center justify-center min-h-screen bg-[var(--sumi-bg-void)]">
|
||||
{/* Logo mark */}
|
||||
<div className="relative mb-8 animate-[sumi-fade-in_0.6s_ease-out]">
|
||||
<svg
|
||||
width="56"
|
||||
height="56"
|
||||
viewBox="0 0 56 56"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect width="56" height="56" rx="16" fill="currentColor" fillOpacity="0.15" />
|
||||
<path
|
||||
d="M18 38V18l20 10-20 10z"
|
||||
fill="currentColor"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/* Brand name */}
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground mb-6 animate-[sumi-fade-in_0.8s_ease-out_0.2s_both]">
|
||||
Veza
|
||||
</h1>
|
||||
{/* Progress bar */}
|
||||
<div className="w-48 h-0.5 bg-muted/30 rounded-full overflow-hidden animate-[sumi-fade-in_1s_ease-out_0.4s_both]">
|
||||
<div className="h-full bg-primary rounded-full animate-[loading-progress_1.5s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -171,6 +195,13 @@ export function App() {
|
|||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<AudioProvider>
|
||||
{/* S3.1: Skip navigation link for keyboard/screen-reader users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[var(--sumi-z-max)] focus:bg-primary focus:text-primary-foreground focus:px-4 focus:py-2 focus:rounded-lg focus:shadow-lg"
|
||||
>
|
||||
{t('nav.skipToContent')}
|
||||
</a>
|
||||
<AstralBackground />
|
||||
{/* Offline/Online Status Indicator */}
|
||||
<OfflineIndicator />
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const BanUserModal: React.FC<BanUserModalProps> = ({
|
|||
const [duration, setDuration] = useState('7'); // days
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const PromoCodeModal: React.FC<PromoCodeModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const RefundRequestModal: React.FC<RefundRequestModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export const CreateAPIKeyModal: React.FC<CreateAPIKeyModalProps> = ({
|
|||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" style={{ zIndex: 9999 }}>
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-max)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-md"
|
||||
onClick={step === 1 ? onClose : undefined}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const CertificateModal: React.FC<CertificateModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export const QuizModal: React.FC<QuizModalProps> = ({
|
|||
|
||||
if (showResults) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={handleFinish}
|
||||
|
|
@ -102,7 +102,7 @@ export const QuizModal: React.FC<QuizModalProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-background/90 backdrop-blur-sm"></div>
|
||||
<div className="relative w-full max-w-2xl bg-muted border border-border rounded-xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col max-h-layout-modal-sm">
|
||||
{/* Header */}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ export function useToastContext() {
|
|||
return context;
|
||||
}
|
||||
|
||||
// Legacy compatibility hook to support (message, type) signature
|
||||
/**
|
||||
* @deprecated S1.2: Use `useToast` from `@/hooks/useToast` or `toast` from `@/utils/toast` instead.
|
||||
* Legacy compatibility hook — delegates to react-hot-toast.
|
||||
*/
|
||||
export function useToast() {
|
||||
const context = useToastContext();
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function EquipmentDetailViewGallery({
|
|||
return (
|
||||
<div className="relative aspect-video bg-black rounded-xl overflow-hidden border border-border group">
|
||||
{src && (
|
||||
<img src={src} alt="" className="w-full h-full object-contain" />
|
||||
<img src={src} alt="" loading="lazy" className="w-full h-full object-contain" />
|
||||
)}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
|
||||
{/* Scrollable Content Container */}
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pt-main pb-main px-4 md:px-8 custom-scrollbar"
|
||||
id="main-scroll-container"
|
||||
data-scroll-container="main"
|
||||
>
|
||||
<div className="max-w-layout-content mx-auto w-full">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function Header(_props: HeaderProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 h-header z-[200] pointer-events-none">
|
||||
<header className="fixed top-0 left-0 right-0 h-header z-[var(--sumi-z-sticky)] pointer-events-none">
|
||||
<div className={cn(
|
||||
'absolute top-0 right-0 h-header bg-[var(--sumi-glass-bg)] backdrop-blur-[12px] border-b border-[var(--sumi-border-faint)] flex items-center justify-between px-4 md:px-6 pointer-events-auto transition-all duration-[var(--sumi-duration-fast)]',
|
||||
sidebarOpen ? 'left-header-expanded' : 'left-header-collapsed',
|
||||
|
|
@ -77,8 +77,8 @@ export function Header(_props: HeaderProps) {
|
|||
<Search className="absolute left-3 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="What do you want to play?"
|
||||
aria-label="Rechercher des pistes, artistes, playlists"
|
||||
placeholder={t('header.searchPlaceholder')}
|
||||
aria-label={t('header.searchAriaLabel')}
|
||||
className="w-full h-10 pl-10 pr-4 bg-muted/30 border-0 rounded-full text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-inset transition-all duration-[var(--duration-fast)]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
|
|
@ -99,7 +99,7 @@ export function Header(_props: HeaderProps) {
|
|||
|
||||
<div className="hidden xl:flex items-center gap-2 mr-2 px-2.5 py-1 rounded-full bg-muted/30 text-muted-foreground">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
|
||||
<span className="text-xs">Online</span>
|
||||
<span className="text-xs">{t('header.online')}</span>
|
||||
</div>
|
||||
|
||||
<NotificationMenu />
|
||||
|
|
@ -147,10 +147,10 @@ export function Header(_props: HeaderProps) {
|
|||
|
||||
<div className="p-1 space-y-0.5">
|
||||
<Link to="/profile" className="flex items-center gap-3 px-3 py-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-xl transition-colors duration-[var(--duration-fast)]">
|
||||
<User className="w-4 h-4" /> Profil
|
||||
<User className="w-4 h-4" /> {t('header.profile')}
|
||||
</Link>
|
||||
<Link to="/settings" className="flex items-center gap-3 px-3 py-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-xl transition-colors duration-[var(--duration-fast)]">
|
||||
<Settings className="w-4 h-4" /> Paramètres
|
||||
<Settings className="w-4 h-4" /> {t('nav.settings')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ export function Header(_props: HeaderProps) {
|
|||
|
||||
<div className="p-1">
|
||||
<button onClick={handleLogout} className="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-destructive hover:bg-destructive/10 rounded-xl transition-colors duration-[var(--duration-fast)]">
|
||||
<LogOut className="w-4 h-4" /> Déconnexion
|
||||
<LogOut className="w-4 h-4" /> {t('header.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function Layout({ children }: LayoutProps) {
|
|||
<Sidebar />
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
className={cn(
|
||||
'flex-1 min-h-layout-main transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-in-out)] pb-20 lg:pb-0',
|
||||
sidebarOpen ? 'lg:ml-main-expanded' : 'lg:ml-main-collapsed max-lg:ml-0',
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, onLogout }) => {
|
|||
{/* Backdrop for closing menus - Lower z-index than Navbar (z-40) but higher than content */}
|
||||
{showUserMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-[300] bg-transparent cursor-default"
|
||||
className="fixed inset-0 z-[var(--sumi-z-overlay)] bg-transparent cursor-default"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,29 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* S5.3: Subtile page transitions (Linear-style fade + slide-up)
|
||||
* Respects prefers-reduced-motion for accessibility.
|
||||
*/
|
||||
export function PageTransition({ children }: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
|
||||
const prefersReducedMotion = useMemo(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
[],
|
||||
);
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
return <div key={location.pathname}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Home, Users, Disc, Radio, Settings, LogOut, ShoppingBag,
|
||||
GraduationCap, BarChart2, Shield, Box, MessageSquare, Cloud,
|
||||
|
|
@ -17,51 +18,60 @@ interface SidebarProps {
|
|||
currentView?: string;
|
||||
}
|
||||
|
||||
const navItems: { section: string; items: NavItem[] }[] = [
|
||||
{
|
||||
section: 'My Studio',
|
||||
items: [
|
||||
{ id: 'dashboard', label: 'Command Center', icon: <Home className="w-4 h-4" /> },
|
||||
{ id: 'studio', label: 'Cloud Files', icon: <Cloud className="w-4 h-4" /> },
|
||||
{ id: 'tracks', label: 'Projects', icon: <Layers className="w-4 h-4" /> },
|
||||
{ id: 'gear', label: 'Gear Locker', icon: <Box className="w-4 h-4" /> },
|
||||
{ id: 'analytics', label: 'Performance', icon: <BarChart2 className="w-4 h-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Veza Network',
|
||||
items: [
|
||||
{ id: 'social', label: 'Community Feed', icon: <Users className="w-4 h-4" /> },
|
||||
{ id: 'marketplace', label: 'Marketplace', icon: <ShoppingBag className="w-4 h-4" /> },
|
||||
{ id: 'live', label: 'Live Sessions', icon: <Radio className="w-4 h-4" />, badge: 3 },
|
||||
{ id: 'chat', label: 'Channels', icon: <MessageSquare className="w-4 h-4" />, badge: 12 },
|
||||
{ id: 'education', label: 'Academy', icon: <GraduationCap className="w-4 h-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Commerce',
|
||||
items: [
|
||||
{ id: 'sell', label: 'Seller Dashboard', icon: <DollarSign className="w-4 h-4" /> },
|
||||
{ id: 'wishlist', label: 'Wishlist', icon: <Heart className="w-4 h-4" /> },
|
||||
{ id: 'purchases', label: 'Purchases', icon: <CreditCard className="w-4 h-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Library',
|
||||
items: [
|
||||
{ id: 'playlists', label: 'Playlists', icon: <ListMusic className="w-4 h-4" /> },
|
||||
{ id: 'queue', label: 'Play Queue', icon: <Disc className="w-4 h-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'System',
|
||||
items: [
|
||||
{ id: 'developer', label: 'Developer API', icon: <Terminal className="w-4 h-4" /> },
|
||||
{ id: 'admin', label: 'Admin Panel', icon: <Shield className="w-4 h-4" /> },
|
||||
],
|
||||
},
|
||||
// Section key mapping for i18n
|
||||
const sectionKeys: Record<string, string> = {
|
||||
myStudio: 'nav.sections.myStudio',
|
||||
vezaNetwork: 'nav.sections.vezaNetwork',
|
||||
commerce: 'nav.sections.commerce',
|
||||
library: 'nav.sections.library',
|
||||
system: 'nav.sections.system',
|
||||
};
|
||||
|
||||
// Icon map — static, does not need translation
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
dashboard: <Home className="w-4 h-4" />,
|
||||
studio: <Cloud className="w-4 h-4" />,
|
||||
tracks: <Layers className="w-4 h-4" />,
|
||||
gear: <Box className="w-4 h-4" />,
|
||||
analytics: <BarChart2 className="w-4 h-4" />,
|
||||
social: <Users className="w-4 h-4" />,
|
||||
marketplace: <ShoppingBag className="w-4 h-4" />,
|
||||
live: <Radio className="w-4 h-4" />,
|
||||
chat: <MessageSquare className="w-4 h-4" />,
|
||||
education: <GraduationCap className="w-4 h-4" />,
|
||||
sell: <DollarSign className="w-4 h-4" />,
|
||||
wishlist: <Heart className="w-4 h-4" />,
|
||||
purchases: <CreditCard className="w-4 h-4" />,
|
||||
playlists: <ListMusic className="w-4 h-4" />,
|
||||
queue: <Disc className="w-4 h-4" />,
|
||||
developer: <Terminal className="w-4 h-4" />,
|
||||
admin: <Shield className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
// Badge data — static
|
||||
const badgeMap: Record<string, number> = { live: 3, chat: 12 };
|
||||
|
||||
// Navigation structure definition (ids only, labels resolved via t())
|
||||
const navStructure: { sectionKey: string; itemIds: string[] }[] = [
|
||||
{ sectionKey: 'myStudio', itemIds: ['dashboard', 'studio', 'tracks', 'gear', 'analytics'] },
|
||||
{ sectionKey: 'vezaNetwork', itemIds: ['social', 'marketplace', 'live', 'chat', 'education'] },
|
||||
{ sectionKey: 'commerce', itemIds: ['sell', 'wishlist', 'purchases'] },
|
||||
{ sectionKey: 'library', itemIds: ['playlists', 'queue'] },
|
||||
{ sectionKey: 'system', itemIds: ['developer', 'admin'] },
|
||||
];
|
||||
|
||||
function buildNavItems(t: (key: string) => string): { section: string; items: NavItem[] }[] {
|
||||
return navStructure.map(({ sectionKey, itemIds }) => ({
|
||||
section: t(sectionKeys[sectionKey] ?? sectionKey),
|
||||
items: itemIds.map((id) => ({
|
||||
id,
|
||||
label: t(`nav.items.${id}`),
|
||||
icon: iconMap[id],
|
||||
...(badgeMap[id] != null ? { badge: badgeMap[id] } : {}),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
const routeMap: Record<string, string> = {
|
||||
dashboard: '/dashboard', studio: '/library', tracks: '/library', gear: '/gear',
|
||||
analytics: '/analytics', social: '/social', marketplace: '/marketplace', live: '/live',
|
||||
|
|
@ -81,9 +91,11 @@ const navItemInactiveClasses =
|
|||
const navItemActiveClasses = 'bg-primary/10 text-primary sidebar-active-indicator';
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { sidebarOpen, setSidebarOpen } = useUIStore();
|
||||
const { handleMobileNav, handleLogout } = useSidebarNavigation();
|
||||
const navItems = useMemo(() => buildNavItems(t), [t]);
|
||||
|
||||
const activeView =
|
||||
currentView ||
|
||||
|
|
@ -236,7 +248,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
|
|||
|
||||
{/* Footer */}
|
||||
<div className="p-2 border-t border-[var(--sumi-border-faint)] space-y-0.5">
|
||||
<Tooltip content="Settings" position="right" disabled={sidebarOpen}>
|
||||
<Tooltip content={t('nav.settings')} position="right" disabled={sidebarOpen}>
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={handleMobileNav}
|
||||
|
|
@ -260,12 +272,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
|
|||
sidebarOpen ? 'opacity-100' : 'w-0 opacity-0 overflow-hidden'
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
{t('nav.settings')}
|
||||
</span>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Sign Out" position="right" disabled={sidebarOpen}>
|
||||
<Tooltip content={t('nav.logout')} position="right" disabled={sidebarOpen}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleLogout}
|
||||
|
|
@ -274,7 +286,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
|
|||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
!sidebarOpen && 'justify-center px-0'
|
||||
)}
|
||||
aria-label="Sign out"
|
||||
aria-label={t('nav.logout')}
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 transition-transform duration-[var(--duration-fast)] group-hover:scale-110" />
|
||||
<span
|
||||
|
|
@ -283,7 +295,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
|
|||
sidebarOpen ? 'opacity-100' : 'w-0 opacity-0 overflow-hidden'
|
||||
)}
|
||||
>
|
||||
Sign Out
|
||||
{t('nav.logout')}
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const WatermarkSettingsModal: React.FC<WatermarkSettingsModalProps> = ({
|
|||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const EditPlaylistModal: React.FC<EditPlaylistModalProps> = ({
|
|||
|
||||
if (showDeleteConfirm) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[500] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-popover)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
|
|
@ -70,7 +70,7 @@ export const EditPlaylistModal: React.FC<EditPlaylistModalProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const SaveQueueAsPlaylistModal: React.FC<
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const TipStreamerModal: React.FC<TipStreamerModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const LicenceDetailsModal: React.FC<LicenceDetailsModalProps> = ({
|
|||
onAddToCart,
|
||||
}) => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function ProductDetailViewGallery({
|
|||
onClick={() => onActiveImageChange(img)}
|
||||
className={`w-20 h-20 rounded-xl overflow-hidden cursor-pointer border-2 transition-all duration-[var(--sumi-duration-normal)] flex-shrink-0 ${activeImage === img ? 'border-primary' : 'border-transparent opacity-60 hover:opacity-100'}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
<img src={img} alt="" loading="lazy" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const CreatorModal: React.FC<CreatorModalProps> = ({
|
|||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ interface NotificationMenuItemProps {
|
|||
isMarking: boolean;
|
||||
}
|
||||
|
||||
export function NotificationMenuItem({
|
||||
function NotificationMenuItemInner({
|
||||
notification,
|
||||
onMarkAsRead,
|
||||
onClick,
|
||||
|
|
@ -91,3 +91,6 @@ export function NotificationMenuItem({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const NotificationMenuItem = React.memo(NotificationMenuItemInner);
|
||||
NotificationMenuItem.displayName = 'NotificationMenuItem';
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const FullPlayer: React.FC<FullPlayerProps> = ({ onClose }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] bg-background flex flex-col animate-fadeIn">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-raised)] bg-background flex flex-col animate-fadeIn">
|
||||
{/* Ambient Backdrop */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/60 z-10 backdrop-blur-3xl"></div>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function CreateProductViewDetailsCard({
|
|||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none min-h-24 transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none focus-visible:ring-2 focus-visible:ring-ring min-h-24 transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
placeholder="Describe your sound pack..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
|
|
@ -61,7 +61,7 @@ export function CreateProductViewDetailsCard({
|
|||
Category
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const FlashSaleModal: React.FC<FlashSaleModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const DeleteAccountConfirmModal: React.FC<
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={loading ? undefined : onClose}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function AccountSettingsPreferencesCard({
|
|||
</label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<select className="w-full bg-muted border border-border rounded-xl py-2.5 pl-10 pr-4 text-foreground text-sm focus:border-primary outline-none appearance-none cursor-pointer transition-colors duration-[var(--sumi-duration-normal)]">
|
||||
<select className="w-full bg-muted border border-border rounded-xl py-2.5 pl-10 pr-4 text-foreground text-sm focus:border-primary outline-none focus-visible:ring-2 focus-visible:ring-ring appearance-none cursor-pointer transition-colors duration-[var(--sumi-duration-normal)]">
|
||||
<option>English (US)</option>
|
||||
<option>Japanese</option>
|
||||
<option>French</option>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const DataExportModal: React.FC<DataExportModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function EditProfileIdentityCard({
|
|||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none min-h-24 transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none focus-visible:ring-2 focus-visible:ring-ring min-h-24 transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
value={formData.bio}
|
||||
onChange={(e) => setFormField('bio', e.target.value)}
|
||||
maxLength={500}
|
||||
|
|
@ -63,7 +63,7 @@ export function EditProfileIdentityCard({
|
|||
Gender
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors duration-[var(--sumi-duration-normal)]"
|
||||
value={formData.gender}
|
||||
onChange={(e) => setFormField('gender', e.target.value)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth';
|
||||
import { useToast } from '@/components/feedback/ToastProvider';
|
||||
import { userService } from '@/services/userService';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const PasskeyModal: React.FC<PasskeyModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
|||
const maxChars = 500;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export const SharePostModal: React.FC<SharePostModalProps> = ({
|
|||
|
||||
if (mode === 'quote') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
@ -86,7 +86,7 @@ export const SharePostModal: React.FC<SharePostModalProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const CreateGroupModal: React.FC<CreateGroupModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
@ -59,7 +59,7 @@ export const CreateGroupModal: React.FC<CreateGroupModalProps> = ({
|
|||
{/* Cover Upload */}
|
||||
<div className="relative w-full h-32 bg-card border-2 border-dashed border-border rounded-lg overflow-hidden flex flex-col items-center justify-center group cursor-pointer hover:border-border/50 transition-colors">
|
||||
{coverImage ? (
|
||||
<img src={coverImage} className="w-full h-full object-cover" />
|
||||
<img src={coverImage} alt="Group cover" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center group-hover:text-foreground">
|
||||
<ImageIcon className="w-8 h-8 mb-2" />
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function CreateProjectModal({ onClose, onCreate }: CreateProjectModalProp
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export const ImageCropper: React.FC<ImageCropperProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4 bg-background/95 backdrop-blur-sm">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4 bg-background/95 backdrop-blur-sm">
|
||||
<div className="relative w-full max-w-2xl bg-muted border border-border rounded-xl shadow-2xl overflow-hidden flex flex-col h-layout-modal-sm">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-card">
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export const ImageViewerModal: React.FC<ImageViewerModalProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[500] bg-black/95 backdrop-blur-xl flex items-center justify-center animate-fadeIn"
|
||||
className="fixed inset-0 z-[var(--sumi-z-popover)] bg-black/95 backdrop-blur-xl flex items-center justify-center animate-fadeIn"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt || 'Image viewer'}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function NavigationProgress() {
|
|||
<AnimatePresence>
|
||||
{isNavigating && (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 z-[400] h-0.5"
|
||||
className="fixed top-0 left-0 right-0 z-[var(--sumi-z-modal)] h-0.5"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Card } from './card';
|
||||
|
||||
export interface SidebarProps {
|
||||
/**
|
||||
* Content to display in the sidebar
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Position of the sidebar
|
||||
* @default 'left'
|
||||
*/
|
||||
position?: 'left' | 'right';
|
||||
|
||||
/**
|
||||
* Width of the sidebar when open
|
||||
* @default 'w-64'
|
||||
*/
|
||||
width?: string;
|
||||
|
||||
/**
|
||||
* Whether the sidebar is open
|
||||
* @default true
|
||||
*/
|
||||
open?: boolean;
|
||||
|
||||
/**
|
||||
* Callback when open state changes
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
||||
/**
|
||||
* Whether the sidebar is collapsible
|
||||
* @default true
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
|
||||
/**
|
||||
* Title/header for the sidebar
|
||||
*/
|
||||
title?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Optional icon to display next to the title
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Additional CSS classes for the sidebar content
|
||||
*/
|
||||
contentClassName?: string;
|
||||
|
||||
/**
|
||||
* Whether to show a backdrop on mobile when open
|
||||
* @default true
|
||||
*/
|
||||
showBackdrop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar - Reusable sidebar component for filters and content
|
||||
*
|
||||
* A generic sidebar component that can be used to display filters,
|
||||
* additional content, or any sidebar-worthy information. Supports
|
||||
* collapsible functionality and positioning.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Sidebar title="Filters" position="left" collapsible>
|
||||
* <div>Filter content here</div>
|
||||
* </Sidebar>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Sidebar
|
||||
* title="Filters"
|
||||
* icon={<Filter />}
|
||||
* position="right"
|
||||
* open={isOpen}
|
||||
* onOpenChange={setIsOpen}
|
||||
* width="w-80"
|
||||
* >
|
||||
* <FilterContent />
|
||||
* </Sidebar>
|
||||
* ```
|
||||
*/
|
||||
export function Sidebar({
|
||||
children,
|
||||
position = 'left',
|
||||
width = 'w-64',
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
collapsible = true,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
contentClassName,
|
||||
showBackdrop = true,
|
||||
}: SidebarProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(true);
|
||||
|
||||
// Use controlled state if provided, otherwise use uncontrolled
|
||||
const isOpen = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen;
|
||||
|
||||
const handleToggle = () => {
|
||||
const newOpen = !isOpen;
|
||||
if (controlledOpen === undefined) {
|
||||
setUncontrolledOpen(newOpen);
|
||||
}
|
||||
onOpenChange?.(newOpen);
|
||||
};
|
||||
|
||||
const ChevronIcon = position === 'left' ? ChevronLeft : ChevronRight;
|
||||
const isCollapsed = collapsible && !isOpen;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
{showBackdrop && isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm lg:hidden z-40"
|
||||
onClick={handleToggle}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col transition-all duration-[var(--sumi-duration-normal)] ease-in-out',
|
||||
position === 'left' ? 'border-r' : 'border-l',
|
||||
'border-white/10 bg-card/40 backdrop-blur-md rounded-xl',
|
||||
isCollapsed ? 'w-0 overflow-hidden' : width,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || collapsible) && (
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
{collapsible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleToggle}
|
||||
className="h-8 w-8"
|
||||
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
>
|
||||
<ChevronIcon className={cn(
|
||||
'w-4 h-4 transition-transform',
|
||||
isOpen && position === 'right' && 'rotate-180',
|
||||
isOpen && position === 'left' && 'rotate-180',
|
||||
)} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto custom-scrollbar',
|
||||
isCollapsed && 'hidden',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SidebarCard - Sidebar with Card styling
|
||||
*
|
||||
* A sidebar variant that wraps content in a Card for consistent styling.
|
||||
*/
|
||||
export interface SidebarCardProps extends Omit<SidebarProps, 'children'> {
|
||||
/**
|
||||
* Content to display in the sidebar card
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarCard({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
...sidebarProps
|
||||
}: SidebarCardProps) {
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn('p-0', className)}
|
||||
contentClassName={cn('p-4', contentClassName)}
|
||||
{...sidebarProps}
|
||||
>
|
||||
<Card className="border-0 bg-transparent shadow-none">
|
||||
{children}
|
||||
</Card>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,6 +32,9 @@ const buttonVariants = cva(
|
|||
ghost: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
|
||||
/** Link - styled as inline link */
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
/** Glass - frosted glass effect for overlays, player bar, floating actions */
|
||||
glass:
|
||||
'bg-[var(--sumi-glass-bg)] text-foreground backdrop-blur-[var(--sumi-glass-blur)] border border-[var(--sumi-glass-border)] hover:bg-white/15 font-medium',
|
||||
},
|
||||
size: {
|
||||
/** Default size - standard buttons */
|
||||
|
|
@ -122,7 +125,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{!loading && icon && (
|
||||
<span className="flex items-center justify-center pointer-events-none">
|
||||
<span className="flex items-center justify-center pointer-events-none" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ const cardVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
/** Shadows wired to SUMI tokens via @theme — auto dark/light intensity */
|
||||
default:
|
||||
'bg-card border border-border shadow-card hover:shadow-card-hover',
|
||||
'bg-card border border-border shadow-card hover:shadow-card-hover hover:bg-card/95',
|
||||
|
||||
elevated:
|
||||
'bg-card border border-border shadow-lg hover:shadow-xl',
|
||||
'bg-card border border-border shadow-lg hover:shadow-xl hover:bg-card/95',
|
||||
|
||||
ghost:
|
||||
'bg-transparent border-0',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* @deprecated S1.4: Prefer using `Dialog` from `@/components/ui/dialog/` for new code.
|
||||
* This modal component is kept for backward compatibility with existing consumers.
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
|
@ -79,7 +84,7 @@ export function Modal({
|
|||
{open && (
|
||||
<motion.div
|
||||
key="modal"
|
||||
className="fixed inset-0 z-[400] flex items-center justify-center p-4"
|
||||
className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4"
|
||||
onClick={handleOverlayClick}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const BulkUploadModal: React.FC<BulkUploadModalProps> = ({
|
|||
const completedCount = files.filter((f) => f.status === 'completed').length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export const CoverArtUploadModal: React.FC<CoverArtUploadModalProps> = ({
|
|||
<div className="p-8 text-center">
|
||||
<div className="w-32 h-32 bg-muted border-2 border-dashed border-border rounded-xl mx-auto mb-6 flex items-center justify-center relative overflow-hidden group">
|
||||
{currentImage ? (
|
||||
<img src={currentImage} className="w-full h-full object-cover" />
|
||||
<img src={currentImage} alt="Cover art preview" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-8 h-8 text-muted-foreground" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth';
|
||||
import type { AuthStep } from './types';
|
||||
|
||||
export function useAuthView(initialStep: AuthStep = 'LOGIN') {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
} from '@/features/user/components/profile/view';
|
||||
import type { ProfileViewTab, ProfileViewMode } from '@/features/user/components/profile/view';
|
||||
import { useToast } from '@/components/feedback/ToastProvider';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth';
|
||||
import { useProfileViewData } from './useProfileViewData';
|
||||
import { ProfileViewSkeleton } from './ProfileViewSkeleton';
|
||||
import { ProfileViewSidebar } from './ProfileViewSidebar';
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { User } from '../types';
|
||||
import { authService } from '../services/authService';
|
||||
import { useToast } from '@/components/feedback/ToastProvider';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { parseApiError } from '@/utils/apiErrorHandler';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (credentials: any) => Promise<void>;
|
||||
register: (data: any) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// SECURITY: Action 5.1.1.2 - Tokens are in httpOnly cookies, not localStorage
|
||||
// Just call getCurrentUser - if it works, user is authenticated
|
||||
const userData = await authService.getCurrentUser();
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
logger.error('Auth check failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
// Tokens are in httpOnly cookies, cleared by backend on logout
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials: any) => {
|
||||
try {
|
||||
// SECURITY: Action 5.1.1.2 - Tokens are set in httpOnly cookies by backend
|
||||
const { user } = await authService.login(credentials);
|
||||
setUser(user);
|
||||
addToast('Welcome back!', 'success');
|
||||
} catch (error: unknown) {
|
||||
const apiError = parseApiError(error);
|
||||
addToast(apiError.message || 'Login failed', 'error');
|
||||
throw apiError;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (data: any) => {
|
||||
try {
|
||||
// SECURITY: Action 5.1.1.2 - Tokens are set in httpOnly cookies by backend
|
||||
const { user } = await authService.register(data);
|
||||
setUser(user);
|
||||
addToast('Account created successfully', 'success');
|
||||
} catch (error: unknown) {
|
||||
const apiError = parseApiError(error);
|
||||
addToast(apiError.message || 'Registration failed', 'error');
|
||||
throw apiError;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
// SECURITY: Action 5.1.1.2 - Backend clears httpOnly cookies on logout
|
||||
await authService.logout();
|
||||
} catch (e) {
|
||||
logger.error('Logout error', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
});
|
||||
}
|
||||
// Backend clears cookies, just clear local state
|
||||
setUser(null);
|
||||
addToast('Logged out', 'info');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -132,9 +132,9 @@ export const ChatInput: React.FC = () => {
|
|||
{/* Attachments Preview */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 p-4 bg-background/90 backdrop-blur-xl border-t border-white/10 flex gap-2 overflow-x-auto">
|
||||
{attachments.map((att, i) => (
|
||||
{attachments.map((att) => (
|
||||
<div
|
||||
key={i}
|
||||
key={att.id || att.file_name}
|
||||
className="relative group flex items-center gap-2 p-2 bg-white/5 rounded-lg border border-white/10 text-xs text-foreground min-w-36"
|
||||
>
|
||||
{att.file_type.startsWith('image') ? (
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export interface ConversationItemProps {
|
|||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export function ConversationItem({
|
||||
function ConversationItemInner({
|
||||
conversation,
|
||||
onSelect,
|
||||
isSelected,
|
||||
|
|
@ -223,3 +223,6 @@ export function ConversationItem({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConversationItem = React.memo(ConversationItemInner);
|
||||
ConversationItem.displayName = 'ConversationItem';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { memo } from 'react';
|
||||
import type { Message } from '@/types/api';
|
||||
import { sanitizeChatMessage } from '@/utils/sanitize';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
|
@ -11,7 +12,7 @@ interface VirtualizedChatMessageItemProps {
|
|||
onMessageClick?: (message: Message) => void;
|
||||
}
|
||||
|
||||
export function VirtualizedChatMessageItem({
|
||||
function VirtualizedChatMessageItemInner({
|
||||
message,
|
||||
index,
|
||||
onMessageClick,
|
||||
|
|
@ -71,3 +72,6 @@ export function VirtualizedChatMessageItem({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const VirtualizedChatMessageItem = memo(VirtualizedChatMessageItemInner);
|
||||
VirtualizedChatMessageItem.displayName = 'VirtualizedChatMessageItem';
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function GearCard({ item, onClick, className }: GearCardProps) {
|
|||
<div className="flex gap-4 mb-4">
|
||||
<div className="w-24 h-24 bg-muted rounded-lg border border-border overflow-hidden flex-shrink-0">
|
||||
{item.image ? (
|
||||
<img src={item.image} className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt="" />
|
||||
<img src={item.image} loading="lazy" className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">No image</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function GearDetailModal({
|
|||
<div className="flex gap-8">
|
||||
<div className="w-32 h-32 bg-muted rounded-lg overflow-hidden border border-border shrink-0">
|
||||
{item.image ? (
|
||||
<img src={item.image} className="w-full h-full object-cover" alt="" />
|
||||
<img src={item.image} loading="lazy" className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">No image</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function MiniPlayer({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-0 right-0 z-[200] bg-[var(--sumi-glass-bg)] backdrop-blur-[16px] border-t border-[var(--sumi-border-faint)] shadow-[var(--sumi-shadow-lg)]',
|
||||
'fixed left-0 right-0 z-[var(--sumi-z-sticky)] bg-[var(--sumi-glass-bg)] backdrop-blur-[16px] border-t border-[var(--sumi-border-faint)] shadow-[var(--sumi-shadow-lg)]',
|
||||
'transition-transform duration-[var(--sumi-duration-normal)] ease-in-out',
|
||||
position === 'bottom' ? 'bottom-0' : 'top-0',
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
|
|||
|
||||
return (
|
||||
<div className={cn(
|
||||
"fixed inset-0 z-[500] bg-black/95 backdrop-blur-3xl overflow-hidden flex flex-col transition-all duration-[var(--sumi-duration-slow)]",
|
||||
"fixed inset-0 z-[var(--sumi-z-popover)] bg-black/95 backdrop-blur-3xl overflow-hidden flex flex-col transition-all duration-[var(--sumi-duration-slow)]",
|
||||
isOpen ? "opacity-100 translate-y-0" : "opacity-0 translate-y-full pointer-events-none"
|
||||
)}>
|
||||
{/* Dynamic Background */}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||
import { apiClient } from '@/services/api/client';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { safeNavigate } from '@/utils/safeNavigate';
|
||||
|
||||
export interface PlaylistNotification {
|
||||
id: string;
|
||||
|
|
@ -200,7 +201,7 @@ function getToastConfig(notification: PlaylistNotification): {
|
|||
? {
|
||||
label: 'Voir',
|
||||
onClick: () => {
|
||||
window.location.href = notification.link!;
|
||||
safeNavigate(notification.link!);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
|
@ -216,7 +217,7 @@ function getToastConfig(notification: PlaylistNotification): {
|
|||
? {
|
||||
label: 'Voir',
|
||||
onClick: () => {
|
||||
window.location.href = notification.link!;
|
||||
safeNavigate(notification.link!);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
|
@ -232,7 +233,7 @@ function getToastConfig(notification: PlaylistNotification): {
|
|||
? {
|
||||
label: 'Voir',
|
||||
onClick: () => {
|
||||
window.location.href = notification.link!;
|
||||
safeNavigate(notification.link!);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
|
@ -248,7 +249,7 @@ function getToastConfig(notification: PlaylistNotification): {
|
|||
? {
|
||||
label: 'Voir',
|
||||
onClick: () => {
|
||||
window.location.href = notification.link!;
|
||||
safeNavigate(notification.link!);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,16 @@ interface SearchPageResultsProps {
|
|||
export function SearchPageResults({ results, query = '' }: SearchPageResultsProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const totalResults =
|
||||
(results.tracks?.length || 0) +
|
||||
(results.users?.length || 0) +
|
||||
(results.playlists?.length || 0);
|
||||
|
||||
return (
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
<div role="status" className="sr-only">
|
||||
{totalResults} result{totalResults !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<TabsList className="bg-transparent border-b border-white/10 w-full justify-start h-auto p-0 gap-8 mb-8">
|
||||
<TabsTrigger
|
||||
|
|
@ -214,5 +223,6 @@ export function SearchPageResults({ results, query = '' }: SearchPageResultsProp
|
|||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function SearchDropdown({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-[500] mt-2 w-full rounded-md border bg-popover shadow-lg',
|
||||
'absolute z-[var(--sumi-z-popover)] mt-2 w-full rounded-md border bg-popover shadow-lg',
|
||||
className,
|
||||
)}
|
||||
role="listbox"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function PlaybackHeatmapGrid({ heatmap }: PlaybackHeatmapGridProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={`${segment.start_time}-${segment.end_time}`}
|
||||
className={cn(
|
||||
'h-8 rounded transition-all cursor-pointer relative group',
|
||||
intensityColor,
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ export function FileToolbar({
|
|||
Sort:
|
||||
</span>
|
||||
<select
|
||||
className="bg-transparent text-xs text-foreground outline-none"
|
||||
className="bg-transparent text-xs text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={sortField}
|
||||
onChange={(e) => onSortFieldChange(e.target.value as SortField)}
|
||||
aria-label="Sort by"
|
||||
|
|
|
|||
|
|
@ -188,10 +188,11 @@ export function LikeButton({
|
|||
<Heart
|
||||
className={cn(
|
||||
'h-4 w-4 transition-colors duration-[var(--sumi-duration-normal)]',
|
||||
animating && 'animate-like-bounce',
|
||||
animating && 'animate-like-bounce drop-shadow-[0_0_8px_var(--sumi-vermillion)]',
|
||||
isLiked && 'fill-current',
|
||||
showCount && 'mr-2',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{showCount && (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export interface TrackListRowProps {
|
|||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export function TrackListRow({
|
||||
function TrackListRowInner({
|
||||
track,
|
||||
showMetadata = true,
|
||||
showCover = true,
|
||||
|
|
@ -318,3 +318,6 @@ export function TrackListRow({
|
|||
listResult
|
||||
);
|
||||
}
|
||||
|
||||
export const TrackListRow = React.memo(TrackListRowInner);
|
||||
TrackListRow.displayName = 'TrackListRow';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useToastContext } from '@/components/feedback/ToastProvider';
|
||||
import toast from '@/utils/toast';
|
||||
import type { UseToastReturn } from './types';
|
||||
|
||||
export interface Toast {
|
||||
|
|
@ -10,20 +10,34 @@ export interface Toast {
|
|||
|
||||
/**
|
||||
* Hook pour afficher des toasts.
|
||||
* S1.2: Unified toast — delegates to react-hot-toast via @/utils/toast
|
||||
* FE-TYPE-012: Fully typed hook return
|
||||
*/
|
||||
export function useToast(): UseToastReturn {
|
||||
const { addToast } = useToastContext();
|
||||
|
||||
return {
|
||||
success: (message: string, duration?: number) =>
|
||||
addToast({ message, type: 'success', duration }),
|
||||
toast.success(message, { duration }),
|
||||
error: (message: string, duration?: number) =>
|
||||
addToast({ message, type: 'error', duration }),
|
||||
toast.error(message, { duration }),
|
||||
warning: (message: string, duration?: number) =>
|
||||
addToast({ message, type: 'warning', duration }),
|
||||
toast(message, { icon: '⚠️', duration }),
|
||||
info: (message: string, duration?: number) =>
|
||||
addToast({ message, type: 'info', duration }),
|
||||
toast: (toast: Omit<Toast, 'id'>) => addToast(toast),
|
||||
toast(message, { icon: 'ℹ️', duration }),
|
||||
toast: (t: Omit<Toast, 'id'>) => {
|
||||
const opts = { duration: t.duration };
|
||||
switch (t.type) {
|
||||
case 'success':
|
||||
toast.success(t.message, opts);
|
||||
break;
|
||||
case 'error':
|
||||
toast.error(t.message, opts);
|
||||
break;
|
||||
case 'warning':
|
||||
toast(t.message, { ...opts, icon: '⚠️' });
|
||||
break;
|
||||
default:
|
||||
toast(t.message, opts);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -419,6 +419,17 @@
|
|||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Shadows — wired to SUMI tokens (auto dark/light) */
|
||||
--shadow-xs: var(--sumi-shadow-xs);
|
||||
--shadow-sm: var(--sumi-shadow-sm);
|
||||
--shadow-md: var(--sumi-shadow-md);
|
||||
--shadow-lg: var(--sumi-shadow-lg);
|
||||
--shadow-xl: var(--sumi-shadow-xl);
|
||||
--shadow-2xl: var(--sumi-shadow-2xl);
|
||||
/* Card-specific shadows */
|
||||
--shadow-card: var(--sumi-shadow-sm);
|
||||
--shadow-card-hover: var(--sumi-shadow-md);
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: var(--sumi-radius-sm);
|
||||
--radius-md: var(--sumi-radius-md);
|
||||
|
|
@ -734,6 +745,13 @@
|
|||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* S5.1: Branded loading progress bar */
|
||||
@keyframes loading-progress {
|
||||
0% { width: 0; transform: translateX(0); }
|
||||
50% { width: 70%; transform: translateX(0); }
|
||||
100% { width: 100%; transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes sumi-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
|
|
|
|||
|
|
@ -449,5 +449,43 @@
|
|||
"unmute": "Unmute",
|
||||
"showQueue": "Show queue",
|
||||
"hideQueue": "Hide queue"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "What do you want to play?",
|
||||
"searchAriaLabel": "Search tracks, artists, playlists",
|
||||
"online": "Online",
|
||||
"profile": "Profile",
|
||||
"signOut": "Sign Out"
|
||||
},
|
||||
"nav": {
|
||||
"sections": {
|
||||
"myStudio": "My Studio",
|
||||
"vezaNetwork": "Veza Network",
|
||||
"commerce": "Commerce",
|
||||
"library": "Library",
|
||||
"system": "System"
|
||||
},
|
||||
"items": {
|
||||
"dashboard": "Command Center",
|
||||
"studio": "Cloud Files",
|
||||
"tracks": "Projects",
|
||||
"gear": "Gear Locker",
|
||||
"analytics": "Performance",
|
||||
"social": "Community Feed",
|
||||
"marketplace": "Marketplace",
|
||||
"live": "Live Sessions",
|
||||
"chat": "Channels",
|
||||
"education": "Academy",
|
||||
"sell": "Seller Dashboard",
|
||||
"wishlist": "Wishlist",
|
||||
"purchases": "Purchases",
|
||||
"playlists": "Playlists",
|
||||
"queue": "Play Queue",
|
||||
"developer": "Developer API",
|
||||
"admin": "Admin Panel"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
"skipToContent": "Skip to content"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,5 +449,43 @@
|
|||
"unmute": "Activer le son",
|
||||
"showQueue": "Afficher la file d'attente",
|
||||
"hideQueue": "Masquer la file d'attente"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Que voulez-vous écouter ?",
|
||||
"searchAriaLabel": "Rechercher des pistes, artistes, playlists",
|
||||
"online": "En ligne",
|
||||
"profile": "Profil",
|
||||
"signOut": "Déconnexion"
|
||||
},
|
||||
"nav": {
|
||||
"sections": {
|
||||
"myStudio": "Mon Studio",
|
||||
"vezaNetwork": "Réseau Veza",
|
||||
"commerce": "Commerce",
|
||||
"library": "Bibliothèque",
|
||||
"system": "Système"
|
||||
},
|
||||
"items": {
|
||||
"dashboard": "Centre de contrôle",
|
||||
"studio": "Fichiers Cloud",
|
||||
"tracks": "Projets",
|
||||
"gear": "Arsenal",
|
||||
"analytics": "Performances",
|
||||
"social": "Communauté",
|
||||
"marketplace": "Marketplace",
|
||||
"live": "Sessions Live",
|
||||
"chat": "Canaux",
|
||||
"education": "Académie",
|
||||
"sell": "Tableau vendeur",
|
||||
"wishlist": "Liste de souhaits",
|
||||
"purchases": "Achats",
|
||||
"playlists": "Playlists",
|
||||
"queue": "File de lecture",
|
||||
"developer": "API Développeur",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"settings": "Paramètres",
|
||||
"logout": "Déconnexion",
|
||||
"skipToContent": "Aller au contenu"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,387 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Login } from './Login';
|
||||
import * as authApi from '@/services/api/auth';
|
||||
import { TokenStorage } from '@/services/tokenStorage';
|
||||
import * as useToastHook from '@/hooks/useToast';
|
||||
|
||||
// Mock ResizeObserver for Radix UI components
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/services/api/auth');
|
||||
vi.mock('@/services/tokenStorage', () => ({
|
||||
TokenStorage: {
|
||||
setTokens: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/hooks/useToast');
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
const mockLogin = vi.mocked(authApi.login);
|
||||
const mockSetTokens = vi.mocked(TokenStorage.setTokens);
|
||||
const mockSuccess = vi.fn();
|
||||
const mockErrorToast = vi.fn();
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
|
||||
describe('Login', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockNavigate.mockClear();
|
||||
vi.mocked(useToastHook.useToast).mockReturnValue({
|
||||
success: mockSuccess,
|
||||
error: mockErrorToast,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render login form', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/sign in/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle successful login', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockLogin.mockResolvedValue({
|
||||
user: { id: 1, email: 'test@example.com' },
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 900,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
remember_me: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockSetTokens).toHaveBeenCalledWith('access-token', 'refresh-token');
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Login successful! Welcome back.');
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
|
||||
// T0178: Test pour affichage message session expirée
|
||||
it('should display session expired message from sessionStorage', () => {
|
||||
const expiredMessage = 'Your session has expired. Please log in again.';
|
||||
sessionStorage.setItem('auth_error', expiredMessage);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(expiredMessage)).toBeInTheDocument();
|
||||
expect(sessionStorage.getItem('auth_error')).toBeNull(); // Should be cleared after reading
|
||||
});
|
||||
|
||||
describe('T0170: Error Handling', () => {
|
||||
it('should display error message for invalid credentials (401)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const apiError = {
|
||||
message: 'Invalid credentials',
|
||||
code: '401',
|
||||
};
|
||||
mockLogin.mockRejectedValue(apiError);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/invalid email or password/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockErrorToast).toHaveBeenCalledWith(
|
||||
'Invalid email or password. Please check your credentials and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display error message for invalid credentials (403)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const apiError = {
|
||||
message: 'Forbidden',
|
||||
code: '403',
|
||||
};
|
||||
mockLogin.mockRejectedValue(apiError);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/invalid email or password/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display error message for network error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const networkError = {
|
||||
message: 'Network error',
|
||||
code: 'NETWORK_ERROR',
|
||||
};
|
||||
mockLogin.mockRejectedValue(networkError);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/unable to connect to the server/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockErrorToast).toHaveBeenCalledWith(
|
||||
'Unable to connect to the server. Please check your internet connection and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display error message for server error (500)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const serverError = {
|
||||
message: 'Internal server error',
|
||||
code: '500',
|
||||
};
|
||||
mockLogin.mockRejectedValue(serverError);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/server error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockErrorToast).toHaveBeenCalledWith(
|
||||
'Server error. Please try again later.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display error message for rate limiting (429)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const rateLimitError = {
|
||||
message: 'Too many requests',
|
||||
code: '429',
|
||||
};
|
||||
mockLogin.mockRejectedValue(rateLimitError);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/too many login attempts/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockErrorToast).toHaveBeenCalledWith(
|
||||
'Too many login attempts. Please wait a moment and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display error message for invalid request (400)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const badRequestError = {
|
||||
message: 'Invalid request',
|
||||
code: '400',
|
||||
};
|
||||
mockLogin.mockRejectedValue(badRequestError);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid request/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display generic error message for unknown errors', async () => {
|
||||
const user = userEvent.setup();
|
||||
const unknownError = {
|
||||
message: 'Something went wrong',
|
||||
code: 'UNKNOWN_ERROR',
|
||||
};
|
||||
mockLogin.mockRejectedValue(unknownError);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear error message on new submission attempt', async () => {
|
||||
const user = userEvent.setup();
|
||||
const apiError = {
|
||||
message: 'Invalid credentials',
|
||||
code: '401',
|
||||
};
|
||||
|
||||
mockLogin.mockRejectedValueOnce(apiError);
|
||||
mockLogin.mockResolvedValueOnce({
|
||||
user: { id: 1, email: 'test@example.com' },
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 900,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Login />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
// First attempt - fails
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/invalid email or password/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Second attempt - succeeds
|
||||
await user.clear(passwordInput);
|
||||
await user.type(passwordInput, 'correctpassword');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/invalid email or password/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LoginForm, LoginFormData } from '@/components/forms/LoginForm';
|
||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||
import { formatErrorMessage, parseApiError } from '@/utils/apiErrorHandler';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { ApiError } from '@/schemas/apiSchemas';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
// T0166: Page de connexion qui utilise le composant LoginForm
|
||||
// T0167: Intègre l'API de connexion avec support remember_me
|
||||
// T0170: Gestion d'erreurs améliorée avec messages spécifiques
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { success, error: showErrorToast } = useToast();
|
||||
const { login: loginStore, isLoading, error: storeError } = useAuthStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// T0178: Vérifier s'il y a un message d'erreur stocké (session expirée)
|
||||
useEffect(() => {
|
||||
const authError = sessionStorage.getItem('auth_error');
|
||||
if (authError) {
|
||||
setError(authError);
|
||||
showErrorToast(authError);
|
||||
sessionStorage.removeItem('auth_error');
|
||||
}
|
||||
}, [showErrorToast]);
|
||||
|
||||
const handleSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Le store gère déjà le login et le stockage des tokens
|
||||
await loginStore({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
remember_me: data.remember_me,
|
||||
});
|
||||
|
||||
success('Login successful! Welcome back.');
|
||||
navigate('/dashboard');
|
||||
} catch (err: unknown) {
|
||||
// Gestion d'erreurs avec formatErrorMessage
|
||||
const apiError = parseApiError(err);
|
||||
const errorMessage = formatErrorMessage(apiError);
|
||||
setError(errorMessage);
|
||||
showErrorToast(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4 relative z-10">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-center">Sign in</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your email and password to access your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(error || storeError) && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>
|
||||
{error || formatErrorMessage(storeError as ApiError)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<LoginForm onSubmit={handleSubmit} disabled={isLoading} />
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Register } from './Register';
|
||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
// Mock useAuthStore
|
||||
const mockRegister = vi.fn();
|
||||
vi.mock('@/features/auth/store/authStore', () => ({
|
||||
useAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useToast
|
||||
const mockShowSuccess = vi.fn();
|
||||
const mockShowError = vi.fn();
|
||||
vi.mock('@/hooks/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: mockShowSuccess,
|
||||
error: mockShowError,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Register', () => {
|
||||
const mockUseAuthStore = vi.mocked(useAuthStore);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockShowSuccess.mockClear();
|
||||
mockShowError.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
mockRegister.mockClear();
|
||||
mockUseAuthStore.mockReturnValue({
|
||||
register: mockRegister,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should render the registration form', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Create an account')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /register/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display error message when registration fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockRegister.mockRejectedValue({
|
||||
message: 'Email already exists',
|
||||
code: '409',
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/^email$/i);
|
||||
const usernameInput = screen.getByLabelText(/username/i);
|
||||
const passwordInput = screen.getByLabelText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(usernameInput, 'testuser');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'SecurePass123!');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/email already exists/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have email input field', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
});
|
||||
|
||||
it('should validate password minimum length', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/^password$/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'short');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/password must be at least 12 characters/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have password confirmation input field', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
||||
expect(confirmPasswordInput).toBeInTheDocument();
|
||||
expect(confirmPasswordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('should submit form with valid data and redirect to dashboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
},
|
||||
token: {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 900,
|
||||
},
|
||||
};
|
||||
mockRegister.mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/^email$/i);
|
||||
const usernameInput = screen.getByLabelText(/username/i);
|
||||
const passwordInput = screen.getByLabelText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(usernameInput, 'testuser');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'SecurePass123!');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
password: 'SecurePass123!',
|
||||
password_confirm: 'SecurePass123!',
|
||||
});
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith(
|
||||
'Registration successful! Welcome to Veza.',
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading state during submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveRegister: (value: any) => void;
|
||||
const registerPromise = new Promise<any>((resolve) => {
|
||||
resolveRegister = resolve;
|
||||
});
|
||||
mockRegister.mockReturnValue(registerPromise);
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const usernameInput = screen.getByLabelText(/username/i);
|
||||
const passwordInput = screen.getByLabelText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(usernameInput, 'testuser');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'SecurePass123!');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/registering.../i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
resolveRegister!({
|
||||
user: { id: 1, email: 'test@example.com' },
|
||||
token: {
|
||||
access_token: 'token',
|
||||
refresh_token: 'refresh',
|
||||
expires_in: 900,
|
||||
},
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/registering.../i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display link to login page', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const loginLink = screen.getByRole('link', { name: /sign in/i });
|
||||
expect(loginLink).toBeInTheDocument();
|
||||
expect(loginLink).toHaveAttribute('href', '/login');
|
||||
});
|
||||
|
||||
it('should show real-time email validation indicator', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/^email$/i);
|
||||
|
||||
// Type invalid email
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for invalid indicator - either error message or aria-invalid
|
||||
const errorMessage = screen.queryByText(/invalid email/i);
|
||||
const hasInvalidAria = emailInput.getAttribute('aria-invalid') === 'true';
|
||||
expect(errorMessage || hasInvalidAria).toBeTruthy();
|
||||
});
|
||||
|
||||
// Clear and type valid email
|
||||
await user.clear(emailInput);
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that validation passes - aria-invalid should be false or not set
|
||||
const ariaInvalid = emailInput.getAttribute('aria-invalid');
|
||||
expect(ariaInvalid === 'false' || ariaInvalid === null).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show email validation error message in real-time', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/^email$/i);
|
||||
|
||||
// Type invalid email - need to type enough characters to trigger validation
|
||||
await user.type(emailInput, 'invalid@');
|
||||
|
||||
// Wait for validation to run after typing
|
||||
await waitFor(
|
||||
() => {
|
||||
// Check for either the specific error message or aria-invalid attribute
|
||||
const errorMessage = screen.queryByText(/invalid email/i);
|
||||
const hasInvalidAria =
|
||||
emailInput.getAttribute('aria-invalid') === 'true';
|
||||
expect(errorMessage || hasInvalidAria).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error toast when registration fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockRegister.mockRejectedValue({
|
||||
message: 'Network error',
|
||||
code: 'NETWORK_ERROR',
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/^email$/i);
|
||||
const usernameInput = screen.getByLabelText(/username/i);
|
||||
const passwordInput = screen.getByLabelText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(usernameInput, 'testuser');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'SecurePass123!');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowError).toHaveBeenCalledWith('Network error');
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should store tokens and show success message on successful registration', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockRegister.mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Register />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/^email$/i);
|
||||
const usernameInput = screen.getByLabelText(/username/i);
|
||||
const passwordInput = screen.getByLabelText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
await user.type(emailInput, 'newuser@example.com');
|
||||
await user.type(usernameInput, 'newuser');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'SecurePass123!');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalled();
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith(
|
||||
'Registration successful! Welcome to Veza.',
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
RegisterForm,
|
||||
RegisterFormData,
|
||||
} from '@/components/forms/RegisterForm';
|
||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||
import { formatErrorMessage, parseApiError } from '@/utils/apiErrorHandler';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { ApiError } from '@/schemas/apiSchemas';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Register() {
|
||||
const navigate = useNavigate();
|
||||
const { success, error: showToastError } = useToast();
|
||||
const { register: registerStore, error: storeError } = useAuthStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Le store gère déjà le register et le stockage des tokens
|
||||
await registerStore({
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
password_confirm: data.passwordConfirm,
|
||||
});
|
||||
|
||||
// Show success message
|
||||
success('Registration successful! Welcome to Veza.');
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/dashboard');
|
||||
} catch (err: unknown) {
|
||||
// Handle error avec formatErrorMessage
|
||||
const apiError = parseApiError(err);
|
||||
const errorMessage = formatErrorMessage(apiError);
|
||||
setError(errorMessage);
|
||||
showToastError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-center">
|
||||
Create an account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your information to create your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(error || storeError) && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>
|
||||
{error || formatErrorMessage(storeError as ApiError)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<RegisterForm onSubmit={handleSubmit} />
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
165
apps/web/src/services/api/helpers.ts
Normal file
165
apps/web/src/services/api/helpers.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* S1.1: API helper utilities
|
||||
* Extracted from client.ts — Cancellable requests, deduplication, utilities
|
||||
*/
|
||||
|
||||
import axios, { InternalAxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { requestDeduplication } from '../requestDeduplication';
|
||||
import { responseCache } from '../responseCache';
|
||||
import { apiClient } from './httpClient';
|
||||
|
||||
/**
|
||||
* Edge 2.2: Create a cancellable request with AbortController support.
|
||||
*/
|
||||
export function createCancellableRequest<T>(
|
||||
requestFn: (signal: AbortSignal) => Promise<T>,
|
||||
): { request: Promise<T>; abort: () => void } {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const request = requestFn(signal).catch((error) => {
|
||||
if (
|
||||
axios.isCancel(error) ||
|
||||
error.name === 'AbortError' ||
|
||||
signal.aborted
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
return {
|
||||
request,
|
||||
abort: () => {
|
||||
if (!signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge 2.2: Create a request with automatic timeout cancellation.
|
||||
*/
|
||||
export function createRequestWithTimeout<T>(
|
||||
requestFn: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
): { request: Promise<T>; abort: () => void } {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const request = requestFn(signal)
|
||||
.catch((error) => {
|
||||
if (
|
||||
axios.isCancel(error) ||
|
||||
error.name === 'AbortError' ||
|
||||
signal.aborted
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
return {
|
||||
request,
|
||||
abort: () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FE-API-016/017: Enhanced API client with deduplication and caching
|
||||
*/
|
||||
export const deduplicatedApiClient = {
|
||||
get: <T = any>(url: string, config?: InternalAxiosRequestConfig) => {
|
||||
if (!(config as any)?._disableCache) {
|
||||
const cachedResponse = responseCache.get({
|
||||
...config,
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
if (cachedResponse) {
|
||||
logger.debug(`[API] Using cached response for: ${url}`);
|
||||
return Promise.resolve(cachedResponse as AxiosResponse<T>);
|
||||
}
|
||||
}
|
||||
return requestDeduplication.getOrCreateRequest(
|
||||
{ ...config, method: 'GET', url },
|
||||
() => apiClient.get<T>(url, config),
|
||||
);
|
||||
},
|
||||
|
||||
post: <T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
) => {
|
||||
return requestDeduplication.getOrCreateRequest(
|
||||
{ ...config, method: 'POST', url, data },
|
||||
() => apiClient.post<T>(url, data, config),
|
||||
);
|
||||
},
|
||||
|
||||
put: <T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
) => {
|
||||
return requestDeduplication.getOrCreateRequest(
|
||||
{ ...config, method: 'PUT', url, data },
|
||||
() => apiClient.put<T>(url, data, config),
|
||||
);
|
||||
},
|
||||
|
||||
patch: <T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
) => {
|
||||
return requestDeduplication.getOrCreateRequest(
|
||||
{ ...config, method: 'PATCH', url, data },
|
||||
() => apiClient.patch<T>(url, data, config),
|
||||
);
|
||||
},
|
||||
|
||||
delete: <T = any>(url: string, config?: InternalAxiosRequestConfig) => {
|
||||
return requestDeduplication.getOrCreateRequest(
|
||||
{ ...config, method: 'DELETE', url },
|
||||
() => apiClient.delete<T>(url, config),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Edge 2.3: Check if a request is slow
|
||||
*/
|
||||
export function isSlowRequest(
|
||||
config?: InternalAxiosRequestConfig,
|
||||
): boolean {
|
||||
if (!config) return false;
|
||||
return (config as any)?._isSlowRequest === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge 2.3: Get request duration in milliseconds
|
||||
*/
|
||||
export function getRequestDuration(
|
||||
config?: InternalAxiosRequestConfig,
|
||||
): number | undefined {
|
||||
if (!config) return undefined;
|
||||
return (config as any)?._requestDuration;
|
||||
}
|
||||
28
apps/web/src/services/api/httpClient.ts
Normal file
28
apps/web/src/services/api/httpClient.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* S1.1: Core HTTP client instance
|
||||
* Extracted from client.ts — Axios instance creation, config, constants
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { env } from '@/config/env';
|
||||
|
||||
// INT-API-004: Timeout configurations per endpoint type
|
||||
export const API_TIMEOUTS = {
|
||||
DEFAULT: 10000,
|
||||
UPLOAD: 300000,
|
||||
LONG_POLLING: 30000,
|
||||
} as const;
|
||||
|
||||
// Edge 2.3: Slow request detection threshold
|
||||
export const SLOW_REQUEST_THRESHOLD = 1000;
|
||||
|
||||
// Client API réutilisable
|
||||
export const apiClient = axios.create({
|
||||
baseURL: env.API_URL,
|
||||
timeout: API_TIMEOUTS.DEFAULT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// SECURITY: Activer withCredentials pour envoyer les cookies httpOnly automatiquement
|
||||
withCredentials: true,
|
||||
});
|
||||
1203
apps/web/src/services/api/interceptors.ts
Normal file
1203
apps/web/src/services/api/interceptors.ts
Normal file
File diff suppressed because it is too large
Load diff
178
apps/web/src/services/api/metrics.ts
Normal file
178
apps/web/src/services/api/metrics.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* S1.1: Validation error metrics tracker
|
||||
* Extracted from client.ts — Tracks validation failure rate for monitoring
|
||||
*/
|
||||
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
export interface ValidationMetrics {
|
||||
totalValidations: number;
|
||||
successfulValidations: number;
|
||||
failedValidations: number;
|
||||
failureRate: number; // percentage
|
||||
lastFailureTime?: string;
|
||||
lastSuccessTime?: string;
|
||||
failuresByEndpoint: Record<string, number>;
|
||||
}
|
||||
|
||||
export class ValidationMetricsTracker {
|
||||
private metrics: ValidationMetrics = {
|
||||
totalValidations: 0,
|
||||
successfulValidations: 0,
|
||||
failedValidations: 0,
|
||||
failureRate: 0,
|
||||
failuresByEndpoint: {},
|
||||
};
|
||||
|
||||
recordSuccess(_url?: string): void {
|
||||
this.metrics.totalValidations++;
|
||||
this.metrics.successfulValidations++;
|
||||
this.metrics.lastSuccessTime = new Date().toISOString();
|
||||
this.updateFailureRate();
|
||||
}
|
||||
|
||||
recordFailure(url?: string): void {
|
||||
this.metrics.totalValidations++;
|
||||
this.metrics.failedValidations++;
|
||||
this.metrics.lastFailureTime = new Date().toISOString();
|
||||
|
||||
if (url) {
|
||||
const endpoint = this.normalizeEndpoint(url);
|
||||
this.metrics.failuresByEndpoint[endpoint] =
|
||||
(this.metrics.failuresByEndpoint[endpoint] || 0) + 1;
|
||||
}
|
||||
|
||||
this.updateFailureRate();
|
||||
}
|
||||
|
||||
private updateFailureRate(): void {
|
||||
if (this.metrics.totalValidations > 0) {
|
||||
this.metrics.failureRate =
|
||||
(this.metrics.failedValidations / this.metrics.totalValidations) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeEndpoint(url?: string): string {
|
||||
if (!url) return 'unknown';
|
||||
try {
|
||||
const urlObj = new URL(url, 'http://localhost');
|
||||
const path = urlObj.pathname;
|
||||
return path
|
||||
.replace(
|
||||
/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
|
||||
'/:id',
|
||||
)
|
||||
.replace(/\/\d+/g, '/:id');
|
||||
} catch {
|
||||
const path = url.split('?')[0];
|
||||
return path || 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
getMetrics(): ValidationMetrics {
|
||||
return { ...this.metrics };
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.metrics = {
|
||||
totalValidations: 0,
|
||||
successfulValidations: 0,
|
||||
failedValidations: 0,
|
||||
failureRate: 0,
|
||||
failuresByEndpoint: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const validationMetrics = new ValidationMetricsTracker();
|
||||
|
||||
// --- Validation Alerting ---
|
||||
|
||||
interface ValidationAlertConfig {
|
||||
failureRateThreshold: number;
|
||||
minValidationsForAlert: number;
|
||||
checkInterval: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ALERT_CONFIG: ValidationAlertConfig = {
|
||||
failureRateThreshold: 5.0,
|
||||
minValidationsForAlert: 10,
|
||||
checkInterval: 5 * 60 * 1000,
|
||||
};
|
||||
|
||||
class ValidationAlerting {
|
||||
private config: ValidationAlertConfig = DEFAULT_ALERT_CONFIG;
|
||||
private checkIntervalId: NodeJS.Timeout | null = null;
|
||||
private lastAlertTime: number = 0;
|
||||
private alertCooldown: number = 15 * 60 * 1000;
|
||||
|
||||
start(config?: Partial<ValidationAlertConfig>): void {
|
||||
if (this.checkIntervalId) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
this.config = { ...DEFAULT_ALERT_CONFIG, ...config };
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
setTimeout(() => this.checkMetrics(), 60 * 1000);
|
||||
this.checkIntervalId = setInterval(
|
||||
() => this.checkMetrics(),
|
||||
this.config.checkInterval,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.checkIntervalId) {
|
||||
clearInterval(this.checkIntervalId);
|
||||
this.checkIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private checkMetrics(): void {
|
||||
const metrics = validationMetrics.getMetrics();
|
||||
|
||||
if (metrics.totalValidations < this.config.minValidationsForAlert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (metrics.failureRate > this.config.failureRateThreshold) {
|
||||
const now = Date.now();
|
||||
if (now - this.lastAlertTime < this.alertCooldown) {
|
||||
return;
|
||||
}
|
||||
this.lastAlertTime = now;
|
||||
|
||||
logger.error(
|
||||
'[API Validation Alert] High validation failure rate detected',
|
||||
{
|
||||
alert_type: 'high_validation_failure_rate',
|
||||
failure_rate: metrics.failureRate.toFixed(2),
|
||||
threshold: this.config.failureRateThreshold,
|
||||
total_validations: metrics.totalValidations,
|
||||
failed_validations: metrics.failedValidations,
|
||||
successful_validations: metrics.successfulValidations,
|
||||
last_failure_time: metrics.lastFailureTime,
|
||||
failures_by_endpoint: metrics.failuresByEndpoint,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<ValidationAlertConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
}
|
||||
|
||||
export const validationAlerting = new ValidationAlerting();
|
||||
|
||||
// Start alerting in production
|
||||
if (typeof window !== 'undefined' && import.meta.env.PROD) {
|
||||
const enableAlerting =
|
||||
import.meta.env.VITE_ENABLE_VALIDATION_ALERTING !== 'false';
|
||||
if (enableAlerting) {
|
||||
validationAlerting.start();
|
||||
}
|
||||
}
|
||||
197
apps/web/src/services/api/retry.ts
Normal file
197
apps/web/src/services/api/retry.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* S1.1: Retry logic and network failure tracking
|
||||
* Extracted from client.ts — Retry configuration, network failure detection, backoff
|
||||
*/
|
||||
|
||||
import axios, { AxiosError } from 'axios';
|
||||
|
||||
/**
|
||||
* Sleep utility function
|
||||
*/
|
||||
export const sleep = (ms: number): Promise<void> => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry configuration
|
||||
*/
|
||||
export interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
retryableStatusCodes: number[];
|
||||
retryableNetworkErrors: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 10000,
|
||||
retryableStatusCodes: [500, 502, 503, 504],
|
||||
retryableNetworkErrors: [
|
||||
'ECONNABORTED',
|
||||
'ETIMEDOUT',
|
||||
'ENOTFOUND',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EAI_AGAIN',
|
||||
'Network Error',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a request method is idempotent (safe to retry)
|
||||
*/
|
||||
export const isIdempotentMethod = (method?: string): boolean => {
|
||||
const idempotentMethods = ['GET', 'HEAD', 'OPTIONS'];
|
||||
return method ? idempotentMethods.includes(method.toUpperCase()) : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edge 2.1: Track network failure patterns
|
||||
*/
|
||||
class NetworkFailureTracker {
|
||||
private recentRequests: Array<{ success: boolean; timestamp: number }> = [];
|
||||
private readonly windowSize = 10;
|
||||
private readonly windowMs = 30000;
|
||||
|
||||
recordRequest(success: boolean): void {
|
||||
const now = Date.now();
|
||||
this.recentRequests.push({ success, timestamp: now });
|
||||
|
||||
this.recentRequests = this.recentRequests.filter(
|
||||
(req) => now - req.timestamp < this.windowMs,
|
||||
);
|
||||
|
||||
if (this.recentRequests.length > this.windowSize) {
|
||||
this.recentRequests = this.recentRequests.slice(-this.windowSize);
|
||||
}
|
||||
}
|
||||
|
||||
isPartialFailure(): boolean {
|
||||
if (this.recentRequests.length === 0) return false;
|
||||
const successCount = this.recentRequests.filter((r) => r.success).length;
|
||||
const failureCount = this.recentRequests.filter((r) => !r.success).length;
|
||||
return successCount > 0 && failureCount > 0;
|
||||
}
|
||||
|
||||
isCompleteFailure(): boolean {
|
||||
if (this.recentRequests.length === 0) return false;
|
||||
return this.recentRequests.every((r) => !r.success);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.recentRequests = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const networkFailureTracker = new NetworkFailureTracker();
|
||||
|
||||
/**
|
||||
* Edge 2.1: Check if an error represents a partial network failure
|
||||
*/
|
||||
export const isPartialNetworkFailure = (error: AxiosError): boolean => {
|
||||
if (error.response?.status === 206) return true;
|
||||
if (
|
||||
error.code === 'ECONNABORTED' &&
|
||||
error.message?.toLowerCase().includes('timeout') &&
|
||||
error.request
|
||||
)
|
||||
return true;
|
||||
if (error.code === 'ECONNRESET' && error.response) return true;
|
||||
if (networkFailureTracker.isPartialFailure()) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edge 2.1: Check if an error represents a complete network failure
|
||||
*/
|
||||
export const isCompleteNetworkFailure = (error: AxiosError): boolean => {
|
||||
if (!error.response && !error.request) return true;
|
||||
if (
|
||||
error.code === 'ECONNREFUSED' ||
|
||||
error.code === 'ERR_CONNECTION_REFUSED'
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
error.code === 'ENETUNREACH' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.code === 'ERR_INTERNET_DISCONNECTED'
|
||||
)
|
||||
return true;
|
||||
if (networkFailureTracker.isCompleteFailure()) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error is retryable
|
||||
*/
|
||||
export const isRetryableError = (
|
||||
error: AxiosError,
|
||||
config: RetryConfig = DEFAULT_RETRY_CONFIG,
|
||||
): boolean => {
|
||||
if (axios.isCancel(error)) return false;
|
||||
|
||||
if (
|
||||
error.code === 'ERR_BAD_RESPONSE' ||
|
||||
error.message?.includes('HTML page instead of JSON')
|
||||
)
|
||||
return false;
|
||||
|
||||
if ((error.config as any)?._disableRetry) return false;
|
||||
|
||||
if (isPartialNetworkFailure(error)) {
|
||||
return isIdempotentMethod(error.config?.method);
|
||||
}
|
||||
|
||||
if (error.response?.status) {
|
||||
return config.retryableStatusCodes.includes(error.response.status);
|
||||
}
|
||||
|
||||
if (error.code) {
|
||||
return config.retryableNetworkErrors.includes(error.code);
|
||||
}
|
||||
|
||||
if (error.message) {
|
||||
const message = error.message.toLowerCase();
|
||||
const networkErrorPatterns = [
|
||||
'network',
|
||||
'timeout',
|
||||
'connection',
|
||||
'econn',
|
||||
'etimedout',
|
||||
'enotfound',
|
||||
];
|
||||
return networkErrorPatterns.some((pattern) => message.includes(pattern));
|
||||
}
|
||||
|
||||
if (!error.response && error.request) {
|
||||
return isIdempotentMethod(error.config?.method);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get retry delay from Retry-After header or use exponential backoff with jitter
|
||||
*/
|
||||
export const getRetryDelay = (
|
||||
error: AxiosError,
|
||||
attempt: number,
|
||||
baseDelay: number = DEFAULT_RETRY_CONFIG.baseDelay,
|
||||
maxDelay: number = DEFAULT_RETRY_CONFIG.maxDelay,
|
||||
): number => {
|
||||
const retryAfterHeader =
|
||||
error.response?.headers['retry-after'] ||
|
||||
error.response?.headers['Retry-After'];
|
||||
if (retryAfterHeader) {
|
||||
const delay = parseInt(String(retryAfterHeader), 10);
|
||||
if (!isNaN(delay) && delay > 0) {
|
||||
return Math.min(delay * 1000, maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
||||
const jitter = Math.random() * baseDelay;
|
||||
return Math.min(exponentialDelay + jitter, maxDelay);
|
||||
};
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { webhookService } from './webhookService';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -9,34 +11,83 @@ interface ApiKey {
|
|||
key?: string; // Full key (only returned on creation)
|
||||
}
|
||||
|
||||
import { webhookService } from './webhookService';
|
||||
|
||||
const STORAGE_KEY = 'veza_dev_api_keys';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
created: string;
|
||||
lastUsed: string;
|
||||
status: 'active' | 'revoked';
|
||||
scopes: string[];
|
||||
key?: string; // Full key (only returned on creation)
|
||||
// S0.4: Encrypt API keys before storing in localStorage using Web Crypto API
|
||||
const ENCRYPTION_KEY_NAME = 'veza_dev_enc_key';
|
||||
|
||||
async function getOrCreateEncryptionKey(): Promise<CryptoKey> {
|
||||
const stored = sessionStorage.getItem(ENCRYPTION_KEY_NAME);
|
||||
if (stored) {
|
||||
const raw = Uint8Array.from(atob(stored), (c) => c.charCodeAt(0));
|
||||
return crypto.subtle.importKey('raw', raw, 'AES-GCM', true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
}
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const exported = await crypto.subtle.exportKey('raw', key);
|
||||
sessionStorage.setItem(
|
||||
ENCRYPTION_KEY_NAME,
|
||||
btoa(String.fromCharCode(...new Uint8Array(exported))),
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
const getStoredKeys = (): ApiKey[] => {
|
||||
async function encryptData(data: string): Promise<string> {
|
||||
const key = await getOrCreateEncryptionKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(data);
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
encoded,
|
||||
);
|
||||
const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
async function decryptData(encrypted: string): Promise<string> {
|
||||
const key = await getOrCreateEncryptionKey();
|
||||
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
ciphertext,
|
||||
);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
const getStoredKeys = async (): Promise<ApiKey[]> => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
if (!stored) return [];
|
||||
try {
|
||||
const decrypted = await decryptData(stored);
|
||||
return JSON.parse(decrypted);
|
||||
} catch {
|
||||
// If decryption fails (e.g., new session), clear corrupted data
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveKeys = (keys: ApiKey[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys));
|
||||
const saveKeys = async (keys: ApiKey[]): Promise<void> => {
|
||||
const encrypted = await encryptData(JSON.stringify(keys));
|
||||
localStorage.setItem(STORAGE_KEY, encrypted);
|
||||
};
|
||||
|
||||
export const developerService = {
|
||||
listKeys: async (): Promise<ApiKey[]> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return getStoredKeys();
|
||||
return await getStoredKeys();
|
||||
},
|
||||
|
||||
createKey: async (data: { name: string; scopes: string[] }): Promise<ApiKey> => {
|
||||
|
|
@ -51,7 +102,7 @@ export const developerService = {
|
|||
const formattedDate = now.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const newKey: ApiKey = {
|
||||
|
|
@ -65,29 +116,31 @@ export const developerService = {
|
|||
key: fullKey,
|
||||
};
|
||||
|
||||
const keys = getStoredKeys();
|
||||
saveKeys([newKey, ...keys]);
|
||||
const keys = await getStoredKeys();
|
||||
await saveKeys([newKey, ...keys]);
|
||||
|
||||
return newKey;
|
||||
},
|
||||
|
||||
revokeKey: async (id: string): Promise<{ success: boolean }> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const keys = getStoredKeys();
|
||||
const updated = keys.map(k => k.id === id ? { ...k, status: 'revoked' as const } : k);
|
||||
saveKeys(updated);
|
||||
const keys = await getStoredKeys();
|
||||
const updated = keys.map((k) =>
|
||||
k.id === id ? { ...k, status: 'revoked' as const } : k,
|
||||
);
|
||||
await saveKeys(updated);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
deleteKey: async (id: string): Promise<void> => {
|
||||
const keys = getStoredKeys();
|
||||
saveKeys(keys.filter(k => k.id !== id));
|
||||
const keys = await getStoredKeys();
|
||||
await saveKeys(keys.filter((k) => k.id !== id));
|
||||
},
|
||||
|
||||
getStats: async () => {
|
||||
// Mix of real (webhooks) and mock/local (keys) stats
|
||||
const keys = getStoredKeys();
|
||||
const activeKeys = keys.filter(k => k.status === 'active').length;
|
||||
const keys = await getStoredKeys();
|
||||
const activeKeys = keys.filter((k) => k.status === 'active').length;
|
||||
|
||||
// Fetch real webhook count
|
||||
let webhookCount = 0;
|
||||
|
|
|
|||
52
apps/web/src/utils/safeNavigate.ts
Normal file
52
apps/web/src/utils/safeNavigate.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Safe navigation utility to prevent open redirect vulnerabilities.
|
||||
* Validates that a URL is same-origin or in an allowed list before navigating.
|
||||
*
|
||||
* @see S0.1 of the UI Remediation Plan
|
||||
*/
|
||||
|
||||
const ALLOWED_ORIGINS: string[] = [
|
||||
// Add trusted external origins here if needed
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates that a URL is safe to navigate to (same-origin or allowlisted).
|
||||
*/
|
||||
export function isSafeUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
|
||||
// Allow same-origin URLs
|
||||
if (parsed.origin === window.location.origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow explicitly allowlisted origins
|
||||
if (ALLOWED_ORIGINS.includes(parsed.origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
// If URL parsing fails, it's not safe
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a URL only if it passes the same-origin or allowlist check.
|
||||
* Falls back to the app root if the URL is not safe.
|
||||
*
|
||||
* @param url - The URL to navigate to
|
||||
* @param fallback - Fallback path if URL is unsafe (default: '/')
|
||||
*/
|
||||
export function safeNavigate(url: string, fallback = '/'): void {
|
||||
if (isSafeUrl(url)) {
|
||||
window.location.href = url;
|
||||
} else {
|
||||
console.warn(
|
||||
`[safeNavigate] Blocked navigation to untrusted URL: ${url}`,
|
||||
);
|
||||
window.location.href = fallback;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue