{children} diff --git a/frontend/src/app/[locale]/(app)/plans/page.test.tsx b/frontend/src/app/[locale]/(app)/plans/page.test.tsx index aed443f..75e60df 100644 --- a/frontend/src/app/[locale]/(app)/plans/page.test.tsx +++ b/frontend/src/app/[locale]/(app)/plans/page.test.tsx @@ -37,6 +37,12 @@ vi.mock('@/hooks/use-pipeline-sse', () => ({ }), })) +vi.mock('@/components/ui/page-transition', () => ({ + PageTransition: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + const defaultParams = Promise.resolve({ locale: 'pt-BR' }) describe('PlansPage', () => { diff --git a/frontend/src/app/[locale]/(app)/settings/settings-content.tsx b/frontend/src/app/[locale]/(app)/settings/settings-content.tsx index 8aa2ac2..70217c3 100644 --- a/frontend/src/app/[locale]/(app)/settings/settings-content.tsx +++ b/frontend/src/app/[locale]/(app)/settings/settings-content.tsx @@ -25,6 +25,7 @@ export function SettingsContent() {
-

+

{t('ai_model')}

@@ -60,6 +61,7 @@ export function SettingsContent() {

-

+

{t('language')}

@@ -92,6 +94,7 @@ export function SettingsContent() {

-

+

{t('accessibility_settings')}

@@ -133,6 +136,7 @@ export function SettingsContent() {

-

+

{t('privacy')}

@@ -163,6 +167,7 @@ export function SettingsContent() {

-

+

{t('about')}

diff --git a/frontend/src/app/[locale]/(app)/sign-language/page.tsx b/frontend/src/app/[locale]/(app)/sign-language/page.tsx index d73f003..a838bb5 100644 --- a/frontend/src/app/[locale]/(app)/sign-language/page.tsx +++ b/frontend/src/app/[locale]/(app)/sign-language/page.tsx @@ -4,21 +4,18 @@ import { lazy, Suspense } from 'react' import { useTranslations } from 'next-intl' import { VLibrasWidget } from '@/components/sign-language/vlibras-widget' import { GestureList } from '@/components/sign-language/gesture-list' -import { LibrasCaptioning } from '@/components/sign-language/libras-captioning' import { SkeletonCard } from '@/components/ui/skeleton' import { PageTransition } from '@/components/ui/page-transition' const WebcamCapture = lazy(() => import('@/components/sign-language/webcam-capture')) /** - * Sign language page -- combines webcam gesture recognition, - * real-time Libras captioning, the VLibras accessibility widget, + * Sign language page -- combines continuous webcam gesture recognition + * with real-time captioning, the VLibras accessibility widget, * and a supported gestures list. * - * MVP scope (ADR-026): 4 basic Libras gestures. - * Extended scope: 30-gloss captioning with CTC decoding + LLM translation. - * Browser-side: MediaPipe JS + BiLSTM ONNX for real-time detection. - * Server-side: POST /sign-language/recognize for captured frames. + * Continuous captioning flow: + * Webcam → rAF @20fps → MediaPipe landmarks → inference worker → WebSocket → LLM → caption */ export default function SignLanguagePage() { const t = useTranslations('sign_language') @@ -36,18 +33,13 @@ export default function SignLanguagePage() {

- {/* Webcam capture and recognition */} + {/* Webcam capture with integrated continuous captioning */}
}>
- {/* Libras captioning */} -
- -
- {/* Supported gestures */}
diff --git a/frontend/src/app/[locale]/error.tsx b/frontend/src/app/[locale]/error.tsx index cf265a9..eafa304 100644 --- a/frontend/src/app/[locale]/error.tsx +++ b/frontend/src/app/[locale]/error.tsx @@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl' import { useParams } from 'next/navigation' import { useEffect, useState, useCallback, useRef } from 'react' -import { motion } from 'motion/react' +import { motion, useReducedMotion } from 'motion/react' import { cn } from '@/lib/cn' /** @@ -32,6 +32,9 @@ export default function ErrorPage({ const copiedTimeoutRef = useRef | null>(null) const retryButtonRef = useRef(null) + const prefersReducedMotion = useReducedMotion() + const noMotion = prefersReducedMotion ?? false + const isSSEError = error.message?.includes('SSE') || error.message?.includes('EventSource') || @@ -47,6 +50,15 @@ export default function ErrorPage({ retryButtonRef.current?.focus() }, []) + // Clean up copied timeout on unmount to avoid setState on unmounted component + useEffect(() => { + return () => { + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current) + } + } + }, []) + // Build diagnostic info string const diagnosticInfo = useCallback(() => { const lines: string[] = [ @@ -104,9 +116,9 @@ export default function ErrorPage({ className="flex min-h-[60vh] items-center justify-center p-6" role="alert" aria-live="assertive" - initial={{ opacity: 0, y: 20, filter: 'blur(8px)' }} - animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }} - transition={{ type: 'spring', stiffness: 200, damping: 24 }} + initial={noMotion ? undefined : { opacity: 0, y: 20, filter: 'blur(8px)' }} + animate={noMotion ? undefined : { opacity: 1, y: 0, filter: 'blur(0px)' }} + transition={noMotion ? undefined : { type: 'spring', stiffness: 200, damping: 24 }} >
; } diff --git a/frontend/src/app/[locale]/login/layout.tsx b/frontend/src/app/[locale]/login/layout.tsx new file mode 100644 index 0000000..afc922e --- /dev/null +++ b/frontend/src/app/[locale]/login/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Login', + description: 'Sign in to AiLine - Adaptive Inclusive Learning', +} + +/** + * Login layout — clean full-screen, no sidebar/topbar. + * Similar to the setup wizard layout. + */ +export default function LoginLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/app/[locale]/login/page.tsx b/frontend/src/app/[locale]/login/page.tsx new file mode 100644 index 0000000..e7ae010 --- /dev/null +++ b/frontend/src/app/[locale]/login/page.tsx @@ -0,0 +1,202 @@ +'use client' + +import { useState, useCallback } from 'react' +import { useRouter, useParams } from 'next/navigation' +import { motion, AnimatePresence, useReducedMotion } from 'motion/react' +import { useTranslations } from 'next-intl' +import { cn } from '@/lib/cn' +import { useAuthStore, type UserRole } from '@/stores/auth-store' +import { API_BASE, setDemoProfile, getAuthHeaders } from '@/lib/api' +import { useAccessibilityStore } from '@/stores/accessibility-store' +import { cssTheme } from '@/hooks/use-theme' +import { DEMO_PROFILES_BY_ROLE, type DemoProfile } from '@/components/auth/login-data' +import { RoleSelectionPhase } from '@/components/auth/role-selection-phase' +import { LoginFormPhase } from '@/components/auth/login-form-phase' + +/* ------------------------------------------------------------------ */ +/* Login Page (orchestrator) */ +/* ------------------------------------------------------------------ */ + +export default function LoginPage() { + const router = useRouter() + const params = useParams() + const locale = (params?.locale as string) ?? 'en' + const t = useTranslations('login') + const login = useAuthStore((s) => s.login) + const setTheme = useAccessibilityStore((s) => s.setTheme) + const prefersReducedMotion = useReducedMotion() + const noMotion = prefersReducedMotion ?? false + + const [selectedRole, setSelectedRole] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const handleRoleSelect = useCallback((role: UserRole) => { + setSelectedRole(role) + setError(null) + }, []) + + const handleBack = useCallback(() => { + setSelectedRole(null) + setError(null) + setEmail('') + setPassword('') + }, []) + + const handleDemoLogin = useCallback( + (profile: DemoProfile) => { + setDemoProfile(profile.key) + + if (profile.accessibility) { + const css = cssTheme(profile.accessibility) + setTheme(profile.accessibility) + if (typeof document !== 'undefined') { + document.body.setAttribute('data-theme', css) + document.documentElement.setAttribute('data-theme', css) + } + } + + router.push(`/${locale}${profile.route}`) + }, + [locale, router, setTheme], + ) + + const handleEmailLogin = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!selectedRole || !email || !password) return + + setIsLoading(true) + setError(null) + + try { + const res = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders(), + }, + body: JSON.stringify({ email, password, role: selectedRole }), + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error( + (body as { detail?: string }).detail ?? t('error_credentials'), + ) + } + + const data = (await res.json()) as { + access_token: string + user: { + id: string + email: string + display_name: string + role: UserRole + org_id?: string + locale: string + avatar_url: string + accessibility_profile: string + is_active: boolean + } + } + + login(data.access_token, data.user) + router.push(`/${locale}/dashboard`) + } catch (err) { + setError( + err instanceof Error ? err.message : t('error_generic'), + ) + } finally { + setIsLoading(false) + } + }, + [selectedRole, email, password, locale, router, login, t], + ) + + const demoProfiles = selectedRole + ? DEMO_PROFILES_BY_ROLE[selectedRole] + : [] + + return ( +
+ {/* Background mesh gradient */} +
+ ) +} diff --git a/frontend/src/app/[locale]/not-found.tsx b/frontend/src/app/[locale]/not-found.tsx index b016ac3..cf2fb53 100644 --- a/frontend/src/app/[locale]/not-found.tsx +++ b/frontend/src/app/[locale]/not-found.tsx @@ -11,7 +11,7 @@ export default function NotFoundPage() { const t = useTranslations('common') return ( -
+
-
+