diff --git a/index.html b/index.html index 27a51a0..a881e85 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,12 @@ + + + APLP — Adaptive Personal Learning Platform diff --git a/public/img/login.png b/public/img/login.png new file mode 100644 index 0000000..c20fc51 Binary files /dev/null and b/public/img/login.png differ diff --git a/src/api/tokenStorage.ts b/src/api/tokenStorage.ts index 7bdd4a3..a12a96a 100644 --- a/src/api/tokenStorage.ts +++ b/src/api/tokenStorage.ts @@ -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); }, }; diff --git a/src/app/App.tsx b/src/app/App.tsx index e3de27b..2aecc4c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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() { }> }> } /> - Learn (placeholder)} /> + Vocabulary (placeholder)} /> + Assignment (placeholder)} /> + } /> + Progress (placeholder)} /> } /> diff --git a/src/app/layouts/AppLayout.tsx b/src/app/layouts/AppLayout.tsx index dccebfe..06a9df3 100644 --- a/src/app/layouts/AppLayout.tsx +++ b/src/app/layouts/AppLayout.tsx @@ -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 ( -
-
-
+
+
+
- + {env.VITE_APP_NAME} -
-
- {user && {user.email}} - +
+
-
+
diff --git a/src/app/pages/HomePage.tsx b/src/app/pages/HomePage.tsx index 7696184..d900e6a 100644 --- a/src/app/pages/HomePage.tsx +++ b/src/app/pages/HomePage.tsx @@ -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 ( +
+
+ {lesson.emoji} +
+
+ {active && ( + + In Progress + + )} +

{lesson.name}

+

{lesson.description}

+
+
+
+ +
+
+ ); +} + +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 ( +
+

Lessons in this Unit

+
+ {lessons.map((lesson) => ( + + ))} +
+ {pageCount > 1 && ( +
+ + {Array.from({ length: pageCount }, (_, i) => ( + +
+ )} +
+ ); +} + export function HomePage() { const { user, isLoading, isError } = useAuthUser(); return ( -
-

- Welcome{user ? `, ${user.name}` : ''} 👋 -

-

{env.VITE_APP_NAME} — your adaptive learning workspace.

-
- {isLoading &&

Loading profile…

} - {isError &&

Could not load profile details.

} - {user && !isLoading && ( -
    -
  • ID: {user.id}
  • -
  • Email: {user.email}
  • -
  • Name: {user.name}
  • -
- )} +
+
+

+ Welcome{user ? `, ${user.name}` : ''} 👋 +

+

+ {env.VITE_APP_NAME} — your adaptive learning workspace. +

+ +
); } diff --git a/src/app/pages/NotFoundPage.tsx b/src/app/pages/NotFoundPage.tsx index 77529c0..adccc19 100644 --- a/src/app/pages/NotFoundPage.tsx +++ b/src/app/pages/NotFoundPage.tsx @@ -2,12 +2,18 @@ import { Link } from 'react-router-dom'; export function NotFoundPage() { return ( -
-

404

-

This page does not exist.

- - Back home - +
+
+

🧭

+

404

+

This page does not exist.

+ + Back home + +
); } diff --git a/src/app/pages/RankingPage.tsx b/src/app/pages/RankingPage.tsx new file mode 100644 index 0000000..5fd2c34 --- /dev/null +++ b/src/app/pages/RankingPage.tsx @@ -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 = { 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 ( +
+

🏆 Ranking

+
+
+
+ Rank + Name + Level + Streak + Star + +
+ {MOCK_LEADERBOARD.map((entry) => ( +
+ + {RANK_MEDAL[entry.rank] ?? entry.rank} + + {entry.name} + Lv.{entry.level} + 🔥 {entry.streak} + ⭐ {entry.star} + +
+ ))} +
+
+ + setSelected(null)} title="User profile"> + {selected && ( +
+ + + + +
+
+
Display name
+
{selected.name}
+
+
+
Created at
+
{selected.createdAt}
+
+
+ + +
+ )} +
+
+ ); +} diff --git a/src/features/auth/components/UserMenu.tsx b/src/features/auth/components/UserMenu.tsx new file mode 100644 index 0000000..b5538b3 --- /dev/null +++ b/src/features/auth/components/UserMenu.tsx @@ -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 ( + <> + + + setOpen(false)} title="User profile"> +
+ + + + +
+ {fields.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+ + +
+
+ + ); +} diff --git a/src/features/auth/hooks/useLogin.ts b/src/features/auth/hooks/useLogin.ts index 191155f..1832ec8 100644 --- a/src/features/auth/hooks/useLogin.ts +++ b/src/features/auth/hooks/useLogin.ts @@ -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), }); } diff --git a/src/features/auth/pages/LoginPage.test.tsx b/src/features/auth/pages/LoginPage.test.tsx index a90c797..b383166 100644 --- a/src/features/auth/pages/LoginPage.test.tsx +++ b/src/features/auth/pages/LoginPage.test.tsx @@ -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 })); diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx index 85b192b..5d871ea 100644 --- a/src/features/auth/pages/LoginPage.tsx +++ b/src/features/auth/pages/LoginPage.tsx @@ -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,61 +19,89 @@ export function LoginPage() { return ; } - function handleSubmit(event: FormEvent) { + function handleSubmit(event: SubmitEvent) { event.preventDefault(); - login.mutate({ email, password }); + login.mutate({ username, password }); } return ( -
-
-

{env.VITE_APP_NAME}

-

Sign in to your learning account

+
+
+

{env.VITE_APP_NAME}

+

Let's learn together!

-
-
{login.isError && ( -

+

{login.error instanceof Error ? login.error.message : 'Login failed'}

)} - +
diff --git a/src/features/auth/store/authStore.test.ts b/src/features/auth/store/authStore.test.ts index 3a9c08d..5fc1bb3 100644 --- a/src/features/auth/store/authStore.test.ts +++ b/src/features/auth/store/authStore.test.ts @@ -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(); diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index c12974a..03f6d1f 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -10,7 +10,7 @@ interface AuthState { user: AuthUser | null; /** Whether a session bootstrap is in progress. */ isInitializing: boolean; - login: (email: string, password: string) => Promise; + login: (username: string, password: string) => Promise; logout: () => Promise; /** Bootstrap session: try refresh on app start. */ init: () => Promise; @@ -20,8 +20,8 @@ export const useAuthStore = create()((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 }); }, diff --git a/src/features/auth/types.ts b/src/features/auth/types.ts index c16f520..694ff2d 100644 --- a/src/features/auth/types.ts +++ b/src/features/auth/types.ts @@ -5,7 +5,7 @@ export interface AuthUser { } export interface LoginCredentials { - email: string; + username: string; password: string; } diff --git a/src/index.css b/src/index.css index d4b5078..51f4c44 100644 --- a/src/index.css +++ b/src/index.css @@ -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); +} diff --git a/src/shared/components/Button.tsx b/src/shared/components/Button.tsx new file mode 100644 index 0000000..b6c3d33 --- /dev/null +++ b/src/shared/components/Button.tsx @@ -0,0 +1,54 @@ +import type { ButtonHTMLAttributes } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'success' | 'outline' | 'ghost'; + +interface ButtonProps extends ButtonHTMLAttributes { + 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 = { + 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> = { + 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> = { + 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 ( + + {layer && } +