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_GOOGLE_CLIENT_ID=google_client_id

11
package-lock.json generated
View File

@ -22,6 +22,7 @@
"react-dom": "^19.2.0",
"react-router-dom": "^7.10.1",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
@ -5658,6 +5659,16 @@
"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": {
"version": "1.2.1",
"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-router-dom": "^7.10.1",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},

View File

@ -1,5 +1,22 @@
import { Toaster } from 'sonner'
import { AppRoutes } from './app/AppRoutes'
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,
}
}
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 { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage"
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
@ -79,6 +80,7 @@ export function AppRoutes() {
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/user-log" element={<UserLogPage />} />
<Route path="/issues" element={<IssuesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/team" element={<TeamManagementPage />} />

View File

@ -2,6 +2,7 @@ import {
BarChart3,
Bell,
BookOpen,
CircleAlert,
ClipboardList,
LayoutDashboard,
LogOut,
@ -9,6 +10,7 @@ import {
UserCircle2,
Users,
Users2,
X,
} from "lucide-react"
import type { ComponentType } from "react"
import { NavLink } from "react-router-dom"
@ -28,16 +30,47 @@ const navItems: NavItem[] = [
{ label: "Content Management", to: "/content", icon: BookOpen },
{ label: "Notifications", to: "/notifications", icon: Bell },
{ label: "User Log", to: "/user-log", icon: ClipboardList },
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
{ label: "Team Management", to: "/team", icon: Users2 },
{ label: "Profile", to: "/profile", icon: UserCircle2 },
]
export function Sidebar() {
type SidebarProps = {
isOpen: boolean
onClose: () => void
}
export function Sidebar({ isOpen, onClose }: SidebarProps) {
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 />
<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>
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
@ -47,6 +80,7 @@ export function Sidebar() {
<NavLink
key={item.to}
to={item.to}
onClick={onClose}
className={({ isActive }) =>
cn(
"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">
<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"
>
<LogOut className="h-4 w-4" />
@ -87,7 +125,6 @@ export function Sidebar() {
</button>
</div>
</aside>
</>
)
}

View File

@ -1,13 +1,16 @@
"use client" // make sure this is a client component
"use client"
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 * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { cn } from "../../lib/utils"
export function Topbar() {
// const [showLogoutConfirm, setShowLogoutConfirm] = useState(false)
type TopbarProps = {
onMenuClick: () => void
}
export function Topbar({ onMenuClick }: TopbarProps) {
const [shortName, setShortName] = useState("AA")
useEffect(() => {
@ -34,20 +37,23 @@ export function Topbar() {
case "logout":
localStorage.clear()
window.location.href = "/login"
// setShowLogoutConfirm(true) // Show confirmation popup instead of immediate logout
break
}
}
// const confirmLogout = () => {
// localStorage.clear()
// window.location.href = "/login"
// }
// const cancelLogout = () => setShowLogoutConfirm(false)
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 */}
<button
type="button"
@ -57,6 +63,9 @@ export function Topbar() {
<Bell className="h-5 w-5" />
</button>
{/* Separator */}
<div className="h-6 w-px bg-grayScale-200" />
{/* Avatar + Radix Dropdown */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
@ -110,35 +119,7 @@ export function Topbar() {
</DropdownMenu.Item>
</DropdownMenu.Content>
</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>
)} */}
</header>
)
}

View File

@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
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,
)}
{...props}

View File

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

View File

@ -4,7 +4,7 @@ import {
CheckCircle2,
Clock,
Globe,
GraduationCap,
// GraduationCap,
Languages,
Mail,
MapPin,
@ -13,10 +13,10 @@ import {
User,
XCircle,
Briefcase,
// RefreshCw,
} from "lucide-react";
import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Separator } from "../components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { cn } from "../lib/utils";
import { getMyProfile } from "../api/users.api";
@ -44,13 +44,37 @@ function formatDateTime(dateStr: string | null | undefined): string {
function LoadingSkeleton() {
return (
<div className="mx-auto w-full max-w-5xl space-y-6 py-8">
<div className="animate-pulse">
<div className="rounded-2xl bg-grayScale-100 h-72" />
<div className="mt-6 grid gap-6 md:grid-cols-3">
<div className="rounded-2xl bg-grayScale-100 h-56" />
<div className="rounded-2xl bg-grayScale-100 h-56" />
<div className="rounded-2xl bg-grayScale-100 h-56" />
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-10 sm:px-6">
<div className="animate-pulse space-y-8">
{/* Hero skeleton */}
<div className="overflow-hidden rounded-2xl border border-grayScale-100">
<div className="h-36 bg-gradient-to-r from-grayScale-100 via-grayScale-200/60 to-grayScale-100" />
<div className="flex flex-col items-center px-8 pb-8">
<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>
@ -69,13 +93,15 @@ function InfoRow({
extra?: React.ReactNode;
}) {
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 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" />
<span>{label}</span>
</div>
<span className="font-medium">{label}</span>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<span>{value || "—"}</span>
<span className="text-right">{value || "—"}</span>
{extra}
</div>
</div>
@ -84,9 +110,48 @@ function InfoRow({
function VerifiedIcon({ verified }: { verified: boolean }) {
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) {
return (
<div className="mx-auto w-full max-w-5xl py-12">
<Card>
<CardContent className="flex flex-col items-center gap-4 p-10">
<div className="h-16 w-16 rounded-full bg-grayScale-100 flex items-center justify-center">
<User className="h-8 w-8 text-grayScale-300" />
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-5 p-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
<User className="h-10 w-10 text-grayScale-300" />
</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"}
</p>
<p className="mt-1 text-sm text-grayScale-400">
Please check your connection and try again.
</p>
</div>
</CardContent>
</Card>
@ -133,57 +203,88 @@ export function ProfilePage() {
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
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 (
<div className="mx-auto w-full max-w-5xl space-y-6 py-8">
<Card className="overflow-hidden">
<div className="h-32 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
<CardContent className="-mt-14 px-8 pb-8 pt-0">
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-8 sm:px-6">
{/* Hero Card */}
<Card className="overflow-hidden border-0 shadow-lg">
{/* 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">
<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} />
<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}
</AvatarFallback>
</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
className={cn(
"mt-2",
"mt-2.5 px-3 py-1",
profile.role === "ADMIN"
? "bg-brand-500/15 text-brand-600 border border-brand-500/25"
: "bg-grayScale-200 text-grayScale-600"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "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}
</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
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"
? "bg-mint-100 text-mint-500"
: "bg-destructive/10 text-destructive"
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-destructive/20 bg-destructive/10 text-destructive"
)}
>
<span
className={cn(
"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}
</div>
{/* Email verification */}
<div
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
? "bg-mint-100 text-mint-500"
: "bg-grayScale-100 text-grayScale-400"
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
)}
>
{profile.email_verified ? (
@ -194,12 +295,13 @@ export function ProfilePage() {
Email {profile.email_verified ? "Verified" : "Unverified"}
</div>
{/* Phone verification */}
<div
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
? "bg-mint-100 text-mint-500"
: "bg-grayScale-100 text-grayScale-400"
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
)}
>
{profile.phone_verified ? (
@ -210,83 +312,101 @@ export function ProfilePage() {
Phone {profile.phone_verified ? "Verified" : "Unverified"}
</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">
<GraduationCap className="h-3 w-3" />
Profile {completionPct}% Complete
{/* Profile completion ring */}
<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">
<ProgressRing percent={completionPct} />
<span>Profile Complete</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Info Cards */}
<div className="grid gap-6 md:grid-cols-3">
<Card className="border-l-4 border-l-brand-500">
<CardHeader className="pb-2">
<CardTitle className="text-base">Personal Information</CardTitle>
{/* Personal Information */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<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>
<CardContent className="space-y-0">
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow icon={User} label="Full Name" value={fullName} />
<Separator />
<InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} />
<Separator />
<InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} />
<Separator />
<InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} />
<Separator />
<InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} />
</CardContent>
</Card>
<Card className="border-l-4 border-l-brand-500">
<CardHeader className="pb-2">
<CardTitle className="text-base">Contact & Location</CardTitle>
{/* Contact & Location */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<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>
<CardContent className="space-y-0">
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow
icon={Mail}
label="Email"
value={profile.email}
extra={<VerifiedIcon verified={profile.email_verified} />}
/>
<Separator />
<InfoRow
icon={Phone}
label="Phone"
value={profile.phone_number}
extra={<VerifiedIcon verified={profile.phone_verified} />}
/>
<Separator />
<InfoRow icon={Globe} label="Country" value={profile.country || "—"} />
<Separator />
<InfoRow icon={MapPin} label="Region" value={profile.region || "—"} />
</CardContent>
</Card>
<Card className="border-l-4 border-l-brand-500">
<CardHeader className="pb-2">
<CardTitle className="text-base">Account Details</CardTitle>
{/* Account Details */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<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>
<CardContent className="space-y-0">
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow icon={Shield} label="Role" value={profile.role} />
<Separator />
<InfoRow
icon={Languages}
label="Language"
value={profile.preferred_language || "—"}
/>
<Separator />
<InfoRow
icon={Clock}
label="Last Login"
value={formatDateTime(profile.last_login)}
/>
<Separator />
<InfoRow
icon={Calendar}
label="Member Since"
value={formatDate(profile.created_at)}
/>
<Separator />
<InfoRow
icon={CheckCircle2}
label="Status"
@ -294,8 +414,10 @@ export function ProfilePage() {
extra={
<span
className={cn(
"h-2 w-2 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
"h-2.5 w-2.5 rounded-full ring-2",
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 { Link } from "react-router-dom"
import { ArrowLeft, Mail } from "lucide-react"
import { BrandLogo } from "../../components/brand/BrandLogo"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
export function ForgotPasswordPage() {
const [email, setEmail] = useState("")
const [submitted, setSubmitted] = useState(false)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Handle forgot password logic here
console.log("Forgot password:", { email })
setSubmitted(true)
}
return (
<div className="flex min-h-screen items-center justify-center bg-grayScale-100 px-4 py-12">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-white p-8 shadow-soft">
<div className="mb-8">
<div className="relative flex min-h-screen overflow-hidden">
{/* Decorative left panel */}
<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">
{/* 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 />
</div>
<div className="mb-8">
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600">Forgot Password</h1>
<p className="text-sm text-grayScale-400">
Enter your email address and we'll send you a reset link.
{/* Back link */}
<Link
to="/login"
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>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-grayScale-600">
Email Address
<label
htmlFor="email"
className="mb-1.5 block text-sm font-medium text-grayScale-600"
>
Email address
</label>
<Input
id="email"
type="email"
placeholder="admin@yimaruacademy.com"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-11 rounded-xl"
/>
</div>
<Button type="submit" className="w-full">
Send Reset Link
<Button
type="submit"
className="mt-2 h-11 w-full rounded-xl text-sm font-semibold tracking-wide"
>
Send reset link
</Button>
</form>
</>
)}
<div className="mt-6 text-center">
<Link
to="/login"
className="text-sm font-medium text-brand-500 hover:text-brand-600"
>
Back to Login
</Link>
</div>
{/* Footer */}
<p className="mt-10 text-center text-xs text-grayScale-400">
© {new Date().getFullYear()} Yimaru Academy · All rights reserved
</p>
</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 { Eye, EyeOff } from "lucide-react";
@ -6,9 +6,60 @@ import { BrandLogo } from "../../components/brand/BrandLogo";
import { Button } from "../../components/ui/button";
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 { 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() {
const navigate = useNavigate();
@ -17,28 +68,102 @@ export function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
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) => {
e.preventDefault();
setError(null);
setLoading(true);
const payload: LoginRequest = {
email,
password,
};
const payload: LoginRequest = { email, password };
try {
const res: LoginResult = await login(payload);
// 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");
storeTokensAndRedirect(res);
} catch (err: any) {
setError(err.response?.data?.message || "Invalid email or password");
} 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 (
<div className="flex min-h-screen items-center justify-center bg-grayScale-100 px-4 py-12">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-white p-8 shadow-soft">
<div className="mb-8 flex justify-center">
<div className="relative flex min-h-screen overflow-hidden">
{/* Decorative left panel */}
<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">
{/* 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 />
</div>
<div className="mb-8 text-center">
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600">
Admin Login
{/* Header */}
<div className="mb-10">
<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>
<p className="text-sm text-grayScale-400">
Please enter your details to continue
<p className="text-sm leading-relaxed text-grayScale-400">
Sign in to your account to continue
</p>
</div>
{/* 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}
</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 */}
<div>
<label
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>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-11 rounded-xl"
/>
</div>
{/* Password */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<label
htmlFor="password"
className="mb-2 block text-sm font-medium text-grayScale-600"
className="block text-sm font-medium text-grayScale-600"
>
Password
</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">
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="pr-10"
className="h-11 rounded-xl pr-10"
/>
<button
type="button"
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}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@ -119,16 +370,19 @@ export function LoginPage() {
</div>
</div>
<div className="flex justify-end">
<Link to="/forgot-password" className="text-sm text-brand-500">
Forgot Password?
</Link>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Login"}
<Button
type="submit"
className="mt-2 h-11 w-full rounded-xl text-sm font-semibold tracking-wide"
disabled={loading}
>
{loading ? "Signing in…" : "Sign in"}
</Button>
</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>

View File

@ -256,16 +256,16 @@ export function AddNewPracticePage() {
{/* Back Link */}
<Link
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
</Link>
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-grayScale-900">Add New Practice</h1>
<p className="mt-1 text-sm text-grayScale-500">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Add New Practice</h1>
<p className="mt-1.5 text-sm text-grayScale-500">
Create a new immersive practice session for students.
</p>
</div>
@ -274,24 +274,28 @@ export function AddNewPracticePage() {
{/* Step Tracker */}
{currentStep !== 5 && (
<div className="flex items-center justify-center py-6">
<div className="flex items-center justify-center py-8">
{STEPS.map((step, index) => (
<div key={step.number} className="flex items-center">
<div className="flex flex-col items-center">
<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
? "bg-brand-500 text-white"
? "bg-brand-500 text-white ring-4 ring-brand-100"
: currentStep > step.number
? "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}
</div>
<span
className={`mt-2 text-xs font-medium ${
currentStep === step.number ? "text-brand-500" : "text-grayScale-400"
className={`mt-2.5 text-xs font-semibold tracking-wide ${
currentStep === step.number
? "text-brand-600"
: currentStep > step.number
? "text-brand-500"
: "text-grayScale-400"
}`}
>
{step.label}
@ -299,7 +303,7 @@ export function AddNewPracticePage() {
</div>
{index < STEPS.length - 1 && (
<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"
}`}
/>
@ -311,13 +315,13 @@ export function AddNewPracticePage() {
{/* Step Content */}
{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>
<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.
</p>
<div className="mt-8 space-y-6">
<div className="mt-8 space-y-7">
{/* Practice Title */}
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Practice Title</label>
@ -335,12 +339,12 @@ export function AddNewPracticePage() {
value={practiceDescription}
onChange={(e) => setPracticeDescription(e.target.value)}
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}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Passing Score */}
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Passing Score</label>
@ -368,16 +372,16 @@ export function AddNewPracticePage() {
</div>
{/* 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
type="button"
onClick={() => setShuffleQuestions(!shuffleQuestions)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
shuffleQuestions ? "bg-brand-500" : "bg-grayScale-200"
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-300"
}`}
>
<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"
}`}
/>
@ -388,34 +392,34 @@ export function AddNewPracticePage() {
{/* Program */}
<div className="space-y-2">
<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>
<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" />
<span className="flex-1 text-sm">{selectedProgram}</span>
<ChevronDown className="h-5 w-5 text-grayScale-400" />
<span className="flex-1 text-sm font-medium text-grayScale-600">{selectedProgram}</span>
<ChevronDown className="h-5 w-5 text-grayScale-300" />
</div>
</div>
{/* Course */}
<div className="space-y-2">
<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>
<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" />
<span className="flex-1 text-sm">{selectedCourse}</span>
<ChevronDown className="h-5 w-5 text-grayScale-400" />
<span className="flex-1 text-sm font-medium text-grayScale-600">{selectedCourse}</span>
<ChevronDown className="h-5 w-5 text-grayScale-300" />
</div>
</div>
</div>
{/* 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}>
Cancel
</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()}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
@ -425,45 +429,49 @@ export function AddNewPracticePage() {
{currentStep === 2 && (
<div className="mx-auto max-w-4xl">
<h2 className="text-xl font-semibold text-grayScale-900">Select Personas</h2>
<p className="mt-1 text-sm text-grayScale-500">
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Select Personas</h2>
<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.
</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) => (
<button
key={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
? "border-brand-500 bg-brand-50"
: "border-grayScale-200 hover:border-grayScale-300"
? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100"
: "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm"
}`}
>
{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">
<Check className="h-3 w-3" />
<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.5 w-3.5" />
</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
src={persona.avatar}
alt={persona.name}
className="h-full w-full object-cover"
/>
</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>
))}
</div>
{/* 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}>
Back
</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()}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
@ -473,28 +481,28 @@ export function AddNewPracticePage() {
{currentStep === 3 && (
<div className="mx-auto max-w-4xl">
<h2 className="text-xl font-semibold text-grayScale-900">Create Practice Questions</h2>
<p className="mt-1 text-sm text-grayScale-500">
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Create Practice Questions</h2>
<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.
</p>
<div className="mt-6 space-y-4">
<div className="mt-6 space-y-5">
{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-center gap-3">
<GripVertical className="h-5 w-5 cursor-grab text-grayScale-400" />
<span className="font-medium text-grayScale-900">Question {index + 1}</span>
<GripVertical className="h-5 w-5 cursor-grab text-grayScale-300 transition-colors hover:text-grayScale-500" />
<span className="text-base font-semibold text-grayScale-900">Question {index + 1}</span>
</div>
<button
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>
</div>
<div className="mt-4 space-y-4">
<div className="mt-5 space-y-5">
{/* Question Text */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -504,12 +512,12 @@ export function AddNewPracticePage() {
value={question.questionText}
onChange={(e) => updateQuestion(question.id, { questionText: e.target.value })}
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}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* Question Type */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -556,20 +564,22 @@ export function AddNewPracticePage() {
{/* MCQ Options */}
{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">
Options
</label>
<div className="space-y-2">
<div className="space-y-2.5">
{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
type="button"
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
? "border-green-500 bg-green-500 text-white"
: "border-grayScale-300 hover:border-brand-400"
? "border-green-500 bg-green-500 text-white shadow-sm"
: "border-grayScale-300 hover:border-brand-400 hover:shadow-sm"
}`}
>
{option.isCorrect && <Check className="h-3 w-3" />}
@ -578,12 +588,12 @@ export function AddNewPracticePage() {
value={option.text}
onChange={(e) => updateOption(question.id, optIdx, { text: e.target.value })}
placeholder={`Option ${optIdx + 1}`}
className="flex-1"
className="flex-1 border-0 bg-transparent shadow-none focus:ring-0"
/>
{question.options.length > 2 && (
<button
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" />
</button>
@ -593,7 +603,7 @@ export function AddNewPracticePage() {
<button
type="button"
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" />
Add Option
@ -633,7 +643,7 @@ export function AddNewPracticePage() {
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Tips */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -659,7 +669,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">
{/* Voice Prompt */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -690,10 +700,10 @@ export function AddNewPracticePage() {
</div>
{/* Add Button */}
<div className="mt-4">
<div className="mt-5">
<button
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" />
Add New Question
@ -701,11 +711,11 @@ export function AddNewPracticePage() {
</div>
{/* 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}>
Back
</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()}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
@ -715,49 +725,49 @@ export function AddNewPracticePage() {
{currentStep === 4 && (
<div className="mx-auto max-w-4xl">
<h2 className="text-xl font-semibold text-grayScale-900">Review & Publish</h2>
<p className="mt-1 text-sm text-grayScale-500">
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Review & Publish</h2>
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
Review your practice details before saving.
</p>
{/* Basic Information Card */}
<Card className="mt-6 p-6">
<div className="flex items-center justify-between">
<Card className="mt-6 overflow-hidden p-0">
<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>
<button
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
</button>
</div>
<div className="mt-4 space-y-3">
<div className="flex justify-between">
<div className="divide-y divide-grayScale-100">
<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 font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
</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="max-w-sm text-right text-sm text-grayScale-700">{practiceDescription || "—"}</span>
</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 font-medium text-grayScale-900">{passingScore}%</span>
</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 font-medium text-grayScale-900">{timeLimitMinutes} minutes</span>
</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 font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span>
</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>
<div className="flex items-center gap-2">
{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
src={PERSONAS.find(p => p.id === selectedPersona)?.avatar}
alt="Persona"
@ -765,7 +775,7 @@ export function AddNewPracticePage() {
/>
</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"}
</span>
</div>
@ -774,60 +784,60 @@ export function AddNewPracticePage() {
</Card>
{/* Questions Review */}
<Card className="mt-6 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Card className="mt-6 overflow-hidden p-0">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<div className="flex items-center gap-2.5">
<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}
</span>
</div>
<button
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
</button>
</div>
<div className="mt-4 space-y-4">
<div className="divide-y divide-grayScale-100 px-6 py-4">
{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">
<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}
</span>
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-grayScale-900">{question.questionText}</p>
<div className="flex items-center gap-2">
<span className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
<div className="flex-1 space-y-2.5">
<p className="text-sm font-medium leading-relaxed text-grayScale-900">{question.questionText}</p>
<div className="flex flex-wrap items-center gap-2">
<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"}
</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}
</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>
{question.questionType === "MCQ" && question.options.length > 0 && (
<div className="mt-2 space-y-1">
{question.options.map((opt, i) => (
<div
key={i}
className={`flex items-center gap-2 rounded px-2 py-1 text-sm ${
opt.isCorrect ? "bg-green-50 text-green-700 font-medium" : "text-grayScale-600"
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
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}`}
</div>
))}
</div>
)}
{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 && (
<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>
@ -837,15 +847,17 @@ export function AddNewPracticePage() {
</Card>
{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 */}
<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}>
Back
</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}>
{saving ? "Saving..." : "Save as Draft"}
</Button>
@ -860,18 +872,18 @@ export function AddNewPracticePage() {
{/* Step 5: Result */}
{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" ? (
<>
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-brand-100">
<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">
<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-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" />
</svg>
</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!
</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">
<Button
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">
<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">
<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-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" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</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!
</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">
<Button
className="w-full bg-brand-500 hover:bg-brand-600"

View File

@ -136,36 +136,43 @@ export function AddPracticePage() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
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>
<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>
<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" />
Save
</Button>
</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} />
</Card>
{/* Step 1: Details */}
{currentStep === 1 && (
<Card className="p-6">
<h2 className="mb-6 text-lg font-semibold text-grayScale-900">Practice Details</h2>
<div className="space-y-4">
<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 tracking-tight text-grayScale-600">
Practice Details
</h2>
<div className="space-y-5">
<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
</label>
<Input
@ -177,7 +184,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">
Description
</label>
<Textarea
@ -189,9 +196,9 @@ export function AddPracticePage() {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<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
</label>
<Select
@ -208,7 +215,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">
Difficulty
</label>
<Select
@ -224,9 +231,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>
<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)
</label>
<Input
@ -239,7 +246,7 @@ export function AddPracticePage() {
</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
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
@ -249,11 +256,11 @@ export function AddPracticePage() {
</div>
</div>
<div className="mt-6 flex justify-end">
<div className="mt-8 flex justify-end border-t border-grayScale-100 pt-6">
<Button
onClick={() => setCurrentStep(2)}
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
</Button>
@ -265,31 +272,43 @@ export function AddPracticePage() {
{currentStep === 2 && (
<div className="space-y-6">
{/* Select Participants Section */}
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Select Participants</h2>
<div className="grid grid-cols-4 gap-4">
<Card className="border-grayScale-200 p-6 shadow-sm">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
Select Participants
</h2>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-4 lg:grid-cols-8">
{mockParticipants.map((participant) => {
const isSelected = formData.participants.includes(participant.id)
return (
<div
key={participant.id}
className="relative flex flex-col items-center"
className="group relative flex cursor-pointer flex-col items-center"
onClick={() => toggleParticipant(participant.id)}
>
<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} />
<AvatarFallback className="bg-brand-100 text-brand-600">
<AvatarFallback className="bg-brand-100 text-brand-600 font-medium">
{participant.name[0]}
</AvatarFallback>
</Avatar>
{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" />
</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}
</span>
</div>
@ -299,28 +318,28 @@ export function AddPracticePage() {
</Card>
{/* Add Questions Section */}
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">
<Card className="border-grayScale-200 p-6 shadow-sm">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
General Practice Questions
</h2>
{/* Existing Questions */}
{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">
<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>
</div>
<p className="text-sm text-grayScale-600">
<p className="text-sm text-grayScale-400">
Type: {q.type} | Correct Answer: {q.correctAnswer}
</p>
</div>
))}
{/* 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>
<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
</label>
<Textarea
@ -333,9 +352,9 @@ export function AddPracticePage() {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<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
</label>
<Select
@ -354,7 +373,7 @@ export function AddPracticePage() {
</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
type="number"
value={currentQuestion.points}
@ -371,7 +390,7 @@ export function AddPracticePage() {
{currentQuestion.type === "multiple-choice" && (
<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
</label>
<div className="space-y-2">
@ -389,7 +408,7 @@ export function AddPracticePage() {
variant="outline"
size="sm"
onClick={addOption}
className="w-full"
className="mt-1 w-full border-dashed"
>
<Plus className="h-4 w-4" />
Add Option
@ -399,7 +418,7 @@ export function AddPracticePage() {
)}
<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
</label>
{currentQuestion.type === "multiple-choice" ? (
@ -434,7 +453,7 @@ export function AddPracticePage() {
type="button"
onClick={addQuestion}
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" />
Add New Question
@ -442,14 +461,14 @@ export function AddPracticePage() {
</div>
</Card>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCurrentStep(1)}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setCurrentStep(1)} className="px-6">
Back
</Button>
<Button
onClick={() => setCurrentStep(3)}
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
</Button>
@ -459,90 +478,84 @@ export function AddPracticePage() {
{/* Step 3: Review */}
{currentStep === 3 && (
<div className="space-y-6">
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Practice Details</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-grayScale-600">Title:</span>
<span className="text-sm font-medium text-grayScale-900">{formData.title}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-grayScale-600">Description:</span>
<span className="text-sm font-medium text-grayScale-900">
{formData.description}
<div className="mx-auto max-w-3xl space-y-6">
<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">
Practice Details
</h2>
<div className="divide-y divide-grayScale-100 overflow-hidden rounded-lg border border-grayScale-200">
{[
{ label: "Title", value: formData.title },
{ label: "Description", value: formData.description },
{ label: "Category", value: formData.category },
{ label: "Difficulty", value: formData.difficulty },
{ 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>
</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>
</Card>
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Questions</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">
Questions
</h2>
<div className="space-y-4">
{formData.questions.map((q, index) => (
<div key={q.id} className="rounded-lg border bg-grayScale-50 p-4">
<div className="mb-2 flex items-start justify-between">
<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 gap-3">
<div>
<p className="font-medium text-grayScale-900">
<p className="font-medium text-grayScale-600">
{index + 1}. {q.question}
</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}
</p>
</div>
</div>
{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) => (
<div
key={optIdx}
className={`rounded px-2 py-1 text-sm ${
className={`rounded-lg px-3 py-1.5 text-sm ${
opt === q.correctAnswer
? "bg-brand-100 text-brand-700 font-medium"
: "bg-white text-grayScale-600"
? "bg-brand-100 text-brand-700 font-medium ring-1 ring-brand-200"
: "bg-white text-grayScale-500 ring-1 ring-grayScale-100"
}`}
>
{opt}
{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 className="mt-2 text-sm text-grayScale-600">
Correct Answer: <span className="font-medium">{q.correctAnswer}</span>
<div className="mt-3 border-t border-grayScale-100 pt-2 text-sm text-grayScale-400">
Correct Answer: <span className="font-medium text-grayScale-600">{q.correctAnswer}</span>
</div>
</div>
))}
</div>
</Card>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCurrentStep(2)}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setCurrentStep(2)} className="px-6">
Back
</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
</Button>
</div>
@ -551,4 +564,3 @@ export function AddPracticePage() {
</div>
)
}

View File

@ -135,25 +135,37 @@ export function AddQuestionPage() {
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/content/questions")}>
<div className="space-y-8">
{/* Page Header */}
<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" />
</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"}
</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 className="max-w-3xl mx-auto">
<form onSubmit={handleSubmit}>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Question Details</CardTitle>
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<CardContent className="space-y-7">
{/* Question Type */}
<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
</label>
<Select
@ -166,9 +178,11 @@ export function AddQuestionPage() {
</Select>
</div>
<hr className="border-grayScale-100" />
{/* Question Text */}
<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
</label>
<Textarea
@ -184,12 +198,15 @@ export function AddQuestionPage() {
{/* Options for Multiple Choice */}
{(formData.type === "multiple-choice" || formData.type === "true-false") && (
<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
</label>
<div className="space-y-2">
<div className="space-y-3">
{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
value={option}
onChange={(e) => handleOptionChange(index, e.target.value)}
@ -203,6 +220,7 @@ export function AddQuestionPage() {
variant="ghost"
size="icon"
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" />
</Button>
@ -210,7 +228,7 @@ export function AddQuestionPage() {
</div>
))}
{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" />
Add Option
</Button>
@ -219,9 +237,11 @@ export function AddQuestionPage() {
</div>
)}
<hr className="border-grayScale-100" />
{/* Correct Answer */}
<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
</label>
{formData.type === "multiple-choice" || formData.type === "true-false" ? (
@ -252,9 +272,13 @@ export function AddQuestionPage() {
)}
</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 */}
<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
</label>
<Input
@ -269,22 +293,9 @@ export function AddQuestionPage() {
/>
</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 */}
<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)
</label>
<Select
@ -297,13 +308,27 @@ export function AddQuestionPage() {
<option value="Hard">Hard</option>
</Select>
</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 */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")}>
<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")} className="w-full sm:w-auto hover:bg-grayScale-50">
Cancel
</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"}
</Button>
</div>
@ -311,6 +336,6 @@ export function AddQuestionPage() {
</Card>
</form>
</div>
</div>
)
}

View File

@ -27,55 +27,72 @@ export function AddVideoPage() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
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>
<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>
<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
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Upload</h2>
<form onSubmit={handleSubmit} className="mx-auto max-w-3xl space-y-6">
{/* Upload Card */}
<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
accept="video/*"
onFileSelect={setVideoFile}
label="Drag & Drop Video Here"
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>
{/* Preview Card */}
{videoFile && (
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Preview</h2>
<div className="aspect-video w-full overflow-hidden rounded-lg bg-grayScale-900">
<Card className="border-grayScale-200 overflow-hidden p-0 shadow-sm">
<div className="border-b border-grayScale-100 px-6 py-4 sm:px-8">
<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
src={URL.createObjectURL(videoFile)}
controls
className="h-full w-full object-contain"
/>
</div>
</div>
</Card>
)}
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Details</h2>
<div className="space-y-4">
{/* Details Card */}
<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 Details
</h2>
<div className="space-y-5">
<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
</label>
<Input
@ -87,7 +104,7 @@ export function AddVideoPage() {
</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
</label>
<Textarea
@ -99,9 +116,9 @@ export function AddVideoPage() {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<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
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
@ -110,7 +127,7 @@ export function AddVideoPage() {
</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
</label>
<Select
@ -127,9 +144,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>
<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
</label>
<Select
@ -145,7 +162,7 @@ export function AddVideoPage() {
</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
</label>
<FileUpload
@ -153,15 +170,15 @@ export function AddVideoPage() {
onFileSelect={(file) => setFormData({ ...formData, thumbnail: file })}
label="Upload Thumbnail"
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>
</Card>
<div className="flex justify-end">
<Button type="submit" className="bg-brand-500 hover:bg-brand-600">
<div className="flex justify-end pb-4">
<Button type="submit" className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors">
Save Video
</Button>
</div>
@ -169,4 +186,3 @@ export function AddVideoPage() {
</div>
)
}

View File

@ -11,10 +11,28 @@ const tabs = [
export function ContentManagementLayout() {
return (
<div className="mx-auto w-full max-w-6xl">
<div className="mb-4 text-sm font-semibold text-grayScale-500">Content Management</div>
<div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
{/* 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) => (
<NavLink
key={t.to}
@ -22,9 +40,10 @@ export function ContentManagementLayout() {
end={t.to === "/content"}
className={({ isActive }) =>
cn(
"rounded-lg px-4 py-2 text-sm font-semibold text-grayScale-500 transition",
"hover:text-brand-600",
isActive && "bg-brand-500 text-white hover:text-white",
"relative whitespace-nowrap rounded-xl px-5 py-2 text-sm font-semibold transition-all duration-200 ease-in-out",
"text-grayScale-500 hover:bg-white/80 hover:text-brand-600 hover:shadow-sm",
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>
{/* Page content */}
<Outlet />
</div>
)
}

View File

@ -1,11 +1,62 @@
import { useEffect, useState } from "react"
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 { Button } from "../../components/ui/button"
import { getCourseCategories } from "../../api/courses.api"
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() {
const { categoryId } = useParams<{ categoryId: string }>()
const [category, setCategory] = useState<CourseCategory | null>(null)
@ -27,81 +78,114 @@ export function ContentOverviewPage() {
}, [categoryId])
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">
<Link
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" />
</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"}
</h1>
</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">
<CardHeader>
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
<Mic className="h-6 w-6" />
{/* Gradient Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<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>
<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">
<CardHeader>
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
<Briefcase className="h-6 w-6" />
</div>
<CardTitle className="text-lg">Practice</CardTitle>
<CardDescription>Manage practice details, members, and leadership</CardDescription>
</CardHeader>
<CardContent>
<Link to="/content/practices">
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Practice</Button>
</Link>
</CardContent>
</Card>
{/* Cards Grid */}
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{contentSections.map((section) => {
const Icon = section.icon
return (
<Link
key={section.key}
to={section.pathFn(categoryId)}
className="group"
>
<Card
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`}
style={{
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>
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
<HelpCircle className="h-6 w-6" />
<CardHeader className="relative pb-2">
<div className="mb-4 flex items-start justify-between">
{/* Icon with gradient ring */}
<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>
<CardTitle className="text-lg">Questions</CardTitle>
<CardDescription>Manage questions, quizzes, and assessments</CardDescription>
{/* Decorative dot */}
<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>
<CardContent>
<Link to="/content/questions">
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Questions</Button>
</Link>
<CardContent className="relative pt-0">
{/* Thin separator */}
<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>
</Card>
</Link>
)
})}
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
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 { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
@ -10,8 +10,9 @@ export function CourseCategoryPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchCategories = async () => {
setLoading(true)
setError(null)
try {
const res = await getCourseCategories()
setCategories(res.data.data.categories)
@ -23,50 +24,107 @@ export function CourseCategoryPage() {
}
}
useEffect(() => {
fetchCategories()
}, [])
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-grayScale-500">Loading categories...</div>
<div className="flex flex-col items-center justify-center gap-4 py-24">
<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>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-red-500">{error}</div>
<div className="flex items-center justify-center py-24">
<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>
)
}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-grayScale-900">Course Categories</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="space-y-8">
{/* Page header */}
<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) => (
<Link key={category.id} to={`/content/category/${category.id}/courses`} className="group">
<Card className="h-full shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-brand-200">
<CardHeader>
<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">
<Link
key={category.id}
to={`/content/category/${category.id}/courses`}
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" />
</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>
<CardContent>
<span className="text-sm font-medium text-brand-500 group-hover:text-brand-600">
View Courses
<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
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
</span>
</span>
</CardContent>
</Card>
</Link>
))}
</div>
{categories.length === 0 && (
<div className="text-center text-sm text-grayScale-500">No categories found</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 { 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 { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge"
@ -237,55 +237,73 @@ export function CoursesPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-grayScale-500">Loading courses...</div>
<div className="flex flex-col items-center justify-center py-32">
<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>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-red-500">{error}</div>
<div className="flex items-center justify-center py-32">
<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>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Header */}
<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
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" />
</Link>
<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
</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>
<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" />
Add New Course
</Button>
</div>
</div>
{/* Course grid or empty state */}
{courses.length === 0 ? (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center justify-center py-12">
<BookOpen className="mb-4 h-12 w-12 text-grayScale-300" />
<p className="text-sm text-grayScale-500">No courses found in this category</p>
<Button variant="outline" className="mt-4" onClick={handleOpenModal}>
<Card className="border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-20">
<div className="rounded-2xl bg-grayScale-50 p-5">
<BookOpen className="h-14 w-14 text-grayScale-300" />
</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
</Button>
</CardContent>
</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) => {
const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-200",
@ -296,48 +314,49 @@ export function CoursesPage() {
return (
<Card
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)}
>
{/* Thumbnail */}
<div className="relative aspect-video w-full">
<div className="relative aspect-video w-full overflow-hidden">
<CourseThumbnail
src={course.thumbnail}
alt={course.title}
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>
{/* Content */}
<div className="p-4 space-y-3">
<div className="space-y-3 border-t border-grayScale-50 p-4">
{/* Status and menu */}
<div className="flex items-center justify-between">
<Badge
className={`text-xs font-medium ${
className={`rounded-full px-2.5 py-0.5 text-[11px] font-semibold tracking-wide ${
course.is_active
? "bg-transparent text-green-600 border border-green-200"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
? "border-0 bg-emerald-50 text-emerald-700"
: "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"}
</Badge>
<div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}>
<button
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" />
</button>
{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
onClick={() => {
handleToggleStatus(course)
setOpenMenuId(null)
}}
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 ? (
<>
@ -351,12 +370,13 @@ export function CoursesPage() {
</>
)}
</button>
<div className="mx-3 my-1 border-t border-grayScale-100" />
<button
onClick={() => {
handleDeleteClick(course)
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" />
Delete
@ -367,15 +387,15 @@ export function CoursesPage() {
</div>
{/* Title */}
<h3 className="font-medium text-grayScale-900">{course.title}</h3>
<p className="text-sm text-grayScale-500 line-clamp-2">
<h3 className="font-semibold text-grayScale-700 line-clamp-1">{course.title}</h3>
<p className="text-sm leading-relaxed text-grayScale-400 line-clamp-2">
{course.description || "No description available"}
</p>
{/* Edit button */}
<Button
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) => {
e.stopPropagation()
handleEditClick(course)
@ -391,22 +411,24 @@ export function CoursesPage() {
</div>
)}
{/* Add Course Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add New Course</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<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 border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2>
<button
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" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-5 px-6 py-6">
{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}
</div>
)}
@ -416,7 +438,7 @@ export function CoursesPage() {
htmlFor="course-title"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Title
Title <span className="text-red-400">*</span>
</label>
<Input
id="course-title"
@ -431,7 +453,7 @@ export function CoursesPage() {
htmlFor="course-description"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Description
Description <span className="text-red-400">*</span>
</label>
<textarea
id="course-description"
@ -439,21 +461,21 @@ export function CoursesPage() {
value={description}
onChange={(e) => setDescription(e.target.value)}
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 className="text-xs text-grayScale-500">
Category: <span className="font-medium">{category?.name}</span>
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<Button variant="outline" onClick={handleCloseModal} disabled={saving}>
<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} className="w-full sm:w-auto">
Cancel
</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}
disabled={saving}
>
@ -464,22 +486,24 @@ export function CoursesPage() {
</div>
)}
{/* Edit Course Modal */}
{showEditModal && courseToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Course</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<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 border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Edit Course</h2>
<button
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" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-5 px-6 py-6">
{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}
</div>
)}
@ -489,7 +513,7 @@ export function CoursesPage() {
htmlFor="edit-course-title"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Title
Title <span className="text-red-400">*</span>
</label>
<Input
id="edit-course-title"
@ -504,7 +528,7 @@ export function CoursesPage() {
htmlFor="edit-course-description"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Description
Description <span className="text-red-400">*</span>
</label>
<textarea
id="edit-course-description"
@ -512,7 +536,7 @@ export function CoursesPage() {
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
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>
@ -532,12 +556,12 @@ export function CoursesPage() {
</div>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating}>
<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} className="w-full sm:w-auto">
Cancel
</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}
disabled={updating}
>
@ -548,37 +572,42 @@ export function CoursesPage() {
</div>
)}
{/* Delete Course Modal */}
{showDeleteModal && courseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Course</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<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 border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Delete Course</h2>
<button
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" />
</button>
</div>
<div className="px-6 py-5">
<p className="text-sm text-grayScale-600">
<div className="px-6 py-6">
<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{" "}
<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.
</p>
</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}
className="w-full sm:w-auto"
>
Cancel
</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}
disabled={deleting}
>

View File

@ -50,38 +50,47 @@ export function PracticeDetailsPage() {
}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-grayScale-900">Practice Management</h1>
<div className="space-y-8">
{/* 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 */}
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-grayScale-900">Practice Leadership</h2>
<Card className="border-grayScale-200 p-6 shadow-sm">
<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 tracking-tight text-grayScale-600">Practice Leadership</h2>
<Button
size="sm"
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" />
Add New Leader
</Button>
</div>
<div className="space-y-3">
<div className="space-y-2">
{mockLeaders.map((leader) => (
<div
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>
<p className="font-medium text-grayScale-900">{leader.name}</p>
<p className="text-sm text-grayScale-600">{leader.role}</p>
<div className="flex items-center gap-3">
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
{leader.name[0]}
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8">
<div>
<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" />
</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" />
</Button>
</div>
@ -91,11 +100,11 @@ export function PracticeDetailsPage() {
</Card>
{/* Practice Details */}
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Practice Details</h2>
<div className="space-y-4">
<Card className="border-grayScale-200 p-6 shadow-sm">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">Practice Details</h2>
<div className="space-y-5">
<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
</label>
<Input
@ -106,7 +115,7 @@ export function PracticeDetailsPage() {
</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
</label>
<Textarea
@ -118,7 +127,7 @@ export function PracticeDetailsPage() {
</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
</label>
<Select
@ -133,7 +142,7 @@ export function PracticeDetailsPage() {
</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
</label>
<div className="space-y-2">
@ -142,7 +151,7 @@ export function PracticeDetailsPage() {
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
placeholder="Street"
/>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Input
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
@ -162,39 +171,46 @@ export function PracticeDetailsPage() {
</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>
</Card>
</div>
{/* Practice Members */}
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-grayScale-900">Practice Members</h2>
<Card className="border-grayScale-200 p-6 shadow-sm">
<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 tracking-tight text-grayScale-600">Practice Members</h2>
<Button
size="sm"
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" />
Add New Member
</Button>
</div>
<div className="space-y-3">
<div className="space-y-2">
{mockMembers.map((member) => (
<div
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>
<p className="font-medium text-grayScale-900">{member.name}</p>
<p className="text-sm text-grayScale-600">{member.role}</p>
<div className="flex items-center gap-3">
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
{member.name[0]}
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8">
<div>
<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" />
</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" />
</Button>
</div>
@ -205,13 +221,13 @@ export function PracticeDetailsPage() {
{/* Add Member Modal */}
<Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}>
<DialogContent>
<DialogContent className="sm:rounded-xl">
<DialogHeader>
<DialogTitle>Add New Member</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-5 py-2">
<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
</label>
<Input
@ -221,7 +237,7 @@ export function PracticeDetailsPage() {
/>
</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
</label>
<Input
@ -235,7 +251,7 @@ export function PracticeDetailsPage() {
<Button variant="outline" onClick={() => setIsMemberModalOpen(false)}>
Cancel
</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
</Button>
</DialogFooter>
@ -244,13 +260,13 @@ export function PracticeDetailsPage() {
{/* Add Leader Modal */}
<Dialog open={isLeaderModalOpen} onOpenChange={setIsLeaderModalOpen}>
<DialogContent>
<DialogContent className="sm:rounded-xl">
<DialogHeader>
<DialogTitle>Add New Leader</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-5 py-2">
<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
</label>
<Input
@ -260,7 +276,7 @@ export function PracticeDetailsPage() {
/>
</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
</label>
<Input
@ -274,7 +290,7 @@ export function PracticeDetailsPage() {
<Button variant="outline" onClick={() => setIsLeaderModalOpen(false)}>
Cancel
</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
</Button>
</DialogFooter>
@ -283,4 +299,3 @@ export function PracticeDetailsPage() {
</div>
)
}

View File

@ -28,43 +28,52 @@ export function PracticeMembersPage() {
}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-grayScale-900">Practice Management</h1>
<div className="space-y-8">
{/* 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">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-grayScale-900">Current Members</h2>
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
<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 tracking-tight text-grayScale-600">Current Members</h2>
<Button
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" />
Add Members
</Button>
</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) => (
<div key={member.id} className="flex flex-col items-center">
<Avatar className="h-16 w-16 border-2 border-grayScale-200">
<div
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} />
<AvatarFallback className="bg-brand-100 text-brand-600">
<AvatarFallback className="bg-brand-100 text-brand-600 font-medium">
{member.name[0]}
</AvatarFallback>
</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>
</Card>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent>
<DialogContent className="sm:rounded-xl">
<DialogHeader>
<DialogTitle>Add New Member</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-5 py-2">
<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
</label>
<Input
@ -74,7 +83,7 @@ export function PracticeMembersPage() {
/>
</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
</label>
<Input
@ -88,7 +97,7 @@ export function PracticeMembersPage() {
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
Cancel
</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
</Button>
</DialogFooter>
@ -97,4 +106,3 @@ export function PracticeMembersPage() {
</div>
)
}

View File

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

View File

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

View File

@ -1,30 +1,49 @@
import { Link } from "react-router-dom"
import { Plus } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Plus, Mic } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
export function SpeakingPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-grayScale-900">Speaking</h1>
<Link to="/content/speaking/add-practice">
<Button className="bg-brand-500 hover:bg-brand-600">
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-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" />
Add New Practice
</Button>
</Link>
</div>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Speaking Practice Management</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Manage speaking practice sessions and exercises here.
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 grid h-20 w-20 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
<Mic className="h-10 w-10 text-brand-500" />
</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>
</Card>
</div>
)
}

View File

@ -285,16 +285,20 @@ export function SubCourseContentPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-grayScale-500">Loading sub-course...</div>
<div className="flex flex-col items-center justify-center py-20">
<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>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-red-500">{error}</div>
<div className="flex flex-col items-center justify-center py-20">
<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>
)
}
@ -304,37 +308,37 @@ export function SubCourseContentPage() {
{/* Back Button */}
<Link
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
</Link>
{/* SubCourse Header */}
<div className="flex items-start justify-between">
<div className="max-w-2xl">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-grayScale-900">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-2xl space-y-2">
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">
{subCourse?.title}
</h1>
{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>
<p className="mt-2 text-sm text-grayScale-500">
<p className="text-sm leading-relaxed text-grayScale-500">
{subCourse?.description || "No description available"}
</p>
</div>
<div className="flex gap-3">
<div className="flex flex-col gap-2.5 sm:flex-row sm:gap-3">
<Button
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}
>
<FileText className="mr-2 h-4 w-4" />
Add Practice
</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" />
Add Video
</Button>
@ -343,26 +347,32 @@ export function SubCourseContentPage() {
{/* Tabs */}
<div className="border-b border-grayScale-200">
<div className="flex gap-8">
<div className="-mb-px flex gap-6">
<button
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"
? "border-b-2 border-brand-500 text-brand-500"
: "text-grayScale-500 hover:text-grayScale-700"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700"
}`}
>
Video
{activeTab === "video" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
<button
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"
? "border-b-2 border-brand-500 text-brand-500"
: "text-grayScale-500 hover:text-grayScale-700"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700"
}`}
>
Practice
{activeTab === "practice" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
</div>
</div>
@ -373,83 +383,88 @@ export function SubCourseContentPage() {
{activeTab === "practice" && (
<>
{practicesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-grayScale-500">Loading practices...</div>
<div className="flex flex-col items-center justify-center py-20">
<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>
) : filteredPractices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<FileText className="mb-4 h-12 w-12 text-grayScale-300" />
<p className="text-sm text-grayScale-500">No practices found</p>
<Button variant="outline" className="mt-4" onClick={handleAddPractice}>
Add your first practice
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
<div className="rounded-full bg-brand-50 p-4">
<FileText className="h-8 w-8 text-brand-400" />
</div>
<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>
</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) => {
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" },
DRAFT: { bg: "bg-grayScale-100 border border-grayScale-200 text-grayScale-600", dot: "bg-grayScale-400", text: "Draft" },
ARCHIVED: { bg: "bg-transparent border border-amber-200 text-amber-600", dot: "bg-amber-500", text: "Archived" },
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-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200", dot: "bg-grayScale-400", text: "Draft" },
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
return (
<Card
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)}
>
<div className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-grayScale-900 line-clamp-2">{practice.title}</h3>
<Badge className={`shrink-0 text-xs font-medium ${status.bg}`}>
<div className="flex h-full flex-col p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{practice.title}</h3>
<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}`} />
{status.text}
</Badge>
</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">
<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}
</Badge>
{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}
</Badge>
)}
</div>
<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" />
<span>{practice.owner_type.replace("_", " ")}</span>
</div>
{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 className="flex items-center justify-between border-t border-grayScale-100 pt-3">
<span className="text-xs text-grayScale-400">
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
<span className="text-xs font-medium text-grayScale-400">
{new Date(practice.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
<button
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" />
</button>
<button
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" />
</button>
@ -467,25 +482,30 @@ export function SubCourseContentPage() {
{activeTab === "video" && (
<>
{videosLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-grayScale-500">Loading videos...</div>
<div className="flex flex-col items-center justify-center py-20">
<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>
) : videos.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Video className="mb-4 h-12 w-12 text-grayScale-300" />
<p className="text-sm text-grayScale-500">No videos found</p>
<Button variant="outline" className="mt-4" onClick={handleAddVideo}>
Add your first video
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
<div className="rounded-full bg-brand-50 p-4">
<Video className="h-8 w-8 text-brand-400" />
</div>
<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>
</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) => {
const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-200",
"bg-gradient-to-br from-yellow-100 to-yellow-200",
"bg-gradient-to-br from-purple-100 to-purple-200",
"bg-gradient-to-br from-green-100 to-green-200",
"bg-gradient-to-br from-blue-100 via-blue-50 to-indigo-100",
"bg-gradient-to-br from-amber-100 via-yellow-50 to-orange-100",
"bg-gradient-to-br from-purple-100 via-fuchsia-50 to-pink-100",
"bg-gradient-to-br from-emerald-100 via-green-50 to-teal-100",
]
const formatDuration = (seconds: number) => {
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 (
<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 */}
<div className="relative aspect-video w-full">
<div className="relative aspect-video w-full overflow-hidden">
{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)}
</div>
</div>
@ -511,10 +533,10 @@ export function SubCourseContentPage() {
{/* Status and menu */}
<div className="flex items-center justify-between">
<Badge
className={`text-xs font-medium ${
className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${
video.is_published
? "bg-transparent text-green-600 border border-green-200"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
? "bg-green-50 text-green-700 ring-1 ring-inset ring-green-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"}`} />
@ -523,18 +545,18 @@ export function SubCourseContentPage() {
<div className="relative">
<button
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" />
</button>
{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
onClick={() => {
handleDeleteVideoClick(video)
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" />
Delete
@ -545,12 +567,12 @@ export function SubCourseContentPage() {
</div>
{/* 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 */}
<Button
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)}
>
<Edit className="mr-2 h-4 w-4" />
@ -559,7 +581,7 @@ export function SubCourseContentPage() {
{/* Publish button */}
<Button
className={`w-full ${
className={`w-full shadow-sm transition-colors ${
video.is_published
? "bg-green-500 hover:bg-green-600"
: "bg-brand-500 hover:bg-brand-600"
@ -578,28 +600,28 @@ export function SubCourseContentPage() {
{/* Delete Modal */}
{showDeleteModal && practiceToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
<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>
<button
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" />
</button>
</div>
<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{" "}
<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>
</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}>
Cancel
</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"}
</Button>
</div>
@ -609,19 +631,19 @@ export function SubCourseContentPage() {
{/* Edit Practice Modal */}
{showEditPracticeModal && practiceToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<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>
<button
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" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-2">
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label>
<Input
value={title}
@ -629,17 +651,17 @@ export function SubCourseContentPage() {
placeholder="Enter practice title"
/>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
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}
/>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Persona (Optional)</label>
<Input
value={persona}
@ -647,14 +669,14 @@ export function SubCourseContentPage() {
placeholder="Enter persona"
/>
</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 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}>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600"
className="bg-brand-500 shadow-sm hover:bg-brand-600"
onClick={handleSaveEditPractice}
disabled={saving || !title.trim()}
>
@ -667,19 +689,19 @@ export function SubCourseContentPage() {
{/* Add Video Modal */}
{showAddVideoModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<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>
<button
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" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-2">
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label>
<Input
value={videoTitle}
@ -687,17 +709,17 @@ export function SubCourseContentPage() {
placeholder="Enter video title"
/>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea
value={videoDescription}
onChange={(e) => setVideoDescription(e.target.value)}
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}
/>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Source URL</label>
<Input
value={videoUrl}
@ -706,7 +728,7 @@ export function SubCourseContentPage() {
/>
</div>
<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>
<Input
type="number"
@ -716,7 +738,7 @@ export function SubCourseContentPage() {
min={0}
/>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Duration (seconds)</label>
<Input
type="number"
@ -727,14 +749,14 @@ export function SubCourseContentPage() {
/>
</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 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}>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600"
className="bg-brand-500 shadow-sm hover:bg-brand-600"
onClick={handleSaveNewVideo}
disabled={saving || !videoTitle.trim() || !videoUrl.trim()}
>
@ -747,19 +769,19 @@ export function SubCourseContentPage() {
{/* Edit Video Modal */}
{showEditVideoModal && videoToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<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>
<button
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" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-2">
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label>
<Input
value={videoTitle}
@ -767,17 +789,17 @@ export function SubCourseContentPage() {
placeholder="Enter video title"
/>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea
value={videoDescription}
onChange={(e) => setVideoDescription(e.target.value)}
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}
/>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Video URL</label>
<Input
value={videoUrl}
@ -785,14 +807,14 @@ export function SubCourseContentPage() {
placeholder="Enter video URL"
/>
</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 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}>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600"
className="bg-brand-500 shadow-sm hover:bg-brand-600"
onClick={handleSaveEditVideo}
disabled={saving || !videoTitle.trim()}
>
@ -805,28 +827,28 @@ export function SubCourseContentPage() {
{/* Delete Video Modal */}
{showDeleteVideoModal && videoToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
<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>
<button
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" />
</button>
</div>
<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{" "}
<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>
</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}>
Cancel
</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"}
</Button>
</div>

View File

@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react"
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 { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
@ -198,57 +198,70 @@ export function SubCoursesPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-grayScale-500">Loading sub-courses...</div>
<div className="flex flex-col items-center justify-center py-24">
<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>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-red-500">{error}</div>
<div className="flex items-center justify-center py-24">
<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>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Header */}
<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
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" />
</Link>
<div>
<div className="flex items-center gap-2 text-xs text-grayScale-500">
<span>{category?.name}</span>
<span></span>
<span>{course?.title}</span>
<div className="min-w-0">
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-grayScale-400">
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{category?.name}</span>
<span className="shrink-0 text-grayScale-300"></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>
<h1 className="text-xl font-semibold text-grayScale-900">Sub-courses</h1>
<p className="text-sm text-grayScale-500">{subCourses.length} sub-courses available</p>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Sub-courses</h1>
<p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
</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
</Button>
</div>
{/* Sub-course grid or empty state */}
{subCourses.length === 0 ? (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center justify-center py-12">
<Layers className="mb-4 h-12 w-12 text-grayScale-300" />
<p className="text-sm text-grayScale-500">No sub-courses found for this course</p>
<Button variant="outline" className="mt-4" onClick={handleAddSubCourse}>
<Card className="border border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="rounded-2xl bg-brand-50 p-5">
<Layers className="h-10 w-10 text-brand-400" />
</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
</Button>
</CardContent>
</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) => {
const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-200",
@ -259,40 +272,40 @@ export function SubCoursesPage() {
return (
<Card
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)}
>
{/* Thumbnail with level badge */}
<div className="relative aspect-video w-full">
<div className="relative aspect-video w-full overflow-hidden">
{subCourse.thumbnail ? (
<img
src={subCourse.thumbnail}
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 && (
<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}
</div>
)}
</div>
{/* Content */}
<div className="p-4 space-y-3">
<div className="flex flex-col gap-3 p-4">
{/* Status and menu */}
<div className="flex items-center justify-between">
<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
? "bg-transparent text-green-600 border border-green-200"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
? "border border-green-200 bg-green-50 text-green-700"
: "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"}`} />
{subCourse.is_active ? "ACTIVE" : "INACTIVE"}
{subCourse.is_active ? "Active" : "Inactive"}
</Badge>
<div
className="relative"
@ -301,19 +314,19 @@ export function SubCoursesPage() {
>
<button
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" />
</button>
{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
onClick={() => {
handleToggleStatus(subCourse)
setOpenMenuId(null)
}}
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 ? (
<>
@ -327,12 +340,13 @@ export function SubCoursesPage() {
</>
)}
</button>
<div className="mx-3 border-t border-grayScale-100" />
<button
onClick={() => {
handleDeleteClick(subCourse)
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" />
Delete
@ -343,20 +357,23 @@ export function SubCoursesPage() {
</div>
{/* Title */}
<h3 className="font-medium text-grayScale-900">{subCourse.title}</h3>
<p className="text-sm text-grayScale-500 line-clamp-2">
<div>
<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"}
</p>
</div>
{/* Edit button */}
<Button
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) => {
e.stopPropagation()
handleEditClick(subCourse)
}}
>
<Edit className="mr-2 h-3.5 w-3.5" />
Edit
</Button>
</div>
@ -366,37 +383,42 @@ export function SubCoursesPage() {
</div>
)}
{/* Delete Modal */}
{showDeleteModal && subCourseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Sub-course</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-700">Delete Sub-course</h2>
<button
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" />
</button>
</div>
<div className="px-6 py-5">
<p className="text-sm text-grayScale-600">
<div className="px-6 py-6">
<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{" "}
<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.
</p>
</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}
className="w-full rounded-lg sm:w-auto"
>
Cancel
</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}
disabled={deleting}
>
@ -407,59 +429,66 @@ export function SubCoursesPage() {
</div>
)}
{/* Add Sub-course Modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add New Sub-course</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-700">Add New Sub-course</h2>
<button
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" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Title</label>
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">Title</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter sub-course title"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Description</label>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
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}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Level</label>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">Level</label>
<Input
value={level}
onChange={(e) => setLevel(e.target.value)}
placeholder="e.g., Beginner, Intermediate, Advanced"
/>
</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 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}
className="w-full rounded-lg sm:w-auto"
>
Cancel
</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}
disabled={saving || !title.trim()}
>
@ -470,59 +499,66 @@ export function SubCoursesPage() {
</div>
)}
{/* Edit Sub-course Modal */}
{showEditModal && subCourseToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Sub-course</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-700">Edit Sub-course</h2>
<button
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" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Title</label>
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">Title</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter sub-course title"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Description</label>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
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}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Level</label>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">Level</label>
<Input
value={level}
onChange={(e) => setLevel(e.target.value)}
placeholder="e.g., Beginner, Intermediate, Advanced"
/>
</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 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}
className="w-full rounded-lg sm:w-auto"
>
Cancel
</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}
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 (
<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>
<h1 className="text-2xl font-bold text-grayScale-600">Team Management</h1>
<p className="text-sm text-grayScale-400">
Manage user access, roles, and platform permissions.
</p>
</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" />
Add Team Member
</Button>
</div>
<div className="flex items-center gap-3 rounded-lg border bg-white p-3">
<div className="relative flex-1">
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-white p-3">
<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" />
<Input
placeholder="Search by name or email address..."
@ -206,7 +206,7 @@ export function TeamManagementPage() {
<TableRow>
<TableHead>USER</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>LAST LOGIN</TableHead>
<TableHead className="hidden sm:table-cell">LAST LOGIN</TableHead>
<TableHead>STATUS</TableHead>
</TableRow>
</TableHeader>
@ -258,7 +258,7 @@ export function TeamManagementPage() {
{formatRoleLabel(member.team_role)}
</span>
</TableCell>
<TableCell>
<TableCell className="hidden sm:table-cell">
{member.last_login ? (
<div>
<div className="text-sm text-grayScale-600">

View File

@ -159,7 +159,7 @@ export function TeamMemberDetailPage() {
<Card className="overflow-hidden">
<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">
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
<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() {
return (
<div className="mx-auto w-full max-w-6xl">
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Log</div>
<Card className="shadow-none">
<CardHeader>
<CardTitle>User Log</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">User Log module placeholder.</CardContent>
</Card>
</div>
)
// ── Action type configuration ──────────────────────────────────────
const ACTION_TYPES = [
"TEAM_MEMBER_CREATED",
"TEAM_MEMBER_UPDATED",
"TEAM_MEMBER_DELETED",
"TEAM_MEMBER_DEACTIVATED",
"TEAM_MEMBER_REACTIVATED",
"VIDEO_UPLOADED",
"VIDEO_DELETED",
"COURSE_CREATED",
"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 { Button } from "../../components/ui/button"
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">
<ArrowLeft className="h-4 w-4" />
</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>
<Card className="p-6">
<form className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Card className="mx-auto max-w-2xl p-6">
<form className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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
</label>
<Input placeholder="Enter first name" required />
</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
</label>
<Input placeholder="Enter last name" required />
@ -36,17 +41,26 @@ export function RegisterUserPage() {
</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 />
</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 />
</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>
<option value="">Select role</option>
<option value="admin">Admin</option>
@ -55,15 +69,18 @@ export function RegisterUserPage() {
</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} />
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => navigate("/users")}>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => navigate("/users")}>
Cancel
</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
</Button>
</div>
@ -72,4 +89,3 @@ export function RegisterUserPage() {
</div>
)
}

View File

@ -105,7 +105,7 @@ export function UserDetailPage() {
{/* Profile card */}
<Card className="overflow-hidden">
<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">
<Avatar className="h-20 w-20 ring-4 ring-white shadow-soft">
<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>
</div>
<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">
Expires
</span>
@ -215,7 +215,7 @@ export function UserDetailPage() {
Extend Subscription
</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">
Mark as Paid
</Button>

View File

@ -1,10 +1,17 @@
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 { 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 { Textarea } from "../../components/ui/textarea"
import { Badge } from "../../components/ui/badge"
const mockGroups = [
{ id: "1", name: "Big 10", userCount: 10 },
@ -26,21 +33,59 @@ export function UserGroupsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-grayScale-900">User Groups</h1>
<Button onClick={() => setIsModalOpen(true)} className="bg-brand-500 hover:bg-brand-600">
<Plus className="h-4 w-4" />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-grayScale-600">User Groups</h1>
<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
</Button>
</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) => (
<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" />
<CardContent className="p-6">
<h3 className="mb-2 text-lg font-semibold text-grayScale-900">{group.name}</h3>
<p className="mb-4 text-sm text-grayScale-600">{group.userCount} Users</p>
<div className="mb-4 flex items-center justify-between">
<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">
<Edit className="mr-2 h-4 w-4" />
Edit Role
@ -49,6 +94,7 @@ export function UserGroupsPage() {
</Card>
))}
</div>
)}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent>
@ -57,7 +103,7 @@ export function UserGroupsPage() {
</DialogHeader>
<div className="space-y-4">
<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
</label>
<Input
@ -67,7 +113,7 @@ export function UserGroupsPage() {
/>
</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
</label>
<Textarea
@ -91,4 +137,3 @@ export function UserGroupsPage() {
</div>
)
}

View File

@ -1,58 +1,136 @@
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 { Button } from "../../components/ui/button"
export function UserManagementDashboard() {
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-grayScale-900">User Management</h1>
<div className="grid gap-6 md:grid-cols-2">
<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">
<UserPlus className="h-6 w-6" />
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-grayScale-600">User Management</h1>
<p className="mt-1 text-sm text-grayScale-400">
Manage users, groups, and registrations.
</p>
</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">
<CardHeader>
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
{/* Stat Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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" />
</div>
<CardTitle className="text-lg">User Groups</CardTitle>
<CardDescription>Manage user groups and permissions</CardDescription>
</CardHeader>
<CardContent>
<Link to="/users/groups">
<Button className="w-full bg-brand-500 hover:bg-brand-600">View Groups</Button>
</Link>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">Total Users</p>
<p className="text-2xl font-bold text-grayScale-600">1,248</p>
</div>
</CardContent>
</Card>
<Card className="shadow-sm md:col-span-2">
<CardHeader>
<CardTitle className="text-lg">User List</CardTitle>
<CardDescription>View and manage all users in the system</CardDescription>
</CardHeader>
<CardContent>
<Link to="/users/list">
<Button variant="outline" className="w-full">
View All Users
</Button>
</Link>
<Card className="border-none bg-mint-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-mint-100 text-mint-600">
<UserCheck className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">Active Users</p>
<p className="text-2xl font-bold text-grayScale-600">1,180</p>
</div>
</CardContent>
</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>
)
}

View File

@ -3,7 +3,6 @@ import { Outlet } from "react-router-dom"
export function UserManagementLayout() {
return (
<div className="mx-auto w-full max-w-6xl">
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Management</div>
<Outlet />
</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 { useNavigate } from "react-router-dom"
import { Input } from "../../components/ui/input"
@ -110,7 +110,15 @@ export function UsersListPage() {
}
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="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="relative w-full md:max-w-sm">
@ -123,12 +131,12 @@ export function UsersListPage() {
/>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-full sm:w-auto">
<select
value={countryFilter}
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="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" />
</div>
<div className="relative">
<div className="relative w-full sm:w-auto">
<select
value={regionFilter}
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="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" />
</div>
<div className="relative">
<div className="relative w-full sm:w-auto">
<select
value={subscriptionFilter}
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="Monthly">Monthly</option>
@ -172,6 +180,7 @@ export function UsersListPage() {
</div>
</div>
{/* Table */}
<Table>
<TableHeader>
<TableRow>
@ -184,9 +193,9 @@ export function UsersListPage() {
/>
</TableHead>
<TableHead>USER</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Country</TableHead>
<TableHead>Region</TableHead>
<TableHead className="hidden md:table-cell">Phone</TableHead>
<TableHead className="hidden md:table-cell">Country</TableHead>
<TableHead className="hidden md:table-cell">Region</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
@ -194,8 +203,16 @@ export function UsersListPage() {
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-grayScale-400">
No users found
<TableCell colSpan={6} className="py-16 text-center">
<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>
</TableRow>
) : (
@ -229,9 +246,9 @@ export function UsersListPage() {
</div>
</div>
</TableCell>
<TableCell className="text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
<TableCell className="text-grayScale-500">{u.country || "-"}</TableCell>
<TableCell className="text-grayScale-500">{u.region || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<button
type="button"
@ -256,7 +273,8 @@ export function UsersListPage() {
</TableBody>
</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">
<span>Row Per Page</span>
<div className="relative">
@ -326,5 +344,6 @@ export function UsersListPage() {
</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;
}