feat: add mock page
This commit is contained in:
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>APLP — Adaptive Personal Learning Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -1,23 +1,22 @@
|
||||
/**
|
||||
* In-memory token storage.
|
||||
* Token storage.
|
||||
*
|
||||
* Access token is kept in memory only (never persisted to localStorage /
|
||||
* sessionStorage) to reduce XSS exposure. The backend issues the refresh token
|
||||
* in the login/refresh response and expects it back in the request body, so we
|
||||
* keep it in memory alongside the access token for the session's lifetime.
|
||||
* Access token: in-memory only (XSS mitigation).
|
||||
* Refresh token: localStorage so it survives page reloads.
|
||||
*/
|
||||
const REFRESH_KEY = 'rt';
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let refreshToken: string | null = null;
|
||||
|
||||
export const tokenStorage = {
|
||||
get: (): string | null => accessToken,
|
||||
getRefreshToken: (): string | null => refreshToken,
|
||||
getRefreshToken: (): string | null => localStorage.getItem(REFRESH_KEY),
|
||||
set: (access: string, refresh: string | null): void => {
|
||||
accessToken = access;
|
||||
refreshToken = refresh;
|
||||
if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
|
||||
},
|
||||
clear: (): void => {
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
+5
-1
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AppLayout } from '@/app/layouts/AppLayout';
|
||||
import { HomePage } from '@/app/pages/HomePage';
|
||||
import { NotFoundPage } from '@/app/pages/NotFoundPage';
|
||||
import { RankingPage } from '@/app/pages/RankingPage';
|
||||
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute';
|
||||
import { LoginPage } from '@/features/auth/pages/LoginPage';
|
||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||
@@ -19,7 +20,10 @@ export default function App() {
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="/learn" element={<div>Learn (placeholder)</div>} />
|
||||
<Route path="/vocabulary" element={<div>Vocabulary (placeholder)</div>} />
|
||||
<Route path="/assignment" element={<div>Assignment (placeholder)</div>} />
|
||||
<Route path="/ranking" element={<RankingPage />} />
|
||||
<Route path="/progress" element={<div>Progress (placeholder)</div>} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/404" element={<NotFoundPage />} />
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
import { Link, NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
import env from '@/shared/config/env';
|
||||
import { useLogout } from '@/features/auth/hooks/useLogout';
|
||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||
import { UserMenu } from '@/features/auth/components/UserMenu';
|
||||
|
||||
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-full px-3 py-1.5 transition-colors ${
|
||||
isActive ? 'bg-primary-light text-primary' : 'text-ink-secondary hover:bg-surface-soft hover:text-ink'
|
||||
}`;
|
||||
|
||||
export function AppLayout() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useLogout();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="border-b border-gray-200 bg-white">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b border-border bg-surface">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link to="/" className="text-lg font-semibold text-gray-900">
|
||||
<Link to="/" className="text-lg font-extrabold text-ink">
|
||||
{env.VITE_APP_NAME}
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<Link to="/" className="hover:text-gray-900">
|
||||
<nav className="flex items-center gap-1 text-sm font-bold">
|
||||
<NavLink to="/" end className={navLinkClass}>
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/learn" className="hover:text-gray-900">
|
||||
Learn
|
||||
</Link>
|
||||
</NavLink>
|
||||
<NavLink to="/vocabulary" className={navLinkClass}>
|
||||
Vocabulary
|
||||
</NavLink>
|
||||
<NavLink to="/assignment" className={navLinkClass}>
|
||||
Assignment
|
||||
</NavLink>
|
||||
<NavLink to="/ranking" className={navLinkClass}>
|
||||
Ranking
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{user && <span className="text-sm text-gray-500">{user.email}</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
{logout.isPending ? 'Signing out…' : 'Sign out'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-5xl px-4 py-8">
|
||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+100
-12
@@ -1,26 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import env from '@/shared/config/env';
|
||||
import { Button } from '@/shared/components/Button';
|
||||
import { useAuthUser } from '@/features/auth/hooks/useAuthUser';
|
||||
|
||||
// ponytail: hardcoded until course/lesson API exists; swap for real lessons of the current unit then.
|
||||
const MOCK_LESSONS = [
|
||||
{ id: 1, name: 'Everyday Greetings', description: 'Say hello and introduce yourself with confidence.', emoji: '👋', progress: 65 },
|
||||
{ id: 2, name: 'Numbers & Counting', description: 'Master 1 to 100 and everyday quantities.', emoji: '🔢' },
|
||||
{ id: 3, name: 'Family Members', description: 'Talk about parents, siblings, and relatives.', emoji: '👨👩👧' },
|
||||
{ id: 4, name: 'Colors & Shapes', description: 'Describe the world around you.', emoji: '🎨' },
|
||||
{ id: 5, name: 'Daily Routines', description: 'Talk about your day from morning to night.', emoji: '⏰' },
|
||||
{ id: 6, name: 'Food & Drinks', description: 'Order meals and talk about your favorite dishes.', emoji: '🍜' },
|
||||
{ id: 7, name: 'Weather Talk', description: 'Small talk about sun, rain, and seasons.', emoji: '⛅' },
|
||||
{ id: 8, name: 'Directions', description: 'Ask for and give directions around town.', emoji: '🧭' },
|
||||
{ id: 9, name: 'Shopping Basics', description: 'Buy things and handle money confidently.', emoji: '🛍️' },
|
||||
];
|
||||
const LESSONS_PER_PAGE = 6;
|
||||
|
||||
function LessonCard({ lesson, active }: { lesson: (typeof MOCK_LESSONS)[number]; active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col overflow-hidden rounded-xl border shadow-[0_5px_0_rgba(37,42,82,0.08)] ${
|
||||
active ? 'border-primary bg-primary-light' : 'border-border bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-28 items-center justify-center text-5xl ${active ? 'bg-primary/15' : 'bg-surface-soft'}`}>
|
||||
{lesson.emoji}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
{active && (
|
||||
<span className="mb-1.5 inline-flex w-fit rounded-full bg-primary px-2.5 py-0.5 text-xs font-extrabold text-ink-inverse">
|
||||
In Progress
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-extrabold text-ink">{lesson.name}</p>
|
||||
<p className="my-1 flex-1 text-xs font-medium text-ink-secondary">{lesson.description}</p>
|
||||
<div className="my-3 h-2 rounded-full border border-border bg-surface">
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${lesson.progress ?? 0}%` }} />
|
||||
</div>
|
||||
<Button variant={active ? 'primary' : 'secondary'} className="w-full text-sm">
|
||||
{active ? 'Continue' : 'Start'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonGrid() {
|
||||
const [page, setPage] = useState(0);
|
||||
const pageCount = Math.ceil(MOCK_LESSONS.length / LESSONS_PER_PAGE);
|
||||
const lessons = MOCK_LESSONS.slice(page * LESSONS_PER_PAGE, page * LESSONS_PER_PAGE + LESSONS_PER_PAGE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-extrabold text-ink">Lessons in this Unit</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{lessons.map((lesson) => (
|
||||
<LessonCard key={lesson.id} lesson={lesson} active={lesson.id === 1} />
|
||||
))}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
aria-label="Previous page"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-ink-secondary hover:bg-surface-soft disabled:opacity-30"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{Array.from({ length: pageCount }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setPage(i)}
|
||||
aria-label={`Page ${i + 1}`}
|
||||
className={`h-2.5 w-2.5 rounded-full ${i === page ? 'bg-primary' : 'bg-surface-soft'}`}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
disabled={page === pageCount - 1}
|
||||
aria-label="Next page"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-ink-secondary hover:bg-surface-soft disabled:opacity-30"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { user, isLoading, isError } = useAuthUser();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
<h1 className="text-3xl font-black text-ink">
|
||||
Welcome{user ? `, ${user.name}` : ''} 👋
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">{env.VITE_APP_NAME} — your adaptive learning workspace.</p>
|
||||
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-600">
|
||||
{isLoading && <p>Loading profile…</p>}
|
||||
{isError && <p>Could not load profile details.</p>}
|
||||
{user && !isLoading && (
|
||||
<ul className="space-y-1">
|
||||
<li>ID: {user.id}</li>
|
||||
<li>Email: {user.email}</li>
|
||||
<li>Name: {user.name}</li>
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-1 font-medium text-ink-secondary">
|
||||
{env.VITE_APP_NAME} — your adaptive learning workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LessonGrid />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,18 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 px-4 text-center">
|
||||
<h1 className="text-4xl font-semibold text-gray-900">404</h1>
|
||||
<p className="mt-2 text-gray-600">This page does not exist.</p>
|
||||
<Link to="/" className="mt-4 text-sm font-medium text-indigo-600 hover:text-indigo-700">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-sky-light px-4 text-center">
|
||||
<div className="rounded-3xl bg-surface p-10">
|
||||
<p className="text-5xl">🧭</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-ink">404</h1>
|
||||
<p className="mt-2 font-medium text-ink-secondary">This page does not exist.</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-5 inline-flex min-h-12 items-center justify-center rounded-2xl border-2 border-primary bg-surface px-6 py-2.5 font-extrabold text-primary hover:bg-primary-light"
|
||||
>
|
||||
Back home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/shared/components/Button';
|
||||
import { Modal } from '@/shared/components/Modal';
|
||||
|
||||
// ponytail: hardcoded until leaderboard API exists; swap for real ranking data then.
|
||||
const MOCK_LEADERBOARD = [
|
||||
{ rank: 1, name: 'Minh Anh', level: 12, star: 45, streak: 30, createdAt: '2024-02-14' },
|
||||
{ rank: 2, name: 'Quốc Bảo', level: 11, star: 41, streak: 18, createdAt: '2024-03-02' },
|
||||
{ rank: 3, name: 'Thu Hà', level: 10, star: 38, streak: 22, createdAt: '2024-01-20' },
|
||||
{ rank: 4, name: 'Đức Long', level: 9, star: 33, streak: 7, createdAt: '2024-05-11' },
|
||||
{ rank: 5, name: 'Ngọc Linh', level: 8, star: 29, streak: 12, createdAt: '2024-04-08' },
|
||||
{ rank: 6, name: 'Bạn', level: 7, star: 25, streak: 5, createdAt: '2024-06-01' },
|
||||
];
|
||||
const RANK_MEDAL: Record<number, string> = { 1: '🥇', 2: '🥈', 3: '🥉' };
|
||||
const rankBadgeClass = (rank: number) =>
|
||||
rank === 1
|
||||
? 'bg-secondary text-ink'
|
||||
: rank === 2
|
||||
? 'bg-surface-soft text-ink'
|
||||
: rank === 3
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'bg-surface-soft text-ink-secondary';
|
||||
|
||||
// ponytail: same identicon trick as UserMenu — seeded by name since ranking has no email.
|
||||
function gravatarUrl(seed: string, size: number) {
|
||||
return `https://www.gravatar.com/avatar/${encodeURIComponent(seed.trim().toLowerCase())}?d=identicon&s=${size}`;
|
||||
}
|
||||
|
||||
export function RankingPage() {
|
||||
const [selected, setSelected] = useState<(typeof MOCK_LEADERBOARD)[number] | null>(null);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h1 className="text-3xl font-black text-ink">🏆 Ranking</h1>
|
||||
<div className="mt-6 rounded-xl border border-border bg-surface p-5 shadow-[0_5px_0_rgba(37,42,82,0.08)]">
|
||||
<div className="space-y-1">
|
||||
<div className="grid grid-cols-[2rem_1fr_3.25rem_4.25rem_3.25rem_2rem] gap-2 px-2 pb-2 text-xs font-bold uppercase tracking-wide text-ink-muted">
|
||||
<span>Rank</span>
|
||||
<span>Name</span>
|
||||
<span className="whitespace-nowrap text-center">Level</span>
|
||||
<span className="whitespace-nowrap text-center">Streak</span>
|
||||
<span className="whitespace-nowrap text-center">Star</span>
|
||||
<span />
|
||||
</div>
|
||||
{MOCK_LEADERBOARD.map((entry) => (
|
||||
<div
|
||||
key={entry.rank}
|
||||
className={`grid grid-cols-[2rem_1fr_3.25rem_4.25rem_3.25rem_2rem] items-center gap-2 rounded-lg px-2 py-2 ${entry.name === 'Bạn' ? 'bg-primary-light' : ''}`}
|
||||
>
|
||||
<span className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-black ${rankBadgeClass(entry.rank)}`}>
|
||||
{RANK_MEDAL[entry.rank] ?? entry.rank}
|
||||
</span>
|
||||
<span className="truncate text-sm font-bold text-ink">{entry.name}</span>
|
||||
<span className="text-center text-sm font-bold text-ink-secondary">Lv.{entry.level}</span>
|
||||
<span className="text-center text-sm font-bold text-ink-secondary">🔥 {entry.streak}</span>
|
||||
<span className="text-center text-sm font-extrabold text-primary">⭐ {entry.star}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(entry)}
|
||||
aria-label={`Xem hồ sơ ${entry.name}`}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-ink-muted hover:bg-surface-soft hover:text-ink"
|
||||
>
|
||||
👤
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={selected !== null} onClose={() => setSelected(null)} title="User profile">
|
||||
{selected && (
|
||||
<div className="relative flex flex-col items-center gap-4 px-6 py-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
aria-label="Close"
|
||||
className="absolute top-3 right-3 rounded-full p-1.5 text-ink-muted hover:bg-surface-soft hover:text-ink"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={gravatarUrl(selected.name, 160)}
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-full border-4 border-primary-light"
|
||||
/>
|
||||
|
||||
<dl className="w-full space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border pb-2">
|
||||
<dt className="text-ink-secondary">Display name</dt>
|
||||
<dd className="truncate font-semibold text-ink">{selected.name}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border pb-2">
|
||||
<dt className="text-ink-secondary">Created at</dt>
|
||||
<dd className="truncate font-semibold text-ink">{selected.createdAt}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Button type="button" variant="outline" onClick={() => setSelected(null)} className="min-h-11 w-full text-sm">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/shared/components/Button';
|
||||
import { Modal } from '@/shared/components/Modal';
|
||||
import { useLogout } from '../hooks/useLogout';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
// ponytail: Gravatar's default-image endpoint seeds off whatever string sits in the
|
||||
// path — no real MD5 needed for a mocked identicon. Swap in a proper md5(email) hash
|
||||
// when real Gravatar photos (not just the generated identicon) are wanted.
|
||||
function gravatarUrl(email: string, size: number) {
|
||||
return `https://www.gravatar.com/avatar/${encodeURIComponent(email.trim().toLowerCase())}?d=identicon&s=${size}`;
|
||||
}
|
||||
|
||||
const PLACEHOLDER = 'Not provided';
|
||||
|
||||
// ponytail: hardcoded until learner/progress feature exposes a real API; swap then.
|
||||
const MOCK_PROGRESS = { level: 4, xp: 320, streak: 7 };
|
||||
|
||||
export function UserMenu() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useLogout();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const fields = [
|
||||
{ label: 'Full name', value: user.name },
|
||||
{ label: 'Date of birth', value: PLACEHOLDER },
|
||||
{ label: 'Email', value: user.email },
|
||||
{ label: 'Phone', value: PLACEHOLDER },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)} className="flex items-center gap-1.5">
|
||||
<span className="flex h-10 items-center justify-center rounded-full bg-accent px-3 text-xs font-extrabold text-ink-inverse">
|
||||
🔥{MOCK_PROGRESS.streak}
|
||||
</span>
|
||||
<span className="flex h-10 items-center justify-center rounded-full bg-secondary px-3 text-xs font-extrabold text-ink">
|
||||
⭐{MOCK_PROGRESS.xp}
|
||||
</span>
|
||||
<div className="relative h-10 w-10">
|
||||
<img
|
||||
src={gravatarUrl(user.email, 80)}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-lg border border-border"
|
||||
/>
|
||||
<span className="absolute -bottom-1 -right-1 inline-flex items-center rounded-full bg-primary px-1.5 py-0.5 text-xs font-extrabold text-ink-inverse">
|
||||
{MOCK_PROGRESS.level}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title="User profile">
|
||||
<div className="relative flex flex-col items-center gap-4 px-6 py-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
className="absolute top-3 right-3 rounded-full p-1.5 text-ink-muted hover:bg-surface-soft hover:text-ink"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={gravatarUrl(user.email, 160)}
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-full border-4 border-primary-light"
|
||||
/>
|
||||
|
||||
<dl className="w-full space-y-3 text-sm">
|
||||
{fields.map((f) => (
|
||||
<div key={f.label} className="flex items-center justify-between gap-4 border-b border-border pb-2">
|
||||
<dt className="text-ink-secondary">{f.label}</dt>
|
||||
<dd className="truncate font-semibold text-ink">{f.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
logout.mutate();
|
||||
}}
|
||||
disabled={logout.isPending}
|
||||
className="min-h-11 w-full text-sm"
|
||||
>
|
||||
{logout.isPending ? 'Signing out…' : 'Sign out'}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,6 @@ export function useLogin() {
|
||||
const login = useAuthStore((s) => s.login);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (credentials: LoginCredentials) => login(credentials.email, credentials.password),
|
||||
mutationFn: (credentials: LoginCredentials) => login(credentials.username, credentials.password),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('LoginPage', () => {
|
||||
renderLogin();
|
||||
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Username')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
@@ -60,12 +60,12 @@ describe('LoginPage', () => {
|
||||
const user = userEvent.setup();
|
||||
renderLogin();
|
||||
|
||||
await user.type(screen.getByLabelText('Email'), 'learner@aplp.io');
|
||||
await user.type(screen.getByLabelText('Username'), 'learner');
|
||||
await user.type(screen.getByLabelText('Password'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
expect(mockedAuthApi.login).toHaveBeenCalledWith({
|
||||
email: 'learner@aplp.io',
|
||||
username: 'learner',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('LoginPage', () => {
|
||||
const user = userEvent.setup();
|
||||
renderLogin();
|
||||
|
||||
await user.type(screen.getByLabelText('Email'), 'learner@aplp.io');
|
||||
await user.type(screen.getByLabelText('Username'), 'learner');
|
||||
await user.type(screen.getByLabelText('Password'), 'wrong');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useState, type SubmitEvent } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import env from '@/shared/config/env';
|
||||
import { Button } from '@/shared/components/Button';
|
||||
import { useLogin } from '../hooks/useLogin';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
@@ -9,7 +10,7 @@ export function LoginPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { state } = useLocation();
|
||||
const login = useLogin();
|
||||
const [email, setEmail] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const from = (state as { from?: string } | null)?.from ?? '/';
|
||||
@@ -18,37 +19,68 @@ export function LoginPage() {
|
||||
return <Navigate to={from} replace />;
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
login.mutate({ email, password });
|
||||
login.mutate({ username, password });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
|
||||
<h1 className="mb-1 text-2xl font-semibold text-gray-900">{env.VITE_APP_NAME}</h1>
|
||||
<p className="mb-6 text-sm text-gray-500">Sign in to your learning account</p>
|
||||
<div className="flex min-h-screen items-center justify-center bg-[url('/img/login.png')] bg-cover bg-center px-4">
|
||||
<div className="w-full max-w-sm rounded-3xl border border-border bg-surface p-8 shadow-[0_20px_60px_rgba(32,35,58,0.1)]">
|
||||
<h1 className="mb-1 text-2xl font-extrabold text-ink text-center">{env.VITE_APP_NAME}</h1>
|
||||
<p className="mb-6 text-sm font-medium text-ink-secondary text-center">Let's learn together!</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" data-testid="login-form">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
<label htmlFor="username" className="mb-1 block text-sm font-bold text-ink">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-secondary"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full rounded-xl border-2 border-border py-2.5 pl-10 pr-4 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-bold text-ink">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-secondary"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -56,23 +88,20 @@ export function LoginPage() {
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
className="w-full rounded-xl border-2 border-border py-2.5 pl-10 pr-4 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{login.isError && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
<p role="alert" className="text-sm font-semibold text-accent">
|
||||
{login.error instanceof Error ? login.error.message : 'Login failed'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={login.isPending}
|
||||
className="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
<Button type="submit" disabled={login.isPending} className="w-full">
|
||||
{login.isPending ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,10 +34,10 @@ describe('authStore', () => {
|
||||
});
|
||||
|
||||
it('login stores token and user', async () => {
|
||||
await useAuthStore.getState().login('learner@aplp.io', 'secret');
|
||||
await useAuthStore.getState().login('learner', 'secret');
|
||||
|
||||
expect(mockedAuthApi.login).toHaveBeenCalledWith({
|
||||
email: 'learner@aplp.io',
|
||||
username: 'learner',
|
||||
password: 'secret',
|
||||
});
|
||||
expect(tokenStorage.get()).toBe('access-123');
|
||||
@@ -48,7 +48,7 @@ describe('authStore', () => {
|
||||
it('login failure leaves user signed out', async () => {
|
||||
mockedAuthApi.login.mockRejectedValue(new Error('invalid credentials'));
|
||||
|
||||
await expect(useAuthStore.getState().login('learner@aplp.io', 'wrong')).rejects.toThrow(
|
||||
await expect(useAuthStore.getState().login('learner', 'wrong')).rejects.toThrow(
|
||||
'invalid credentials',
|
||||
);
|
||||
expect(tokenStorage.get()).toBeNull();
|
||||
@@ -56,7 +56,7 @@ describe('authStore', () => {
|
||||
});
|
||||
|
||||
it('logout clears token and user even when API call fails', async () => {
|
||||
await useAuthStore.getState().login('learner@aplp.io', 'secret');
|
||||
await useAuthStore.getState().login('learner', 'secret');
|
||||
mockedAuthApi.logout.mockRejectedValue(new Error('network'));
|
||||
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
@@ -10,7 +10,7 @@ interface AuthState {
|
||||
user: AuthUser | null;
|
||||
/** Whether a session bootstrap is in progress. */
|
||||
isInitializing: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
/** Bootstrap session: try refresh on app start. */
|
||||
init: () => Promise<void>;
|
||||
@@ -20,8 +20,8 @@ export const useAuthStore = create<AuthState>()((set) => ({
|
||||
user: null,
|
||||
isInitializing: true,
|
||||
|
||||
login: async (email, password) => {
|
||||
const { accessToken, refreshToken, user } = await authApi.login({ email, password });
|
||||
login: async (username, password) => {
|
||||
const { accessToken, refreshToken, user } = await authApi.login({ username, password });
|
||||
tokenStorage.set(accessToken, refreshToken);
|
||||
set({ user, isInitializing: false });
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface AuthUser {
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,50 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-display: 'Nunito', system-ui, sans-serif;
|
||||
|
||||
--color-primary: #5b5fef;
|
||||
--color-primary-dark: #4548c9;
|
||||
--color-primary-light: #e8e9ff;
|
||||
|
||||
--color-secondary: #ffb84d;
|
||||
--color-secondary-dark: #e59a28;
|
||||
--color-secondary-light: #fff1d6;
|
||||
|
||||
--color-success: #35c978;
|
||||
--color-success-dark: #239b58;
|
||||
--color-success-light: #ddf7e8;
|
||||
|
||||
--color-accent: #ff6b6b;
|
||||
--color-accent-light: #ffe3e3;
|
||||
|
||||
--color-sky: #55b9f3;
|
||||
--color-sky-light: #e2f4ff;
|
||||
|
||||
--color-canvas: #f7f8fc;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-soft: #f0f2f8;
|
||||
--color-surface-hover: #e9ebf5;
|
||||
|
||||
--color-ink: #20233a;
|
||||
--color-ink-secondary: #62677f;
|
||||
--color-ink-muted: #969bae;
|
||||
--color-ink-inverse: #ffffff;
|
||||
|
||||
--color-border: #e2e5ef;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-display);
|
||||
background-color: var(--color-canvas);
|
||||
background-image:
|
||||
radial-gradient(circle at 8% -8%, var(--color-primary-light) 0%, transparent 38%),
|
||||
radial-gradient(circle at 92% 8%, var(--color-sky-light) 0%, transparent 32%),
|
||||
radial-gradient(circle at 50% 105%, var(--color-secondary-light) 0%, transparent 42%);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'success' | 'outline' | 'ghost';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
const base =
|
||||
'relative z-10 inline-flex items-center justify-center rounded-2xl font-extrabold disabled:opacity-50 disabled:pointer-events-none';
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-primary text-ink-inverse px-6 py-3.5 min-h-13 hover:brightness-105',
|
||||
secondary: 'bg-secondary text-ink px-6 py-3 min-h-12 hover:brightness-105',
|
||||
success: 'bg-success text-ink-inverse px-6 py-3 min-h-12 hover:brightness-105',
|
||||
outline: 'bg-surface text-primary border-2 border-primary px-6 py-2.5 min-h-12 hover:bg-primary-light',
|
||||
ghost: 'bg-transparent text-ink-secondary rounded-xl px-4 py-3 hover:bg-surface-soft',
|
||||
};
|
||||
|
||||
// Wrapper press: translate whole group (button + lip) down on active.
|
||||
// Lip collapses bottom offset to 0 → 3D depth disappears when pressed (DESIGN.md §4).
|
||||
const wrapperPress: Partial<Record<ButtonVariant, string>> = {
|
||||
primary: 'active:translate-y-[5px]',
|
||||
secondary: 'active:translate-y-[4px]',
|
||||
success: 'active:translate-y-[4px]',
|
||||
};
|
||||
|
||||
// Static "base" layer behind the button — gives the 3D depth lip without animating
|
||||
// box-shadow (which isn't GPU-composited and looked janky). On press the lip
|
||||
// collapses to bottom-0 so the button sinks and loses its elevation (DESIGN.md §4).
|
||||
// Uses group-active because this span is a sibling of button, not its parent.
|
||||
// Lip sits flush behind-and-below the button: top aligns with button top (z-10 covers it),
|
||||
// bottom extends 5px below → only the protruding bottom strip is visible = 3D ledge below.
|
||||
// On press: wrapper translates down, lip top stays flush, bottom collapses to 0 → ledge gone.
|
||||
const depthLayer: Partial<Record<ButtonVariant, string>> = {
|
||||
primary: 'absolute inset-x-0 top-0 -bottom-[5px] rounded-2xl bg-primary-dark transition-[bottom] duration-75 ease-out group-active:-bottom-0',
|
||||
secondary: 'absolute inset-x-0 top-0 -bottom-[4px] rounded-2xl bg-secondary-dark transition-[bottom] duration-75 ease-out group-active:-bottom-0',
|
||||
success: 'absolute inset-x-0 top-0 -bottom-[4px] rounded-2xl bg-success-dark transition-[bottom] duration-75 ease-out group-active:-bottom-0',
|
||||
};
|
||||
|
||||
export function Button({ variant = 'primary', className = '', ...props }: ButtonProps) {
|
||||
const layer = depthLayer[variant];
|
||||
const press = wrapperPress[variant] ?? '';
|
||||
// ponytail: string-matching "w-full" to size the wrapper is a heuristic, not general —
|
||||
// fine for this app's handful of call sites; revisit if more layout classes need forwarding.
|
||||
const stretch = className.includes('w-full') ? 'w-full' : '';
|
||||
|
||||
return (
|
||||
<span className={`group relative inline-flex transform-gpu transition-transform duration-75 ease-out ${press} ${stretch}`}>
|
||||
{layer && <span aria-hidden className={layer} />}
|
||||
<button className={`${base} ${variants[variant]} ${className}`} {...props} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export function EmptyState({ title, description }: { title: string; description?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center">
|
||||
<p className="text-sm font-medium text-gray-700">{title}</p>
|
||||
{description && <p className="mt-1 text-sm text-gray-500">{description}</p>}
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-surface-soft px-6 py-12 text-center">
|
||||
<p className="text-sm font-bold text-ink-secondary">{title}</p>
|
||||
{description && <p className="mt-1 text-sm font-medium text-ink-muted">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 text-center">
|
||||
<p className="text-sm text-gray-600">Something went wrong. Please reload the page.</p>
|
||||
<div className="flex min-h-screen items-center justify-center bg-canvas px-4 text-center">
|
||||
<p className="text-sm font-semibold text-ink-secondary">
|
||||
Something went wrong. Please reload the page.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -7,9 +7,9 @@ interface ErrorStateProps {
|
||||
|
||||
export function ErrorState({ title = 'Something went wrong', error }: ErrorStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 px-6 py-8 text-center">
|
||||
<p className="text-sm font-medium text-red-700">{title}</p>
|
||||
{error && <p className="mt-1 text-sm text-red-600">{error.message}</p>}
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-accent bg-accent-light px-6 py-8 text-center">
|
||||
<p className="text-sm font-bold text-accent">{title}</p>
|
||||
{error && <p className="mt-1 text-sm font-medium text-accent">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Spinner } from '@/shared/components/Spinner';
|
||||
|
||||
export function FullPageSpinner({ label = 'Loading' }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex min-h-screen items-center justify-center bg-canvas">
|
||||
<Spinner label={label} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
onClick={(e) => e.target === ref.current && onClose()}
|
||||
aria-label={title}
|
||||
className="m-auto w-full max-w-sm rounded-3xl border border-border bg-surface p-0 shadow-xl backdrop:bg-ink/40 backdrop:backdrop-blur-sm"
|
||||
>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export function Spinner({ label = 'Loading' }: { label?: string }) {
|
||||
return (
|
||||
<span role="status" className="inline-flex items-center gap-2 text-sm text-gray-500">
|
||||
<svg className="h-4 w-4 animate-spin text-indigo-600" viewBox="0 0 24 24" fill="none">
|
||||
<span role="status" className="inline-flex items-center gap-2 text-sm font-semibold text-ink-secondary">
|
||||
<svg className="h-4 w-4 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
|
||||
Reference in New Issue
Block a user