feat: full feature buildout — streaming, i18n, mastery map, admin, jobs

Progressive lesson streaming via onSegment callback (fixes SSE for non-English
users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers,
Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval
runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema,
mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions),
image queries, source citations, view transitions, reading animations, i18n
(next-intl), PDF export, surprise endpoint, and 402 passing unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:08:14 +02:00
parent 4e38b5a791
commit 5bf6013460
161 changed files with 27152 additions and 1161 deletions
+29 -16
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
interface Props {
hasOidc: boolean;
@@ -13,6 +14,7 @@ interface Props {
}
export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }: Props) {
const t = useTranslations('auth.login');
const router = useRouter();
const params = useSearchParams();
const callbackUrl = params.get('callbackUrl') ?? '/';
@@ -33,7 +35,7 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
});
setLoading(false);
if (res?.error) {
setError('Invalid email or password.');
setError(t('error'));
} else {
await migrateThenRedirect(callbackUrl, router);
}
@@ -41,12 +43,12 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
return (
<main style={styles.page}>
<h1 style={styles.heading}>Sign in</h1>
<h1 style={styles.heading}>{t('title')}</h1>
{resetSuccess && (
<p role="status" style={styles.success}>Password updated. Sign in with your new password.</p>
<p role="status" style={styles.success}>{t('resetSuccess')}</p>
)}
<form onSubmit={handleCredentials} style={styles.form} noValidate>
<label style={styles.label} htmlFor="email">Email</label>
<label style={styles.label} htmlFor="email">{t('email')}</label>
<input
id="email"
type="email"
@@ -56,7 +58,7 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
autoComplete="email"
style={styles.input}
/>
<label style={styles.label} htmlFor="password">Password</label>
<label style={styles.label} htmlFor="password">{t('password')}</label>
<input
id="password"
type="password"
@@ -68,34 +70,34 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
/>
{error && <p role="alert" style={styles.error}>{error}</p>}
<button type="submit" disabled={loading} style={styles.btn}>
{loading ? 'Signing in…' : 'Sign in'}
{loading ? t('submitting') : t('submit')}
</button>
<Link href="/auth/forgot-password" style={styles.forgotLink}>Forgot password?</Link>
<Link href="/auth/forgot-password" style={styles.forgotLink}>{t('forgotPassword')}</Link>
</form>
{(hasGoogle || hasGitHub || hasOidc) && <div style={styles.divider}><span>or</span></div>}
{(hasGoogle || hasGitHub || hasOidc) && <div style={styles.divider}><span>{t('or')}</span></div>}
<div style={styles.oauthGroup}>
{hasGoogle && (
<button type="button" onClick={() => signIn('google', { callbackUrl })} style={styles.oauthBtn}>
Continue with Google
{t('continueWithGoogle')}
</button>
)}
{hasGitHub && (
<button type="button" onClick={() => signIn('github', { callbackUrl })} style={styles.oauthBtn}>
Continue with GitHub
{t('continueWithGitHub')}
</button>
)}
{hasOidc && (
<button type="button" onClick={() => signIn('authentik', { callbackUrl })} style={styles.oauthBtn}>
Continue with {oidcLabel}
{t('continueWith', { provider: oidcLabel })}
</button>
)}
</div>
<p style={styles.footer}>
No account?{' '}
<Link href="/auth/register" style={styles.link}>Register</Link>
{t('noAccount')}{' '}
<Link href="/auth/register" style={styles.link}>{t('register')}</Link>
</p>
</main>
);
@@ -115,6 +117,17 @@ async function migrateThenRedirect(callbackUrl: string, router: ReturnType<typeo
// non-blocking
}
}
// Sync saved locale preference to cookie so UI reflects user's language
try {
const localeRes = await fetch('/api/user/locale');
if (localeRes.ok) {
const { locale } = (await localeRes.json()) as { locale: string };
document.cookie = `NEXT_LOCALE=${locale};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
}
} catch {
// non-blocking
}
router.refresh();
router.push(callbackUrl);
}
@@ -169,7 +182,7 @@ const styles = {
marginTop: 'var(--space-4)',
padding: 'var(--space-3) var(--space-6)',
background: 'var(--color-accent)',
color: '#fff',
color: 'var(--color-on-accent)',
border: 'none',
borderRadius: '4px',
fontFamily: 'var(--font-display)',
@@ -225,8 +238,8 @@ const styles = {
width: '100%',
maxWidth: '26rem',
padding: 'var(--space-3) var(--space-4)',
background: 'var(--color-mastered-bg, #eaf5ea)',
color: 'var(--color-mastered, #1a6e2e)',
background: 'var(--color-mastered-subtle)',
color: 'var(--color-mastered)',
borderRadius: '4px',
fontSize: '0.875rem',
margin: 0,