activity log + issue reporting integrations + responsiveness fix + more advanced styling + minor fixes

This commit is contained in:
Yared Yemane 2026-02-13 05:28:38 -08:00
parent a29d82bfee
commit 25badbcca5
42 changed files with 4416 additions and 1603 deletions

1
.env
View File

@ -1 +1,2 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1 VITE_API_BASE_URL=http://localhost:8080/api/v1
VITE_GOOGLE_CLIENT_ID=google_client_id

11
package-lock.json generated
View File

@ -22,6 +22,7 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
@ -5658,6 +5659,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@ -24,6 +24,7 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },

View File

@ -1,5 +1,22 @@
import { Toaster } from 'sonner'
import { AppRoutes } from './app/AppRoutes' import { AppRoutes } from './app/AppRoutes'
export default function App() { export default function App() {
return <AppRoutes /> return (
<>
<AppRoutes />
<Toaster
position="top-center"
toastOptions={{
className: 'font-sans',
style: {
padding: '14px 20px',
borderRadius: '12px',
fontSize: '14px',
},
}}
richColors
/>
</>
)
} }

View File

@ -0,0 +1,14 @@
import http from "./http";
import type {
GetActivityLogsResponse,
GetActivityLogResponse,
ActivityLogFilters,
} from "../types/activity-log.types";
export const getActivityLogs = (filters?: ActivityLogFilters) =>
http.get<GetActivityLogsResponse>("/activity-logs", {
params: filters,
});
export const getActivityLogById = (id: number) =>
http.get<GetActivityLogResponse>(`/activity-logs/${id}`);

View File

@ -20,3 +20,18 @@ export const login = async (payload: LoginRequest): Promise<LoginResult> => {
memberId: data.member_id, memberId: data.member_id,
} }
} }
export const loginWithGoogle = async (credential: string): Promise<LoginResult> => {
const res = await http.post<LoginResponse>("/team/google-login", {
token: credential,
})
const data: LoginResponseData = res.data.data
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
role: data.team_role,
memberId: data.member_id,
}
}

25
src/api/issues.api.ts Normal file
View File

@ -0,0 +1,25 @@
import http from "./http";
import type {
GetIssuesResponse,
GetIssueResponse,
UpdateIssueStatusResponse,
DeleteIssueResponse,
IssueFilters,
} from "../types/issue.types";
export const getIssues = (filters?: IssueFilters) =>
http.get<GetIssuesResponse>("/issues", {
params: filters,
});
export const getIssuesByUserId = (userId: number) =>
http.get<GetIssuesResponse>(`/issues/user/${userId}`);
export const getIssueById = (id: number) =>
http.get<GetIssueResponse>(`/issues/${id}`);
export const updateIssueStatus = (id: number, status: string) =>
http.patch<UpdateIssueStatusResponse>(`/issues/${id}/status`, { status });
export const deleteIssue = (id: number) =>
http.delete<DeleteIssueResponse>(`/issues/${id}`);

View File

