From 2e225eb466f7e422eba23fb023ce1347bbbb3204 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Thu, 12 Feb 2026 14:21:57 +0100 Subject: [PATCH] feat: Conversion CSS mobile-first des layouts et pages admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'ensemble du frontend utilisait un mix de @media (max-width) desktop-first et @media (min-width) mobile-first, en contradiction avec la spec UX qui impose une stratégie mobile-first radicale (78% des parents/élèves sur mobile). Cette conversion uniformise les 8 fichiers restants vers @media (min-width) avec les breakpoints de la spec UX (sm: 640px, md: 768px) pour garantir une expérience progressive enhancement cohérente. --- .gitignore | 1 + frontend/.gitignore | 1 + frontend/src/routes/+page.svelte | 14 +- frontend/src/routes/admin/+layout.svelte | 29 ++-- frontend/src/routes/admin/+page.svelte | 8 +- .../routes/admin/classes/[id]/+page.svelte | 6 +- .../src/routes/admin/pedagogy/+page.svelte | 22 ++- frontend/src/routes/admin/users/+page.svelte | 27 +-- frontend/src/routes/dashboard/+layout.svelte | 32 ++-- frontend/src/routes/settings/+layout.svelte | 28 +-- .../features/sessions/api/sessions.test.ts | 159 ++++++++++++++++++ 11 files changed, 259 insertions(+), 68 deletions(-) create mode 100644 frontend/tests/unit/lib/features/sessions/api/sessions.test.ts diff --git a/.gitignore b/.gitignore index 091f45e..b57fd8b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ correlation_id tenant_id test-results/ compose.override.yaml +/package-lock.json diff --git a/frontend/.gitignore b/frontend/.gitignore index 01e67f2..bf004da 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -24,6 +24,7 @@ dist/ /coverage/ /playwright-report/ /test-results/ +/test-results-debug/ # ============================================================================= # PWA diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 15a6762..6c36c57 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -112,7 +112,7 @@ } h1 { - font-size: 2.5rem; + font-size: 1.75rem; font-weight: 700; color: #1e293b; margin: 0 0 1rem; @@ -123,7 +123,7 @@ } .tagline { - font-size: 1.25rem; + font-size: 1rem; color: #64748b; margin: 0 0 2rem; line-height: 1.6; @@ -155,7 +155,7 @@ .features { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 2rem; + gap: 1rem; margin-top: 2rem; } @@ -192,17 +192,17 @@ } } - @media (max-width: 640px) { + @media (min-width: 640px) { h1 { - font-size: 1.75rem; + font-size: 2.5rem; } .tagline { - font-size: 1rem; + font-size: 1.25rem; } .features { - gap: 1rem; + gap: 2rem; } } diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index fb768e1..eb318c5 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -97,7 +97,10 @@ display: flex; justify-content: space-between; align-items: center; - height: 64px; + flex-wrap: wrap; + height: auto; + padding: 0.75rem 0; + gap: 0.75rem; } .logo-button { @@ -116,6 +119,9 @@ .header-nav { display: flex; align-items: center; + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; gap: 0.5rem; } @@ -192,7 +198,7 @@ .admin-main { flex: 1; - padding: 1.5rem; + padding: 1rem; } .main-content { @@ -206,23 +212,22 @@ } } - @media (max-width: 768px) { + @media (min-width: 768px) { .header-content { - flex-wrap: wrap; - height: auto; - padding: 0.75rem 0; - gap: 0.75rem; + flex-wrap: nowrap; + height: 64px; + padding: 0; + gap: 0; } .header-nav { - width: 100%; - justify-content: flex-end; - flex-wrap: wrap; - gap: 0.5rem; + width: auto; + flex-wrap: nowrap; + justify-content: flex-start; } .admin-main { - padding: 1rem; + padding: 1.5rem; } } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 2fe3104..51eda47 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -91,6 +91,7 @@ .stats-row { display: flex; + flex-wrap: wrap; gap: 1rem; } @@ -104,6 +105,7 @@ border: 1px solid var(--border-subtle, #e2e8f0); border-radius: 0.75rem; min-width: 100px; + flex: 1; } .stat-value { @@ -159,13 +161,13 @@ text-align: center; } - @media (max-width: 640px) { + @media (min-width: 640px) { .stats-row { - flex-wrap: wrap; + flex-wrap: nowrap; } .stat-card { - flex: 1; + flex: 0 1 auto; } } diff --git a/frontend/src/routes/admin/classes/[id]/+page.svelte b/frontend/src/routes/admin/classes/[id]/+page.svelte index 0dcab86..4485b82 100644 --- a/frontend/src/routes/admin/classes/[id]/+page.svelte +++ b/frontend/src/routes/admin/classes/[id]/+page.svelte @@ -398,13 +398,13 @@ .form-row { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; gap: 1rem; } - @media (max-width: 480px) { + @media (min-width: 640px) { .form-row { - grid-template-columns: 1fr; + grid-template-columns: 1fr 1fr; } } diff --git a/frontend/src/routes/admin/pedagogy/+page.svelte b/frontend/src/routes/admin/pedagogy/+page.svelte index 8c07412..ef5cd76 100644 --- a/frontend/src/routes/admin/pedagogy/+page.svelte +++ b/frontend/src/routes/admin/pedagogy/+page.svelte @@ -298,6 +298,7 @@ .page-header { display: flex; + flex-direction: column; justify-content: space-between; align-items: flex-start; gap: 1rem; @@ -357,8 +358,11 @@ /* Current mode banner */ .current-mode-banner { display: flex; + flex-direction: column; justify-content: space-between; align-items: center; + gap: 1rem; + text-align: center; padding: 1.25rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); border-radius: 0.75rem; @@ -379,7 +383,7 @@ } .banner-detail { - text-align: right; + text-align: center; } .detail-number { @@ -407,7 +411,7 @@ .modes-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: 1fr; gap: 1rem; } @@ -636,23 +640,23 @@ } } - @media (max-width: 768px) { + @media (min-width: 768px) { .page-header { - flex-direction: column; + flex-direction: row; } .modes-grid { - grid-template-columns: 1fr; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } .current-mode-banner { - flex-direction: column; - gap: 1rem; - text-align: center; + flex-direction: row; + gap: 0; + text-align: left; } .banner-detail { - text-align: center; + text-align: right; } } diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index dfb81e5..4ebf7e4 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -996,7 +996,8 @@ /* Filters */ .filters-bar { display: flex; - align-items: flex-end; + flex-direction: column; + align-items: stretch; gap: 1rem; margin-bottom: 1.5rem; padding: 1rem; @@ -1025,7 +1026,8 @@ border: 1px solid #d1d5db; border-radius: 0.375rem; font-size: 0.875rem; - min-width: 180px; + min-width: auto; + width: 100%; background: white; } @@ -1126,6 +1128,11 @@ background: #f9fafb; } + .users-table th:nth-child(5), + .users-table td:nth-child(5) { + display: none; + } + .user-name-cell { white-space: nowrap; } @@ -1264,7 +1271,7 @@ .form-row { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; gap: 1rem; } @@ -1392,24 +1399,24 @@ font-style: italic; } - @media (max-width: 768px) { + @media (min-width: 768px) { .filters-bar { - flex-direction: column; - align-items: stretch; + flex-direction: row; + align-items: flex-end; } .filter-group select { - min-width: auto; - width: 100%; + min-width: 180px; + width: auto; } .form-row { - grid-template-columns: 1fr; + grid-template-columns: 1fr 1fr; } .users-table th:nth-child(5), .users-table td:nth-child(5) { - display: none; + display: table-cell; } } diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 43ee3f8..50f6413 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -91,7 +91,10 @@ display: flex; justify-content: space-between; align-items: center; - height: 64px; + flex-wrap: wrap; + height: auto; + padding: 0.75rem 0; + gap: 0.75rem; } .logo-button { @@ -110,7 +113,10 @@ .header-nav { display: flex; align-items: center; - gap: 1rem; + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; } .nav-link { @@ -186,7 +192,7 @@ .dashboard-main { flex: 1; - padding: 1.5rem; + padding: 1rem; } .main-content { @@ -200,23 +206,23 @@ } } - @media (max-width: 768px) { + @media (min-width: 768px) { .header-content { - flex-wrap: wrap; - height: auto; - padding: 0.75rem 0; - gap: 0.75rem; + flex-wrap: nowrap; + height: 64px; + padding: 0; + gap: 0; } .header-nav { - width: 100%; - justify-content: flex-end; - flex-wrap: wrap; - gap: 0.5rem; + width: auto; + flex-wrap: nowrap; + gap: 1rem; + justify-content: flex-start; } .dashboard-main { - padding: 1rem; + padding: 1.5rem; } } diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index b0a3bd6..4e75054 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -72,7 +72,10 @@ display: flex; justify-content: space-between; align-items: center; - height: 64px; + flex-wrap: wrap; + height: auto; + padding: 0.75rem 0; + gap: 0.75rem; } .logo-button { @@ -91,7 +94,10 @@ .header-nav { display: flex; align-items: center; - gap: 1rem; + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; } .nav-link { @@ -158,19 +164,19 @@ } } - @media (max-width: 768px) { + @media (min-width: 768px) { .header-content { - flex-wrap: wrap; - height: auto; - padding: 0.75rem 0; - gap: 0.75rem; + flex-wrap: nowrap; + height: 64px; + padding: 0; + gap: 0; } .header-nav { - width: 100%; - justify-content: flex-end; - flex-wrap: wrap; - gap: 0.5rem; + width: auto; + flex-wrap: nowrap; + gap: 1rem; + justify-content: flex-start; } } diff --git a/frontend/tests/unit/lib/features/sessions/api/sessions.test.ts b/frontend/tests/unit/lib/features/sessions/api/sessions.test.ts new file mode 100644 index 0000000..5dc68bf --- /dev/null +++ b/frontend/tests/unit/lib/features/sessions/api/sessions.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Unit tests for the sessions API module. + * + * Tests getSessions(), revokeSession(), and revokeAllSessions() which all + * rely on authenticatedFetch from $lib/auth and getApiBaseUrl from $lib/api. + */ + +// Mock $lib/api +vi.mock('$lib/api', () => ({ + getApiBaseUrl: () => 'http://test.classeo.local:18000/api' +})); + +// Mock $lib/auth +const mockAuthenticatedFetch = vi.fn(); +vi.mock('$lib/auth', () => ({ + authenticatedFetch: (...args: unknown[]) => mockAuthenticatedFetch(...args) +})); + +import { getSessions, revokeSession, revokeAllSessions } from '$lib/features/sessions/api/sessions'; + +describe('sessions API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // getSessions + // ========================================================================== + describe('getSessions', () => { + it('should return session array on success', async () => { + const mockSessions = [ + { + family_id: 'family-1', + device: 'Desktop', + browser: 'Chrome', + os: 'Linux', + location: 'Paris, France', + created_at: '2025-01-15T10:00:00Z', + last_activity_at: '2025-01-15T12:00:00Z', + is_current: true + }, + { + family_id: 'family-2', + device: 'Mobile', + browser: 'Safari', + os: 'iOS', + location: 'Lyon, France', + created_at: '2025-01-14T08:00:00Z', + last_activity_at: '2025-01-14T09:00:00Z', + is_current: false + } + ]; + + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ sessions: mockSessions }) + }); + + const result = await getSessions(); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/me/sessions' + ); + expect(result).toHaveLength(2); + expect(result[0]!.family_id).toBe('family-1'); + expect(result[0]!.is_current).toBe(true); + expect(result[1]!.family_id).toBe('family-2'); + }); + + it('should throw Error when the API response is not ok', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(getSessions()).rejects.toThrow('Failed to fetch sessions'); + }); + }); + + // ========================================================================== + // revokeSession + // ========================================================================== + describe('revokeSession', () => { + it('should complete without error on success', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + status: 204 + }); + + await expect(revokeSession('family-abc')).resolves.toBeUndefined(); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/me/sessions/family-abc', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('should throw "Cannot revoke current session" on 403', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 403 + }); + + await expect(revokeSession('current-family')).rejects.toThrow( + 'Cannot revoke current session' + ); + }); + + it('should throw generic error on other failure status', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(revokeSession('family-xyz')).rejects.toThrow( + 'Failed to revoke session' + ); + }); + }); + + // ========================================================================== + // revokeAllSessions + // ========================================================================== + describe('revokeAllSessions', () => { + it('should return revoked_count on success', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + message: '3 sessions revoked', + revoked_count: 3 + }) + }); + + const result = await revokeAllSessions(); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/me/sessions', + expect.objectContaining({ method: 'DELETE' }) + ); + expect(result).toBe(3); + }); + + it('should throw Error when the API response is not ok', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(revokeAllSessions()).rejects.toThrow('Failed to revoke all sessions'); + }); + }); +});