@ -29,6 +29,7 @@ import { PracticeMembersPage } from "../pages/content-management/PracticeMembers
import { QuestionsPage } from "../pages/content-management/QuestionsPage" import { QuestionsPage } from "../pages/content-management/QuestionsPage"
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage" import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
import { UserLogPage } from "../pages/user-log/UserLogPage" import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage" import { ProfilePage } from "../pages/ProfilePage"
import { TeamManagementPage } from "../pages/team/TeamManagementPage" import { TeamManagementPage } from "../pages/team/TeamManagementPage"
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage" import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
@ -79,6 +80,7 @@ export function AppRoutes() {
<Route path="/notifications" element={<NotificationsPage />} /> <Route path="/notifications" element={<NotificationsPage />} />
<Route path="/user-log" element={<UserLogPage />} /> <Route path="/user-log" element={<UserLogPage />} />
<Route path="/issues" element={<IssuesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/team" element={<TeamManagementPage />} /> <Route path="/team" element={<TeamManagementPage />} />

View File

@ -2,6 +2,7 @@ import {
BarChart3, BarChart3,
Bell, Bell,
BookOpen, BookOpen,
CircleAlert,
ClipboardList, ClipboardList,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
@ -9,6 +10,7 @@ import {
UserCircle2, UserCircle2,
Users, Users,
Users2, Users2,
X,
} from "lucide-react" } from "lucide-react"
import type { ComponentType } from "react" import type { ComponentType } from "react"
import { NavLink } from "react-router-dom" import { NavLink } from "react-router-dom"
@ -28,16 +30,47 @@ const navItems: NavItem[] = [
{ label: "Content Management", to: "/content", icon: BookOpen }, { label: "Content Management", to: "/content", icon: BookOpen },
{ label: "Notifications", to: "/notifications", icon: Bell }, { label: "Notifications", to: "/notifications", icon: Bell },
{ label: "User Log", to: "/user-log", icon: ClipboardList }, { label: "User Log", to: "/user-log", icon: ClipboardList },
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
{ label: "Analytics", to: "/analytics", icon: BarChart3 }, { label: "Analytics", to: "/analytics", icon: BarChart3 },
{ label: "Team Management", to: "/team", icon: Users2 }, { label: "Team Management", to: "/team", icon: Users2 },
{ label: "Profile", to: "/profile", icon: UserCircle2 }, { label: "Profile", to: "/profile", icon: UserCircle2 },
] ]
export function Sidebar() { type SidebarProps = {
isOpen: boolean
onClose: () => void
}
export function Sidebar({ isOpen, onClose }: SidebarProps) {
return ( return (
<aside className="fixed left-0 top-0 flex h-screen w-[264px] flex-col border-r bg-grayScale-50 px-4 py-5"> <>
<div className="px-2"> {/* Mobile overlay */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
isOpen ? "opacity-100" : "pointer-events-none opacity-0",
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Sidebar panel */}
<aside
className={cn(
"fixed left-0 top-0 z-50 flex h-screen w-[264px] flex-col border-r bg-grayScale-50 px-4 py-5 transition-transform duration-300 lg:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex items-center justify-between px-2">
<BrandLogo /> <BrandLogo />
<button
type="button"
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600 lg:hidden"
onClick={onClose}
aria-label="Close sidebar"
>
<X className="h-5 w-5" />
</button>
</div> </div>
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto"> <nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
@ -47,6 +80,7 @@ export function Sidebar() {
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
onClick={onClose}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 transition", "group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 transition",
@ -80,6 +114,10 @@ export function Sidebar() {
<div className="px-2 pt-6"> <div className="px-2 pt-6">
<button <button
type="button" type="button"
onClick={() => {
localStorage.clear()
window.location.href = "/login"
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
@ -87,7 +125,6 @@ export function Sidebar() {
</button> </button>
</div> </div>
</aside> </aside>
</>
) )
} }

View File

@ -1,13 +1,16 @@
"use client" // make sure this is a client component "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Bell, LogOut, Settings, UserCircle2 } from "lucide-react" import { Bell, LogOut, Menu, Settings, UserCircle2 } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu" import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
export function Topbar() { type TopbarProps = {
// const [showLogoutConfirm, setShowLogoutConfirm] = useState(false) onMenuClick: () => void
}
export function Topbar({ onMenuClick }: TopbarProps) {
const [shortName, setShortName] = useState("AA") const [shortName, setShortName] = useState("AA")
useEffect(() => { useEffect(() => {
@ -34,20 +37,23 @@ export function Topbar() {
case "logout": case "logout":
localStorage.clear() localStorage.clear()
window.location.href = "/login" window.location.href = "/login"
// setShowLogoutConfirm(true) // Show confirmation popup instead of immediate logout
break break
} }
} }
// const confirmLogout = () => {
// localStorage.clear()
// window.location.href = "/login"
// }
// const cancelLogout = () => setShowLogoutConfirm(false)
return ( return (
<header className="sticky top-0 z-10 flex h-16 items-center justify-end gap-3 border-b bg-grayScale-50/85 px-6 backdrop-blur"> <header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
{/* Mobile hamburger */}
<button
type="button"
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600 lg:hidden"
onClick={onMenuClick}
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-3">
{/* Notifications */} {/* Notifications */}
<button <button
type="button" type="button"
@ -57,6 +63,9 @@ export function Topbar() {
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
</button> </button>
{/* Separator */}
<div className="h-6 w-px bg-grayScale-200" />
{/* Avatar + Radix Dropdown */} {/* Avatar + Radix Dropdown */}
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
@ -110,35 +119,7 @@ export function Topbar() {
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
{/* Logout Confirmation Modal */}
{/* {showLogoutConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 transition-animation-fade">
<div className="w-80 rounded-lg bg-white p-6 shadow-lg">
<h3 className="mb-4 text-lg font-semibold text-grayScale-700">
Confirm Logout
</h3>
<p className="mb-6 text-sm text-grayScale-500">
Are you sure you want to log out?
</p>
<div className="flex justify-end gap-2">
<button
onClick={cancelLogout}
className="rounded bg-grayScale-200 px-4 py-2 text-sm font-medium text-grayScale-700 hover:bg-grayScale-300"
>
Cancel
</button>
<button
onClick={confirmLogout}
className="rounded bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
>
Logout
</button>
</div> </div>
</div>
</div>
)} */}
</header> </header>
) )
} }

View File

@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className, className,
)} )}
{...props} {...props}

View File

@ -1,19 +1,28 @@
import { useState, useCallback } from "react"
import { Outlet } from "react-router-dom" import { Outlet } from "react-router-dom"
import { Sidebar } from "../components/sidebar/Sidebar" import { Sidebar } from "../components/sidebar/Sidebar"
import { Topbar } from "../components/topbar/Topbar" import { Topbar } from "../components/topbar/Topbar"
export function AppLayout() { export function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const handleMenuClick = useCallback(() => {
setSidebarOpen(true)
}, [])
const handleSidebarClose = useCallback(() => {
setSidebarOpen(false)
}, [])
return ( return (
<div className="flex min-h-screen bg-grayScale-100"> <div className="flex min-h-screen bg-grayScale-100">
<Sidebar /> <Sidebar isOpen={sidebarOpen} onClose={handleSidebarClose} />
<div className="ml-[264px] flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col lg:ml-[264px]">
<Topbar /> <Topbar onMenuClick={handleMenuClick} />
<main className="min-w-0 flex-1 overflow-y-auto px-6 pb-8 pt-4"> <main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
</div> </div>
) )
} }

View File

@ -4,7 +4,7 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
Globe, Globe,
GraduationCap, // GraduationCap,
Languages, Languages,
Mail, Mail,
MapPin, MapPin,
@ -13,10 +13,10 @@ import {
User, User,
XCircle, XCircle,
Briefcase, Briefcase,
// RefreshCw,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "../components/ui/badge"; import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Separator } from "../components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { getMyProfile } from "../api/users.api"; import { getMyProfile } from "../api/users.api";
@ -44,13 +44,37 @@ function formatDateTime(dateStr: string | null | undefined): string {
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="mx-auto w-full max-w-5xl space-y-6 py-8"> <div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-10 sm:px-6">
<div className="animate-pulse"> <div className="animate-pulse space-y-8">
<div className="rounded-2xl bg-grayScale-100 h-72" /> {/* Hero skeleton */}
<div className="mt-6 grid gap-6 md:grid-cols-3"> <div className="overflow-hidden rounded-2xl border border-grayScale-100">
<div className="rounded-2xl bg-grayScale-100 h-56" /> <div className="h-36 bg-gradient-to-r from-grayScale-100 via-grayScale-200/60 to-grayScale-100" />
<div className="rounded-2xl bg-grayScale-100 h-56" /> <div className="flex flex-col items-center px-8 pb-8">
<div className="rounded-2xl bg-grayScale-100 h-56" /> <div className="-mt-14 h-28 w-28 rounded-full bg-grayScale-100 ring-4 ring-white" />
<div className="mt-4 h-6 w-48 rounded-lg bg-grayScale-100" />
<div className="mt-3 h-5 w-24 rounded-full bg-grayScale-100" />
<div className="mt-5 flex gap-3">
<div className="h-7 w-20 rounded-full bg-grayScale-100" />
<div className="h-7 w-28 rounded-full bg-grayScale-100" />
<div className="h-7 w-28 rounded-full bg-grayScale-100" />
</div>
</div>
</div>
{/* Info cards skeleton */}
<div className="grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-2xl border border-grayScale-100 p-6">
<div className="mb-5 h-5 w-40 rounded bg-grayScale-100" />
<div className="space-y-4">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="flex items-center justify-between">
<div className="h-4 w-20 rounded bg-grayScale-100" />
<div className="h-4 w-28 rounded bg-grayScale-100" />
</div>
))}
</div>
</div>
))}
</div> </div>
</div> </div>
</div> </div>
@ -69,13 +93,15 @@ function InfoRow({
extra?: React.ReactNode; extra?: React.ReactNode;
}) { }) {
return ( return (
<div className="flex items-center justify-between py-3"> <div className="group flex items-center justify-between rounded-lg px-3 py-3 transition-colors hover:bg-grayScale-100/60">
<div className="flex items-center gap-3 text-sm text-grayScale-400"> <div className="flex items-center gap-3 text-sm text-grayScale-400">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400 transition-colors group-hover:bg-brand-100 group-hover:text-brand-500">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<span>{label}</span> </div>
<span className="font-medium">{label}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600"> <div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<span>{value || "—"}</span> <span className="text-right">{value || "—"}</span>
{extra} {extra}
</div> </div>
</div> </div>
@ -84,9 +110,48 @@ function InfoRow({
function VerifiedIcon({ verified }: { verified: boolean }) { function VerifiedIcon({ verified }: { verified: boolean }) {
return verified ? ( return verified ? (
<CheckCircle2 className="h-4 w-4 text-mint-500" /> <div className="flex h-5 w-5 items-center justify-center rounded-full bg-mint-100">
<CheckCircle2 className="h-3.5 w-3.5 text-mint-500" />
</div>
) : ( ) : (
<XCircle className="h-4 w-4 text-grayScale-300" /> <div className="flex h-5 w-5 items-center justify-center rounded-full bg-grayScale-100">
<XCircle className="h-3.5 w-3.5 text-grayScale-300" />
</div>
);
}
function ProgressRing({ percent }: { percent: number }) {
const radius = 18;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg className="h-11 w-11 -rotate-90" viewBox="0 0 44 44">
<circle
cx="22"
cy="22"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-grayScale-200"
/>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className="text-brand-500 transition-all duration-700"
/>
</svg>
<span className="absolute text-[10px] font-bold text-brand-600">{percent}%</span>
</div>
); );
} }
@ -114,14 +179,19 @@ export function ProfilePage() {
if (error || !profile) { if (error || !profile) {
return ( return (
<div className="mx-auto w-full max-w-5xl py-12"> <div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
<Card> <Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-4 p-10"> <CardContent className="flex flex-col items-center gap-5 p-12">
<div className="h-16 w-16 rounded-full bg-grayScale-100 flex items-center justify-center"> <div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
<User className="h-8 w-8 text-grayScale-300" /> <User className="h-10 w-10 text-grayScale-300" />
</div> </div>
<div className="text-lg font-semibold text-grayScale-600"> <div className="text-center">
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
{error || "Profile not available"} {error || "Profile not available"}
</p>
<p className="mt-1 text-sm text-grayScale-400">
Please check your connection and try again.
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -133,57 +203,88 @@ export function ProfilePage() {
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase(); const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
const completionPct = profile.profile_completion_percentage ?? 0; const completionPct = profile.profile_completion_percentage ?? 0;
const sectionCardIcons: Record<string, { icon: typeof User; color: string }> = {
personal: { icon: User, color: "from-brand-500 to-brand-600" },
contact: { icon: Mail, color: "from-brand-400 to-brand-500" },
account: { icon: Shield, color: "from-brand-600 to-brand-500" },
};
return ( return (
<div className="mx-auto w-full max-w-5xl space-y-6 py-8"> <div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-8 sm:px-6">
<Card className="overflow-hidden"> {/* Hero Card */}
<div className="h-32 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" /> <Card className="overflow-hidden border-0 shadow-lg">
<CardContent className="-mt-14 px-8 pb-8 pt-0"> {/* Banner gradient */}
<div className="relative h-36 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 sm:h-40">
{/* Decorative pattern overlay */}
<div className="absolute inset-0 opacity-10">
<div
className="h-full w-full"
style={{
backgroundImage:
"radial-gradient(circle at 25% 50%, white 1px, transparent 1px), radial-gradient(circle at 75% 50%, white 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
</div>
{/* Bottom fade */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white/20 to-transparent" />
</div>
<CardContent className="-mt-16 px-6 pb-8 pt-0 sm:px-10">
<div className="flex flex-col items-center text-center"> <div className="flex flex-col items-center text-center">
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft"> {/* Avatar */}
<Avatar className="h-28 w-28 ring-4 ring-white shadow-lg">
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} /> <AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
<AvatarFallback className="bg-brand-100 text-brand-600 text-2xl font-bold"> <AvatarFallback className="bg-gradient-to-br from-brand-100 to-brand-200 text-2xl font-bold text-brand-600">
{initials} {initials}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<h1 className="mt-4 text-2xl font-bold text-grayScale-600">{fullName}</h1> {/* Name */}
<h1 className="mt-4 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
{fullName}
</h1>
{/* Role badge */}
<Badge <Badge
className={cn( className={cn(
"mt-2", "mt-2.5 px-3 py-1",
profile.role === "ADMIN" profile.role === "ADMIN"
? "bg-brand-500/15 text-brand-600 border border-brand-500/25" ? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-200 text-grayScale-600" : "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
)} )}
> >
<Shield className="h-3 w-3 mr-1" /> <Shield className="h-3 w-3 mr-1.5" />
{profile.role} {profile.role}
</Badge> </Badge>
<div className="mt-5 flex flex-wrap items-center justify-center gap-3"> {/* Status pills */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2.5">
{/* Active status */}
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium", "flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
profile.status === "ACTIVE" profile.status === "ACTIVE"
? "bg-mint-100 text-mint-500" ? "border-mint-300 bg-mint-100/60 text-mint-500"
: "bg-destructive/10 text-destructive" : "border-destructive/20 bg-destructive/10 text-destructive"
)} )}
> >
<span <span
className={cn( className={cn(
"h-2 w-2 rounded-full", "h-2 w-2 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive" profile.status === "ACTIVE" ? "bg-mint-500 animate-pulse" : "bg-destructive"
)} )}
/> />
{profile.status} {profile.status}
</div> </div>
{/* Email verification */}
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium", "flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
profile.email_verified profile.email_verified
? "bg-mint-100 text-mint-500" ? "border-mint-300 bg-mint-100/60 text-mint-500"
: "bg-grayScale-100 text-grayScale-400" : "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
)} )}
> >
{profile.email_verified ? ( {profile.email_verified ? (
@ -194,12 +295,13 @@ export function ProfilePage() {
Email {profile.email_verified ? "Verified" : "Unverified"} Email {profile.email_verified ? "Verified" : "Unverified"}
</div> </div>
{/* Phone verification */}
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium", "flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
profile.phone_verified profile.phone_verified
? "bg-mint-100 text-mint-500" ? "border-mint-300 bg-mint-100/60 text-mint-500"
: "bg-grayScale-100 text-grayScale-400" : "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
)} )}
> >
{profile.phone_verified ? ( {profile.phone_verified ? (
@ -210,83 +312,101 @@ export function ProfilePage() {
Phone {profile.phone_verified ? "Verified" : "Unverified"} Phone {profile.phone_verified ? "Verified" : "Unverified"}
</div> </div>
<div className="flex items-center gap-1.5 rounded-full bg-brand-100/60 px-3 py-1.5 text-xs font-medium text-brand-600"> {/* Profile completion ring */}
<GraduationCap className="h-3 w-3" /> <div className="flex items-center gap-2 rounded-full border border-brand-200 bg-brand-100/30 px-3 py-1 text-xs font-semibold text-brand-600">
Profile {completionPct}% Complete <ProgressRing percent={completionPct} />
<span>Profile Complete</span>
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Info Cards */}
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<Card className="border-l-4 border-l-brand-500"> {/* Personal Information */}
<CardHeader className="pb-2"> <Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<CardTitle className="text-base">Personal Information</CardTitle> <div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<User className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Personal Information
</CardTitle>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-0"> <CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow icon={User} label="Full Name" value={fullName} /> <InfoRow icon={User} label="Full Name" value={fullName} />
<Separator />
<InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} /> <InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} />
<Separator />
<InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} /> <InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} />
<Separator />
<InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} /> <InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} />
<Separator />
<InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} /> <InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} />
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-l-4 border-l-brand-500"> {/* Contact & Location */}
<CardHeader className="pb-2"> <Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<CardTitle className="text-base">Contact & Location</CardTitle> <div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
<Mail className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Contact & Location
</CardTitle>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-0"> <CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow <InfoRow
icon={Mail} icon={Mail}
label="Email" label="Email"
value={profile.email} value={profile.email}
extra={<VerifiedIcon verified={profile.email_verified} />} extra={<VerifiedIcon verified={profile.email_verified} />}
/> />
<Separator />
<InfoRow <InfoRow
icon={Phone} icon={Phone}
label="Phone" label="Phone"
value={profile.phone_number} value={profile.phone_number}
extra={<VerifiedIcon verified={profile.phone_verified} />} extra={<VerifiedIcon verified={profile.phone_verified} />}
/> />
<Separator />
<InfoRow icon={Globe} label="Country" value={profile.country || "—"} /> <InfoRow icon={Globe} label="Country" value={profile.country || "—"} />
<Separator />
<InfoRow icon={MapPin} label="Region" value={profile.region || "—"} /> <InfoRow icon={MapPin} label="Region" value={profile.region || "—"} />
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-l-4 border-l-brand-500"> {/* Account Details */}
<CardHeader className="pb-2"> <Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<CardTitle className="text-base">Account Details</CardTitle> <div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
<Shield className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Account Details
</CardTitle>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-0"> <CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow icon={Shield} label="Role" value={profile.role} /> <InfoRow icon={Shield} label="Role" value={profile.role} />
<Separator />
<InfoRow <InfoRow
icon={Languages} icon={Languages}
label="Language" label="Language"
value={profile.preferred_language || "—"} value={profile.preferred_language || "—"}
/> />
<Separator />
<InfoRow <InfoRow
icon={Clock} icon={Clock}
label="Last Login" label="Last Login"
value={formatDateTime(profile.last_login)} value={formatDateTime(profile.last_login)}
/> />
<Separator />
<InfoRow <InfoRow
icon={Calendar} icon={Calendar}
label="Member Since" label="Member Since"
value={formatDate(profile.created_at)} value={formatDate(profile.created_at)}
/> />
<Separator />
<InfoRow <InfoRow
icon={CheckCircle2} icon={CheckCircle2}
label="Status" label="Status"
@ -294,8 +414,10 @@ export function ProfilePage() {
extra={ extra={
<span <span
className={cn( className={cn(
"h-2 w-2 rounded-full", "h-2.5 w-2.5 rounded-full ring-2",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive" profile.status === "ACTIVE"
? "bg-mint-500 ring-mint-100"
: "bg-destructive ring-destructive/20"
)} )}
/> />
} }

View File

@ -1,64 +1,140 @@
import { useState } from "react" import { useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { ArrowLeft, Mail } from "lucide-react"
import { BrandLogo } from "../../components/brand/BrandLogo" import { BrandLogo } from "../../components/brand/BrandLogo"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
export function ForgotPasswordPage() { export function ForgotPasswordPage() {
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [submitted, setSubmitted] = useState(false)
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
// Handle forgot password logic here // Handle forgot password logic here
console.log("Forgot password:", { email }) console.log("Forgot password:", { email })
setSubmitted(true)
} }
return ( return (
<div className="flex min-h-screen items-center justify-center bg-grayScale-100 px-4 py-12"> <div className="relative flex min-h-screen overflow-hidden">
<div className="w-full max-w-md"> {/* Decorative left panel */}
<div className="rounded-2xl bg-white p-8 shadow-soft"> <div className="hidden lg:flex lg:w-1/2 xl:w-[55%] items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 relative">
<div className="mb-8"> {/* Abstract decorative shapes */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
<div className="absolute left-1/3 top-1/4 h-64 w-64 rounded-full bg-white/5" />
<div className="absolute bottom-1/4 left-1/4 h-48 w-48 rotate-45 rounded-3xl bg-white/[0.03]" />
</div>
<div className="relative z-10 max-w-md px-12 text-center">
{/* Large brand icon */}
<div className="mx-auto mb-8 grid h-20 w-20 place-items-center rounded-2xl bg-white/15 shadow-lg backdrop-blur-sm">
<div className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
</div>
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
Yimaru Academy
</h2>
<p className="text-base leading-relaxed text-white/70">
Manage your academy, track student progress, and streamline
operations all from one powerful dashboard.
</p>
</div>
</div>
{/* Right panel form */}
<div className="flex w-full flex-col items-center justify-center bg-white px-6 py-12 lg:w-1/2 xl:w-[45%]">
<div className="w-full max-w-[420px]">
{/* Mobile-only logo */}
<div className="mb-10 flex justify-center lg:hidden">
<BrandLogo /> <BrandLogo />
</div> </div>
<div className="mb-8"> {/* Back link */}
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600">Forgot Password</h1> <Link
<p className="text-sm text-grayScale-400"> to="/login"
Enter your email address and we'll send you a reset link. className="mb-8 inline-flex items-center gap-1.5 text-sm font-medium text-grayScale-400 transition-colors hover:text-grayScale-600"
>
<ArrowLeft size={16} />
Back to sign in
</Link>
{submitted ? (
/* Success state */
<div className="text-center">
<div className="mx-auto mb-6 grid h-16 w-16 place-items-center rounded-full bg-brand-100/60">
<Mail className="h-7 w-7 text-brand-500" />
</div>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-grayScale-600">
Check your email
</h1>
<p className="mb-8 text-sm leading-relaxed text-grayScale-400">
We've sent a password reset link to{" "}
<span className="font-medium text-grayScale-600">{email}</span>.
Please check your inbox and follow the instructions.
</p>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl px-6 text-sm font-semibold"
onClick={() => setSubmitted(false)}
>
Try a different email
</Button>
</div>
) : (
/* Form state */
<>
{/* Header */}
<div className="mb-10">
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-400">
Account Recovery
</p>
<h1 className="mb-2 text-3xl font-bold tracking-tight text-grayScale-600">
Forgot password?
</h1>
<p className="text-sm leading-relaxed text-grayScale-400">
No worries enter your email and we'll send you a reset link.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-grayScale-600"> <label
Email Address htmlFor="email"
className="mb-1.5 block text-sm font-medium text-grayScale-600"
>
Email address
</label> </label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="admin@yimaruacademy.com" placeholder="you@example.com"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
className="h-11 rounded-xl"
/> />
</div> </div>
<Button type="submit" className="w-full"> <Button
Send Reset Link type="submit"
className="mt-2 h-11 w-full rounded-xl text-sm font-semibold tracking-wide"
>
Send reset link
</Button> </Button>
</form> </form>
</>
)}
<div className="mt-6 text-center"> {/* Footer */}
<Link <p className="mt-10 text-center text-xs text-grayScale-400">
to="/login" © {new Date().getFullYear()} Yimaru Academy · All rights reserved
className="text-sm font-medium text-brand-500 hover:text-brand-600" </p>
>
Back to Login
</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
@ -6,9 +6,60 @@ import { BrandLogo } from "../../components/brand/BrandLogo";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { login } from "../../api/auth.api"; import { login, loginWithGoogle } from "../../api/auth.api";
import type { LoginRequest } from "../../types/auth.types"; import type { LoginRequest } from "../../types/auth.types";
import type { LoginResult } from "../../api/auth.api"; import type { LoginResult } from "../../api/auth.api";
import { toast } from "sonner";
declare global {
interface Window {
google?: {
accounts: {
id: {
initialize: (config: {
client_id: string;
callback: (response: { credential: string }) => void;
auto_select?: boolean;
}) => void;
renderButton: (
element: HTMLElement,
config: {
theme?: string;
size?: string;
width?: number;
text?: string;
shape?: string;
logo_alignment?: string;
}
) => void;
};
};
};
}
}
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18A10.96 10.96 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.84Z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z"
fill="#EA4335"
/>
</svg>
);
}
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -17,28 +68,102 @@ export function LoginPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [googleReady, setGoogleReady] = useState(false);
const googleBtnRef = useRef<HTMLDivElement>(null);
const storeTokensAndRedirect = useCallback(
(result: LoginResult) => {
localStorage.setItem("access_token", result.accessToken);
localStorage.setItem("refresh_token", result.refreshToken);
localStorage.setItem("role", result.role);
localStorage.setItem("member_id", result.memberId.toString());
toast.success("Welcome back!", {
description: "You have signed in successfully.",
});
navigate("/dashboard");
},
[navigate]
);
const handleGoogleCallback = useCallback(
async (response: { credential: string }) => {
setError(null);
setGoogleLoading(true);
try {
const result = await loginWithGoogle(response.credential);
storeTokensAndRedirect(result);
} catch (err: any) {
setError(
err.response?.data?.message || "Google sign-in failed. Please try again."
);
} finally {
setGoogleLoading(false);
}
},
[storeTokensAndRedirect]
);
// Load Google Identity Services script
useEffect(() => {
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
if (!clientId) return;
// If already loaded
if (window.google?.accounts) {
setGoogleReady(true);
return;
}
const script = document.createElement("script");
script.src = "https://accounts.google.com/gsi/client";
script.async = true;
script.defer = true;
script.onload = () => setGoogleReady(true);
document.head.appendChild(script);
return () => {
// Cleanup only if we added it
if (document.head.contains(script)) {
document.head.removeChild(script);
}
};
}, []);
// Initialize Google button once ready
useEffect(() => {
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
if (!googleReady || !clientId || !window.google?.accounts) return;
window.google.accounts.id.initialize({
client_id: clientId,
callback: handleGoogleCallback,
});
if (googleBtnRef.current) {
// Render the hidden native button (we use our own styled button)
window.google.accounts.id.renderButton(googleBtnRef.current, {
theme: "outline",
size: "large",
width: 400,
text: "signin_with",
shape: "rectangular",
});
}
}, [googleReady, handleGoogleCallback]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setLoading(true); setLoading(true);
const payload: LoginRequest = { const payload: LoginRequest = { email, password };
email,
password,
};
try { try {
const res: LoginResult = await login(payload); const res: LoginResult = await login(payload);
storeTokensAndRedirect(res);
// Store tokens
localStorage.setItem("access_token", res.accessToken);
localStorage.setItem("refresh_token", res.refreshToken);
localStorage.setItem("role", res.role);
localStorage.setItem("member_id", res.memberId.toString());
navigate("/dashboard");
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || "Invalid email or password"); setError(err.response?.data?.message || "Invalid email or password");
} finally { } finally {
@ -46,72 +171,198 @@ export function LoginPage() {
} }
}; };
const handleGoogleClick = () => {
// Click the hidden Google button to trigger the native popup
const iframe = googleBtnRef.current?.querySelector("iframe");
const innerBtn =
googleBtnRef.current?.querySelector('[role="button"]') as HTMLElement | null;
if (innerBtn) {
innerBtn.click();
} else if (iframe) {
// Fallback: the native button may render as an iframe
iframe.click();
}
};
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
return ( return (
<div className="flex min-h-screen items-center justify-center bg-grayScale-100 px-4 py-12"> <div className="relative flex min-h-screen overflow-hidden">
<div className="w-full max-w-md"> {/* Decorative left panel */}
<div className="rounded-2xl bg-white p-8 shadow-soft"> <div className="hidden lg:flex lg:w-1/2 xl:w-[55%] items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 relative">
<div className="mb-8 flex justify-center"> {/* Abstract decorative shapes */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
<div className="absolute left-1/3 top-1/4 h-64 w-64 rounded-full bg-white/5" />
<div className="absolute bottom-1/4 left-1/4 h-48 w-48 rotate-45 rounded-3xl bg-white/[0.03]" />
</div>
<div className="relative z-10 max-w-md px-12 text-center">
{/* Large brand icon */}
<div className="mx-auto mb-8 grid h-20 w-20 place-items-center rounded-2xl bg-white/15 shadow-lg backdrop-blur-sm">
<div className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
</div>
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
Yimaru Academy
</h2>
<p className="text-base leading-relaxed text-white/70">
Manage your academy, track student progress, and streamline
operations all from one powerful dashboard.
</p>
</div>
</div>
{/* Right panel login form */}
<div className="flex w-full flex-col items-center justify-center bg-white px-6 py-12 lg:w-1/2 xl:w-[45%]">
<div className="w-full max-w-[420px]">
{/* Mobile-only logo */}
<div className="mb-10 flex justify-center lg:hidden">
<BrandLogo /> <BrandLogo />
</div> </div>
<div className="mb-8 text-center"> {/* Header */}
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600"> <div className="mb-10">
Admin Login <p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-400">
Admin Portal
</p>
<h1 className="mb-2 text-3xl font-bold tracking-tight text-grayScale-600">
Welcome back
</h1> </h1>
<p className="text-sm text-grayScale-400"> <p className="text-sm leading-relaxed text-grayScale-400">
Please enter your details to continue Sign in to your account to continue
</p> </p>
</div> </div>
{/* Error */}
{error && ( {error && (
<div className="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600"> <div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3.5 text-sm text-red-600">
<svg
className="mt-0.5 h-4 w-4 shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clipRule="evenodd"
/>
</svg>
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-6" autoComplete="on" method="post"> {/* Google Sign-In Button */}
{googleClientId && (
<>
{/* Hidden native Google button */}
<div
ref={googleBtnRef}
className="absolute h-0 w-0 overflow-hidden opacity-0"
aria-hidden="true"
/>
<button
type="button"
onClick={handleGoogleClick}
disabled={googleLoading || !googleReady}
className="group mb-6 flex w-full items-center justify-center gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm font-medium text-grayScale-600 transition-all duration-200 hover:border-grayScale-300 hover:bg-grayScale-100 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{googleLoading ? (
<svg
className="h-5 w-5 animate-spin text-grayScale-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<GoogleIcon className="h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
)}
{googleLoading ? "Signing in…" : "Continue with Google"}
</button>
{/* Divider */}
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center text-xs font-medium uppercase tracking-wider">
<span className="bg-white px-4 text-grayScale-400">
or sign in with email
</span>
</div>
</div>
</>
)}
<form onSubmit={handleSubmit} className="space-y-5" autoComplete="on" method="post">
{/* Email */} {/* Email */}
<div> <div>
<label <label
htmlFor="email" htmlFor="email"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-1.5 block text-sm font-medium text-grayScale-600"
> >
Email Email address
</label> </label>
<Input <Input
id="email" id="email"
name="email" name="email"
type="email" type="email"
placeholder="you@example.com"
autoComplete="email" autoComplete="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
className="h-11 rounded-xl"
/> />
</div> </div>
{/* Password */} {/* Password */}
<div> <div>
<div className="mb-1.5 flex items-center justify-between">
<label <label
htmlFor="password" htmlFor="password"
className="mb-2 block text-sm font-medium text-grayScale-600" className="block text-sm font-medium text-grayScale-600"
> >
Password Password
</label> </label>
<Link
to="/forgot-password"
className="text-xs font-medium text-brand-500 transition-colors hover:text-brand-600"
>
Forgot password?
</Link>
</div>
<div className="relative"> <div className="relative">
<Input <Input
id="password" id="password"
name="password" name="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="••••••••"
autoComplete="current-password" autoComplete="current-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
className="pr-10" className="h-11 rounded-xl pr-10"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400" className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 transition-colors hover:text-grayScale-600"
tabIndex={-1} tabIndex={-1}
> >
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />} {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@ -119,16 +370,19 @@ export function LoginPage() {
</div> </div>
</div> </div>
<div className="flex justify-end"> <Button
<Link to="/forgot-password" className="text-sm text-brand-500"> type="submit"
Forgot Password? className="mt-2 h-11 w-full rounded-xl text-sm font-semibold tracking-wide"
</Link> disabled={loading}
</div> >
{loading ? "Signing in…" : "Sign in"}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Login"}
</Button> </Button>
</form> </form>
{/* Footer */}
<p className="mt-10 text-center text-xs text-grayScale-400">
© {new Date().getFullYear()} Yimaru Academy · All rights reserved
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -256,16 +256,16 @@ export function AddNewPracticePage() {
{/* Back Link */} {/* Back Link */}
<Link <Link
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`} to={`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`}
className="inline-flex items-center gap-2 text-sm text-grayScale-600 hover:text-grayScale-900" className="group inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-grayScale-600 transition-colors hover:bg-brand-50 hover:text-brand-600"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
Back to Sub-course Back to Sub-course
</Link> </Link>
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-grayScale-900">Add New Practice</h1> <h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Add New Practice</h1>
<p className="mt-1 text-sm text-grayScale-500"> <p className="mt-1.5 text-sm text-grayScale-500">
Create a new immersive practice session for students. Create a new immersive practice session for students.
</p> </p>
</div> </div>
@ -274,24 +274,28 @@ export function AddNewPracticePage() {
{/* Step Tracker */} {/* Step Tracker */}
{currentStep !== 5 && ( {currentStep !== 5 && (
<div className="flex items-center justify-center py-6"> <div className="flex items-center justify-center py-8">
{STEPS.map((step, index) => ( {STEPS.map((step, index) => (
<div key={step.number} className="flex items-center"> <div key={step.number} className="flex items-center">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors ${ className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition-all duration-300 ${
currentStep === step.number currentStep === step.number
? "bg-brand-500 text-white" ? "bg-brand-500 text-white ring-4 ring-brand-100"
: currentStep > step.number : currentStep > step.number
? "bg-brand-500 text-white" ? "bg-brand-500 text-white"
: "border-2 border-grayScale-300 text-grayScale-400" : "border-2 border-grayScale-300 bg-white text-grayScale-400"
}`} }`}
> >
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number} {currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
</div> </div>
<span <span
className={`mt-2 text-xs font-medium ${ className={`mt-2.5 text-xs font-semibold tracking-wide ${
currentStep === step.number ? "text-brand-500" : "text-grayScale-400" currentStep === step.number
? "text-brand-600"
: currentStep > step.number
? "text-brand-500"
: "text-grayScale-400"
}`} }`}
> >
{step.label} {step.label}
@ -299,7 +303,7 @@ export function AddNewPracticePage() {
</div> </div>
{index < STEPS.length - 1 && ( {index < STEPS.length - 1 && (
<div <div
className={`mx-4 h-0.5 w-32 ${ className={`mx-3 h-0.5 w-16 rounded-full transition-colors duration-300 sm:mx-5 sm:w-24 md:w-32 ${
currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200" currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200"
}`} }`}
/> />
@ -311,13 +315,13 @@ export function AddNewPracticePage() {
{/* Step Content */} {/* Step Content */}
{currentStep === 1 && ( {currentStep === 1 && (
<Card className="mx-auto max-w-2xl p-8"> <Card className="mx-auto max-w-2xl p-6 sm:p-10">
<h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context Definition</h2> <h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context Definition</h2>
<p className="mt-1 text-sm text-grayScale-500"> <p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
Define the educational level and curriculum module for this practice. Define the educational level and curriculum module for this practice.
</p> </p>
<div className="mt-8 space-y-6"> <div className="mt-8 space-y-7">
{/* Practice Title */} {/* Practice Title */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Practice Title</label> <label className="text-sm font-medium text-grayScale-700">Practice Title</label>
@ -335,12 +339,12 @@ export function AddNewPracticePage() {
value={practiceDescription} value={practiceDescription}
onChange={(e) => setPracticeDescription(e.target.value)} onChange={(e) => setPracticeDescription(e.target.value)}
placeholder="Enter practice description" placeholder="Enter practice description"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
rows={3} rows={3}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Passing Score */} {/* Passing Score */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Passing Score</label> <label className="text-sm font-medium text-grayScale-700">Passing Score</label>
@ -368,16 +372,16 @@ export function AddNewPracticePage() {
</div> </div>
{/* Shuffle Questions */} {/* Shuffle Questions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 rounded-lg bg-grayScale-50 px-4 py-3">
<button <button
type="button" type="button"
onClick={() => setShuffleQuestions(!shuffleQuestions)} onClick={() => setShuffleQuestions(!shuffleQuestions)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${ className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out ${
shuffleQuestions ? "bg-brand-500" : "bg-grayScale-200" shuffleQuestions ? "bg-brand-500" : "bg-grayScale-300"
}`} }`}
> >
<span <span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${ className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-in-out ${
shuffleQuestions ? "translate-x-5" : "translate-x-0" shuffleQuestions ? "translate-x-5" : "translate-x-0"
}`} }`}
/> />
@ -388,34 +392,34 @@ export function AddNewPracticePage() {
{/* Program */} {/* Program */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Program <span className="text-brand-500">(Auto-selected)</span> Program <span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto-selected</span>
</label> </label>
<div className="flex items-center gap-3 rounded-lg border border-grayScale-200 px-4 py-3"> <div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-300 bg-grayScale-50/50 px-4 py-3">
<Grid3X3 className="h-5 w-5 text-grayScale-400" /> <Grid3X3 className="h-5 w-5 text-grayScale-400" />
<span className="flex-1 text-sm">{selectedProgram}</span> <span className="flex-1 text-sm font-medium text-grayScale-600">{selectedProgram}</span>
<ChevronDown className="h-5 w-5 text-grayScale-400" /> <ChevronDown className="h-5 w-5 text-grayScale-300" />
</div> </div>
</div> </div>
{/* Course */} {/* Course */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Course <span className="text-brand-500">(Auto-selected)</span> Course <span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto-selected</span>
</label> </label>
<div className="flex items-center gap-3 rounded-lg border border-grayScale-200 px-4 py-3"> <div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-300 bg-grayScale-50/50 px-4 py-3">
<Grid3X3 className="h-5 w-5 text-grayScale-400" /> <Grid3X3 className="h-5 w-5 text-grayScale-400" />
<span className="flex-1 text-sm">{selectedCourse}</span> <span className="flex-1 text-sm font-medium text-grayScale-600">{selectedCourse}</span>
<ChevronDown className="h-5 w-5 text-grayScale-400" /> <ChevronDown className="h-5 w-5 text-grayScale-300" />
</div> </div>
</div> </div>
</div> </div>
{/* Navigation */} {/* Navigation */}
<div className="mt-8 flex items-center justify-between border-t pt-6"> <div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 border-t border-grayScale-100 pt-6 sm:flex-row">
<Button variant="ghost" onClick={handleCancel}> <Button variant="ghost" onClick={handleCancel}>
Cancel Cancel
</Button> </Button>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}> <Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}>
{getNextButtonLabel()} {getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
@ -425,45 +429,49 @@ export function AddNewPracticePage() {
{currentStep === 2 && ( {currentStep === 2 && (
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<h2 className="text-xl font-semibold text-grayScale-900">Select Personas</h2> <h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Select Personas</h2>
<p className="mt-1 text-sm text-grayScale-500"> <p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
Choose the characters that will participate in this practice scenario. Students will interact with these personas. Choose the characters that will participate in this practice scenario. Students will interact with these personas.
</p> </p>
<div className="mt-8 grid grid-cols-4 gap-4"> <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{PERSONAS.map((persona) => ( {PERSONAS.map((persona) => (
<button <button
key={persona.id} key={persona.id}
onClick={() => setSelectedPersona(persona.id)} onClick={() => setSelectedPersona(persona.id)}
className={`relative flex flex-col items-center rounded-xl border-2 p-6 transition-all ${ className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${
selectedPersona === persona.id selectedPersona === persona.id
? "border-brand-500 bg-brand-50" ? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100"
: "border-grayScale-200 hover:border-grayScale-300" : "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm"
}`} }`}
> >
{selectedPersona === persona.id && ( {selectedPersona === persona.id && (
<div className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white"> <div className="absolute right-2.5 top-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm">
<Check className="h-3 w-3" /> <Check className="h-3.5 w-3.5" />
</div> </div>
)} )}
<div className="mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100"> <div className={`mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100 ring-2 transition-all duration-200 ${
selectedPersona === persona.id ? "ring-brand-300 ring-offset-2" : "ring-transparent group-hover:ring-grayScale-200"
}`}>
<img <img
src={persona.avatar} src={persona.avatar}
alt={persona.name} alt={persona.name}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
</div> </div>
<span className="font-medium text-grayScale-900">{persona.name}</span> <span className={`text-sm font-semibold transition-colors ${
selectedPersona === persona.id ? "text-brand-600" : "text-grayScale-900"
}`}>{persona.name}</span>
</button> </button>
))} ))}
</div> </div>
{/* Navigation */} {/* Navigation */}
<div className="mt-8 flex items-center justify-between"> <div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<Button variant="outline" onClick={handleBack}> <Button variant="outline" onClick={handleBack}>
Back Back
</Button> </Button>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}> <Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}>
{getNextButtonLabel()} {getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
@ -473,28 +481,28 @@ export function AddNewPracticePage() {
{currentStep === 3 && ( {currentStep === 3 && (
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<h2 className="text-xl font-semibold text-grayScale-900">Create Practice Questions</h2> <h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Create Practice Questions</h2>
<p className="mt-1 text-sm text-grayScale-500"> <p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
Add questions to your practice. Support for MCQ, True/False, and Short Answer types. Add questions to your practice. Support for MCQ, True/False, and Short Answer types.
</p> </p>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-5">
{questions.map((question, index) => ( {questions.map((question, index) => (
<Card key={question.id} className="border-l-4 border-l-brand-500 p-6"> <Card key={question.id} className="border-l-4 border-l-brand-500 p-5 shadow-sm transition-shadow hover:shadow-md sm:p-7">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 cursor-grab text-grayScale-400" /> <GripVertical className="h-5 w-5 cursor-grab text-grayScale-300 transition-colors hover:text-grayScale-500" />
<span className="font-medium text-grayScale-900">Question {index + 1}</span> <span className="text-base font-semibold text-grayScale-900">Question {index + 1}</span>
</div> </div>
<button <button
onClick={() => removeQuestion(question.id)} onClick={() => removeQuestion(question.id)}
className="rounded p-1 text-grayScale-400 hover:bg-red-50 hover:text-red-500" className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
> >
<Trash2 className="h-5 w-5" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="mt-4 space-y-4"> <div className="mt-5 space-y-5">
{/* Question Text */} {/* Question Text */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -504,12 +512,12 @@ export function AddNewPracticePage() {
value={question.questionText} value={question.questionText}
onChange={(e) => updateQuestion(question.id, { questionText: e.target.value })} onChange={(e) => updateQuestion(question.id, { questionText: e.target.value })}
placeholder="Enter your question..." placeholder="Enter your question..."
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
rows={2} rows={2}
/> />
</div> </div>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* Question Type */} {/* Question Type */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -556,20 +564,22 @@ export function AddNewPracticePage() {
{/* MCQ Options */} {/* MCQ Options */}
{question.questionType === "MCQ" && ( {question.questionType === "MCQ" && (
<div className="space-y-2"> <div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Options Options
</label> </label>
<div className="space-y-2"> <div className="space-y-2.5">
{question.options.map((option, optIdx) => ( {question.options.map((option, optIdx) => (
<div key={optIdx} className="flex items-center gap-2"> <div key={optIdx} className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors ${
option.isCorrect ? "border-green-200 bg-green-50/50" : "border-grayScale-200 bg-white"
}`}>
<button <button
type="button" type="button"
onClick={() => setCorrectOption(question.id, optIdx)} onClick={() => setCorrectOption(question.id, optIdx)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${ className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
option.isCorrect option.isCorrect
? "border-green-500 bg-green-500 text-white" ? "border-green-500 bg-green-500 text-white shadow-sm"
: "border-grayScale-300 hover:border-brand-400" : "border-grayScale-300 hover:border-brand-400 hover:shadow-sm"
}`} }`}
> >
{option.isCorrect && <Check className="h-3 w-3" />} {option.isCorrect && <Check className="h-3 w-3" />}
@ -578,12 +588,12 @@ export function AddNewPracticePage() {
value={option.text} value={option.text}
onChange={(e) => updateOption(question.id, optIdx, { text: e.target.value })} onChange={(e) => updateOption(question.id, optIdx, { text: e.target.value })}
placeholder={`Option ${optIdx + 1}`} placeholder={`Option ${optIdx + 1}`}
className="flex-1" className="flex-1 border-0 bg-transparent shadow-none focus:ring-0"
/> />
{question.options.length > 2 && ( {question.options.length > 2 && (
<button <button
onClick={() => removeOption(question.id, optIdx)} onClick={() => removeOption(question.id, optIdx)}
className="rounded p-1 text-grayScale-400 hover:bg-red-50 hover:text-red-500" className="rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@ -593,7 +603,7 @@ export function AddNewPracticePage() {
<button <button
type="button" type="button"
onClick={() => addOption(question.id)} onClick={() => addOption(question.id)}
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600" className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Option Add Option
@ -633,7 +643,7 @@ export function AddNewPracticePage() {
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Tips */} {/* Tips */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -659,7 +669,7 @@ export function AddNewPracticePage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Voice Prompt */} {/* Voice Prompt */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -690,10 +700,10 @@ export function AddNewPracticePage() {
</div> </div>
{/* Add Button */} {/* Add Button */}
<div className="mt-4"> <div className="mt-5">
<button <button
onClick={addQuestion} onClick={addQuestion}
className="flex items-center gap-2 text-sm font-medium text-brand-500 hover:text-brand-600" className="inline-flex items-center gap-2 rounded-lg border-2 border-dashed border-brand-200 px-4 py-2.5 text-sm font-semibold text-brand-500 transition-all hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add New Question Add New Question
@ -701,11 +711,11 @@ export function AddNewPracticePage() {
</div> </div>
{/* Navigation */} {/* Navigation */}
<div className="mt-8 flex items-center justify-between"> <div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<Button variant="outline" onClick={handleBack}> <Button variant="outline" onClick={handleBack}>
Back Back
</Button> </Button>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}> <Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}>
{getNextButtonLabel()} {getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
@ -715,49 +725,49 @@ export function AddNewPracticePage() {
{currentStep === 4 && ( {currentStep === 4 && (
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<h2 className="text-xl font-semibold text-grayScale-900">Review & Publish</h2> <h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Review & Publish</h2>
<p className="mt-1 text-sm text-grayScale-500"> <p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
Review your practice details before saving. Review your practice details before saving.
</p> </p>
{/* Basic Information Card */} {/* Basic Information Card */}
<Card className="mt-6 p-6"> <Card className="mt-6 overflow-hidden p-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h3 className="font-semibold text-grayScale-900">Basic Information</h3> <h3 className="font-semibold text-grayScale-900">Basic Information</h3>
<button <button
onClick={() => setCurrentStep(1)} onClick={() => setCurrentStep(1)}
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600" className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
> >
<Edit className="h-4 w-4" /> <Edit className="h-3.5 w-3.5" />
Edit Edit
</button> </button>
</div> </div>
<div className="mt-4 space-y-3"> <div className="divide-y divide-grayScale-100">
<div className="flex justify-between"> <div className="flex justify-between px-6 py-3.5 odd:bg-grayScale-50/50">
<span className="text-sm text-grayScale-500">Title</span> <span className="text-sm text-grayScale-500">Title</span>
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span> <span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">Description</span> <span className="text-sm text-grayScale-500">Description</span>
<span className="max-w-sm text-right text-sm text-grayScale-700">{practiceDescription || "—"}</span> <span className="max-w-sm text-right text-sm text-grayScale-700">{practiceDescription || "—"}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Passing Score</span> <span className="text-sm text-grayScale-500">Passing Score</span>
<span className="text-sm font-medium text-grayScale-900">{passingScore}%</span> <span className="text-sm font-medium text-grayScale-900">{passingScore}%</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">Time Limit</span> <span className="text-sm text-grayScale-500">Time Limit</span>
<span className="text-sm font-medium text-grayScale-900">{timeLimitMinutes} minutes</span> <span className="text-sm font-medium text-grayScale-900">{timeLimitMinutes} minutes</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Shuffle Questions</span> <span className="text-sm text-grayScale-500">Shuffle Questions</span>
<span className="text-sm font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span> <span className="text-sm font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">Persona</span> <span className="text-sm text-grayScale-500">Persona</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedPersona && ( {selectedPersona && (
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100"> <div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
<img <img
src={PERSONAS.find(p => p.id === selectedPersona)?.avatar} src={PERSONAS.find(p => p.id === selectedPersona)?.avatar}
alt="Persona" alt="Persona"
@ -765,7 +775,7 @@ export function AddNewPracticePage() {
/> />
</div> </div>
)} )}
<span className="text-sm font-medium text-brand-500"> <span className="text-sm font-medium text-brand-600">
{PERSONAS.find(p => p.id === selectedPersona)?.name || "None selected"} {PERSONAS.find(p => p.id === selectedPersona)?.name || "None selected"}
</span> </span>
</div> </div>
@ -774,60 +784,60 @@ export function AddNewPracticePage() {
</Card> </Card>
{/* Questions Review */} {/* Questions Review */}
<Card className="mt-6 p-6"> <Card className="mt-6 overflow-hidden p-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<h3 className="font-semibold text-grayScale-900">Questions</h3> <h3 className="font-semibold text-grayScale-900">Questions</h3>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-grayScale-100 text-xs"> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-600">
{questions.length} {questions.length}
</span> </span>
</div> </div>
<button <button
onClick={() => setCurrentStep(3)} onClick={() => setCurrentStep(3)}
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600" className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
> >
<Edit className="h-4 w-4" /> <Edit className="h-3.5 w-3.5" />
Edit Edit
</button> </button>
</div> </div>
<div className="mt-4 space-y-4"> <div className="divide-y divide-grayScale-100 px-6 py-4">
{questions.map((question, index) => ( {questions.map((question, index) => (
<div key={question.id} className="rounded-lg border border-grayScale-200 p-4"> <div key={question.id} className="rounded-lg border border-grayScale-200 p-4 transition-colors first:mt-0 [&:not(:first-child)]:mt-3 hover:border-grayScale-300">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-brand-100 text-xs font-semibold text-brand-600"> <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
{index + 1} {index + 1}
</span> </span>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2.5">
<p className="text-sm font-medium text-grayScale-900">{question.questionText}</p> <p className="text-sm font-medium leading-relaxed text-grayScale-900">{question.questionText}</p>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-600"> <span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
{question.questionType === "MCQ" ? "Multiple Choice" : question.questionType === "TRUE_FALSE" ? "True/False" : "Short Answer"} {question.questionType === "MCQ" ? "Multiple Choice" : question.questionType === "TRUE_FALSE" ? "True/False" : "Short Answer"}
</span> </span>
<span className="rounded bg-purple-50 px-2 py-0.5 text-xs text-purple-600"> <span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
{question.difficultyLevel} {question.difficultyLevel}
</span> </span>
<span className="text-xs text-grayScale-500">{question.points} pt{question.points !== 1 ? "s" : ""}</span> <span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-xs font-medium text-grayScale-600">{question.points} pt{question.points !== 1 ? "s" : ""}</span>
</div> </div>
{question.questionType === "MCQ" && question.options.length > 0 && ( {question.questionType === "MCQ" && question.options.length > 0 && (
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{question.options.map((opt, i) => ( {question.options.map((opt, i) => (
<div <div
key={i} key={i}
className={`flex items-center gap-2 rounded px-2 py-1 text-sm ${ className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
opt.isCorrect ? "bg-green-50 text-green-700 font-medium" : "text-grayScale-600" opt.isCorrect ? "bg-green-50 font-medium text-green-700" : "text-grayScale-600"
}`} }`}
> >
{opt.isCorrect && <Check className="h-3 w-3" />} {opt.isCorrect && <Check className="h-3.5 w-3.5" />}
{opt.text || `Option ${i + 1}`} {opt.text || `Option ${i + 1}`}
</div> </div>
))} ))}
</div> </div>
)} )}
{question.tips && ( {question.tips && (
<p className="text-xs text-amber-600">Tip: {question.tips}</p> <p className="rounded-md bg-amber-50 px-2.5 py-1.5 text-xs text-amber-600">💡 Tip: {question.tips}</p>
)} )}
{question.explanation && ( {question.explanation && (
<p className="text-xs text-grayScale-500">Explanation: {question.explanation}</p> <p className="rounded-md bg-grayScale-50 px-2.5 py-1.5 text-xs text-grayScale-500">Explanation: {question.explanation}</p>
)} )}
</div> </div>
</div> </div>
@ -837,15 +847,17 @@ export function AddNewPracticePage() {
</Card> </Card>
{saveError && ( {saveError && (
<p className="mt-4 text-sm text-red-500">{saveError}</p> <div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
)} )}
{/* Navigation */} {/* Navigation */}
<div className="mt-8 flex items-center justify-between"> <div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<Button variant="outline" onClick={handleBack}> <Button variant="outline" onClick={handleBack}>
Back Back
</Button> </Button>
<div className="flex gap-3"> <div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
<Button variant="outline" onClick={handleSaveAsDraft} disabled={saving}> <Button variant="outline" onClick={handleSaveAsDraft} disabled={saving}>
{saving ? "Saving..." : "Save as Draft"} {saving ? "Saving..." : "Save as Draft"}
</Button> </Button>
@ -860,18 +872,18 @@ export function AddNewPracticePage() {
{/* Step 5: Result */} {/* Step 5: Result */}
{currentStep === 5 && resultStatus && ( {currentStep === 5 && resultStatus && (
<div className="flex flex-col items-center justify-center py-20"> <div className="flex flex-col items-center justify-center px-4 py-20">
{resultStatus === "success" ? ( {resultStatus === "success" ? (
<> <>
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-brand-100"> <div className="flex h-32 w-32 items-center justify-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200 shadow-lg shadow-brand-100/50">
<svg viewBox="0 0 24 24" className="h-14 w-14 text-brand-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" className="h-16 w-16 text-brand-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" /> <path d="M20 6 9 17l-5-5" />
</svg> </svg>
</div> </div>
<h2 className="mt-8 text-2xl font-bold text-grayScale-900"> <h2 className="mt-8 text-center text-2xl font-bold tracking-tight text-grayScale-900">
Practice Published Successfully! Practice Published Successfully!
</h2> </h2>
<p className="mt-2 text-sm text-grayScale-500">{resultMessage}</p> <p className="mt-3 text-center text-sm text-grayScale-500">{resultMessage}</p>
<div className="mt-10 flex w-full max-w-sm flex-col gap-3"> <div className="mt-10 flex w-full max-w-sm flex-col gap-3">
<Button <Button
className="w-full bg-brand-500 hover:bg-brand-600" className="w-full bg-brand-500 hover:bg-brand-600"
@ -902,17 +914,17 @@ export function AddNewPracticePage() {
</> </>
) : ( ) : (
<> <>
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-amber-100"> <div className="flex h-32 w-32 items-center justify-center rounded-full bg-gradient-to-br from-amber-100 to-amber-200 shadow-lg shadow-amber-100/50">
<svg viewBox="0 0 24 24" className="h-14 w-14 text-amber-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" className="h-16 w-16 text-amber-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /> <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
</div> </div>
<h2 className="mt-8 text-2xl font-bold text-grayScale-900"> <h2 className="mt-8 text-center text-2xl font-bold tracking-tight text-grayScale-900">
Publish Error! Publish Error!
</h2> </h2>
<p className="mt-2 text-sm text-grayScale-500">{resultMessage}</p> <p className="mt-3 max-w-md text-center text-sm text-grayScale-500">{resultMessage}</p>
<div className="mt-10 flex w-full max-w-sm flex-col gap-3"> <div className="mt-10 flex w-full max-w-sm flex-col gap-3">
<Button <Button
className="w-full bg-brand-500 hover:bg-brand-600" className="w-full bg-brand-500 hover:bg-brand-600"

View File

@ -136,36 +136,43 @@ export function AddPracticePage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> {/* Header */}
<div className="flex items-center gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate("/content/speaking")} onClick={() => navigate("/content/speaking")}
className="h-8 w-8" className="h-9 w-9 rounded-lg border border-grayScale-200 bg-white shadow-sm transition-colors hover:bg-grayScale-50 hover:border-grayScale-300"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 text-grayScale-500" />
</Button> </Button>
<h1 className="text-xl font-semibold text-grayScale-900">Add New Practice</h1> <div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Add New Practice</h1>
<p className="text-sm text-grayScale-400">Create a new practice session with questions</p>
</div> </div>
<Button className="bg-brand-500 hover:bg-brand-600"> </div>
<Button className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
Save Save
</Button> </Button>
</div> </div>
<Card className="p-6"> {/* Stepper */}
<Card className="border-grayScale-200 bg-white/80 p-5 shadow-sm sm:p-6">
<Stepper steps={STEPS} currentStep={currentStep} /> <Stepper steps={STEPS} currentStep={currentStep} />
</Card> </Card>
{/* Step 1: Details */} {/* Step 1: Details */}
{currentStep === 1 && ( {currentStep === 1 && (
<Card className="p-6"> <Card className="mx-auto max-w-3xl border-grayScale-200 p-6 shadow-sm sm:p-8">
<h2 className="mb-6 text-lg font-semibold text-grayScale-900">Practice Details</h2> <h2 className="mb-6 text-lg font-semibold tracking-tight text-grayScale-600">
<div className="space-y-4"> Practice Details
</h2>
<div className="space-y-5">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Title Practice Title
</label> </label>
<Input <Input
@ -177,7 +184,7 @@ export function AddPracticePage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Description Description
</label> </label>
<Textarea <Textarea
@ -189,9 +196,9 @@ export function AddPracticePage() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Category Category
</label> </label>
<Select <Select
@ -208,7 +215,7 @@ export function AddPracticePage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Difficulty Difficulty
</label> </label>
<Select <Select
@ -224,9 +231,9 @@ export function AddPracticePage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Duration (minutes) Duration (minutes)
</label> </label>
<Input <Input
@ -239,7 +246,7 @@ export function AddPracticePage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Tags</label> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tags</label>
<Input <Input
value={formData.tags} value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })} onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
@ -249,11 +256,11 @@ export function AddPracticePage() {
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end"> <div className="mt-8 flex justify-end border-t border-grayScale-100 pt-6">
<Button <Button
onClick={() => setCurrentStep(2)} onClick={() => setCurrentStep(2)}
disabled={!canProceedToStep2()} disabled={!canProceedToStep2()}
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors"
> >
Next Next
</Button> </Button>
@ -265,31 +272,43 @@ export function AddPracticePage() {
{currentStep === 2 && ( {currentStep === 2 && (
<div className="space-y-6"> <div className="space-y-6">
{/* Select Participants Section */} {/* Select Participants Section */}
<Card className="p-6"> <Card className="border-grayScale-200 p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Select Participants</h2> <h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
<div className="grid grid-cols-4 gap-4"> Select Participants
</h2>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-4 lg:grid-cols-8">
{mockParticipants.map((participant) => { {mockParticipants.map((participant) => {
const isSelected = formData.participants.includes(participant.id) const isSelected = formData.participants.includes(participant.id)
return ( return (
<div <div
key={participant.id} key={participant.id}
className="relative flex flex-col items-center" className="group relative flex cursor-pointer flex-col items-center"
onClick={() => toggleParticipant(participant.id)} onClick={() => toggleParticipant(participant.id)}
> >
<div className="relative"> <div className="relative">
<Avatar className="h-16 w-16 cursor-pointer border-2 border-grayScale-200 transition-all hover:border-brand-500"> <Avatar
className={`h-16 w-16 border-2 transition-all duration-200 ${
isSelected
? "border-brand-500 ring-2 ring-brand-500/20 scale-105"
: "border-grayScale-200 group-hover:border-brand-400 group-hover:shadow-md group-hover:scale-105"
}`}
>
<AvatarImage src={participant.avatar} /> <AvatarImage src={participant.avatar} />
<AvatarFallback className="bg-brand-100 text-brand-600"> <AvatarFallback className="bg-brand-100 text-brand-600 font-medium">
{participant.name[0]} {participant.name[0]}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
{isSelected && ( {isSelected && (
<div className="absolute -right-1 -top-1 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white"> <div className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-brand-500 text-white shadow-sm ring-2 ring-white">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</div> </div>
)} )}
</div> </div>
<span className="mt-2 text-sm font-medium text-grayScale-700"> <span
className={`mt-2 text-xs font-medium transition-colors ${
isSelected ? "text-brand-600" : "text-grayScale-500 group-hover:text-grayScale-600"
}`}
>
{participant.name} {participant.name}
</span> </span>
</div> </div>
@ -299,28 +318,28 @@ export function AddPracticePage() {
</Card> </Card>
{/* Add Questions Section */} {/* Add Questions Section */}
<Card className="p-6"> <Card className="border-grayScale-200 p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900"> <h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
General Practice Questions General Practice Questions
</h2> </h2>
{/* Existing Questions */} {/* Existing Questions */}
{formData.questions.map((q) => ( {formData.questions.map((q) => (
<div key={q.id} className="mb-4 rounded-lg border bg-grayScale-50 p-4"> <div key={q.id} className="mb-4 rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-4 transition-colors hover:bg-grayScale-50">
<div className="mb-2 flex items-start justify-between"> <div className="mb-2 flex items-start justify-between">
<p className="font-medium text-grayScale-900">{q.question}</p> <p className="font-medium text-grayScale-600">{q.question}</p>
<Badge variant="secondary">{q.points} points</Badge> <Badge variant="secondary">{q.points} points</Badge>
</div> </div>
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-400">
Type: {q.type} | Correct Answer: {q.correctAnswer} Type: {q.type} | Correct Answer: {q.correctAnswer}
</p> </p>
</div> </div>
))} ))}
{/* Add New Question Form */} {/* Add New Question Form */}
<div className="space-y-4 rounded-lg border bg-white p-4"> <div className="space-y-5 rounded-xl border border-dashed border-grayScale-300 bg-white p-5">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question Question
</label> </label>
<Textarea <Textarea
@ -333,9 +352,9 @@ export function AddPracticePage() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question Type Question Type
</label> </label>
<Select <Select
@ -354,7 +373,7 @@ export function AddPracticePage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Points</label> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">Points</label>
<Input <Input
type="number" type="number"
value={currentQuestion.points} value={currentQuestion.points}
@ -371,7 +390,7 @@ export function AddPracticePage() {
{currentQuestion.type === "multiple-choice" && ( {currentQuestion.type === "multiple-choice" && (
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Options Options
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@ -389,7 +408,7 @@ export function AddPracticePage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={addOption} onClick={addOption}
className="w-full" className="mt-1 w-full border-dashed"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Option Add Option
@ -399,7 +418,7 @@ export function AddPracticePage() {
)} )}
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Correct Answer Correct Answer
</label> </label>
{currentQuestion.type === "multiple-choice" ? ( {currentQuestion.type === "multiple-choice" ? (
@ -434,7 +453,7 @@ export function AddPracticePage() {
type="button" type="button"
onClick={addQuestion} onClick={addQuestion}
disabled={!currentQuestion.question || !currentQuestion.correctAnswer} disabled={!currentQuestion.question || !currentQuestion.correctAnswer}
className="w-full bg-brand-500 hover:bg-brand-600" className="w-full bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add New Question Add New Question
@ -442,14 +461,14 @@ export function AddPracticePage() {
</div> </div>
</Card> </Card>
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setCurrentStep(1)}> <Button variant="outline" onClick={() => setCurrentStep(1)} className="px-6">
Back Back
</Button> </Button>
<Button <Button
onClick={() => setCurrentStep(3)} onClick={() => setCurrentStep(3)}
disabled={!canProceedToStep3()} disabled={!canProceedToStep3()}
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors"
> >
Next Next
</Button> </Button>
@ -459,90 +478,84 @@ export function AddPracticePage() {
{/* Step 3: Review */} {/* Step 3: Review */}
{currentStep === 3 && ( {currentStep === 3 && (
<div className="space-y-6"> <div className="mx-auto max-w-3xl space-y-6">
<Card className="p-6"> <Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Practice Details</h2> <h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
<div className="space-y-3"> Practice Details
<div className="flex justify-between"> </h2>
<span className="text-sm text-grayScale-600">Title:</span> <div className="divide-y divide-grayScale-100 overflow-hidden rounded-lg border border-grayScale-200">
<span className="text-sm font-medium text-grayScale-900">{formData.title}</span> {[
</div> { label: "Title", value: formData.title },
<div className="flex justify-between"> { label: "Description", value: formData.description },
<span className="text-sm text-grayScale-600">Description:</span> { label: "Category", value: formData.category },
<span className="text-sm font-medium text-grayScale-900"> { label: "Difficulty", value: formData.difficulty },
{formData.description} { label: "Duration", value: `${formData.duration} minutes` },
{ label: "Tags", value: formData.tags },
].map((row, idx) => (
<div
key={row.label}
className={`flex items-baseline justify-between px-4 py-3 ${
idx % 2 === 0 ? "bg-grayScale-50/50" : "bg-white"
}`}
>
<span className="text-sm font-medium text-grayScale-400">{row.label}</span>
<span className="text-right text-sm font-medium text-grayScale-600">
{row.value}
</span> </span>
</div> </div>
<div className="flex justify-between"> ))}
<span className="text-sm text-grayScale-600">Category:</span>
<span className="text-sm font-medium text-grayScale-900">{formData.category}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-grayScale-600">Difficulty:</span>
<span className="text-sm font-medium text-grayScale-900">
{formData.difficulty}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-grayScale-600">Duration:</span>
<span className="text-sm font-medium text-grayScale-900">
{formData.duration} minutes
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-grayScale-600">Tags:</span>
<span className="text-sm font-medium text-grayScale-900">{formData.tags}</span>
</div>
</div> </div>
</Card> </Card>
<Card className="p-6"> <Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Questions</h2> <h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
Questions
</h2>
<div className="space-y-4"> <div className="space-y-4">
{formData.questions.map((q, index) => ( {formData.questions.map((q, index) => (
<div key={q.id} className="rounded-lg border bg-grayScale-50 p-4"> <div key={q.id} className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-5 transition-colors hover:bg-grayScale-50">
<div className="mb-2 flex items-start justify-between"> <div className="mb-2 flex items-start justify-between gap-3">
<div> <div>
<p className="font-medium text-grayScale-900"> <p className="font-medium text-grayScale-600">
{index + 1}. {q.question} {index + 1}. {q.question}
</p> </p>
<p className="mt-1 text-sm text-grayScale-600"> <p className="mt-1.5 text-sm text-grayScale-400">
Type: {q.type} | Points: {q.points} Type: {q.type} | Points: {q.points}
</p> </p>
</div> </div>
</div> </div>
{q.type === "multiple-choice" && q.options.length > 0 && ( {q.type === "multiple-choice" && q.options.length > 0 && (
<div className="mt-2 space-y-1"> <div className="mt-3 space-y-1.5">
{q.options.map((opt, optIdx) => ( {q.options.map((opt, optIdx) => (
<div <div
key={optIdx} key={optIdx}
className={`rounded px-2 py-1 text-sm ${ className={`rounded-lg px-3 py-1.5 text-sm ${
opt === q.correctAnswer opt === q.correctAnswer
? "bg-brand-100 text-brand-700 font-medium" ? "bg-brand-100 text-brand-700 font-medium ring-1 ring-brand-200"
: "bg-white text-grayScale-600" : "bg-white text-grayScale-500 ring-1 ring-grayScale-100"
}`} }`}
> >
{opt} {opt}
{opt === q.correctAnswer && ( {opt === q.correctAnswer && (
<Check className="ml-2 inline h-3 w-3" /> <Check className="ml-2 inline h-3.5 w-3.5" />
)} )}
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="mt-2 text-sm text-grayScale-600"> <div className="mt-3 border-t border-grayScale-100 pt-2 text-sm text-grayScale-400">
Correct Answer: <span className="font-medium">{q.correctAnswer}</span> Correct Answer: <span className="font-medium text-grayScale-600">{q.correctAnswer}</span>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</Card> </Card>
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setCurrentStep(2)}> <Button variant="outline" onClick={() => setCurrentStep(2)} className="px-6">
Back Back
</Button> </Button>
<Button onClick={handleSubmit} className="bg-brand-500 hover:bg-brand-600"> <Button onClick={handleSubmit} className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors">
Create Practice Create Practice
</Button> </Button>
</div> </div>
@ -551,4 +564,3 @@ export function AddPracticePage() {
</div> </div>
) )
} }

View File

@ -135,25 +135,37 @@ export function AddQuestionPage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center gap-4"> {/* Page Header */}
<Button variant="ghost" size="icon" onClick={() => navigate("/content/questions")}> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate("/content/questions")}
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors"
>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-xl font-semibold text-grayScale-900"> <div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
{isEditing ? "Edit Question" : "Add New Question"} {isEditing ? "Edit Question" : "Add New Question"}
</h1> </h1>
<p className="mt-1 text-sm text-grayScale-400">
{isEditing ? "Update the question details below" : "Fill in the details to create a new question"}
</p>
</div>
</div> </div>
<div className="max-w-3xl mx-auto">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Card className="shadow-none"> <Card className="shadow-sm border border-grayScale-100 rounded-xl">
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Question Details</CardTitle> <CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-7">
{/* Question Type */} {/* Question Type */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-600"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question Type Question Type
</label> </label>
<Select <Select
@ -166,9 +178,11 @@ export function AddQuestionPage() {
</Select> </Select>
</div> </div>
<hr className="border-grayScale-100" />
{/* Question Text */} {/* Question Text */}
<div> <div>
<label htmlFor="question" className="mb-2 block text-sm font-medium text-grayScale-600"> <label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question Question
</label> </label>
<Textarea <Textarea
@ -184,12 +198,15 @@ export function AddQuestionPage() {
{/* Options for Multiple Choice */} {/* Options for Multiple Choice */}
{(formData.type === "multiple-choice" || formData.type === "true-false") && ( {(formData.type === "multiple-choice" || formData.type === "true-false") && (
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-600"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Options Options
</label> </label>
<div className="space-y-2"> <div className="space-y-3">
{formData.options.map((option, index) => ( {formData.options.map((option, index) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-2 group">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-grayScale-50 text-grayScale-400 text-xs font-medium flex items-center justify-center">
{index + 1}
</span>
<Input <Input
value={option} value={option}
onChange={(e) => handleOptionChange(index, e.target.value)} onChange={(e) => handleOptionChange(index, e.target.value)}
@ -203,6 +220,7 @@ export function AddQuestionPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => removeOption(index)} onClick={() => removeOption(index)}
className="opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@ -210,7 +228,7 @@ export function AddQuestionPage() {
</div> </div>
))} ))}
{formData.type === "multiple-choice" && ( {formData.type === "multiple-choice" && (
<Button type="button" variant="outline" onClick={addOption} className="w-full"> <Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Option Add Option
</Button> </Button>
@ -219,9 +237,11 @@ export function AddQuestionPage() {
</div> </div>
)} )}
<hr className="border-grayScale-100" />
{/* Correct Answer */} {/* Correct Answer */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-600"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Correct Answer Correct Answer
</label> </label>
{formData.type === "multiple-choice" || formData.type === "true-false" ? ( {formData.type === "multiple-choice" || formData.type === "true-false" ? (
@ -252,9 +272,13 @@ export function AddQuestionPage() {
)} )}
</div> </div>
<hr className="border-grayScale-100" />
{/* Points and Difficulty side by side */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Points */} {/* Points */}
<div> <div>
<label htmlFor="points" className="mb-2 block text-sm font-medium text-grayScale-600"> <label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500">
Points Points
</label> </label>
<Input <Input
@ -269,22 +293,9 @@ export function AddQuestionPage() {
/> />
</div> </div>
{/* Category */}
<div>
<label htmlFor="category" className="mb-2 block text-sm font-medium text-grayScale-600">
Category (Optional)
</label>
<Input
id="category"
placeholder="e.g., Programming, Geography"
value={formData.category || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, category: e.target.value }))}
/>
</div>
{/* Difficulty */} {/* Difficulty */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-600"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Difficulty (Optional) Difficulty (Optional)
</label> </label>
<Select <Select
@ -297,13 +308,27 @@ export function AddQuestionPage() {
<option value="Hard">Hard</option> <option value="Hard">Hard</option>
</Select> </Select>
</div> </div>
</div>
{/* Category */}
<div>
<label htmlFor="category" className="mb-1.5 block text-sm font-medium text-grayScale-500">
Category (Optional)
</label>
<Input
id="category"
placeholder="e.g., Programming, Geography"
value={formData.category || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, category: e.target.value }))}
/>
</div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-4"> <div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-6 border-t border-grayScale-100">
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")}> <Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
Cancel Cancel
</Button> </Button>
<Button type="submit" className="bg-brand-500 hover:bg-brand-600"> <Button type="submit" className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
{isEditing ? "Update Question" : "Create Question"} {isEditing ? "Update Question" : "Create Question"}
</Button> </Button>
</div> </div>
@ -311,6 +336,6 @@ export function AddQuestionPage() {
</Card> </Card>
</form> </form>
</div> </div>
</div>
) )
} }

View File

@ -27,55 +27,72 @@ export function AddVideoPage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> {/* Header */}
<div className="flex items-center gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate("/content/courses")} onClick={() => navigate("/content/courses")}
className="h-8 w-8" className="h-9 w-9 rounded-lg border border-grayScale-200 bg-white shadow-sm transition-colors hover:bg-grayScale-50 hover:border-grayScale-300"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 text-grayScale-500" />
</Button> </Button>
<h1 className="text-xl font-semibold text-grayScale-900">Add New Video</h1> <div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Add New Video</h1>
<p className="text-sm text-grayScale-400">Upload and configure a new video</p>
</div> </div>
<Button onClick={handleSubmit} className="bg-brand-500 hover:bg-brand-600"> </div>
<Button onClick={handleSubmit} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
Save Save
</Button> </Button>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="mx-auto max-w-3xl space-y-6">
<Card className="p-6"> {/* Upload Card */}
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Upload</h2> <Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
Video Upload
</h2>
<FileUpload <FileUpload
accept="video/*" accept="video/*"
onFileSelect={setVideoFile} onFileSelect={setVideoFile}
label="Drag & Drop Video Here" label="Drag & Drop Video Here"
description="or click to browse files" description="or click to browse files"
className="min-h-[200px]" className="min-h-[200px] rounded-xl border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/> />
</Card> </Card>
{/* Preview Card */}
{videoFile && ( {videoFile && (
<Card className="p-6"> <Card className="border-grayScale-200 overflow-hidden p-0 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Preview</h2> <div className="border-b border-grayScale-100 px-6 py-4 sm:px-8">
<div className="aspect-video w-full overflow-hidden rounded-lg bg-grayScale-900"> <h2 className="text-lg font-semibold tracking-tight text-grayScale-600">
Video Preview
</h2>
</div>
<div className="p-4 sm:p-6">
<div className="aspect-video w-full overflow-hidden rounded-xl bg-grayScale-900 shadow-inner">
<video <video
src={URL.createObjectURL(videoFile)} src={URL.createObjectURL(videoFile)}
controls controls
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
</div> </div>
</div>
</Card> </Card>
)} )}
<Card className="p-6"> {/* Details Card */}
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Details</h2> <Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
<div className="space-y-4"> <h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
Video Details
</h2>
<div className="space-y-5">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Video Title Video Title
</label> </label>
<Input <Input
@ -87,7 +104,7 @@ export function AddVideoPage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Description Description
</label> </label>
<Textarea <Textarea
@ -99,9 +116,9 @@ export function AddVideoPage() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Tags</label> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tags</label>
<Input <Input
value={formData.tags} value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })} onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
@ -110,7 +127,7 @@ export function AddVideoPage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Category Category
</label> </label>
<Select <Select
@ -127,9 +144,9 @@ export function AddVideoPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Visibility Visibility
</label> </label>
<Select <Select
@ -145,7 +162,7 @@ export function AddVideoPage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Thumbnail Thumbnail
</label> </label>
<FileUpload <FileUpload
@ -153,15 +170,15 @@ export function AddVideoPage() {
onFileSelect={(file) => setFormData({ ...formData, thumbnail: file })} onFileSelect={(file) => setFormData({ ...formData, thumbnail: file })}
label="Upload Thumbnail" label="Upload Thumbnail"
description="or click to browse" description="or click to browse"
className="min-h-[100px]" className="min-h-[100px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/> />
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
<div className="flex justify-end"> <div className="flex justify-end pb-4">
<Button type="submit" className="bg-brand-500 hover:bg-brand-600"> <Button type="submit" className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors">
Save Video Save Video
</Button> </Button>
</div> </div>
@ -169,4 +186,3 @@ export function AddVideoPage() {
</div> </div>
) )
} }

View File

@ -11,10 +11,28 @@ const tabs = [
export function ContentManagementLayout() { export function ContentManagementLayout() {
return ( return (
<div className="mx-auto w-full max-w-6xl"> <div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
<div className="mb-4 text-sm font-semibold text-grayScale-500">Content Management</div> {/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3">
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
Content Management
</h1>
<p className="mt-0.5 text-sm text-grayScale-400">
Manage courses, speaking exercises, practices, and questions
</p>
</div>
</div>
</div>
<div className="mb-4 flex items-center gap-2 rounded-xl border bg-white p-1"> {/* Tab bar */}
<div
className="scroll-hide mb-8 flex items-center gap-1 overflow-x-auto rounded-2xl border border-grayScale-100 bg-grayScale-50/60 p-1.5 shadow-sm backdrop-blur"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
<style>{`.scroll-hide::-webkit-scrollbar { display: none; }`}</style>
{tabs.map((t) => ( {tabs.map((t) => (
<NavLink <NavLink
key={t.to} key={t.to}
@ -22,9 +40,10 @@ export function ContentManagementLayout() {
end={t.to === "/content"} end={t.to === "/content"}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"rounded-lg px-4 py-2 text-sm font-semibold text-grayScale-500 transition", "relative whitespace-nowrap rounded-xl px-5 py-2 text-sm font-semibold transition-all duration-200 ease-in-out",
"hover:text-brand-600", "text-grayScale-500 hover:bg-white/80 hover:text-brand-600 hover:shadow-sm",
isActive && "bg-brand-500 text-white hover:text-white", isActive &&
"bg-brand-500 text-white shadow-md shadow-brand-500/25 hover:bg-brand-600 hover:text-white",
) )
} }
> >
@ -33,9 +52,8 @@ export function ContentManagementLayout() {
))} ))}
</div> </div>
{/* Page content */}
<Outlet /> <Outlet />
</div> </div>
) )
} }

View File

@ -1,11 +1,62 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link, useParams } from "react-router-dom" import { Link, useParams } from "react-router-dom"
import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft } from "lucide-react" import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft, ArrowRight, ChevronRight } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { getCourseCategories } from "../../api/courses.api" import { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types" import type { CourseCategory } from "../../types/course.types"
const contentSections = [
{
key: "courses",
pathFn: (categoryId: string | undefined) => `/content/category/${categoryId}/courses`,
icon: BookOpen,
title: "Courses",
description: "Manage course videos and educational content",
action: "Manage Courses",
count: 12,
countLabel: "courses",
gradient: "from-brand-500/10 via-brand-400/5 to-transparent",
accentBorder: "group-hover:border-brand-400",
},
{
key: "speaking",
pathFn: () => "/content/speaking",
icon: Mic,
title: "Speaking",
description: "Manage speaking practice sessions and exercises",
action: "Manage Speaking",
count: 8,
countLabel: "sessions",
gradient: "from-purple-500/10 via-purple-400/5 to-transparent",
accentBorder: "group-hover:border-purple-400",
},
{
key: "practices",
pathFn: () => "/content/practices",
icon: Briefcase,
title: "Practice",
description: "Manage practice details, members, and leadership",
action: "Manage Practice",
count: 5,
countLabel: "practices",
gradient: "from-indigo-500/10 via-indigo-400/5 to-transparent",
accentBorder: "group-hover:border-indigo-400",
},
{
key: "questions",
pathFn: () => "/content/questions",
icon: HelpCircle,
title: "Questions",
description: "Manage questions, quizzes, and assessments",
action: "Manage Questions",
count: 34,
countLabel: "questions",
gradient: "from-rose-500/10 via-rose-400/5 to-transparent",
accentBorder: "group-hover:border-rose-400",
},
] as const
export function ContentOverviewPage() { export function ContentOverviewPage() {
const { categoryId } = useParams<{ categoryId: string }>() const { categoryId } = useParams<{ categoryId: string }>()
const [category, setCategory] = useState<CourseCategory | null>(null) const [category, setCategory] = useState<CourseCategory | null>(null)
@ -27,81 +78,114 @@ export function ContentOverviewPage() {
}, [categoryId]) }, [categoryId])
return ( return (
<div className="space-y-6"> <div className="space-y-8">
{/* Header & Breadcrumb */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to="/content" to="/content"
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600" className="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-grayScale-100 bg-white text-grayScale-400 shadow-sm transition-all duration-200 hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600 hover:shadow-md"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Link> </Link>
<h1 className="text-xl font-semibold text-grayScale-900">
<div className="flex items-center gap-1.5 text-sm text-grayScale-400">
<Link to="/content" className="transition-colors hover:text-brand-500">
Content
</Link>
<ChevronRight className="h-3.5 w-3.5" />
<span className="font-medium text-grayScale-600">
{category?.name ?? "Overview"}
</span>
</div>
</div>
<h1 className="text-xl font-bold tracking-tight text-grayScale-700">
{category?.name ?? "Content Management"} {category?.name ?? "Content Management"}
</h1> </h1>
</div> </div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card className="shadow-sm">
<CardHeader>
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
<BookOpen className="h-6 w-6" />
</div>
<CardTitle className="text-lg">Courses</CardTitle>
<CardDescription>Manage course videos and educational content</CardDescription>
</CardHeader>
<CardContent>
<Link to={`/content/category/${categoryId}/courses`}>
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Courses</Button>
</Link>
</CardContent>
</Card>
<Card className="shadow-sm"> {/* Gradient Divider */}
<CardHeader> <div className="relative">
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600"> <div className="absolute inset-0 flex items-center" aria-hidden="true">
<Mic className="h-6 w-6" /> <div className="w-full border-t border-grayScale-100" />
</div>
<div className="relative flex justify-center">
<div
className="h-1 w-24 rounded-full"
style={{
background: "linear-gradient(90deg, #9E2891 0%, #6A1B9A 100%)",
}}
/>
</div>
</div> </div>
<CardTitle className="text-lg">Speaking</CardTitle>
<CardDescription>Manage speaking practice sessions and exercises</CardDescription>
</CardHeader>
<CardContent>
<Link to="/content/speaking">
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Speaking</Button>
</Link>
</CardContent>
</Card>
<Card className="shadow-sm"> {/* Cards Grid */}
<CardHeader> <div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600"> {contentSections.map((section) => {
<Briefcase className="h-6 w-6" /> const Icon = section.icon
</div> return (
<CardTitle className="text-lg">Practice</CardTitle> <Link
<CardDescription>Manage practice details, members, and leadership</CardDescription> key={section.key}
</CardHeader> to={section.pathFn(categoryId)}
<CardContent> className="group"
<Link to="/content/practices"> >
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Practice</Button> <Card
</Link> className={`relative h-full overflow-hidden border border-grayScale-100 bg-white transition-all duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] ${section.accentBorder} group-hover:shadow-lg`}
</CardContent> style={{
</Card> boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
}}
>
{/* Subtle gradient background on icon area */}
<div
className={`absolute inset-x-0 top-0 h-28 bg-gradient-to-b ${section.gradient} pointer-events-none`}
/>
<Card className="shadow-sm"> <CardHeader className="relative pb-2">
<CardHeader> <div className="mb-4 flex items-start justify-between">
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600"> {/* Icon with gradient ring */}
<HelpCircle className="h-6 w-6" /> <div className="relative">
<div
className="grid h-12 w-12 place-items-center rounded-xl bg-white text-brand-600 shadow-sm ring-1 ring-grayScale-100 transition-all duration-300 group-hover:ring-brand-300 group-hover:shadow-md"
style={{
background:
"linear-gradient(135deg, rgba(158,40,145,0.08) 0%, rgba(106,27,154,0.04) 100%)",
}}
>
<Icon className="h-5.5 w-5.5 transition-transform duration-300 group-hover:scale-110" />
</div> </div>
<CardTitle className="text-lg">Questions</CardTitle> {/* Decorative dot */}
<CardDescription>Manage questions, quizzes, and assessments</CardDescription> <div className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-brand-400 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
</div>
{/* Count Badge */}
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-50 px-2.5 py-1 text-xs font-medium text-grayScale-500 ring-1 ring-inset ring-grayScale-100 transition-all duration-300 group-hover:bg-brand-50 group-hover:text-brand-600 group-hover:ring-brand-200">
{section.count} {section.countLabel}
</span>
</div>
<CardTitle className="text-[15px] font-semibold text-grayScale-700 transition-colors duration-200 group-hover:text-brand-600">
{section.title}
</CardTitle>
<CardDescription className="mt-1 text-[13px] leading-relaxed text-grayScale-400">
{section.description}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent>
<Link to="/content/questions"> <CardContent className="relative pt-0">
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Questions</Button> {/* Thin separator */}
</Link> <div className="mb-3 h-px w-full bg-gradient-to-r from-transparent via-grayScale-100 to-transparent" />
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors duration-200 group-hover:text-brand-600">
{section.action}
<ArrowRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1.5" />
</span>
</CardContent> </CardContent>
</Card> </Card>
</Link>
)
})}
</div> </div>
</div> </div>
) )
} }

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { FolderOpen } from "lucide-react" import { FolderOpen, RefreshCw, AlertCircle, BookOpen } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { getCourseCategories } from "../../api/courses.api" import { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types" import type { CourseCategory } from "../../types/course.types"
@ -10,8 +10,9 @@ export function CourseCategoryPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchCategories = async () => { const fetchCategories = async () => {
setLoading(true)
setError(null)
try { try {
const res = await getCourseCategories() const res = await getCourseCategories()
setCategories(res.data.data.categories) setCategories(res.data.data.categories)
@ -23,50 +24,107 @@ export function CourseCategoryPage() {
} }
} }
useEffect(() => {
fetchCategories() fetchCategories()
}, []) }, [])
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center gap-4 py-24">
<div className="text-sm text-grayScale-500">Loading categories...</div> <div className="relative">
<div className="h-12 w-12 rounded-full border-4 border-brand-100" />
<div className="absolute inset-0 h-12 w-12 animate-spin rounded-full border-4 border-transparent border-t-brand-500" />
</div>
<span className="text-sm font-medium text-grayScale-400">Loading categories</span>
</div> </div>
) )
} }
if (error) { if (error) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-24">
<div className="text-sm text-red-500">{error}</div> <div className="flex flex-col items-center gap-4 rounded-2xl border border-red-100 bg-red-50/60 px-10 py-8 text-center shadow-sm">
<div className="grid h-12 w-12 place-items-center rounded-full bg-red-100">
<AlertCircle className="h-6 w-6 text-red-500" />
</div>
<div>
<p className="text-sm font-semibold text-red-700">{error}</p>
<p className="mt-1 text-xs text-red-400">
Please check your connection and try again
</p>
</div>
<button
onClick={fetchCategories}
className="mt-1 inline-flex items-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-red-600"
>
<RefreshCw className="h-3.5 w-3.5" />
Retry
</button>
</div>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<h1 className="text-xl font-semibold text-grayScale-900">Course Categories</h1> {/* Page header */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div>
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
<p className="mt-1 text-sm text-grayScale-400">
Browse and manage your course categories below
</p>
</div>
{categories.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
<div className="relative">
<div className="grid h-20 w-20 place-items-center rounded-2xl bg-gradient-to-br from-brand-100 to-brand-200 shadow-sm">
<FolderOpen className="h-9 w-9 text-brand-500" />
</div>
<div className="absolute -bottom-1 -right-1 grid h-8 w-8 place-items-center rounded-lg bg-white shadow ring-1 ring-grayScale-100">
<BookOpen className="h-4 w-4 text-grayScale-300" />
</div>
</div>
<div className="text-center">
<p className="text-sm font-semibold text-grayScale-500">No categories yet</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-grayScale-400">
Course categories will appear here once created. Start by adding your first category.
</p>
</div>
</div>
) : (
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categories.map((category) => ( {categories.map((category) => (
<Link key={category.id} to={`/content/category/${category.id}/courses`} className="group"> <Link
<Card className="h-full shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-brand-200"> key={category.id}
<CardHeader> to={`/content/category/${category.id}/courses`}
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600 transition group-hover:bg-brand-500 group-hover:text-white"> className="group"
>
<Card className="relative h-full overflow-hidden border border-grayScale-100 shadow-sm transition-all duration-300 group-hover:scale-[1.02] group-hover:border-brand-200 group-hover:shadow-lg">
{/* Decorative gradient strip */}
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600 opacity-70 transition-opacity duration-300 group-hover:opacity-100" />
<CardHeader className="pt-5">
<div className="mb-4 grid h-12 w-12 place-items-center rounded-xl bg-gradient-to-br from-brand-100 to-brand-200 text-brand-600 shadow-sm transition-all duration-300 group-hover:from-brand-500 group-hover:to-brand-600 group-hover:text-white group-hover:shadow-md">
<FolderOpen className="h-6 w-6" /> <FolderOpen className="h-6 w-6" />
</div> </div>
<CardTitle className="text-lg">{category.name}</CardTitle> <CardTitle className="text-lg font-semibold text-grayScale-600 transition-colors group-hover:text-grayScale-700">
{category.name}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<span className="text-sm font-medium text-brand-500 group-hover:text-brand-600"> <span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
View Courses View Courses
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
</span>
</span> </span>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
))} ))}
</div> </div>
{categories.length === 0 && (
<div className="text-center text-sm text-grayScale-500">No categories found</div>
)} )}
</div> </div>
) )

View File

@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react" import { useEffect, useState, useRef } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" import { Link, useParams, useNavigate } from "react-router-dom"
import { Plus, ArrowLeft, BookOpen, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit } from "lucide-react" import { Plus, ArrowLeft, BookOpen, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit, RefreshCw, AlertCircle } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
@ -237,55 +237,73 @@ export function CoursesPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-32">
<div className="text-sm text-grayScale-500">Loading courses...</div> <div className="rounded-2xl bg-brand-50/50 p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p>
</div> </div>
) )
} }
if (error) { if (error) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-32">
<div className="text-sm text-red-500">{error}</div> <div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
<div className="rounded-full bg-red-100 p-2">
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
</div>
<p className="text-sm font-medium text-red-600">{error}</p>
</div>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> {/* Header */}
<div className="flex items-center gap-3"> <div className="rounded-2xl border border-grayScale-100 bg-white px-5 py-4 shadow-sm sm:px-6 sm:py-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3.5">
<Link <Link
to="/content" to="/content"
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600" className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Link> </Link>
<div> <div>
<h1 className="text-xl font-semibold text-grayScale-900"> <h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">
{category?.name} Courses {category?.name} Courses
</h1> </h1>
<p className="text-sm text-grayScale-500">{courses.length} courses available</p> <p className="mt-0.5 text-sm text-grayScale-400">
<span className="font-medium text-grayScale-500">{courses.length}</span> courses available
</p>
</div> </div>
</div> </div>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleOpenModal}> <Button className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleOpenModal}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add New Course Add New Course
</Button> </Button>
</div> </div>
</div>
{/* Course grid or empty state */}
{courses.length === 0 ? ( {courses.length === 0 ? (
<Card className="shadow-none"> <Card className="border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-20">
<BookOpen className="mb-4 h-12 w-12 text-grayScale-300" /> <div className="rounded-2xl bg-grayScale-50 p-5">
<p className="text-sm text-grayScale-500">No courses found in this category</p> <BookOpen className="h-14 w-14 text-grayScale-300" />
<Button variant="outline" className="mt-4" onClick={handleOpenModal}> </div>
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No courses yet</h3>
<p className="mt-1.5 text-sm text-grayScale-400">No courses found in this category</p>
<Button variant="outline" className="mt-6 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50" onClick={handleOpenModal}>
<Plus className="mr-2 h-4 w-4" />
Add your first course Add your first course
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{courses.map((course, index) => { {courses.map((course, index) => {
const gradients = [ const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-200", "bg-gradient-to-br from-blue-100 to-blue-200",
@ -296,48 +314,49 @@ export function CoursesPage() {
return ( return (
<Card <Card
key={course.id} key={course.id}
className="cursor-pointer overflow-hidden border-0 bg-white shadow-sm transition hover:shadow-md" className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-grayScale-200"
onClick={() => handleCourseClick(course.id)} onClick={() => handleCourseClick(course.id)}
> >
{/* Thumbnail */} {/* Thumbnail */}
<div className="relative aspect-video w-full"> <div className="relative aspect-video w-full overflow-hidden">
<CourseThumbnail <CourseThumbnail
src={course.thumbnail} src={course.thumbnail}
alt={course.title} alt={course.title}
gradient={gradients[index % gradients.length]} gradient={gradients[index % gradients.length]}
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
</div> </div>
{/* Content */} {/* Content */}
<div className="p-4 space-y-3"> <div className="space-y-3 border-t border-grayScale-50 p-4">
{/* Status and menu */} {/* Status and menu */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge <Badge
className={`text-xs font-medium ${ className={`rounded-full px-2.5 py-0.5 text-[11px] font-semibold tracking-wide ${
course.is_active course.is_active
? "bg-transparent text-green-600 border border-green-200" ? "border-0 bg-emerald-50 text-emerald-700"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200" : "border-0 bg-grayScale-100 text-grayScale-500"
}`} }`}
> >
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-green-500" : "bg-grayScale-400"}`} /> <span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-emerald-500" : "bg-grayScale-400"}`} />
{course.is_active ? "ACTIVE" : "INACTIVE"} {course.is_active ? "ACTIVE" : "INACTIVE"}
</Badge> </Badge>
<div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}> <div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}>
<button <button
onClick={() => setOpenMenuId(openMenuId === course.id ? null : course.id)} onClick={() => setOpenMenuId(openMenuId === course.id ? null : course.id)}
className="text-grayScale-400 hover:text-grayScale-600" className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</button> </button>
{openMenuId === course.id && ( {openMenuId === course.id && (
<div className="absolute right-0 top-full z-10 mt-1 w-40 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"> <div className="absolute right-0 top-full z-10 mt-1.5 w-44 animate-in fade-in slide-in-from-top-1 rounded-xl border border-grayScale-100 bg-white py-1.5 shadow-lg">
<button <button
onClick={() => { onClick={() => {
handleToggleStatus(course) handleToggleStatus(course)
setOpenMenuId(null) setOpenMenuId(null)
}} }}
disabled={togglingId === course.id} disabled={togglingId === course.id}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-grayScale-700 hover:bg-grayScale-100 disabled:opacity-50" className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
> >
{course.is_active ? ( {course.is_active ? (
<> <>
@ -351,12 +370,13 @@ export function CoursesPage() {
</> </>
)} )}
</button> </button>
<div className="mx-3 my-1 border-t border-grayScale-100" />
<button <button
onClick={() => { onClick={() => {
handleDeleteClick(course) handleDeleteClick(course)
setOpenMenuId(null) setOpenMenuId(null)
}} }}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50" className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-red-500 transition-colors hover:bg-red-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Delete Delete
@ -367,15 +387,15 @@ export function CoursesPage() {
</div> </div>
{/* Title */} {/* Title */}
<h3 className="font-medium text-grayScale-900">{course.title}</h3> <h3 className="font-semibold text-grayScale-700 line-clamp-1">{course.title}</h3>
<p className="text-sm text-grayScale-500 line-clamp-2"> <p className="text-sm leading-relaxed text-grayScale-400 line-clamp-2">
{course.description || "No description available"} {course.description || "No description available"}
</p> </p>
{/* Edit button */} {/* Edit button */}
<Button <Button
variant="outline" variant="outline"
className="w-full border-grayScale-200 text-grayScale-700" className="w-full border-grayScale-200 text-grayScale-600 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleEditClick(course) handleEditClick(course)
@ -391,22 +411,24 @@ export function CoursesPage() {
</div> </div>
)} )}
{/* Add Course Modal */}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-semibold text-grayScale-900">Add New Course</h2> <h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2>
<button <button
onClick={handleCloseModal} onClick={handleCloseModal}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
{saveError && ( {saveError && (
<div className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600"> <div className="flex items-center gap-2.5 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-600">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError} {saveError}
</div> </div>
)} )}
@ -416,7 +438,7 @@ export function CoursesPage() {
htmlFor="course-title" htmlFor="course-title"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Title Title <span className="text-red-400">*</span>
</label> </label>
<Input <Input
id="course-title" id="course-title"
@ -431,7 +453,7 @@ export function CoursesPage() {
htmlFor="course-description" htmlFor="course-description"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Description Description <span className="text-red-400">*</span>
</label> </label>
<textarea <textarea
id="course-description" id="course-description"
@ -439,21 +461,21 @@ export function CoursesPage() {
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
rows={4} rows={4}
className="flex w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex w-full rounded-xl border border-grayScale-200 bg-white px-3.5 py-2.5 text-sm transition-colors ring-offset-background placeholder:text-grayScale-400 focus-visible:border-brand-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100"
/> />
</div> </div>
<div className="text-xs text-grayScale-500"> <div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
Category: <span className="font-medium">{category?.name}</span> Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCloseModal} disabled={saving}> <Button variant="outline" onClick={handleCloseModal} disabled={saving} className="w-full sm:w-auto">
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto"
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
> >
@ -464,22 +486,24 @@ export function CoursesPage() {
</div> </div>
)} )}
{/* Edit Course Modal */}
{showEditModal && courseToEdit && ( {showEditModal && courseToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Course</h2> <h2 className="text-lg font-bold text-grayScale-700">Edit Course</h2>
<button <button
onClick={handleCloseEditModal} onClick={handleCloseEditModal}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
{updateError && ( {updateError && (
<div className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600"> <div className="flex items-center gap-2.5 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-600">
<AlertCircle className="h-4 w-4 shrink-0" />
{updateError} {updateError}
</div> </div>
)} )}
@ -489,7 +513,7 @@ export function CoursesPage() {
htmlFor="edit-course-title" htmlFor="edit-course-title"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Title Title <span className="text-red-400">*</span>
</label> </label>
<Input <Input
id="edit-course-title" id="edit-course-title"
@ -504,7 +528,7 @@ export function CoursesPage() {
htmlFor="edit-course-description" htmlFor="edit-course-description"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Description Description <span className="text-red-400">*</span>
</label> </label>
<textarea <textarea
id="edit-course-description" id="edit-course-description"
@ -512,7 +536,7 @@ export function CoursesPage() {
value={editDescription} value={editDescription}
onChange={(e) => setEditDescription(e.target.value)} onChange={(e) => setEditDescription(e.target.value)}
rows={4} rows={4}
className="flex w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex w-full rounded-xl border border-grayScale-200 bg-white px-3.5 py-2.5 text-sm transition-colors ring-offset-background placeholder:text-grayScale-400 focus-visible:border-brand-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100"
/> />
</div> </div>
@ -532,12 +556,12 @@ export function CoursesPage() {
</div> </div>
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating}> <Button variant="outline" onClick={handleCloseEditModal} disabled={updating} className="w-full sm:w-auto">
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto"
onClick={handleUpdate} onClick={handleUpdate}
disabled={updating} disabled={updating}
> >
@ -548,37 +572,42 @@ export function CoursesPage() {
</div> </div>
)} )}
{/* Delete Course Modal */}
{showDeleteModal && courseToDelete && ( {showDeleteModal && courseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Course</h2> <h2 className="text-lg font-bold text-grayScale-700">Delete Course</h2>
<button <button
onClick={() => setShowDeleteModal(false)} onClick={() => setShowDeleteModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="px-6 py-5"> <div className="px-6 py-6">
<p className="text-sm text-grayScale-600"> <div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold">{courseToDelete.title}</span>? This action cannot <span className="font-semibold text-grayScale-700">{courseToDelete.title}</span>? This action cannot
be undone. be undone.
</p> </p>
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowDeleteModal(false)} onClick={() => setShowDeleteModal(false)}
disabled={deleting} disabled={deleting}
className="w-full sm:w-auto"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-red-500 hover:bg-red-600" className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
onClick={handleConfirmDelete} onClick={handleConfirmDelete}
disabled={deleting} disabled={deleting}
> >

View File

@ -50,38 +50,47 @@ export function PracticeDetailsPage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<h1 className="text-xl font-semibold text-grayScale-900">Practice Management</h1> {/* Page Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
<p className="mt-1 text-sm text-grayScale-400">Manage your practice details, leadership, and members</p>
</div>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
{/* Practice Leadership */} {/* Practice Leadership */}
<Card className="p-6"> <Card className="border-grayScale-200 p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between"> <div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-grayScale-900">Practice Leadership</h2> <h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Leadership</h2>
<Button <Button
size="sm" size="sm"
onClick={() => setIsLeaderModalOpen(true)} onClick={() => setIsLeaderModalOpen(true)}
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add New Leader Add New Leader
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
{mockLeaders.map((leader) => ( {mockLeaders.map((leader) => (
<div <div
key={leader.id} key={leader.id}
className="flex items-center justify-between rounded-lg border p-3" className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
> >
<div> <div className="flex items-center gap-3">
<p className="font-medium text-grayScale-900">{leader.name}</p> <div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
<p className="text-sm text-grayScale-600">{leader.role}</p> {leader.name[0]}
</div> </div>
<div className="flex gap-2"> <div>
<Button variant="ghost" size="icon" className="h-8 w-8"> <p className="font-medium text-grayScale-600">{leader.name}</p>
<p className="text-xs text-grayScale-400">{leader.role}</p>
</div>
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"> <Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@ -91,11 +100,11 @@ export function PracticeDetailsPage() {
</Card> </Card>
{/* Practice Details */} {/* Practice Details */}
<Card className="p-6"> <Card className="border-grayScale-200 p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Practice Details</h2> <h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">Practice Details</h2>
<div className="space-y-4"> <div className="space-y-5">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Name Practice Name
</label> </label>
<Input <Input
@ -106,7 +115,7 @@ export function PracticeDetailsPage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Description Practice Description
</label> </label>
<Textarea <Textarea
@ -118,7 +127,7 @@ export function PracticeDetailsPage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Type Practice Type
</label> </label>
<Select <Select
@ -133,7 +142,7 @@ export function PracticeDetailsPage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Address Practice Address
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@ -142,7 +151,7 @@ export function PracticeDetailsPage() {
onChange={(e) => setFormData({ ...formData, street: e.target.value })} onChange={(e) => setFormData({ ...formData, street: e.target.value })}
placeholder="Street" placeholder="Street"
/> />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Input <Input
value={formData.city} value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })} onChange={(e) => setFormData({ ...formData, city: e.target.value })}
@ -162,39 +171,46 @@ export function PracticeDetailsPage() {
</div> </div>
</div> </div>
<Button className="w-full bg-brand-500 hover:bg-brand-600">Save Changes</Button> <Button className="w-full bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
Save Changes
</Button>
</div> </div>
</Card> </Card>
</div> </div>
{/* Practice Members */} {/* Practice Members */}
<Card className="p-6"> <Card className="border-grayScale-200 p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between"> <div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-grayScale-900">Practice Members</h2> <h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Members</h2>
<Button <Button
size="sm" size="sm"
onClick={() => setIsMemberModalOpen(true)} onClick={() => setIsMemberModalOpen(true)}
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add New Member Add New Member
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
{mockMembers.map((member) => ( {mockMembers.map((member) => (
<div <div
key={member.id} key={member.id}
className="flex items-center justify-between rounded-lg border p-3" className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
> >
<div> <div className="flex items-center gap-3">
<p className="font-medium text-grayScale-900">{member.name}</p> <div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
<p className="text-sm text-grayScale-600">{member.role}</p> {member.name[0]}
</div> </div>
<div className="flex gap-2"> <div>
<Button variant="ghost" size="icon" className="h-8 w-8"> <p className="font-medium text-grayScale-600">{member.name}</p>
<p className="text-xs text-grayScale-400">{member.role}</p>
</div>
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"> <Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@ -205,13 +221,13 @@ export function PracticeDetailsPage() {
{/* Add Member Modal */} {/* Add Member Modal */}
<Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}> <Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}>
<DialogContent> <DialogContent className="sm:rounded-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add New Member</DialogTitle> <DialogTitle>Add New Member</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-5 py-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Member Name Member Name
</label> </label>
<Input <Input
@ -221,7 +237,7 @@ export function PracticeDetailsPage() {
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Member Role Member Role
</label> </label>
<Input <Input
@ -235,7 +251,7 @@ export function PracticeDetailsPage() {
<Button variant="outline" onClick={() => setIsMemberModalOpen(false)}> <Button variant="outline" onClick={() => setIsMemberModalOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleAddMember} className="bg-brand-500 hover:bg-brand-600"> <Button onClick={handleAddMember} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
Add Member Add Member
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -244,13 +260,13 @@ export function PracticeDetailsPage() {
{/* Add Leader Modal */} {/* Add Leader Modal */}
<Dialog open={isLeaderModalOpen} onOpenChange={setIsLeaderModalOpen}> <Dialog open={isLeaderModalOpen} onOpenChange={setIsLeaderModalOpen}>
<DialogContent> <DialogContent className="sm:rounded-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add New Leader</DialogTitle> <DialogTitle>Add New Leader</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-5 py-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Leader Name Leader Name
</label> </label>
<Input <Input
@ -260,7 +276,7 @@ export function PracticeDetailsPage() {
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Leader Role Leader Role
</label> </label>
<Input <Input
@ -274,7 +290,7 @@ export function PracticeDetailsPage() {
<Button variant="outline" onClick={() => setIsLeaderModalOpen(false)}> <Button variant="outline" onClick={() => setIsLeaderModalOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleAddLeader} className="bg-brand-500 hover:bg-brand-600"> <Button onClick={handleAddLeader} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
Add Leader Add Leader
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -283,4 +299,3 @@ export function PracticeDetailsPage() {
</div> </div>
) )
} }

View File

@ -28,43 +28,52 @@ export function PracticeMembersPage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<h1 className="text-xl font-semibold text-grayScale-900">Practice Management</h1> {/* Page Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
<p className="mt-1 text-sm text-grayScale-400">View and manage your practice members</p>
</div>
<Card className="p-6"> <Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
<div className="mb-4 flex items-center justify-between"> <div className="mb-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-grayScale-900">Current Members</h2> <h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Current Members</h2>
<Button <Button
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Members Add Members
</Button> </Button>
</div> </div>
<div className="grid grid-cols-3 gap-4 md:grid-cols-6"> <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-6">
{mockMembers.map((member) => ( {mockMembers.map((member) => (
<div key={member.id} className="flex flex-col items-center"> <div
<Avatar className="h-16 w-16 border-2 border-grayScale-200"> key={member.id}
className="group flex flex-col items-center"
>
<Avatar className="h-16 w-16 border-2 border-grayScale-200 transition-all duration-200 group-hover:border-brand-400 group-hover:shadow-md group-hover:scale-105">
<AvatarImage src={member.avatar} /> <AvatarImage src={member.avatar} />
<AvatarFallback className="bg-brand-100 text-brand-600"> <AvatarFallback className="bg-brand-100 text-brand-600 font-medium">
{member.name[0]} {member.name[0]}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="mt-2 text-sm font-medium text-grayScale-700">{member.name}</span> <span className="mt-2.5 text-sm font-medium text-grayScale-500 transition-colors group-hover:text-grayScale-600">
{member.name}
</span>
</div> </div>
))} ))}
</div> </div>
</Card> </Card>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent> <DialogContent className="sm:rounded-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add New Member</DialogTitle> <DialogTitle>Add New Member</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-5 py-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Member Name Member Name
</label> </label>
<Input <Input
@ -74,7 +83,7 @@ export function PracticeMembersPage() {
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Member Role Member Role
</label> </label>
<Input <Input
@ -88,7 +97,7 @@ export function PracticeMembersPage() {
<Button variant="outline" onClick={() => setIsModalOpen(false)}> <Button variant="outline" onClick={() => setIsModalOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleAddMember} className="bg-brand-500 hover:bg-brand-600"> <Button onClick={handleAddMember} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
Add Member Add Member
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -97,4 +106,3 @@ export function PracticeMembersPage() {
</div> </div>
) )
} }

View File

@ -170,33 +170,37 @@ export function PracticeQuestionsPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-20">
<div className="text-sm text-grayScale-500">Loading questions...</div> <div className="h-8 w-8 animate-spin rounded-full border-2 border-grayScale-200 border-t-brand-500" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading questions...</p>
</div> </div>
) )
} }
if (error) { if (error) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-20">
<div className="text-sm text-red-500">{error}</div> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<X className="h-6 w-6 text-red-500" />
</div>
<p className="mt-4 text-sm font-medium text-red-600">{error}</p>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to={backLink} to={backLink}
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600" className="group grid h-9 w-9 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition-all hover:bg-brand-100 hover:text-brand-600 hover:shadow-sm"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
</Link> </Link>
<div> <div>
<h1 className="text-xl font-semibold text-grayScale-900">Practice Questions</h1> <h1 className="text-xl font-bold tracking-tight text-grayScale-900">Practice Questions</h1>
<p className="text-sm text-grayScale-500">{questions.length} questions available</p> <p className="mt-0.5 text-sm text-grayScale-500">{questions.length} questions available</p>
</div> </div>
</div> </div>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}> <Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}>
@ -206,11 +210,15 @@ export function PracticeQuestionsPage() {
</div> </div>
{questions.length === 0 ? ( {questions.length === 0 ? (
<Card className="shadow-none"> <Card className="border-2 border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-16">
<HelpCircle className="mb-4 h-12 w-12 text-grayScale-300" /> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-grayScale-100">
<p className="text-sm text-grayScale-500">No questions found for this practice</p> <HelpCircle className="h-8 w-8 text-grayScale-300" />
<Button variant="outline" className="mt-4" onClick={handleAddQuestion}> </div>
<p className="mt-4 text-sm font-medium text-grayScale-600">No questions found for this practice</p>
<p className="mt-1 text-xs text-grayScale-400">Get started by adding your first question</p>
<Button variant="outline" className="mt-6 border-brand-200 text-brand-600 hover:bg-brand-50" onClick={handleAddQuestion}>
<Plus className="mr-2 h-4 w-4" />
Add your first question Add your first question
</Button> </Button>
</CardContent> </CardContent>
@ -218,22 +226,22 @@ export function PracticeQuestionsPage() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{questions.map((question, index) => ( {questions.map((question, index) => (
<Card key={question.id} className="shadow-sm"> <Card key={question.id} className="shadow-sm transition-shadow hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600"> <div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-100 text-sm font-bold text-brand-600 shadow-sm">
{index + 1} {index + 1}
</div> </div>
<Badge className={typeColors[question.type]}> <Badge className={`${typeColors[question.type]} font-medium`}>
{typeLabels[question.type]} {typeLabels[question.type]}
</Badge> </Badge>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8 rounded-lg text-grayScale-400 hover:text-brand-600"
onClick={() => handleEditClick(question)} onClick={() => handleEditClick(question)}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
@ -241,24 +249,24 @@ export function PracticeQuestionsPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600" className="h-8 w-8 rounded-lg text-grayScale-400 hover:bg-red-50 hover:text-red-500"
onClick={() => handleDeleteClick(question)} onClick={() => handleDeleteClick(question)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
<CardTitle className="mt-3 text-base font-medium">{question.question}</CardTitle> <CardTitle className="mt-3 text-base font-medium leading-relaxed">{question.question}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-0"> <CardContent className="space-y-3 pt-0">
<div className="rounded-lg bg-grayScale-50 p-3"> <div className="rounded-lg border border-grayScale-100 bg-grayScale-50 p-4">
<p className="text-xs font-medium text-grayScale-500">Sample Answer</p> <p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400">Sample Answer</p>
<p className="mt-1 text-sm text-grayScale-700">{question.sample_answer}</p> <p className="mt-2 text-sm leading-relaxed text-grayScale-700">{question.sample_answer}</p>
</div> </div>
{question.tips && ( {question.tips && (
<div className="rounded-lg bg-amber-50 p-3"> <div className="rounded-lg border border-amber-100 bg-amber-50 p-4">
<p className="text-xs font-medium text-amber-600">Tips</p> <p className="text-xs font-semibold uppercase tracking-wider text-amber-500">💡 Tips</p>
<p className="mt-1 text-sm text-amber-700">{question.tips}</p> <p className="mt-2 text-sm leading-relaxed text-amber-700">{question.tips}</p>
</div> </div>
)} )}
</CardContent> </CardContent>
@ -269,23 +277,23 @@ export function PracticeQuestionsPage() {
{/* Delete Modal */} {/* Delete Modal */}
{showDeleteModal && questionToDelete && ( {showDeleteModal && questionToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-sm rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Question</h2> <h2 className="text-lg font-semibold text-grayScale-900">Delete Question</h2>
<button <button
onClick={() => setShowDeleteModal(false)} onClick={() => setShowDeleteModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="px-6 py-5"> <div className="px-6 py-6">
<p className="text-sm text-grayScale-600"> <p className="text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete this question? This action cannot be undone. Are you sure you want to delete this question? This action cannot be undone.
</p> </p>
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}> <Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
Cancel Cancel
</Button> </Button>
@ -299,19 +307,19 @@ export function PracticeQuestionsPage() {
{/* Add Modal */} {/* Add Modal */}
{showAddModal && ( {showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-xl"> <div className="mx-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add New Question</h2> <h2 className="text-lg font-semibold text-grayScale-900">Add New Question</h2>
<button <button
onClick={() => setShowAddModal(false)} onClick={() => setShowAddModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Question Type</label> <label className="text-sm font-medium text-grayScale-700">Question Type</label>
<Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}> <Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}>
@ -326,7 +334,7 @@ export function PracticeQuestionsPage() {
value={questionText} value={questionText}
onChange={(e) => setQuestionText(e.target.value)} onChange={(e) => setQuestionText(e.target.value)}
placeholder="Enter your question" placeholder="Enter your question"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
rows={3} rows={3}
/> />
</div> </div>
@ -336,7 +344,7 @@ export function PracticeQuestionsPage() {
value={sampleAnswer} value={sampleAnswer}
onChange={(e) => setSampleAnswer(e.target.value)} onChange={(e) => setSampleAnswer(e.target.value)}
placeholder="Enter the sample answer" placeholder="Enter the sample answer"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
rows={3} rows={3}
/> />
</div> </div>
@ -346,7 +354,7 @@ export function PracticeQuestionsPage() {
value={tips} value={tips}
onChange={(e) => setTips(e.target.value)} onChange={(e) => setTips(e.target.value)}
placeholder="Enter helpful tips" placeholder="Enter helpful tips"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
rows={2} rows={2}
/> />
</div> </div>
@ -366,10 +374,14 @@ export function PracticeQuestionsPage() {
placeholder="Voice prompt for sample answer" placeholder="Voice prompt for sample answer"
/> />
</div> </div>
{saveError && <p className="text-sm text-red-500">{saveError}</p>} {saveError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
)}
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setShowAddModal(false)} disabled={saving}> <Button variant="outline" onClick={() => setShowAddModal(false)} disabled={saving}>
Cancel Cancel
</Button> </Button>
@ -387,19 +399,19 @@ export function PracticeQuestionsPage() {
{/* Edit Modal */} {/* Edit Modal */}
{showEditModal && questionToEdit && ( {showEditModal && questionToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-xl"> <div className="mx-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Question</h2> <h2 className="text-lg font-semibold text-grayScale-900">Edit Question</h2>
<button <button
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Question Type</label> <label className="text-sm font-medium text-grayScale-700">Question Type</label>
<Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}> <Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}>
@ -414,7 +426,7 @@ export function PracticeQuestionsPage() {
value={questionText} value={questionText}
onChange={(e) => setQuestionText(e.target.value)} onChange={(e) => setQuestionText(e.target.value)}
placeholder="Enter your question" placeholder="Enter your question"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
rows={3} rows={3}
/> />
</div> </div>
@ -424,7 +436,7 @@ export function PracticeQuestionsPage() {
value={sampleAnswer} value={sampleAnswer}
onChange={(e) => setSampleAnswer(e.target.value)} onChange={(e) => setSampleAnswer(e.target.value)}
placeholder="Enter the sample answer" placeholder="Enter the sample answer"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
rows={3} rows={3}
/> />
</div> </div>
@ -434,7 +446,7 @@ export function PracticeQuestionsPage() {
value={tips} value={tips}
onChange={(e) => setTips(e.target.value)} onChange={(e) => setTips(e.target.value)}
placeholder="Enter helpful tips" placeholder="Enter helpful tips"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
rows={2} rows={2}
/> />
</div> </div>
@ -454,10 +466,14 @@ export function PracticeQuestionsPage() {
placeholder="Voice prompt for sample answer" placeholder="Voice prompt for sample answer"
/> />
</div> </div>
{saveError && <p className="text-sm text-red-500">{saveError}</p>} {saveError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
)}
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setShowEditModal(false)} disabled={saving}> <Button variant="outline" onClick={() => setShowEditModal(false)} disabled={saving}>
Cancel Cancel
</Button> </Button>

View File

@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Plus, Search, Edit, Trash2 } from "lucide-react" import { Plus, Search, Edit, Trash2, HelpCircle } from "lucide-react"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -95,9 +95,9 @@ const typeLabels: Record<QuestionType, string> = {
} }
const typeColors: Record<QuestionType, string> = { const typeColors: Record<QuestionType, string> = {
"multiple-choice": "bg-blue-100 text-blue-700", "multiple-choice": "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
"short-answer": "bg-green-100 text-green-700", "short-answer": "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
"true-false": "bg-purple-100 text-purple-700", "true-false": "bg-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200",
} }
export function QuestionsPage() { export function QuestionsPage() {
@ -126,35 +126,45 @@ export function QuestionsPage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> {/* Page Header */}
<h1 className="text-xl font-semibold text-grayScale-900">Questions</h1> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Link to="/content/questions/add"> <div>
<Button className="bg-brand-500 hover:bg-brand-600"> <h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
Questions
</h1>
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-400">
Create and manage your question bank
</p>
</div>
<Link to="/content/questions/add" className="w-full sm:w-auto">
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add New Question Add New Question
</Button> </Button>
</Link> </Link>
</div> </div>
<Card className="shadow-none"> <Card className="shadow-soft">
<CardHeader> <CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle>Question Management</CardTitle> <CardTitle className="text-base font-semibold text-grayScale-600">
Question Management
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-5 pt-5">
{/* Search and Filters */} {/* Search and Filters */}
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input <Input
placeholder="Search questions..." placeholder="Search questions..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9" className="pl-10 transition-colors focus:border-brand-300 focus:ring-brand-200"
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}> <Select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
<option value="all">All Types</option> <option value="all">All Types</option>
<option value="multiple-choice">Multiple Choice</option> <option value="multiple-choice">Multiple Choice</option>
@ -190,42 +200,63 @@ export function QuestionsPage() {
</div> </div>
{/* Results count */} {/* Results count */}
<div className="text-sm text-grayScale-500"> <div className="text-xs font-medium text-grayScale-400">
Showing {filteredQuestions.length} of {questions.length} questions Showing {filteredQuestions.length} of {questions.length} questions
</div> </div>
{/* Questions Table */} {/* Questions Table */}
{filteredQuestions.length > 0 ? ( {filteredQuestions.length > 0 ? (
<div className="rounded-lg border"> <div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead>Question</TableHead> <TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
<TableHead>Type</TableHead> Question
<TableHead>Category</TableHead> </TableHead>
<TableHead>Difficulty</TableHead> <TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
<TableHead>Points</TableHead> Type
<TableHead className="text-right">Actions</TableHead> </TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Category
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Difficulty
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Points
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredQuestions.map((question) => ( {filteredQuestions.map((question, index) => (
<TableRow key={question.id}> <TableRow
<TableCell className="max-w-md"> key={question.id}
<div className="truncate font-medium">{question.question}</div> className={`transition-colors hover:bg-brand-100/30 ${
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
}`}
>
<TableCell className="max-w-md py-3.5">
<div className="truncate text-sm font-medium text-grayScale-600">
{question.question}
</div>
{question.type === "multiple-choice" && question.options.length > 0 && ( {question.type === "multiple-choice" && question.options.length > 0 && (
<div className="mt-1 text-xs text-grayScale-400"> <div className="mt-1 truncate text-xs text-grayScale-400">
Options: {question.options.join(", ")} Options: {question.options.join(", ")}
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3.5">
<Badge className={typeColors[question.type]}> <Badge className={`text-xs font-medium ${typeColors[question.type]}`}>
{typeLabels[question.type]} {typeLabels[question.type]}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{question.category || "-"}</TableCell> <TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
<TableCell> {question.category || "—"}
</TableCell>
<TableCell className="hidden py-3.5 md:table-cell">
{question.difficulty && ( {question.difficulty && (
<Badge <Badge
variant={ variant={
@ -240,20 +271,27 @@ export function QuestionsPage() {
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
<TableCell>{question.points}</TableCell> <TableCell className="py-3.5 text-sm font-semibold text-grayScale-600">
<TableCell className="text-right"> {question.points}
<div className="flex justify-end gap-2"> </TableCell>
<TableCell className="py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<Link to={`/content/questions/edit/${question.id}`}> <Link to={`/content/questions/edit/${question.id}`}>
<Button variant="ghost" size="icon"> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-red-50 hover:text-destructive"
onClick={() => handleDelete(question.id)} onClick={() => handleDelete(question.id)}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
@ -263,8 +301,17 @@ export function QuestionsPage() {
</Table> </Table>
</div> </div>
) : ( ) : (
<div className="py-12 text-center text-grayScale-400"> <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">
<p>No questions found matching your criteria.</p> <div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
<HelpCircle className="h-8 w-8 text-grayScale-400" />
</div>
<p className="text-base font-semibold text-grayScale-600">
No questions found
</p>
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-grayScale-400">
Try adjusting your search or filter criteria to find what you're
looking for.
</p>
</div> </div>
)} )}
</CardContent> </CardContent>
@ -272,4 +319,3 @@ export function QuestionsPage() {
</div> </div>
) )
} }

View File

@ -1,30 +1,49 @@
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Plus } from "lucide-react" import { Plus, Mic } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
export function SpeakingPage() { export function SpeakingPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-xl font-semibold text-grayScale-900">Speaking</h1> <div>
<Link to="/content/speaking/add-practice"> <h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
<Button className="bg-brand-500 hover:bg-brand-600"> Speaking
</h1>
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-400">
Create and manage speaking practice sessions for your learners.
</p>
</div>
<Link to="/content/speaking/add-practice" className="w-full sm:w-auto">
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add New Practice Add New Practice
</Button> </Button>
</Link> </Link>
</div> </div>
<Card className="shadow-none">
<CardHeader> <Card className="border-2 border-dashed border-grayScale-200 shadow-none">
<CardTitle>Speaking Practice Management</CardTitle> <CardContent className="flex flex-col items-center justify-center py-20 text-center">
</CardHeader> <div className="mb-6 grid h-20 w-20 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
<CardContent className="text-sm text-muted-foreground"> <Mic className="h-10 w-10 text-brand-500" />
Manage speaking practice sessions and exercises here. </div>
<h3 className="text-lg font-semibold text-grayScale-600">
No speaking practices yet
</h3>
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
Get started by adding your first speaking practice session. Your
learners will be able to practice pronunciation and conversation
skills.
</p>
<Link to="/content/speaking/add-practice" className="mt-8">
<Button className="bg-brand-500 px-6 hover:bg-brand-600">
<Plus className="h-4 w-4" />
Create Your First Practice
</Button>
</Link>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) )
} }

View File

@ -285,16 +285,20 @@ export function SubCourseContentPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-20">
<div className="text-sm text-grayScale-500">Loading sub-course...</div> <div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading sub-course</p>
</div> </div>
) )
} }
if (error) { if (error) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-20">
<div className="text-sm text-red-500">{error}</div> <div className="rounded-full bg-red-50 p-3">
<X className="h-6 w-6 text-red-500" />
</div>
<p className="mt-3 text-sm font-medium text-red-600">{error}</p>
</div> </div>
) )
} }
@ -304,37 +308,37 @@ export function SubCourseContentPage() {
{/* Back Button */} {/* Back Button */}
<Link <Link
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses`} to={`/content/category/${categoryId}/courses/${courseId}/sub-courses`}
className="inline-flex items-center gap-2 text-sm text-grayScale-600 hover:text-grayScale-900" className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-grayScale-500 transition-all hover:bg-grayScale-50 hover:text-grayScale-900"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
Back to Sub-courses Back to Sub-courses
</Link> </Link>
{/* SubCourse Header */} {/* SubCourse Header */}
<div className="flex items-start justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-2xl"> <div className="max-w-2xl space-y-2">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-2xl font-semibold text-grayScale-900"> <h1 className="text-2xl font-bold tracking-tight text-grayScale-900">
{subCourse?.title} {subCourse?.title}
</h1> </h1>
{subCourse?.level && ( {subCourse?.level && (
<Badge className="bg-purple-100 text-purple-700">{subCourse.level}</Badge> <Badge className="rounded-full bg-purple-50 px-2.5 py-0.5 text-xs font-semibold text-purple-700 ring-1 ring-inset ring-purple-200">{subCourse.level}</Badge>
)} )}
</div> </div>
<p className="mt-2 text-sm text-grayScale-500"> <p className="text-sm leading-relaxed text-grayScale-500">
{subCourse?.description || "No description available"} {subCourse?.description || "No description available"}
</p> </p>
</div> </div>
<div className="flex gap-3"> <div className="flex flex-col gap-2.5 sm:flex-row sm:gap-3">
<Button <Button
variant="outline" variant="outline"
className="border-brand-500 text-brand-500 hover:bg-brand-50" className="border-brand-200 text-brand-600 transition-colors hover:border-brand-500 hover:bg-brand-50"
onClick={handleAddPractice} onClick={handleAddPractice}
> >
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
Add Practice Add Practice
</Button> </Button>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddVideo}> <Button className="bg-brand-500 shadow-sm transition-colors hover:bg-brand-600" onClick={handleAddVideo}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Video Add Video
</Button> </Button>
@ -343,26 +347,32 @@ export function SubCourseContentPage() {
{/* Tabs */} {/* Tabs */}
<div className="border-b border-grayScale-200"> <div className="border-b border-grayScale-200">
<div className="flex gap-8"> <div className="-mb-px flex gap-6">
<button <button
onClick={() => setActiveTab("video")} onClick={() => setActiveTab("video")}
className={`pb-3 text-sm font-medium transition-colors ${ className={`relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all ${
activeTab === "video" activeTab === "video"
? "border-b-2 border-brand-500 text-brand-500" ? "text-brand-600"
: "text-grayScale-500 hover:text-grayScale-700" : "text-grayScale-400 hover:text-grayScale-700"
}`} }`}
> >
Video Video
{activeTab === "video" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button> </button>
<button <button
onClick={() => setActiveTab("practice")} onClick={() => setActiveTab("practice")}
className={`pb-3 text-sm font-medium transition-colors ${ className={`relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all ${
activeTab === "practice" activeTab === "practice"
? "border-b-2 border-brand-500 text-brand-500" ? "text-brand-600"
: "text-grayScale-500 hover:text-grayScale-700" : "text-grayScale-400 hover:text-grayScale-700"
}`} }`}
> >
Practice Practice
{activeTab === "practice" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button> </button>
</div> </div>
</div> </div>
@ -373,83 +383,88 @@ export function SubCourseContentPage() {
{activeTab === "practice" && ( {activeTab === "practice" && (
<> <>
{practicesLoading ? ( {practicesLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-20">
<div className="text-sm text-grayScale-500">Loading practices...</div> <div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading practices</p>
</div> </div>
) : filteredPractices.length === 0 ? ( ) : filteredPractices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
<FileText className="mb-4 h-12 w-12 text-grayScale-300" /> <div className="rounded-full bg-brand-50 p-4">
<p className="text-sm text-grayScale-500">No practices found</p> <FileText className="h-8 w-8 text-brand-400" />
<Button variant="outline" className="mt-4" onClick={handleAddPractice}> </div>
Add your first practice <p className="mt-4 text-sm font-semibold text-grayScale-700">No practices yet</p>
<p className="mt-1 text-sm text-grayScale-400">Create your first practice to get started</p>
<Button variant="outline" className="mt-5 border-brand-200 text-brand-600 hover:bg-brand-50" onClick={handleAddPractice}>
<Plus className="mr-2 h-4 w-4" />
Add Practice
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredPractices.map((practice) => { {filteredPractices.map((practice) => {
const statusConfig: Record<string, { bg: string; dot: string; text: string }> = { const statusConfig: Record<string, { bg: string; dot: string; text: string }> = {
PUBLISHED: { bg: "bg-transparent border border-green-200 text-green-600", dot: "bg-green-500", text: "Published" }, PUBLISHED: { bg: "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200", dot: "bg-green-500", text: "Published" },
DRAFT: { bg: "bg-grayScale-100 border border-grayScale-200 text-grayScale-600", dot: "bg-grayScale-400", text: "Draft" }, DRAFT: { bg: "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200", dot: "bg-grayScale-400", text: "Draft" },
ARCHIVED: { bg: "bg-transparent border border-amber-200 text-amber-600", dot: "bg-amber-500", text: "Archived" }, ARCHIVED: { bg: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200", dot: "bg-amber-500", text: "Archived" },
} }
const status = statusConfig[practice.status] ?? statusConfig.DRAFT const status = statusConfig[practice.status] ?? statusConfig.DRAFT
return ( return (
<Card <Card
key={practice.id} key={practice.id}
className="cursor-pointer overflow-hidden border border-grayScale-200 shadow-sm transition hover:shadow-md hover:border-brand-200" className="group cursor-pointer overflow-hidden rounded-xl border border-grayScale-200 bg-white shadow-sm transition-all duration-200 hover:border-brand-300 hover:shadow-md hover:ring-1 hover:ring-brand-100"
onClick={() => handlePracticeClick(practice.id)} onClick={() => handlePracticeClick(practice.id)}
> >
<div className="p-4 space-y-3"> <div className="flex h-full flex-col p-5 space-y-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-3">
<h3 className="font-semibold text-grayScale-900 line-clamp-2">{practice.title}</h3> <h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{practice.title}</h3>
<Badge className={`shrink-0 text-xs font-medium ${status.bg}`}> <Badge className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ${status.bg}`}>
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${status.dot}`} /> <span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${status.dot}`} />
{status.text} {status.text}
</Badge> </Badge>
</div> </div>
<p className="text-sm text-grayScale-500 line-clamp-2">{practice.description}</p> <p className="text-sm leading-relaxed text-grayScale-500 line-clamp-2">{practice.description}</p>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Badge className="bg-brand-50 text-brand-600 text-xs px-2 py-0.5 border border-brand-200"> <Badge className="rounded-full bg-brand-50 text-brand-600 text-[11px] font-medium px-2.5 py-0.5 ring-1 ring-inset ring-brand-200">
{practice.set_type} {practice.set_type}
</Badge> </Badge>
{practice.persona && ( {practice.persona && (
<Badge className="bg-purple-50 text-purple-600 text-xs px-2 py-0.5 border border-purple-200"> <Badge className="rounded-full bg-purple-50 text-purple-600 text-[11px] font-medium px-2.5 py-0.5 ring-1 ring-inset ring-purple-200">
{practice.persona} {practice.persona}
</Badge> </Badge>
)} )}
</div> </div>
<div className="flex items-center gap-3 text-xs text-grayScale-400"> <div className="flex items-center gap-3 text-xs text-grayScale-400">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" /> <Layers className="h-3.5 w-3.5" />
<span>{practice.owner_type.replace("_", " ")}</span> <span>{practice.owner_type.replace("_", " ")}</span>
</div> </div>
{practice.shuffle_questions && ( {practice.shuffle_questions && (
<span className="text-amber-500">Shuffle ON</span> <span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
)} )}
</div> </div>
<div className="flex items-center justify-between border-t border-grayScale-100 pt-3"> <div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
<span className="text-xs text-grayScale-400"> <span className="text-xs font-medium text-grayScale-400">
{new Date(practice.created_at).toLocaleDateString("en-US", { {new Date(practice.created_at).toLocaleDateString("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
})} })}
</span> </span>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}> <div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={() => handleEditClick(practice)} onClick={() => handleEditClick(practice)}
className="rounded p-1.5 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => handleDeleteClick(practice)} onClick={() => handleDeleteClick(practice)}
className="rounded p-1.5 text-grayScale-400 hover:bg-red-50 hover:text-red-500" className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
@ -467,25 +482,30 @@ export function SubCourseContentPage() {
{activeTab === "video" && ( {activeTab === "video" && (
<> <>
{videosLoading ? ( {videosLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-20">
<div className="text-sm text-grayScale-500">Loading videos...</div> <div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading videos</p>
</div> </div>
) : videos.length === 0 ? ( ) : videos.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
<Video className="mb-4 h-12 w-12 text-grayScale-300" /> <div className="rounded-full bg-brand-50 p-4">
<p className="text-sm text-grayScale-500">No videos found</p> <Video className="h-8 w-8 text-brand-400" />
<Button variant="outline" className="mt-4" onClick={handleAddVideo}> </div>
Add your first video <p className="mt-4 text-sm font-semibold text-grayScale-700">No videos yet</p>
<p className="mt-1 text-sm text-grayScale-400">Upload your first video to get started</p>
<Button variant="outline" className="mt-5 border-brand-200 text-brand-600 hover:bg-brand-50" onClick={handleAddVideo}>
<Plus className="mr-2 h-4 w-4" />
Add Video
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{videos.map((video, index) => { {videos.map((video, index) => {
const gradients = [ const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-200", "bg-gradient-to-br from-blue-100 via-blue-50 to-indigo-100",
"bg-gradient-to-br from-yellow-100 to-yellow-200", "bg-gradient-to-br from-amber-100 via-yellow-50 to-orange-100",
"bg-gradient-to-br from-purple-100 to-purple-200", "bg-gradient-to-br from-purple-100 via-fuchsia-50 to-pink-100",
"bg-gradient-to-br from-green-100 to-green-200", "bg-gradient-to-br from-emerald-100 via-green-50 to-teal-100",
] ]
const formatDuration = (seconds: number) => { const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60) const mins = Math.floor(seconds / 60)
@ -493,15 +513,17 @@ export function SubCourseContentPage() {
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
} }
return ( return (
<Card key={video.id} className="overflow-hidden border-0 bg-white shadow-sm"> <Card key={video.id} className="group overflow-hidden rounded-xl border border-grayScale-200 bg-white shadow-sm transition-all duration-200 hover:border-brand-300 hover:shadow-md hover:ring-1 hover:ring-brand-100">
{/* Thumbnail with duration */} {/* Thumbnail with duration */}
<div className="relative aspect-video w-full"> <div className="relative aspect-video w-full overflow-hidden">
{video.thumbnail ? ( {video.thumbnail ? (
<img src={video.thumbnail} alt={video.title} className="h-full w-full object-cover rounded-t-lg" /> <img src={video.thumbnail} alt={video.title} className="h-full w-full rounded-t-xl object-cover transition-transform duration-300 group-hover:scale-105" />
) : ( ) : (
<div className={`h-full w-full rounded-t-lg ${gradients[index % gradients.length]}`} /> <div className={`flex h-full w-full items-center justify-center rounded-t-xl ${gradients[index % gradients.length]}`}>
<Video className="h-10 w-10 text-white/40" />
</div>
)} )}
<div className="absolute bottom-2 right-2 rounded bg-grayScale-900/80 px-2 py-0.5 text-xs font-medium text-white"> <div className="absolute bottom-2.5 right-2.5 rounded-md bg-grayScale-900/75 px-2 py-0.5 text-xs font-semibold tabular-nums text-white backdrop-blur-sm">
{formatDuration(video.duration || 0)} {formatDuration(video.duration || 0)}
</div> </div>
</div> </div>
@ -511,10 +533,10 @@ export function SubCourseContentPage() {
{/* Status and menu */} {/* Status and menu */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge <Badge
className={`text-xs font-medium ${ className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${
video.is_published video.is_published
? "bg-transparent text-green-600 border border-green-200" ? "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200" : "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200"
}`} }`}
> >
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${video.is_published ? "bg-green-500" : "bg-grayScale-400"}`} /> <span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${video.is_published ? "bg-green-500" : "bg-grayScale-400"}`} />
@ -523,18 +545,18 @@ export function SubCourseContentPage() {
<div className="relative"> <div className="relative">
<button <button
onClick={() => setOpenVideoMenuId(openVideoMenuId === video.id ? null : video.id)} onClick={() => setOpenVideoMenuId(openVideoMenuId === video.id ? null : video.id)}
className="text-grayScale-400 hover:text-grayScale-600" className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</button> </button>
{openVideoMenuId === video.id && ( {openVideoMenuId === video.id && (
<div className="absolute right-0 top-full z-10 mt-1 w-32 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"> <div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-xl bg-white py-1.5 shadow-lg ring-1 ring-grayScale-200">
<button <button
onClick={() => { onClick={() => {
handleDeleteVideoClick(video) handleDeleteVideoClick(video)
setOpenVideoMenuId(null) setOpenVideoMenuId(null)
}} }}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50" className="flex w-full items-center gap-2 px-4 py-2 text-sm font-medium text-red-500 transition-colors hover:bg-red-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Delete Delete
@ -545,12 +567,12 @@ export function SubCourseContentPage() {
</div> </div>
{/* Title */} {/* Title */}
<h3 className="font-medium text-grayScale-900">{video.title}</h3> <h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{video.title}</h3>
{/* Edit button */} {/* Edit button */}
<Button <Button
variant="outline" variant="outline"
className="w-full border-grayScale-200 text-grayScale-700" className="w-full border-grayScale-200 text-grayScale-700 transition-colors hover:border-grayScale-300 hover:bg-grayScale-50"
onClick={() => handleEditVideoClick(video)} onClick={() => handleEditVideoClick(video)}
> >
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
@ -559,7 +581,7 @@ export function SubCourseContentPage() {
{/* Publish button */} {/* Publish button */}
<Button <Button
className={`w-full ${ className={`w-full shadow-sm transition-colors ${
video.is_published video.is_published
? "bg-green-500 hover:bg-green-600" ? "bg-green-500 hover:bg-green-600"
: "bg-brand-500 hover:bg-brand-600" : "bg-brand-500 hover:bg-brand-600"
@ -578,28 +600,28 @@ export function SubCourseContentPage() {
{/* Delete Modal */} {/* Delete Modal */}
{showDeleteModal && practiceToDelete && ( {showDeleteModal && practiceToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Practice</h2> <h2 className="text-lg font-semibold text-grayScale-900">Delete Practice</h2>
<button <button
onClick={() => setShowDeleteModal(false)} onClick={() => setShowDeleteModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="px-6 py-5"> <div className="px-6 py-5">
<p className="text-sm text-grayScale-600"> <p className="text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold">{practiceToDelete.title}</span>? This action cannot be undone. <span className="font-semibold text-grayScale-900">{practiceToDelete.title}</span>? This action cannot be undone.
</p> </p>
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}> <Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
Cancel Cancel
</Button> </Button>
<Button className="bg-red-500 hover:bg-red-600" onClick={handleConfirmDelete} disabled={deleting}> <Button className="bg-red-500 shadow-sm hover:bg-red-600" onClick={handleConfirmDelete} disabled={deleting}>
{deleting ? "Deleting..." : "Delete"} {deleting ? "Deleting..." : "Delete"}
</Button> </Button>
</div> </div>
@ -609,19 +631,19 @@ export function SubCourseContentPage() {
{/* Edit Practice Modal */} {/* Edit Practice Modal */}
{showEditPracticeModal && practiceToEdit && ( {showEditPracticeModal && practiceToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Practice</h2> <h2 className="text-lg font-semibold text-grayScale-900">Edit Practice</h2>
<button <button
onClick={() => setShowEditPracticeModal(false)} onClick={() => setShowEditPracticeModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label> <label className="text-sm font-medium text-grayScale-700">Title</label>
<Input <Input
value={title} value={title}
@ -629,17 +651,17 @@ export function SubCourseContentPage() {
placeholder="Enter practice title" placeholder="Enter practice title"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Description</label> <label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Enter practice description" placeholder="Enter practice description"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
rows={3} rows={3}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Persona (Optional)</label> <label className="text-sm font-medium text-grayScale-700">Persona (Optional)</label>
<Input <Input
value={persona} value={persona}
@ -647,14 +669,14 @@ export function SubCourseContentPage() {
placeholder="Enter persona" placeholder="Enter persona"
/> />
</div> </div>
{saveError && <p className="text-sm text-red-500">{saveError}</p>} {saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>}
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
<Button variant="outline" onClick={() => setShowEditPracticeModal(false)} disabled={saving}> <Button variant="outline" onClick={() => setShowEditPracticeModal(false)} disabled={saving}>
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 shadow-sm hover:bg-brand-600"
onClick={handleSaveEditPractice} onClick={handleSaveEditPractice}
disabled={saving || !title.trim()} disabled={saving || !title.trim()}
> >
@ -667,19 +689,19 @@ export function SubCourseContentPage() {
{/* Add Video Modal */} {/* Add Video Modal */}
{showAddVideoModal && ( {showAddVideoModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2> <h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
<button <button
onClick={() => setShowAddVideoModal(false)} onClick={() => setShowAddVideoModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label> <label className="text-sm font-medium text-grayScale-700">Title</label>
<Input <Input
value={videoTitle} value={videoTitle}
@ -687,17 +709,17 @@ export function SubCourseContentPage() {
placeholder="Enter video title" placeholder="Enter video title"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Description</label> <label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea <textarea
value={videoDescription} value={videoDescription}
onChange={(e) => setVideoDescription(e.target.value)} onChange={(e) => setVideoDescription(e.target.value)}
placeholder="Enter video description" placeholder="Enter video description"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
rows={3} rows={3}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Source URL</label> <label className="text-sm font-medium text-grayScale-700">Source URL</label>
<Input <Input
value={videoUrl} value={videoUrl}
@ -706,7 +728,7 @@ export function SubCourseContentPage() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">File Size (bytes)</label> <label className="text-sm font-medium text-grayScale-700">File Size (bytes)</label>
<Input <Input
type="number" type="number"
@ -716,7 +738,7 @@ export function SubCourseContentPage() {
min={0} min={0}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Duration (seconds)</label> <label className="text-sm font-medium text-grayScale-700">Duration (seconds)</label>
<Input <Input
type="number" type="number"
@ -727,14 +749,14 @@ export function SubCourseContentPage() {
/> />
</div> </div>
</div> </div>
{saveError && <p className="text-sm text-red-500">{saveError}</p>} {saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>}
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
<Button variant="outline" onClick={() => setShowAddVideoModal(false)} disabled={saving}> <Button variant="outline" onClick={() => setShowAddVideoModal(false)} disabled={saving}>
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 shadow-sm hover:bg-brand-600"
onClick={handleSaveNewVideo} onClick={handleSaveNewVideo}
disabled={saving || !videoTitle.trim() || !videoUrl.trim()} disabled={saving || !videoTitle.trim() || !videoUrl.trim()}
> >
@ -747,19 +769,19 @@ export function SubCourseContentPage() {
{/* Edit Video Modal */} {/* Edit Video Modal */}
{showEditVideoModal && videoToEdit && ( {showEditVideoModal && videoToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Video</h2> <h2 className="text-lg font-semibold text-grayScale-900">Edit Video</h2>
<button <button
onClick={() => setShowEditVideoModal(false)} onClick={() => setShowEditVideoModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label> <label className="text-sm font-medium text-grayScale-700">Title</label>
<Input <Input
value={videoTitle} value={videoTitle}
@ -767,17 +789,17 @@ export function SubCourseContentPage() {
placeholder="Enter video title" placeholder="Enter video title"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Description</label> <label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea <textarea
value={videoDescription} value={videoDescription}
onChange={(e) => setVideoDescription(e.target.value)} onChange={(e) => setVideoDescription(e.target.value)}
placeholder="Enter video description" placeholder="Enter video description"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
rows={3} rows={3}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Video URL</label> <label className="text-sm font-medium text-grayScale-700">Video URL</label>
<Input <Input
value={videoUrl} value={videoUrl}
@ -785,14 +807,14 @@ export function SubCourseContentPage() {
placeholder="Enter video URL" placeholder="Enter video URL"
/> />
</div> </div>
{saveError && <p className="text-sm text-red-500">{saveError}</p>} {saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>}
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
<Button variant="outline" onClick={() => setShowEditVideoModal(false)} disabled={saving}> <Button variant="outline" onClick={() => setShowEditVideoModal(false)} disabled={saving}>
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 shadow-sm hover:bg-brand-600"
onClick={handleSaveEditVideo} onClick={handleSaveEditVideo}
disabled={saving || !videoTitle.trim()} disabled={saving || !videoTitle.trim()}
> >
@ -805,28 +827,28 @@ export function SubCourseContentPage() {
{/* Delete Video Modal */} {/* Delete Video Modal */}
{showDeleteVideoModal && videoToDelete && ( {showDeleteVideoModal && videoToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Video</h2> <h2 className="text-lg font-semibold text-grayScale-900">Delete Video</h2>
<button <button
onClick={() => setShowDeleteVideoModal(false)} onClick={() => setShowDeleteVideoModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="px-6 py-5"> <div className="px-6 py-5">
<p className="text-sm text-grayScale-600"> <p className="text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold">{videoToDelete.title}</span>? This action cannot be undone. <span className="font-semibold text-grayScale-900">{videoToDelete.title}</span>? This action cannot be undone.
</p> </p>
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
<Button variant="outline" onClick={() => setShowDeleteVideoModal(false)} disabled={deletingVideo}> <Button variant="outline" onClick={() => setShowDeleteVideoModal(false)} disabled={deletingVideo}>
Cancel Cancel
</Button> </Button>
<Button className="bg-red-500 hover:bg-red-600" onClick={handleConfirmDeleteVideo} disabled={deletingVideo}> <Button className="bg-red-500 shadow-sm hover:bg-red-600" onClick={handleConfirmDeleteVideo} disabled={deletingVideo}>
{deletingVideo ? "Deleting..." : "Delete"} {deletingVideo ? "Deleting..." : "Delete"}
</Button> </Button>
</div> </div>

View File

@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react" import { useEffect, useState, useRef } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" import { Link, useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, Layers, ToggleLeft, ToggleRight, MoreVertical, X, Trash2 } from "lucide-react" import { ArrowLeft, Layers, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, RefreshCw, AlertCircle, Edit } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
@ -198,57 +198,70 @@ export function SubCoursesPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<div className="text-sm text-grayScale-500">Loading sub-courses...</div> <div className="rounded-full bg-brand-50 p-4">
<RefreshCw className="h-8 w-8 animate-spin text-brand-500" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p>
</div> </div>
) )
} }
if (error) { if (error) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-24">
<div className="text-sm text-red-500">{error}</div> <div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-xl border border-red-100 bg-red-50 px-5 py-4 shadow-md">
<div className="rounded-full bg-red-100 p-2">
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
</div>
<p className="text-sm font-medium text-red-600">{error}</p>
</div>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> {/* Header */}
<div className="flex items-center gap-3"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<Link <Link
to={`/content/category/${categoryId}/courses`} to={`/content/category/${categoryId}/courses`}
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600" className="mt-1 grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-grayScale-200 bg-white text-grayScale-500 shadow-sm transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600 hover:shadow-md"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Link> </Link>
<div> <div className="min-w-0">
<div className="flex items-center gap-2 text-xs text-grayScale-500"> <div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-grayScale-400">
<span>{category?.name}</span> <span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{category?.name}</span>
<span></span> <span className="shrink-0 text-grayScale-300"></span>
<span>{course?.title}</span> <span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{course?.title}</span>
</div> </div>
<h1 className="text-xl font-semibold text-grayScale-900">Sub-courses</h1> <h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Sub-courses</h1>
<p className="text-sm text-grayScale-500">{subCourses.length} sub-courses available</p> <p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
</div> </div>
</div> </div>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddSubCourse}> <Button className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleAddSubCourse}>
Add New Sub-course Add New Sub-course
</Button> </Button>
</div> </div>
{/* Sub-course grid or empty state */}
{subCourses.length === 0 ? ( {subCourses.length === 0 ? (
<Card className="shadow-none"> <Card className="border border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-16">
<Layers className="mb-4 h-12 w-12 text-grayScale-300" /> <div className="rounded-2xl bg-brand-50 p-5">
<p className="text-sm text-grayScale-500">No sub-courses found for this course</p> <Layers className="h-10 w-10 text-brand-400" />
<Button variant="outline" className="mt-4" onClick={handleAddSubCourse}> </div>
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No sub-courses yet</h3>
<p className="mt-1.5 max-w-xs text-center text-sm text-grayScale-400">Get started by adding your first sub-course to this course</p>
<Button className="mt-5 rounded-xl bg-brand-500 px-5 shadow-sm hover:bg-brand-600 hover:shadow-md" onClick={handleAddSubCourse}>
Add your first sub-course Add your first sub-course
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{subCourses.map((subCourse, index) => { {subCourses.map((subCourse, index) => {
const gradients = [ const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-200", "bg-gradient-to-br from-blue-100 to-blue-200",
@ -259,40 +272,40 @@ export function SubCoursesPage() {
return ( return (
<Card <Card
key={subCourse.id} key={subCourse.id}
className="cursor-pointer overflow-hidden border-0 bg-white shadow-sm transition hover:shadow-md" className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-soft hover:-translate-y-1 hover:border-brand-100"
onClick={() => handleSubCourseClick(subCourse.id)} onClick={() => handleSubCourseClick(subCourse.id)}
> >
{/* Thumbnail with level badge */} {/* Thumbnail with level badge */}
<div className="relative aspect-video w-full"> <div className="relative aspect-video w-full overflow-hidden">
{subCourse.thumbnail ? ( {subCourse.thumbnail ? (
<img <img
src={subCourse.thumbnail} src={subCourse.thumbnail}
alt={subCourse.title} alt={subCourse.title}
className="h-full w-full object-cover rounded-t-lg" className="h-full w-full object-cover rounded-t-lg transition-transform duration-300 group-hover:scale-105"
/> />
) : ( ) : (
<div className={`h-full w-full rounded-t-lg ${gradients[index % gradients.length]}`} /> <div className={`h-full w-full rounded-t-lg transition-transform duration-300 group-hover:scale-105 ${gradients[index % gradients.length]}`} />
)} )}
{subCourse.level && ( {subCourse.level && (
<div className="absolute bottom-2 right-2 rounded bg-purple-600 px-3 py-1 text-sm font-semibold text-white"> <div className="absolute bottom-2.5 right-2.5 rounded-md bg-brand-600/90 px-2.5 py-1 text-xs font-semibold tracking-wide text-white shadow-sm backdrop-blur-sm">
{subCourse.level} {subCourse.level}
</div> </div>
)} )}
</div> </div>
{/* Content */} {/* Content */}
<div className="p-4 space-y-3"> <div className="flex flex-col gap-3 p-4">
{/* Status and menu */} {/* Status and menu */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge <Badge
className={`text-xs font-medium ${ className={`rounded-full px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${
subCourse.is_active subCourse.is_active
? "bg-transparent text-green-600 border border-green-200" ? "border border-green-200 bg-green-50 text-green-700"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200" : "border border-grayScale-200 bg-grayScale-50 text-grayScale-500"
}`} }`}
> >
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`} /> <span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`} />
{subCourse.is_active ? "ACTIVE" : "INACTIVE"} {subCourse.is_active ? "Active" : "Inactive"}
</Badge> </Badge>
<div <div
className="relative" className="relative"
@ -301,19 +314,19 @@ export function SubCoursesPage() {
> >
<button <button
onClick={() => setOpenMenuId(openMenuId === subCourse.id ? null : subCourse.id)} onClick={() => setOpenMenuId(openMenuId === subCourse.id ? null : subCourse.id)}
className="text-grayScale-400 hover:text-grayScale-600" className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</button> </button>
{openMenuId === subCourse.id && ( {openMenuId === subCourse.id && (
<div className="absolute right-0 top-full z-10 mt-1 w-40 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"> <div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
<button <button
onClick={() => { onClick={() => {
handleToggleStatus(subCourse) handleToggleStatus(subCourse)
setOpenMenuId(null) setOpenMenuId(null)
}} }}
disabled={togglingId === subCourse.id} disabled={togglingId === subCourse.id}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-grayScale-700 hover:bg-grayScale-100 disabled:opacity-50" className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
> >
{subCourse.is_active ? ( {subCourse.is_active ? (
<> <>
@ -327,12 +340,13 @@ export function SubCoursesPage() {
</> </>
)} )}
</button> </button>
<div className="mx-3 border-t border-grayScale-100" />
<button <button
onClick={() => { onClick={() => {
handleDeleteClick(subCourse) handleDeleteClick(subCourse)
setOpenMenuId(null) setOpenMenuId(null)
}} }}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50" className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-500 transition-colors hover:bg-red-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Delete Delete
@ -343,20 +357,23 @@ export function SubCoursesPage() {
</div> </div>
{/* Title */} {/* Title */}
<h3 className="font-medium text-grayScale-900">{subCourse.title}</h3> <div>
<p className="text-sm text-grayScale-500 line-clamp-2"> <h3 className="font-semibold text-grayScale-700 group-hover:text-brand-600 transition-colors">{subCourse.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-grayScale-400 line-clamp-2">
{subCourse.description || "No description available"} {subCourse.description || "No description available"}
</p> </p>
</div>
{/* Edit button */} {/* Edit button */}
<Button <Button
variant="outline" variant="outline"
className="w-full border-grayScale-200 text-grayScale-700" className="mt-auto w-full rounded-lg border-grayScale-200 text-grayScale-500 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleEditClick(subCourse) handleEditClick(subCourse)
}} }}
> >
<Edit className="mr-2 h-3.5 w-3.5" />
Edit Edit
</Button> </Button>
</div> </div>
@ -366,37 +383,42 @@ export function SubCoursesPage() {
</div> </div>
)} )}
{/* Delete Modal */}
{showDeleteModal && subCourseToDelete && ( {showDeleteModal && subCourseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Sub-course</h2> <h2 className="text-lg font-semibold text-grayScale-700">Delete Sub-course</h2>
<button <button
onClick={() => setShowDeleteModal(false)} onClick={() => setShowDeleteModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="px-6 py-5"> <div className="px-6 py-6">
<p className="text-sm text-grayScale-600"> <div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold">{subCourseToDelete.title}</span>? This action cannot <span className="font-semibold text-grayScale-700">{subCourseToDelete.title}</span>? This action cannot
be undone. be undone.
</p> </p>
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowDeleteModal(false)} onClick={() => setShowDeleteModal(false)}
disabled={deleting} disabled={deleting}
className="w-full rounded-lg sm:w-auto"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-red-500 hover:bg-red-600" className="w-full rounded-lg bg-red-500 shadow-sm hover:bg-red-600 sm:w-auto"
onClick={handleConfirmDelete} onClick={handleConfirmDelete}
disabled={deleting} disabled={deleting}
> >
@ -407,59 +429,66 @@ export function SubCoursesPage() {
</div> </div>
)} )}
{/* Add Sub-course Modal */}
{showAddModal && ( {showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add New Sub-course</h2> <h2 className="text-lg font-semibold text-grayScale-700">Add New Sub-course</h2>
<button <button
onClick={() => setShowAddModal(false)} onClick={() => setShowAddModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label> <label className="text-sm font-semibold text-grayScale-600">Title</label>
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter sub-course title" placeholder="Enter sub-course title"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Description</label> <label className="text-sm font-semibold text-grayScale-600">Description</label>
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Enter sub-course description" placeholder="Enter sub-course description"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
rows={3} rows={3}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Level</label> <label className="text-sm font-semibold text-grayScale-600">Level</label>
<Input <Input
value={level} value={level}
onChange={(e) => setLevel(e.target.value)} onChange={(e) => setLevel(e.target.value)}
placeholder="e.g., Beginner, Intermediate, Advanced" placeholder="e.g., Beginner, Intermediate, Advanced"
/> />
</div> </div>
{saveError && <p className="text-sm text-red-500">{saveError}</p>} {saveError && (
<div className="flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowAddModal(false)} onClick={() => setShowAddModal(false)}
disabled={saving} disabled={saving}
className="w-full rounded-lg sm:w-auto"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="w-full rounded-lg bg-brand-500 shadow-sm hover:bg-brand-600 sm:w-auto"
onClick={handleSaveNewSubCourse} onClick={handleSaveNewSubCourse}
disabled={saving || !title.trim()} disabled={saving || !title.trim()}
> >
@ -470,59 +499,66 @@ export function SubCoursesPage() {
</div> </div>
)} )}
{/* Edit Sub-course Modal */}
{showEditModal && subCourseToEdit && ( {showEditModal && subCourseToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl"> <div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Sub-course</h2> <h2 className="text-lg font-semibold text-grayScale-700">Edit Sub-course</h2>
<button <button
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-5 px-6 py-6">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label> <label className="text-sm font-semibold text-grayScale-600">Title</label>
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter sub-course title" placeholder="Enter sub-course title"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Description</label> <label className="text-sm font-semibold text-grayScale-600">Description</label>
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Enter sub-course description" placeholder="Enter sub-course description"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
rows={3} rows={3}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Level</label> <label className="text-sm font-semibold text-grayScale-600">Level</label>
<Input <Input
value={level} value={level}
onChange={(e) => setLevel(e.target.value)} onChange={(e) => setLevel(e.target.value)}
placeholder="e.g., Beginner, Intermediate, Advanced" placeholder="e.g., Beginner, Intermediate, Advanced"
/> />
</div> </div>
{saveError && <p className="text-sm text-red-500">{saveError}</p>} {saveError && (
<div className="flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
</div> </div>
<div className="flex justify-end gap-3 border-t px-6 py-4"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
disabled={saving} disabled={saving}
className="w-full rounded-lg sm:w-auto"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="w-full rounded-lg bg-brand-500 shadow-sm hover:bg-brand-600 sm:w-auto"
onClick={handleSaveEditSubCourse} onClick={handleSaveEditSubCourse}
disabled={saving || !title.trim()} disabled={saving || !title.trim()}
> >

View File

@ -0,0 +1,917 @@
import { useCallback, useEffect, useState } from "react";
import {
Search,
ChevronDown,
ChevronLeft,
ChevronRight,
AlertCircle,
Eye,
RefreshCw,
Clock,
User,
Trash2,
X,
Info,
Bug,
Video,
BookOpen,
HelpCircle,
Loader2,
CheckCircle2,
XCircle,
ArrowUpCircle,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Badge } from "../../components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../../components/ui/dialog";
import { cn } from "../../lib/utils";
import {
getIssues,
getIssueById,
updateIssueStatus,
deleteIssue,
} from "../../api/issues.api";
import type { Issue, IssueFilters } from "../../types/issue.types";
// ── Status configuration ───────────────────────────────────────────
const STATUSES = ["pending", "in_progress", "resolved", "closed"] as const;
const ISSUE_TYPES = ["bug", "video", "course", "account", "payment", "other"] as const;
function getStatusConfig(status: string): {
label: string;
classes: string;
icon: typeof CheckCircle2;
} {
switch (status) {
case "pending":
return {
label: "Pending",
classes: "bg-amber-50 text-amber-700 border-amber-200",
icon: Clock,
};
case "in_progress":
return {
label: "In Progress",
classes: "bg-blue-50 text-blue-700 border-blue-200",
icon: Loader2,
};
case "resolved":
return {
label: "Resolved",
classes: "bg-emerald-50 text-emerald-700 border-emerald-200",
icon: CheckCircle2,
};
case "closed":
return {
label: "Closed",
classes: "bg-grayScale-100 text-grayScale-500 border-grayScale-200",
icon: XCircle,
};
default:
return {
label: status,
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
icon: HelpCircle,
};
}
}
function getIssueTypeConfig(type: string): {
label: string;
classes: string;
icon: typeof Bug;
} {
switch (type) {
case "bug":
return {
label: "Bug",
classes: "bg-red-50 text-red-700 border-red-200",
icon: Bug,
};
case "video":
return {
label: "Video",
classes: "bg-violet-50 text-violet-700 border-violet-200",
icon: Video,
};
case "course":
return {
label: "Course",
classes: "bg-brand-50 text-brand-700 border-brand-200",
icon: BookOpen,
};
case "account":
return {
label: "Account",
classes: "bg-sky-50 text-sky-700 border-sky-200",
icon: User,
};
case "payment":
return {
label: "Payment",
classes: "bg-emerald-50 text-emerald-700 border-emerald-200",
icon: ArrowUpCircle,
};
default:
return {
label: type.charAt(0).toUpperCase() + type.slice(1),
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
icon: HelpCircle,
};
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function getRelativeTime(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return formatDate(dateStr);
}
function formatRoleLabel(role: string): string {
return role
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
// ── Main Component ─────────────────────────────────────────────────
export function IssuesPage() {
const [issues, setIssues] = useState<Issue[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [typeFilter, setTypeFilter] = useState("");
// Detail dialog
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
// Delete confirmation
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [issueToDelete, setIssueToDelete] = useState<Issue | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
// Status update
const [statusUpdating, setStatusUpdating] = useState<number | null>(null);
const fetchIssues = useCallback(async () => {
setLoading(true);
try {
const filters: IssueFilters = {
limit: pageSize,
offset: (page - 1) * pageSize,
};
const res = await getIssues(filters);
setIssues(res.data.data.issues);
setTotalCount(res.data.data.total_count);
} catch (error) {
console.error("Failed to fetch issues:", error);
setIssues([]);
setTotalCount(0);
} finally {
setLoading(false);
}
}, [page, pageSize]);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
const handleViewDetail = async (issueId: number) => {
setDialogOpen(true);
setDetailLoading(true);
try {
const res = await getIssueById(issueId);
setSelectedIssue(res.data.data);
} catch (error) {
console.error("Failed to fetch issue detail:", error);
} finally {
setDetailLoading(false);
}
};
const handleStatusChange = async (issueId: number, newStatus: string) => {
setStatusUpdating(issueId);
try {
await updateIssueStatus(issueId, newStatus);
// Update the issue in the list
setIssues((prev) =>
prev.map((issue) =>
issue.id === issueId ? { ...issue, status: newStatus } : issue
)
);
// Also update the detail dialog if it's showing this issue
if (selectedIssue?.id === issueId) {
setSelectedIssue((prev) => (prev ? { ...prev, status: newStatus } : prev));
}
} catch (error) {
console.error("Failed to update issue status:", error);
} finally {
setStatusUpdating(null);
}
};
const handleDeleteClick = (issue: Issue) => {
setIssueToDelete(issue);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!issueToDelete) return;
setDeleteLoading(true);
try {
await deleteIssue(issueToDelete.id);
setDeleteDialogOpen(false);
setIssueToDelete(null);
// Close detail dialog if the deleted issue was being viewed
if (selectedIssue?.id === issueToDelete.id) {
setDialogOpen(false);
setSelectedIssue(null);
}
fetchIssues();
} catch (error) {
console.error("Failed to delete issue:", error);
} finally {
setDeleteLoading(false);
}
};
const hasActiveFilters = statusFilter || typeFilter;
const clearFilters = () => {
setSearchQuery("");
setStatusFilter("");
setTypeFilter("");
setPage(1);
};
// Client-side filtering (status, type, search)
const filteredIssues = issues.filter((issue) => {
if (statusFilter && issue.status !== statusFilter) return false;
if (typeFilter && issue.issue_type !== typeFilter) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
return (
issue.subject.toLowerCase().includes(q) ||
issue.description.toLowerCase().includes(q) ||
issue.issue_type.toLowerCase().includes(q)
);
}
return true;
});
// Pagination
const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
const safePage = Math.min(page, pageCount);
const handlePrev = () => safePage > 1 && setPage(safePage - 1);
const handleNext = () => safePage < pageCount && setPage(safePage + 1);
const getPageNumbers = () => {
const pages: (number | string)[] = [];
if (pageCount <= 7) {
for (let i = 1; i <= pageCount; i++) pages.push(i);
} else {
pages.push(1, 2, 3);
if (safePage > 4) pages.push("...");
if (safePage > 3 && safePage < pageCount - 2) pages.push(safePage);
if (safePage < pageCount - 3) pages.push("...");
pages.push(pageCount);
}
return pages;
};
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
const endEntry = Math.min(safePage * pageSize, totalCount);
// Stats
const pendingCount = issues.filter((i) => i.status === "pending").length;
const inProgressCount = issues.filter((i) => i.status === "in_progress").length;
const resolvedCount = issues.filter((i) => i.status === "resolved").length;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grayScale-600">Issue Reports</h1>
<p className="text-sm text-grayScale-400">
Review and manage user-reported issues across the platform.
</p>
</div>
<Button
variant="outline"
className="gap-2"
onClick={() => {
setPage(1);
fetchIssues();
}}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
{/* Stats cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
<AlertCircle className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{totalCount}</p>
<p className="text-xs text-grayScale-400">Total Issues</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
<Clock className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{pendingCount}</p>
<p className="text-xs text-grayScale-400">Pending</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-blue-100 text-blue-600">
<Loader2 className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{inProgressCount}</p>
<p className="text-xs text-grayScale-400">In Progress</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
<CheckCircle2 className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{resolvedCount}</p>
<p className="text-xs text-grayScale-400">Resolved</p>
</div>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-white p-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
placeholder="Search by subject, description, or type..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="relative">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="h-10 appearance-none rounded-lg border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Status: All</option>
{STATUSES.map((s) => (
<option key={s} value={s}>
{getStatusConfig(s).label}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
<div className="relative">
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value);
setPage(1);
}}
className="h-10 appearance-none rounded-lg border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Type: All</option>
{ISSUE_TYPES.map((t) => (
<option key={t} value={t}>
{getIssueTypeConfig(t).label}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="gap-1 text-grayScale-400 hover:text-grayScale-600"
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
)}
</div>
{/* Table */}
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>SUBJECT</TableHead>
<TableHead>TYPE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>REPORTER</TableHead>
<TableHead>CREATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
<span className="text-sm text-grayScale-400">Loading issues...</span>
</div>
</TableCell>
</TableRow>
) : filteredIssues.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<AlertCircle className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No issues found</p>
<p className="text-xs text-grayScale-400 mt-1">
{hasActiveFilters || searchQuery
? "Try adjusting your filters or search query"
: "Reported issues will appear here"}
</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
filteredIssues.map((issue) => {
const typeConfig = getIssueTypeConfig(issue.issue_type);
const statusConfig = getStatusConfig(issue.status);
const TypeIcon = typeConfig.icon;
const StatusIcon = statusConfig.icon;
return (
<TableRow key={issue.id} className="group">
<TableCell>
<div className="flex items-start gap-3 max-w-[300px]">
<div className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
<TypeIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-600 truncate">
{issue.subject}
</p>
<p className="text-xs text-grayScale-400 truncate mt-0.5">
{issue.description}
</p>
</div>
</div>
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium",
typeConfig.classes
)}
>
{typeConfig.label}
</span>
</TableCell>
<TableCell>
<div className="relative" onClick={(e) => e.stopPropagation()}>
<select
value={issue.status}
disabled={statusUpdating === issue.id}
onChange={(e) => handleStatusChange(issue.id, e.target.value)}
className={cn(
"h-8 appearance-none rounded-full border pl-3 pr-7 text-xs font-medium cursor-pointer focus:outline-none focus:ring-2 focus:ring-ring transition-colors",
statusConfig.classes,
statusUpdating === issue.id && "opacity-50 cursor-wait"
)}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{getStatusConfig(s).label}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
<User className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-600">
User #{issue.user_id}
</p>
<p className="text-xs text-grayScale-400">
{formatRoleLabel(issue.user_role)}
</p>
</div>
</div>
</TableCell>
<TableCell>
<div>
<p className="text-sm text-grayScale-600">
{formatDate(issue.created_at)}
</p>
<p className="text-xs text-grayScale-400">
{getRelativeTime(issue.created_at)}
</p>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleViewDetail(issue.id)}
>
<Eye className="h-4 w-4 text-grayScale-400" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-red-50 hover:text-red-600"
onClick={() => handleDeleteClick(issue)}
>
<Trash2 className="h-4 w-4 text-grayScale-400" />
</Button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
{/* Pagination */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setPage(1);
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={handlePrev}
disabled={safePage === 1}
className={cn(
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "opacity-50 cursor-not-allowed"
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50"
)}
>
{n}
</button>
)
)}
<button
onClick={handleNext}
disabled={safePage === pageCount}
className={cn(
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === pageCount && "opacity-50 cursor-not-allowed"
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* Detail Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Info className="h-5 w-5 text-brand-500" />
Issue Detail
</DialogTitle>
<DialogDescription>
Full details for this reported issue.
</DialogDescription>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
</div>
) : selectedIssue ? (
<div className="space-y-4">
{/* Subject & badges */}
<div>
<h3 className="text-base font-semibold text-grayScale-600 mb-2">
{selectedIssue.subject}
</h3>
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium",
getIssueTypeConfig(selectedIssue.issue_type).classes
)}
>
{getIssueTypeConfig(selectedIssue.issue_type).label}
</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium",
getStatusConfig(selectedIssue.status).classes
)}
>
{getStatusConfig(selectedIssue.status).label}
</span>
<Badge variant="secondary" className="text-xs">
ID #{selectedIssue.id}
</Badge>
</div>
</div>
{/* Description */}
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400 mb-1.5">
Description
</p>
<p className="text-sm text-grayScale-600 leading-relaxed">
{selectedIssue.description}
</p>
</div>
{/* Detail grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<DetailItem
icon={<User className="h-4 w-4" />}
label="Reporter"
value={`User #${selectedIssue.user_id}`}
/>
<DetailItem
icon={<Info className="h-4 w-4" />}
label="Role"
value={formatRoleLabel(selectedIssue.user_role)}
/>
<DetailItem
icon={<Clock className="h-4 w-4" />}
label="Created"
value={formatDateTime(selectedIssue.created_at)}
/>
<DetailItem
icon={<RefreshCw className="h-4 w-4" />}
label="Updated"
value={formatDateTime(selectedIssue.updated_at)}
/>
</div>
{/* Status changer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400 mb-2">
Update Status
</p>
<div className="flex gap-2 flex-wrap">
{STATUSES.map((s) => {
const config = getStatusConfig(s);
const isActive = selectedIssue.status === s;
return (
<button
key={s}
disabled={statusUpdating === selectedIssue.id}
onClick={() => handleStatusChange(selectedIssue.id, s)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
isActive
? cn(config.classes, "ring-2 ring-offset-1 ring-current")
: "bg-white text-grayScale-500 border-grayScale-200 hover:bg-grayScale-50",
statusUpdating === selectedIssue.id && "opacity-50 cursor-wait"
)}
>
{config.label}
</button>
);
})}
</div>
</div>
{/* Metadata */}
{selectedIssue.metadata &&
Object.keys(selectedIssue.metadata).length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400 mb-2">
Metadata
</p>
<div className="rounded-lg border bg-grayScale-50 p-3">
<div className="space-y-1.5">
{Object.entries(selectedIssue.metadata).map(([key, value]) => (
<div key={key} className="flex items-baseline justify-between gap-4">
<span className="text-xs font-medium text-grayScale-400 capitalize">
{key.replace(/_/g, " ")}
</span>
<span className="text-xs text-grayScale-600 font-mono text-right">
{String(value)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button
variant="destructive"
size="sm"
className="gap-1.5"
onClick={() => {
setDialogOpen(false);
handleDeleteClick(selectedIssue);
}}
>
<Trash2 className="h-3.5 w-3.5" />
Delete Issue
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="h-5 w-5" />
Delete Issue
</DialogTitle>
<DialogDescription>
Are you sure you want to delete this issue? This action cannot be undone.
</DialogDescription>
</DialogHeader>
{issueToDelete && (
<div className="rounded-lg bg-red-50 border border-red-100 p-3">
<p className="text-sm font-medium text-red-700">{issueToDelete.subject}</p>
<p className="text-xs text-red-500 mt-0.5">Issue #{issueToDelete.id}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setDeleteDialogOpen(false);
setIssueToDelete(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
className="gap-1.5"
disabled={deleteLoading}
onClick={handleDeleteConfirm}
>
{deleteLoading ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{deleteLoading ? "Deleting..." : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// ── Sub-components ─────────────────────────────────────────────────
function DetailItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-2.5 rounded-lg border bg-white p-2.5">
<div className="mt-0.5 text-grayScale-400">{icon}</div>
<div className="min-w-0">
<p className="text-xs text-grayScale-400">{label}</p>
<p className="text-sm font-medium text-grayScale-600 truncate" title={value}>
{value}
</p>
</div>
</div>
);
}

View File

@ -138,21 +138,21 @@ export function TeamManagementPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-grayScale-600">Team Management</h1> <h1 className="text-2xl font-bold text-grayScale-600">Team Management</h1>
<p className="text-sm text-grayScale-400"> <p className="text-sm text-grayScale-400">
Manage user access, roles, and platform permissions. Manage user access, roles, and platform permissions.
</p> </p>
</div> </div>
<Button className="bg-brand-600 hover:bg-brand-500 text-white"> <Button className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Team Member Add Team Member
</Button> </Button>
</div> </div>
<div className="flex items-center gap-3 rounded-lg border bg-white p-3"> <div className="flex flex-wrap items-center gap-3 rounded-lg border bg-white p-3">
<div className="relative flex-1"> <div className="relative w-full sm:flex-1 sm:w-auto">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input <Input
placeholder="Search by name or email address..." placeholder="Search by name or email address..."
@ -206,7 +206,7 @@ export function TeamManagementPage() {
<TableRow> <TableRow>
<TableHead>USER</TableHead> <TableHead>USER</TableHead>
<TableHead>ROLE</TableHead> <TableHead>ROLE</TableHead>
<TableHead>LAST LOGIN</TableHead> <TableHead className="hidden sm:table-cell">LAST LOGIN</TableHead>
<TableHead>STATUS</TableHead> <TableHead>STATUS</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -258,7 +258,7 @@ export function TeamManagementPage() {
{formatRoleLabel(member.team_role)} {formatRoleLabel(member.team_role)}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden sm:table-cell">
{member.last_login ? ( {member.last_login ? (
<div> <div>
<div className="text-sm text-grayScale-600"> <div className="text-sm text-grayScale-600">

View File

@ -159,7 +159,7 @@ export function TeamMemberDetailPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="h-28 bg-gradient-to-r from-brand-600 via-brand-400 to-mint-500" /> <div className="h-28 bg-gradient-to-r from-brand-600 via-brand-400 to-mint-500" />
<CardContent className="-mt-12 px-8 pb-8 pt-0"> <CardContent className="-mt-12 px-4 sm:px-8 pb-4 sm:pb-8 pt-0">
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end"> <div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end">
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft"> <Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
<AvatarImage src={undefined} alt={fullName} /> <AvatarImage src={undefined} alt={fullName} />

View File

@ -1,17 +1,676 @@
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { useCallback, useEffect, useState } from "react";
import {
Search,
ChevronDown,
ChevronLeft,
ChevronRight,
Activity,
Eye,
RefreshCw,
Clock,
User,
Globe,
Monitor,
FileText,
X,
Info,
Shield,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Badge } from "../../components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../../components/ui/dialog";
import { cn } from "../../lib/utils";
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
export function UserLogPage() { // ── Action type configuration ──────────────────────────────────────
return ( const ACTION_TYPES = [
<div className="mx-auto w-full max-w-6xl"> "TEAM_MEMBER_CREATED",
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Log</div> "TEAM_MEMBER_UPDATED",
<Card className="shadow-none"> "TEAM_MEMBER_DELETED",
<CardHeader> "TEAM_MEMBER_DEACTIVATED",
<CardTitle>User Log</CardTitle> "TEAM_MEMBER_REACTIVATED",
</CardHeader> "VIDEO_UPLOADED",
<CardContent className="text-sm text-muted-foreground">User Log module placeholder.</CardContent> "VIDEO_DELETED",
</Card> "COURSE_CREATED",
</div> "COURSE_UPDATED",
) "COURSE_DELETED",
"USER_REGISTERED",
"USER_UPDATED",
"USER_DELETED",
"ROLE_CREATED",
"ROLE_UPDATED",
"ROLE_DELETED",
"LOGIN",
"LOGOUT",
] as const;
function getActionBadgeClasses(action: string): string {
if (action.includes("CREATED") || action.includes("REGISTERED") || action.includes("UPLOADED"))
return "bg-emerald-50 text-emerald-700 border-emerald-200";
if (action.includes("UPDATED") || action.includes("REACTIVATED"))
return "bg-blue-50 text-blue-700 border-blue-200";
if (action.includes("DELETED") || action.includes("DEACTIVATED"))
return "bg-red-50 text-red-700 border-red-200";
if (action.includes("LOGIN") || action.includes("LOGOUT"))
return "bg-amber-50 text-amber-700 border-amber-200";
return "bg-grayScale-100 text-grayScale-600 border-grayScale-200";
} }
function getActionIcon(action: string) {
if (action.includes("TEAM_MEMBER")) return User;
if (action.includes("VIDEO")) return FileText;
if (action.includes("COURSE")) return FileText;
if (action.includes("ROLE")) return Shield;
return Activity;
}
function formatActionLabel(action: string): string {
return action
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function getRelativeTime(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return formatDate(dateStr);
}
function formatRoleLabel(role: string): string {
return role
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
// ── Main Component ─────────────────────────────────────────────────
export function UserLogPage() {
const [logs, setLogs] = useState<ActivityLog[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [actionFilter, setActionFilter] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [dateAfter, setDateAfter] = useState("");
const [dateBefore, setDateBefore] = useState("");
// Detail dialog
const [selectedLog, setSelectedLog] = useState<ActivityLog | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const filters: ActivityLogFilters = {
limit: pageSize,
offset: (page - 1) * pageSize,
};
if (actionFilter) filters.action = actionFilter;
if (dateAfter) filters.after = new Date(dateAfter).toISOString();
if (dateBefore) filters.before = new Date(dateBefore).toISOString();
const res = await getActivityLogs(filters);
setLogs(res.data.data.logs);
setTotalCount(res.data.data.total_count);
} catch (error) {
console.error("Failed to fetch activity logs:", error);
setLogs([]);
setTotalCount(0);
} finally {
setLoading(false);
}
}, [page, pageSize, actionFilter, dateAfter, dateBefore]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const handleViewDetail = async (logId: number) => {
setDialogOpen(true);
setDetailLoading(true);
try {
const res = await getActivityLogById(logId);
setSelectedLog(res.data.data);
} catch (error) {
console.error("Failed to fetch log detail:", error);
} finally {
setDetailLoading(false);
}
};
const handleCloseDialog = () => {
setDialogOpen(false);
setSelectedLog(null);
};
const clearFilters = () => {
setActionFilter("");
setSearchQuery("");
setDateAfter("");
setDateBefore("");
setPage(1);
};
const hasActiveFilters = actionFilter || dateAfter || dateBefore;
// Pagination
const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
const safePage = Math.min(page, pageCount);
const handlePrev = () => safePage > 1 && setPage(safePage - 1);
const handleNext = () => safePage < pageCount && setPage(safePage + 1);
const getPageNumbers = () => {
const pages: (number | string)[] = [];
if (pageCount <= 7) {
for (let i = 1; i <= pageCount; i++) pages.push(i);
} else {
pages.push(1, 2, 3);
if (safePage > 4) pages.push("...");
if (safePage > 3 && safePage < pageCount - 2) pages.push(safePage);
if (safePage < pageCount - 3) pages.push("...");
pages.push(pageCount);
}
return pages;
};
// Filter logs by search (client-side on the message field)
const filteredLogs = searchQuery
? logs.filter(
(log) =>
log.message?.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.actor_role?.toLowerCase().includes(searchQuery.toLowerCase())
)
: logs;
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
const endEntry = Math.min(safePage * pageSize, totalCount);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grayScale-600">Activity Log</h1>
<p className="text-sm text-grayScale-400">
Track all actions and changes made across the platform.
</p>
</div>
<Button
variant="outline"
className="gap-2"
onClick={() => {
setPage(1);
fetchLogs();
}}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
{/* Stats cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
<Activity className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{totalCount}</p>
<p className="text-xs text-grayScale-400">Total Logs</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
<Clock className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">
{logs.length > 0 ? getRelativeTime(logs[0].created_at) : "—"}
</p>
<p className="text-xs text-grayScale-400">Latest Activity</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
<FileText className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{filteredLogs.length}</p>
<p className="text-xs text-grayScale-400">Showing Results</p>
</div>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-white p-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
placeholder="Search by message, action, or role..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="relative">
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value);
setPage(1);
}}
className="h-10 appearance-none rounded-lg border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Action: All</option>
{ACTION_TYPES.map((action) => (
<option key={action} value={action}>
{formatActionLabel(action)}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-grayScale-400">From</label>
<input
type="date"
value={dateAfter}
onChange={(e) => {
setDateAfter(e.target.value);
setPage(1);
}}
className="h-10 rounded-lg border bg-white px-3 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-grayScale-400">To</label>
<input
type="date"
value={dateBefore}
onChange={(e) => {
setDateBefore(e.target.value);
setPage(1);
}}
className="h-10 rounded-lg border bg-white px-3 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="gap-1 text-grayScale-400 hover:text-grayScale-600"
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
)}
</div>
{/* Table */}
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>ACTION</TableHead>
<TableHead>MESSAGE</TableHead>
<TableHead>ACTOR</TableHead>
<TableHead>RESOURCE</TableHead>
<TableHead>TIMESTAMP</TableHead>
<TableHead className="text-right">DETAILS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
<span className="text-sm text-grayScale-400">Loading activity logs...</span>
</div>
</TableCell>
</TableRow>
) : filteredLogs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<Activity className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No activity logs found</p>
<p className="text-xs text-grayScale-400 mt-1">
{hasActiveFilters
? "Try adjusting your filters"
: "Activity will appear here once actions are performed"}
</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
filteredLogs.map((log) => {
const ActionIcon = getActionIcon(log.action);
return (
<TableRow key={log.id} className="group">
<TableCell>
<div className="flex items-center gap-2.5">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
<ActionIcon className="h-4 w-4" />
</div>
<span
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap",
getActionBadgeClasses(log.action)
)}
>
{formatActionLabel(log.action)}
</span>
</div>
</TableCell>
<TableCell>
<p className="text-sm text-grayScale-600 max-w-[280px] truncate">
{log.message || "—"}
</p>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
<User className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-600">
ID: {log.actor_id ?? "System"}
</p>
{log.actor_role && (
<p className="text-xs text-grayScale-400">
{formatRoleLabel(log.actor_role)}
</p>
)}
</div>
</div>
</TableCell>
<TableCell>
<div>
<p className="text-sm text-grayScale-600">{log.resource_type}</p>
{log.resource_id !== null && (
<p className="text-xs text-grayScale-400">#{log.resource_id}</p>
)}
</div>
</TableCell>
<TableCell>
<div>
<p className="text-sm text-grayScale-600">{formatDate(log.created_at)}</p>
<p className="text-xs text-grayScale-400">
{getRelativeTime(log.created_at)}
</p>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleViewDetail(log.id)}
>
<Eye className="h-4 w-4 text-grayScale-400" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
{/* Pagination */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setPage(1);
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={handlePrev}
disabled={safePage === 1}
className={cn(
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "opacity-50 cursor-not-allowed"
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50"
)}
>
{n}
</button>
)
)}
<button
onClick={handleNext}
disabled={safePage === pageCount}
className={cn(
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === pageCount && "opacity-50 cursor-not-allowed"
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* Detail Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Info className="h-5 w-5 text-brand-500" />
Activity Log Detail
</DialogTitle>
<DialogDescription>
Full details for this activity log entry.
</DialogDescription>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
</div>
) : selectedLog ? (
<div className="space-y-4">
{/* Action badge */}
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold",
getActionBadgeClasses(selectedLog.action)
)}
>
{formatActionLabel(selectedLog.action)}
</span>
<Badge variant="secondary" className="text-xs">
ID #{selectedLog.id}
</Badge>
</div>
{/* Message */}
{selectedLog.message && (
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm text-grayScale-600">{selectedLog.message}</p>
</div>
)}
{/* Detail grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<DetailItem
icon={<User className="h-4 w-4" />}
label="Actor"
value={`ID: ${selectedLog.actor_id ?? "System"}`}
/>
<DetailItem
icon={<Shield className="h-4 w-4" />}
label="Role"
value={selectedLog.actor_role ? formatRoleLabel(selectedLog.actor_role) : "—"}
/>
<DetailItem
icon={<FileText className="h-4 w-4" />}
label="Resource"
value={`${selectedLog.resource_type}${selectedLog.resource_id !== null ? ` #${selectedLog.resource_id}` : ""}`}
/>
<DetailItem
icon={<Clock className="h-4 w-4" />}
label="Time"
value={`${formatDate(selectedLog.created_at)} ${formatTime(selectedLog.created_at)}`}
/>
<DetailItem
icon={<Globe className="h-4 w-4" />}
label="IP Address"
value={selectedLog.ip_address || "—"}
/>
<DetailItem
icon={<Monitor className="h-4 w-4" />}
label="User Agent"
value={selectedLog.user_agent ? truncateUA(selectedLog.user_agent) : "—"}
/>
</div>
{/* Metadata */}
{selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400 mb-2">
Metadata
</p>
<div className="rounded-lg border bg-grayScale-50 p-3">
<pre className="text-xs text-grayScale-600 whitespace-pre-wrap break-words font-mono">
{JSON.stringify(selectedLog.metadata, null, 2)}
</pre>
</div>
</div>
)}
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}
// ── Sub-components ─────────────────────────────────────────────────
function DetailItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-2.5 rounded-lg border bg-white p-2.5">
<div className="mt-0.5 text-grayScale-400">{icon}</div>
<div className="min-w-0">
<p className="text-xs text-grayScale-400">{label}</p>
<p className="text-sm font-medium text-grayScale-600 truncate" title={value}>
{value}
</p>
</div>
</div>
);
}
function truncateUA(ua: string): string {
if (ua.length <= 40) return ua;
return ua.substring(0, 37) + "...";
}

View File

@ -1,4 +1,4 @@
import { ArrowLeft } from "lucide-react" import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
@ -15,20 +15,25 @@ export function RegisterUserPage() {
<Button variant="ghost" size="icon" onClick={() => navigate("/users")} className="h-8 w-8"> <Button variant="ghost" size="icon" onClick={() => navigate("/users")} className="h-8 w-8">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-xl font-semibold text-grayScale-900">Register New User</h1> <div>
<h1 className="text-2xl font-bold text-grayScale-600">Register New User</h1>
<p className="text-sm text-grayScale-400">Add a new user to the system</p>
</div>
</div> </div>
<Card className="p-6"> <Card className="mx-auto max-w-2xl p-6">
<form className="space-y-4"> <form className="space-y-5">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
First Name First Name
</label> </label>
<Input placeholder="Enter first name" required /> <Input placeholder="Enter first name" required />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
Last Name Last Name
</label> </label>
<Input placeholder="Enter last name" required /> <Input placeholder="Enter last name" required />
@ -36,17 +41,26 @@ export function RegisterUserPage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Email</label> <label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Mail className="h-4 w-4" />
Email
</label>
<Input type="email" placeholder="Enter email address" required /> <Input type="email" placeholder="Enter email address" required />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Phone</label> <label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Phone className="h-4 w-4" />
Phone
</label>
<Input type="tel" placeholder="Enter phone number" required /> <Input type="tel" placeholder="Enter phone number" required />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Role</label> <label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Shield className="h-4 w-4" />
Role
</label>
<Select required> <Select required>
<option value="">Select role</option> <option value="">Select role</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
@ -55,15 +69,18 @@ export function RegisterUserPage() {
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Notes</label> <label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<FileText className="h-4 w-4" />
Notes
</label>
<Textarea placeholder="Enter any additional notes" rows={3} /> <Textarea placeholder="Enter any additional notes" rows={3} />
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => navigate("/users")}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => navigate("/users")}>
Cancel Cancel
</Button> </Button>
<Button type="submit" className="bg-brand-500 hover:bg-brand-600"> <Button type="submit" className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
Register User Register User
</Button> </Button>
</div> </div>
@ -72,4 +89,3 @@ export function RegisterUserPage() {
</div> </div>
) )
} }

View File

@ -105,7 +105,7 @@ export function UserDetailPage() {
{/* Profile card */} {/* Profile card */}
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="h-24 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400" /> <div className="h-24 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400" />
<CardContent className="-mt-12 space-y-5 px-6 pb-6 pt-0"> <CardContent className="-mt-12 space-y-5 px-4 sm:px-6 pb-6 pt-0">
<div className="flex flex-col items-center text-center"> <div className="flex flex-col items-center text-center">
<Avatar className="h-20 w-20 ring-4 ring-white shadow-soft"> <Avatar className="h-20 w-20 ring-4 ring-white shadow-soft">
<AvatarImage src={user.profile_picture_url ?? undefined} alt={fullName} /> <AvatarImage src={user.profile_picture_url ?? undefined} alt={fullName} />
@ -197,7 +197,7 @@ export function UserDetailPage() {
<span className="text-sm font-semibold text-grayScale-600">6-Month</span> <span className="text-sm font-semibold text-grayScale-600">6-Month</span>
</div> </div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-1">
<span className="text-xs font-medium uppercase tracking-wider text-grayScale-400"> <span className="text-xs font-medium uppercase tracking-wider text-grayScale-400">
Expires Expires
</span> </span>
@ -215,7 +215,7 @@ export function UserDetailPage() {
Extend Subscription Extend Subscription
</Button> </Button>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button variant="outline" className="w-full text-sm"> <Button variant="outline" className="w-full text-sm">
Mark as Paid Mark as Paid
</Button> </Button>

View File

@ -1,10 +1,17 @@
import { useState } from "react" import { useState } from "react"
import { Plus, Edit } from "lucide-react" import { Edit, FolderOpen, Plus, Users } from "lucide-react"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../../components/ui/dialog" import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { Badge } from "../../components/ui/badge"
const mockGroups = [ const mockGroups = [
{ id: "1", name: "Big 10", userCount: 10 }, { id: "1", name: "Big 10", userCount: 10 },
@ -26,21 +33,59 @@ export function UserGroupsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-xl font-semibold text-grayScale-900">User Groups</h1> <div>
<Button onClick={() => setIsModalOpen(true)} className="bg-brand-500 hover:bg-brand-600"> <h1 className="text-2xl font-bold text-grayScale-600">User Groups</h1>
<Plus className="h-4 w-4" /> <p className="text-sm text-grayScale-400">
Organize users into groups for easier management.
</p>
</div>
<Button
onClick={() => setIsModalOpen(true)}
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
>
<Plus className="mr-1.5 h-4 w-4" />
Add New Group Add New Group
</Button> </Button>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> {mockGroups.length === 0 ? (
<Card className="flex flex-col items-center justify-center px-6 py-16 text-center shadow-sm">
<div className="mb-4 grid h-14 w-14 place-items-center rounded-full bg-grayScale-100">
<FolderOpen className="h-7 w-7 text-grayScale-400" />
</div>
<h3 className="text-lg font-semibold text-grayScale-600">No groups found</h3>
<p className="mt-1 max-w-sm text-sm text-grayScale-400">
Get started by creating your first user group to organize users and manage permissions
more efficiently.
</p>
<Button
onClick={() => setIsModalOpen(true)}
className="mt-6 bg-brand-500 hover:bg-brand-600"
>
<Plus className="mr-1.5 h-4 w-4" />
Create First Group
</Button>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{mockGroups.map((group) => ( {mockGroups.map((group) => (
<Card key={group.id} className="overflow-hidden shadow-sm"> <Card
key={group.id}
className="group overflow-hidden shadow-sm transition-shadow hover:shadow-md"
>
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" /> <div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardContent className="p-6"> <CardContent className="p-6">
<h3 className="mb-2 text-lg font-semibold text-grayScale-900">{group.name}</h3> <div className="mb-4 flex items-center justify-between">
<p className="mb-4 text-sm text-grayScale-600">{group.userCount} Users</p> <h3 className="text-lg font-semibold text-grayScale-600">{group.name}</h3>
<Badge variant="secondary" className="gap-1">
<Users className="h-3 w-3" />
{group.userCount}
</Badge>
</div>
<p className="mb-4 text-sm text-grayScale-400">
{group.userCount} {group.userCount === 1 ? "User" : "Users"} in this group
</p>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full">
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Role Edit Role
@ -49,6 +94,7 @@ export function UserGroupsPage() {
</Card> </Card>
))} ))}
</div> </div>
)}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent> <DialogContent>
@ -57,7 +103,7 @@ export function UserGroupsPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-2 block text-sm font-medium text-grayScale-600">
Group Name Group Name
</label> </label>
<Input <Input
@ -67,7 +113,7 @@ export function UserGroupsPage() {
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-grayScale-700"> <label className="mb-2 block text-sm font-medium text-grayScale-600">
Group Description Group Description
</label> </label>
<Textarea <Textarea
@ -91,4 +137,3 @@ export function UserGroupsPage() {
</div> </div>
) )
} }

View File

@ -1,58 +1,136 @@
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { UserPlus, Users } from "lucide-react" import {
Users,
UserPlus,
UserCheck,
TrendingUp,
ArrowRight,
List,
UsersRound,
} from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
export function UserManagementDashboard() { export function UserManagementDashboard() {
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<h1 className="text-xl font-semibold text-grayScale-900">User Management</h1> {/* Page Header */}
<div className="grid gap-6 md:grid-cols-2"> <div>
<Card className="shadow-sm"> <h1 className="text-2xl font-bold text-grayScale-600">User Management</h1>
<CardHeader> <p className="mt-1 text-sm text-grayScale-400">
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600"> Manage users, groups, and registrations.
<UserPlus className="h-6 w-6" /> </p>
</div> </div>
<CardTitle className="text-lg">User Register</CardTitle>
<CardDescription>Register new users to the system</CardDescription>
</CardHeader>
<CardContent>
<Link to="/users/register">
<Button className="w-full bg-brand-500 hover:bg-brand-600">Register User</Button>
</Link>
</CardContent>
</Card>
<Card className="shadow-sm"> {/* Stat Cards */}
<CardHeader> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600"> <Card className="border-none bg-brand-50 shadow-sm">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<Users className="h-6 w-6" /> <Users className="h-6 w-6" />
</div> </div>
<CardTitle className="text-lg">User Groups</CardTitle> <div className="min-w-0">
<CardDescription>Manage user groups and permissions</CardDescription> <p className="text-sm font-medium text-grayScale-400">Total Users</p>
</CardHeader> <p className="text-2xl font-bold text-grayScale-600">1,248</p>
<CardContent> </div>
<Link to="/users/groups">
<Button className="w-full bg-brand-500 hover:bg-brand-600">View Groups</Button>
</Link>
</CardContent> </CardContent>
</Card> </Card>
<Card className="shadow-sm md:col-span-2"> <Card className="border-none bg-mint-50 shadow-sm">
<CardHeader> <CardContent className="flex items-center gap-4 p-5">
<CardTitle className="text-lg">User List</CardTitle> <div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-mint-100 text-mint-600">
<CardDescription>View and manage all users in the system</CardDescription> <UserCheck className="h-6 w-6" />
</CardHeader> </div>
<CardContent> <div className="min-w-0">
<Link to="/users/list"> <p className="text-sm font-medium text-grayScale-400">Active Users</p>
<Button variant="outline" className="w-full"> <p className="text-2xl font-bold text-grayScale-600">1,180</p>
View All Users </div>
</Button>
</Link>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-none bg-gold-50 shadow-sm sm:col-span-2 lg:col-span-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-gold-100 text-gold-600">
<TrendingUp className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">New This Month</p>
<p className="text-2xl font-bold text-grayScale-600">64</p>
</div>
</CardContent>
</Card>
</div>
{/* Action Cards */}
<div>
<h2 className="mb-4 text-lg font-semibold text-grayScale-600">Quick Actions</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link to="/users/register" className="group">
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
<CardHeader className="pb-3">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<UserPlus className="h-5 w-5" />
</div>
<CardTitle className="text-base font-semibold text-grayScale-600">
Register User
</CardTitle>
<CardDescription className="text-sm text-grayScale-400">
Add new users to the system with role assignment.
</CardDescription>
</CardHeader>
<CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
Get started
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</CardContent>
</Card>
</Link>
<Link to="/users/groups" className="group">
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
<CardHeader className="pb-3">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<UsersRound className="h-5 w-5" />
</div>
<CardTitle className="text-base font-semibold text-grayScale-600">
User Groups
</CardTitle>
<CardDescription className="text-sm text-grayScale-400">
Manage groups, roles, and permission settings.
</CardDescription>
</CardHeader>
<CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
Manage groups
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</CardContent>
</Card>
</Link>
<Link to="/users/list" className="group sm:col-span-2 lg:col-span-1">
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
<CardHeader className="pb-3">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<List className="h-5 w-5" />
</div>
<CardTitle className="text-base font-semibold text-grayScale-600">
User List
</CardTitle>
<CardDescription className="text-sm text-grayScale-400">
Browse, search, and manage all registered users.
</CardDescription>
</CardHeader>
<CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
View all users
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</CardContent>
</Card>
</Link>
</div>
</div> </div>
</div> </div>
) )
} }

View File

@ -3,7 +3,6 @@ import { Outlet } from "react-router-dom"
export function UserManagementLayout() { export function UserManagementLayout() {
return ( return (
<div className="mx-auto w-full max-w-6xl"> <div className="mx-auto w-full max-w-6xl">
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Management</div>
<Outlet /> <Outlet />
</div> </div>
) )

View File

@ -1,4 +1,4 @@
import { ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react" import { ChevronDown, ChevronLeft, ChevronRight, Search, Users } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -110,7 +110,15 @@ export function UsersListPage() {
} }
return ( return (
<div className="bg-white rounded-lg border"> <div className="space-y-4">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-grayScale-600">Users List</h1>
<p className="text-sm text-grayScale-400">View and manage all registered users.</p>
</div>
<div className="bg-white rounded-xl border">
{/* Search & Filters */}
<div className="p-4 border-b"> <div className="p-4 border-b">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="relative w-full md:max-w-sm"> <div className="relative w-full md:max-w-sm">
@ -123,12 +131,12 @@ export function UsersListPage() {
/> />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative"> <div className="relative w-full sm:w-auto">
<select <select
value={countryFilter} value={countryFilter}
onChange={(e) => setCountryFilter(e.target.value)} onChange={(e) => setCountryFilter(e.target.value)}
className="h-9 appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500" className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
> >
<option value="">Country</option> <option value="">Country</option>
<option value="USA">USA</option> <option value="USA">USA</option>
@ -138,11 +146,11 @@ export function UsersListPage() {
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" /> <ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div> </div>
<div className="relative"> <div className="relative w-full sm:w-auto">
<select <select
value={regionFilter} value={regionFilter}
onChange={(e) => setRegionFilter(e.target.value)} onChange={(e) => setRegionFilter(e.target.value)}
className="h-9 appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500" className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
> >
<option value="">Region</option> <option value="">Region</option>
<option value="North">North</option> <option value="North">North</option>
@ -153,11 +161,11 @@ export function UsersListPage() {
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" /> <ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div> </div>
<div className="relative"> <div className="relative w-full sm:w-auto">
<select <select
value={subscriptionFilter} value={subscriptionFilter}
onChange={(e) => setSubscriptionFilter(e.target.value)} onChange={(e) => setSubscriptionFilter(e.target.value)}
className="h-9 appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500" className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
> >
<option value="">Subscription</option> <option value="">Subscription</option>
<option value="Monthly">Monthly</option> <option value="Monthly">Monthly</option>
@ -172,6 +180,7 @@ export function UsersListPage() {
</div> </div>
</div> </div>
{/* Table */}
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -184,9 +193,9 @@ export function UsersListPage() {
/> />
</TableHead> </TableHead>
<TableHead>USER</TableHead> <TableHead>USER</TableHead>
<TableHead>Phone</TableHead> <TableHead className="hidden md:table-cell">Phone</TableHead>
<TableHead>Country</TableHead> <TableHead className="hidden md:table-cell">Country</TableHead>
<TableHead>Region</TableHead> <TableHead className="hidden md:table-cell">Region</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -194,8 +203,16 @@ export function UsersListPage() {
<TableBody> <TableBody>
{users.length === 0 ? ( {users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-grayScale-400"> <TableCell colSpan={6} className="py-16 text-center">
No users found <div className="flex flex-col items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
<Users className="h-7 w-7 text-grayScale-400" />
</div>
<div>
<p className="font-medium text-grayScale-500">No users found</p>
<p className="text-sm text-grayScale-400">Try adjusting your search or filters.</p>
</div>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@ -229,9 +246,9 @@ export function UsersListPage() {
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-grayScale-500">{u.phoneNumber || "-"}</TableCell> <TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
<TableCell className="text-grayScale-500">{u.country || "-"}</TableCell> <TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
<TableCell className="text-grayScale-500">{u.region || "-"}</TableCell> <TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell onClick={(e) => e.stopPropagation()}>
<button <button
type="button" type="button"
@ -256,7 +273,8 @@ export function UsersListPage() {
</TableBody> </TableBody>
</Table> </Table>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500"> {/* Pagination */}
<div className="flex flex-col items-center gap-3 border-t px-4 py-3 text-sm text-grayScale-500 sm:flex-row sm:justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>Row Per Page</span> <span>Row Per Page</span>
<div className="relative"> <div className="relative">
@ -326,5 +344,6 @@ export function UsersListPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@ -0,0 +1,47 @@
export interface ActivityLog {
id: number
actor_id: number | null
actor_role: string | null
action: string
resource_type: string
resource_id: number | null
message: string | null
metadata: Record<string, unknown> | null
ip_address: string | null
user_agent: string | null
created_at: string
}
export interface ActivityLogListData {
logs: ActivityLog[]
total_count: number
limit: number
offset: number
}
export interface GetActivityLogsResponse {
message: string
data: ActivityLogListData
success: boolean
status_code: number
metadata: null
}
export interface GetActivityLogResponse {
message: string
data: ActivityLog
success: boolean
status_code: number
metadata: null
}
export interface ActivityLogFilters {
action?: string
actor_id?: number
resource_type?: string
resource_id?: number
after?: string
before?: string
limit?: number
offset?: number
}

52
src/types/issue.types.ts Normal file
View File

@ -0,0 +1,52 @@
export interface Issue {
id: number;
user_id: number;
user_role: string;
subject: string;
description: string;
issue_type: string;
status: string;
metadata: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
export interface IssueListData {
issues: Issue[];
total_count: number;
}
export interface GetIssuesResponse {
message: string;
data: IssueListData;
success: boolean;
status_code: number;
metadata: null;
}
export interface GetIssueResponse {
message: string;
data: Issue;
success: boolean;
status_code: number;
metadata: null;
}
export interface UpdateIssueStatusResponse {
message: string;
success: boolean;
status_code: number;
metadata: null;
}
export interface DeleteIssueResponse {
message: string;
success: boolean;
status_code: number;
metadata: null;
}
export interface IssueFilters {
limit?: number;
offset?: number;
}