activity log + issue reporting integrations + responsiveness fix + more advanced styling + minor fixes
This commit is contained in:
parent
a29d82bfee
commit
25badbcca5
1
.env
1
.env
|
|
@ -1 +1,2 @@
|
||||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
|
VITE_GOOGLE_CLIENT_ID=google_client_id
|
||||||
|
|
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|
@ -5658,6 +5659,16 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
19
src/App.tsx
19
src/App.tsx
|
|
@ -1,5 +1,22 @@
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
import { AppRoutes } from './app/AppRoutes'
|
import { AppRoutes } from './app/AppRoutes'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <AppRoutes />
|
return (
|
||||||
|
<>
|
||||||
|
<AppRoutes />
|
||||||
|
<Toaster
|
||||||
|
position="top-center"
|
||||||
|
toastOptions={{
|
||||||
|
className: 'font-sans',
|
||||||
|
style: {
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
richColors
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
src/api/activity-logs.api.ts
Normal file
14
src/api/activity-logs.api.ts
Normal 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}`);
|
||||||
|
|
@ -20,3 +20,18 @@ export const login = async (payload: LoginRequest): Promise<LoginResult> => {
|
||||||
memberId: data.member_id,
|
memberId: data.member_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const loginWithGoogle = async (credential: string): Promise<LoginResult> => {
|
||||||
|
const res = await http.post<LoginResponse>("/team/google-login", {
|
||||||
|
token: credential,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: LoginResponseData = res.data.data
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
role: data.team_role,
|
||||||
|
memberId: data.member_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
25
src/api/issues.api.ts
Normal file
25
src/api/issues.api.ts
Normal 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}`);
|
||||||
|
|
@ -29,6 +29,7 @@ import { PracticeMembersPage } from "../pages/content-management/PracticeMembers
|
||||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
||||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
||||||
|
import { IssuesPage } from "../pages/issues/IssuesPage"
|
||||||
import { ProfilePage } from "../pages/ProfilePage"
|
import { ProfilePage } from "../pages/ProfilePage"
|
||||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
||||||
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
||||||
|
|
@ -79,6 +80,7 @@ export function AppRoutes() {
|
||||||
|
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
<Route path="/user-log" element={<UserLogPage />} />
|
<Route path="/user-log" element={<UserLogPage />} />
|
||||||
|
<Route path="/issues" element={<IssuesPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
|
||||||
<Route path="/team" element={<TeamManagementPage />} />
|
<Route path="/team" element={<TeamManagementPage />} />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Bell,
|
Bell,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
CircleAlert,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
|
@ -9,6 +10,7 @@ import {
|
||||||
UserCircle2,
|
UserCircle2,
|
||||||
Users,
|
Users,
|
||||||
Users2,
|
Users2,
|
||||||
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { ComponentType } from "react"
|
import type { ComponentType } from "react"
|
||||||
import { NavLink } from "react-router-dom"
|
import { NavLink } from "react-router-dom"
|
||||||
|
|
@ -28,66 +30,101 @@ const navItems: NavItem[] = [
|
||||||
{ label: "Content Management", to: "/content", icon: BookOpen },
|
{ label: "Content Management", to: "/content", icon: BookOpen },
|
||||||
{ label: "Notifications", to: "/notifications", icon: Bell },
|
{ label: "Notifications", to: "/notifications", icon: Bell },
|
||||||
{ label: "User Log", to: "/user-log", icon: ClipboardList },
|
{ label: "User Log", to: "/user-log", icon: ClipboardList },
|
||||||
|
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
|
||||||
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||||
{ label: "Team Management", to: "/team", icon: Users2 },
|
{ label: "Team Management", to: "/team", icon: Users2 },
|
||||||
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
type SidebarProps = {
|
||||||
return (
|
isOpen: boolean
|
||||||
<aside className="fixed left-0 top-0 flex h-screen w-[264px] flex-col border-r bg-grayScale-50 px-4 py-5">
|
onClose: () => void
|
||||||
<div className="px-2">
|
|
||||||
<BrandLogo />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 transition",
|
|
||||||
"hover:bg-grayScale-100 hover:text-brand-600",
|
|
||||||
isActive &&
|
|
||||||
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
|
||||||
isActive && "bg-brand-500 text-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
{isActive ? (
|
|
||||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="px-2 pt-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 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">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<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",
|
||||||
|
"hover:bg-grayScale-100 hover:text-brand-600",
|
||||||
|
isActive &&
|
||||||
|
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
||||||
|
isActive && "bg-brand-500 text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
{isActive ? (
|
||||||
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
"use client" // make sure this is a client component
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Bell, LogOut, Settings, UserCircle2 } from "lucide-react"
|
import { Bell, LogOut, Menu, Settings, UserCircle2 } from "lucide-react"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
export function Topbar() {
|
type TopbarProps = {
|
||||||
// const [showLogoutConfirm, setShowLogoutConfirm] = useState(false)
|
onMenuClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||||
const [shortName, setShortName] = useState("AA")
|
const [shortName, setShortName] = useState("AA")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -34,111 +37,89 @@ export function Topbar() {
|
||||||
case "logout":
|
case "logout":
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
window.location.href = "/login"
|
window.location.href = "/login"
|
||||||
// setShowLogoutConfirm(true) // Show confirmation popup instead of immediate logout
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// const confirmLogout = () => {
|
|
||||||
// localStorage.clear()
|
|
||||||
// window.location.href = "/login"
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const cancelLogout = () => setShowLogoutConfirm(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-end gap-3 border-b bg-grayScale-50/85 px-6 backdrop-blur">
|
<header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
|
||||||
{/* Notifications */}
|
{/* Mobile hamburger */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 hover:text-brand-600 transition-colors"
|
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"
|
||||||
aria-label="Notifications"
|
onClick={onMenuClick}
|
||||||
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Avatar + Radix Dropdown */}
|
<div className="flex items-center gap-3">
|
||||||
<DropdownMenu.Root>
|
{/* Notifications */}
|
||||||
<DropdownMenu.Trigger asChild>
|
<button
|
||||||
<button className="focus:outline-none">
|
type="button"
|
||||||
<Avatar className="h-10 w-10 ring-2 ring-brand-100">
|
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 hover:text-brand-600 transition-colors"
|
||||||
<AvatarImage src="" alt="Admin" />
|
aria-label="Notifications"
|
||||||
<AvatarFallback className="bg-brand-500 text-sm font-medium text-white">
|
|
||||||
{shortName}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</button>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
|
|
||||||
<DropdownMenu.Content
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
className="z-50 w-48 rounded-lg bg-white p-2 shadow-lg ring-1 ring-black ring-opacity-5"
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<Bell className="h-5 w-5" />
|
||||||
className={cn(
|
</button>
|
||||||
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
|
||||||
)}
|
|
||||||
onClick={() => handleOptionClick("profile")}
|
|
||||||
>
|
|
||||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
|
||||||
<UserCircle2 className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Profile
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
className={cn(
|
|
||||||
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
|
||||||
)}
|
|
||||||
onClick={() => handleOptionClick("settings")}
|
|
||||||
>
|
|
||||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Settings
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
className={cn(
|
|
||||||
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
|
||||||
)}
|
|
||||||
onClick={() => handleOptionClick("logout")}
|
|
||||||
>
|
|
||||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Logout
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
|
|
||||||
{/* Logout Confirmation Modal */}
|
{/* Separator */}
|
||||||
{/* {showLogoutConfirm && (
|
<div className="h-6 w-px bg-grayScale-200" />
|
||||||
<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>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
|
{/* Avatar + Radix Dropdown */}
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button className="focus:outline-none">
|
||||||
|
<Avatar className="h-10 w-10 ring-2 ring-brand-100">
|
||||||
|
<AvatarImage src="" alt="Admin" />
|
||||||
|
<AvatarFallback className="bg-brand-500 text-sm font-medium text-white">
|
||||||
|
{shortName}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Content
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
className="z-50 w-48 rounded-lg bg-white p-2 shadow-lg ring-1 ring-black ring-opacity-5"
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
className={cn(
|
||||||
|
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
||||||
|
)}
|
||||||
|
onClick={() => handleOptionClick("profile")}
|
||||||
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
||||||
|
<UserCircle2 className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
Profile
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
className={cn(
|
||||||
|
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
||||||
|
)}
|
||||||
|
onClick={() => handleOptionClick("settings")}
|
||||||
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
Settings
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
className={cn(
|
||||||
|
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
||||||
|
)}
|
||||||
|
onClick={() => handleOptionClick("logout")}
|
||||||
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
Logout
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef<
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,28 @@
|
||||||
|
import { useState, useCallback } from "react"
|
||||||
import { Outlet } from "react-router-dom"
|
import { Outlet } from "react-router-dom"
|
||||||
import { Sidebar } from "../components/sidebar/Sidebar"
|
import { Sidebar } from "../components/sidebar/Sidebar"
|
||||||
import { Topbar } from "../components/topbar/Topbar"
|
import { Topbar } from "../components/topbar/Topbar"
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleMenuClick = useCallback(() => {
|
||||||
|
setSidebarOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSidebarClose = useCallback(() => {
|
||||||
|
setSidebarOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-grayScale-100">
|
<div className="flex min-h-screen bg-grayScale-100">
|
||||||
<Sidebar />
|
<Sidebar isOpen={sidebarOpen} onClose={handleSidebarClose} />
|
||||||
<div className="ml-[264px] flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col lg:ml-[264px]">
|
||||||
<Topbar />
|
<Topbar onMenuClick={handleMenuClick} />
|
||||||
<main className="min-w-0 flex-1 overflow-y-auto px-6 pb-8 pt-4">
|
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
Globe,
|
Globe,
|
||||||
GraduationCap,
|
// GraduationCap,
|
||||||
Languages,
|
Languages,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
|
@ -13,10 +13,10 @@ import {
|
||||||
User,
|
User,
|
||||||
XCircle,
|
XCircle,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
// RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "../components/ui/badge";
|
import { Badge } from "../components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
import { Separator } from "../components/ui/separator";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { getMyProfile } from "../api/users.api";
|
import { getMyProfile } from "../api/users.api";
|
||||||
|
|
@ -44,13 +44,37 @@ function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
|
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl space-y-6 py-8">
|
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-10 sm:px-6">
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse space-y-8">
|
||||||
<div className="rounded-2xl bg-grayScale-100 h-72" />
|
{/* Hero skeleton */}
|
||||||
<div className="mt-6 grid gap-6 md:grid-cols-3">
|
<div className="overflow-hidden rounded-2xl border border-grayScale-100">
|
||||||
<div className="rounded-2xl bg-grayScale-100 h-56" />
|
<div className="h-36 bg-gradient-to-r from-grayScale-100 via-grayScale-200/60 to-grayScale-100" />
|
||||||
<div className="rounded-2xl bg-grayScale-100 h-56" />
|
<div className="flex flex-col items-center px-8 pb-8">
|
||||||
<div className="rounded-2xl bg-grayScale-100 h-56" />
|
<div className="-mt-14 h-28 w-28 rounded-full bg-grayScale-100 ring-4 ring-white" />
|
||||||
|
<div className="mt-4 h-6 w-48 rounded-lg bg-grayScale-100" />
|
||||||
|
<div className="mt-3 h-5 w-24 rounded-full bg-grayScale-100" />
|
||||||
|
<div className="mt-5 flex gap-3">
|
||||||
|
<div className="h-7 w-20 rounded-full bg-grayScale-100" />
|
||||||
|
<div className="h-7 w-28 rounded-full bg-grayScale-100" />
|
||||||
|
<div className="h-7 w-28 rounded-full bg-grayScale-100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Info cards skeleton */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="rounded-2xl border border-grayScale-100 p-6">
|
||||||
|
<div className="mb-5 h-5 w-40 rounded bg-grayScale-100" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3, 4].map((j) => (
|
||||||
|
<div key={j} className="flex items-center justify-between">
|
||||||
|
<div className="h-4 w-20 rounded bg-grayScale-100" />
|
||||||
|
<div className="h-4 w-28 rounded bg-grayScale-100" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -69,13 +93,15 @@ function InfoRow({
|
||||||
extra?: React.ReactNode;
|
extra?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="group flex items-center justify-between rounded-lg px-3 py-3 transition-colors hover:bg-grayScale-100/60">
|
||||||
<div className="flex items-center gap-3 text-sm text-grayScale-400">
|
<div className="flex items-center gap-3 text-sm text-grayScale-400">
|
||||||
<Icon className="h-4 w-4" />
|
<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">
|
||||||
<span>{label}</span>
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
|
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
|
||||||
<span>{value || "—"}</span>
|
<span className="text-right">{value || "—"}</span>
|
||||||
{extra}
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,9 +110,48 @@ function InfoRow({
|
||||||
|
|
||||||
function VerifiedIcon({ verified }: { verified: boolean }) {
|
function VerifiedIcon({ verified }: { verified: boolean }) {
|
||||||
return verified ? (
|
return verified ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-mint-500" />
|
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-mint-100">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-mint-500" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-4 w-4 text-grayScale-300" />
|
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-grayScale-100">
|
||||||
|
<XCircle className="h-3.5 w-3.5 text-grayScale-300" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressRing({ percent }: { percent: number }) {
|
||||||
|
const radius = 18;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference - (percent / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center justify-center">
|
||||||
|
<svg className="h-11 w-11 -rotate-90" viewBox="0 0 44 44">
|
||||||
|
<circle
|
||||||
|
cx="22"
|
||||||
|
cy="22"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
className="text-grayScale-200"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="22"
|
||||||
|
cy="22"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
className="text-brand-500 transition-all duration-700"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute text-[10px] font-bold text-brand-600">{percent}%</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,14 +179,19 @@ export function ProfilePage() {
|
||||||
|
|
||||||
if (error || !profile) {
|
if (error || !profile) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl py-12">
|
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
|
||||||
<Card>
|
<Card className="border-dashed">
|
||||||
<CardContent className="flex flex-col items-center gap-4 p-10">
|
<CardContent className="flex flex-col items-center gap-5 p-12">
|
||||||
<div className="h-16 w-16 rounded-full bg-grayScale-100 flex items-center justify-center">
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
|
||||||
<User className="h-8 w-8 text-grayScale-300" />
|
<User className="h-10 w-10 text-grayScale-300" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-semibold text-grayScale-600">
|
<div className="text-center">
|
||||||
{error || "Profile not available"}
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -133,57 +203,88 @@ export function ProfilePage() {
|
||||||
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
|
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
|
||||||
const completionPct = profile.profile_completion_percentage ?? 0;
|
const completionPct = profile.profile_completion_percentage ?? 0;
|
||||||
|
|
||||||
|
const sectionCardIcons: Record<string, { icon: typeof User; color: string }> = {
|
||||||
|
personal: { icon: User, color: "from-brand-500 to-brand-600" },
|
||||||
|
contact: { icon: Mail, color: "from-brand-400 to-brand-500" },
|
||||||
|
account: { icon: Shield, color: "from-brand-600 to-brand-500" },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl space-y-6 py-8">
|
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-8 sm:px-6">
|
||||||
<Card className="overflow-hidden">
|
{/* Hero Card */}
|
||||||
<div className="h-32 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
|
<Card className="overflow-hidden border-0 shadow-lg">
|
||||||
<CardContent className="-mt-14 px-8 pb-8 pt-0">
|
{/* Banner gradient */}
|
||||||
|
<div className="relative h-36 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 sm:h-40">
|
||||||
|
{/* Decorative pattern overlay */}
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<div
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle at 25% 50%, white 1px, transparent 1px), radial-gradient(circle at 75% 50%, white 1px, transparent 1px)",
|
||||||
|
backgroundSize: "40px 40px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Bottom fade */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white/20 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="-mt-16 px-6 pb-8 pt-0 sm:px-10">
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
|
{/* Avatar */}
|
||||||
|
<Avatar className="h-28 w-28 ring-4 ring-white shadow-lg">
|
||||||
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
|
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
|
||||||
<AvatarFallback className="bg-brand-100 text-brand-600 text-2xl font-bold">
|
<AvatarFallback className="bg-gradient-to-br from-brand-100 to-brand-200 text-2xl font-bold text-brand-600">
|
||||||
{initials}
|
{initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<h1 className="mt-4 text-2xl font-bold text-grayScale-600">{fullName}</h1>
|
{/* Name */}
|
||||||
|
<h1 className="mt-4 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
|
||||||
|
{fullName}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Role badge */}
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2",
|
"mt-2.5 px-3 py-1",
|
||||||
profile.role === "ADMIN"
|
profile.role === "ADMIN"
|
||||||
? "bg-brand-500/15 text-brand-600 border border-brand-500/25"
|
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
|
||||||
: "bg-grayScale-200 text-grayScale-600"
|
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Shield className="h-3 w-3 mr-1" />
|
<Shield className="h-3 w-3 mr-1.5" />
|
||||||
{profile.role}
|
{profile.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
|
{/* Status pills */}
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-2.5">
|
||||||
|
{/* Active status */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
|
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
|
||||||
profile.status === "ACTIVE"
|
profile.status === "ACTIVE"
|
||||||
? "bg-mint-100 text-mint-500"
|
? "border-mint-300 bg-mint-100/60 text-mint-500"
|
||||||
: "bg-destructive/10 text-destructive"
|
: "border-destructive/20 bg-destructive/10 text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-2 w-2 rounded-full",
|
"h-2 w-2 rounded-full",
|
||||||
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
|
profile.status === "ACTIVE" ? "bg-mint-500 animate-pulse" : "bg-destructive"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{profile.status}
|
{profile.status}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email verification */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
|
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
|
||||||
profile.email_verified
|
profile.email_verified
|
||||||
? "bg-mint-100 text-mint-500"
|
? "border-mint-300 bg-mint-100/60 text-mint-500"
|
||||||
: "bg-grayScale-100 text-grayScale-400"
|
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{profile.email_verified ? (
|
{profile.email_verified ? (
|
||||||
|
|
@ -194,12 +295,13 @@ export function ProfilePage() {
|
||||||
Email {profile.email_verified ? "Verified" : "Unverified"}
|
Email {profile.email_verified ? "Verified" : "Unverified"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phone verification */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
|
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
|
||||||
profile.phone_verified
|
profile.phone_verified
|
||||||
? "bg-mint-100 text-mint-500"
|
? "border-mint-300 bg-mint-100/60 text-mint-500"
|
||||||
: "bg-grayScale-100 text-grayScale-400"
|
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{profile.phone_verified ? (
|
{profile.phone_verified ? (
|
||||||
|
|
@ -210,83 +312,101 @@ export function ProfilePage() {
|
||||||
Phone {profile.phone_verified ? "Verified" : "Unverified"}
|
Phone {profile.phone_verified ? "Verified" : "Unverified"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 rounded-full bg-brand-100/60 px-3 py-1.5 text-xs font-medium text-brand-600">
|
{/* Profile completion ring */}
|
||||||
<GraduationCap className="h-3 w-3" />
|
<div className="flex items-center gap-2 rounded-full border border-brand-200 bg-brand-100/30 px-3 py-1 text-xs font-semibold text-brand-600">
|
||||||
Profile {completionPct}% Complete
|
<ProgressRing percent={completionPct} />
|
||||||
|
<span>Profile Complete</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Info Cards */}
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<Card className="border-l-4 border-l-brand-500">
|
{/* Personal Information */}
|
||||||
<CardHeader className="pb-2">
|
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
|
||||||
<CardTitle className="text-base">Personal Information</CardTitle>
|
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Personal Information
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-0">
|
<CardContent className="space-y-0.5 px-3 pb-4">
|
||||||
<InfoRow icon={User} label="Full Name" value={fullName} />
|
<InfoRow icon={User} label="Full Name" value={fullName} />
|
||||||
<Separator />
|
|
||||||
<InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} />
|
<InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} />
|
||||||
<Separator />
|
|
||||||
<InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} />
|
<InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} />
|
||||||
<Separator />
|
|
||||||
<InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} />
|
<InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} />
|
||||||
<Separator />
|
|
||||||
<InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} />
|
<InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-brand-500">
|
{/* Contact & Location */}
|
||||||
<CardHeader className="pb-2">
|
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
|
||||||
<CardTitle className="text-base">Contact & Location</CardTitle>
|
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Contact & Location
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-0">
|
<CardContent className="space-y-0.5 px-3 pb-4">
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Mail}
|
icon={Mail}
|
||||||
label="Email"
|
label="Email"
|
||||||
value={profile.email}
|
value={profile.email}
|
||||||
extra={<VerifiedIcon verified={profile.email_verified} />}
|
extra={<VerifiedIcon verified={profile.email_verified} />}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Phone}
|
icon={Phone}
|
||||||
label="Phone"
|
label="Phone"
|
||||||
value={profile.phone_number}
|
value={profile.phone_number}
|
||||||
extra={<VerifiedIcon verified={profile.phone_verified} />}
|
extra={<VerifiedIcon verified={profile.phone_verified} />}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
|
||||||
<InfoRow icon={Globe} label="Country" value={profile.country || "—"} />
|
<InfoRow icon={Globe} label="Country" value={profile.country || "—"} />
|
||||||
<Separator />
|
|
||||||
<InfoRow icon={MapPin} label="Region" value={profile.region || "—"} />
|
<InfoRow icon={MapPin} label="Region" value={profile.region || "—"} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-brand-500">
|
{/* Account Details */}
|
||||||
<CardHeader className="pb-2">
|
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
|
||||||
<CardTitle className="text-base">Account Details</CardTitle>
|
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Account Details
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-0">
|
<CardContent className="space-y-0.5 px-3 pb-4">
|
||||||
<InfoRow icon={Shield} label="Role" value={profile.role} />
|
<InfoRow icon={Shield} label="Role" value={profile.role} />
|
||||||
<Separator />
|
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Languages}
|
icon={Languages}
|
||||||
label="Language"
|
label="Language"
|
||||||
value={profile.preferred_language || "—"}
|
value={profile.preferred_language || "—"}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label="Last Login"
|
label="Last Login"
|
||||||
value={formatDateTime(profile.last_login)}
|
value={formatDateTime(profile.last_login)}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Calendar}
|
icon={Calendar}
|
||||||
label="Member Since"
|
label="Member Since"
|
||||||
value={formatDate(profile.created_at)}
|
value={formatDate(profile.created_at)}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={CheckCircle2}
|
icon={CheckCircle2}
|
||||||
label="Status"
|
label="Status"
|
||||||
|
|
@ -294,8 +414,10 @@ export function ProfilePage() {
|
||||||
extra={
|
extra={
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-2 w-2 rounded-full",
|
"h-2.5 w-2.5 rounded-full ring-2",
|
||||||
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
|
profile.status === "ACTIVE"
|
||||||
|
? "bg-mint-500 ring-mint-100"
|
||||||
|
: "bg-destructive ring-destructive/20"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,140 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
|
import { ArrowLeft, Mail } from "lucide-react"
|
||||||
|
|
||||||
import { BrandLogo } from "../../components/brand/BrandLogo"
|
import { BrandLogo } from "../../components/brand/BrandLogo"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
|
||||||
export function ForgotPasswordPage() {
|
export function ForgotPasswordPage() {
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// Handle forgot password logic here
|
// Handle forgot password logic here
|
||||||
console.log("Forgot password:", { email })
|
console.log("Forgot password:", { email })
|
||||||
|
setSubmitted(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-grayScale-100 px-4 py-12">
|
<div className="relative flex min-h-screen overflow-hidden">
|
||||||
<div className="w-full max-w-md">
|
{/* Decorative left panel */}
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-soft">
|
<div className="hidden lg:flex lg:w-1/2 xl:w-[55%] items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 relative">
|
||||||
<div className="mb-8">
|
{/* Abstract decorative shapes */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
|
||||||
|
<div className="absolute left-1/3 top-1/4 h-64 w-64 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute bottom-1/4 left-1/4 h-48 w-48 rotate-45 rounded-3xl bg-white/[0.03]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-md px-12 text-center">
|
||||||
|
{/* Large brand icon */}
|
||||||
|
<div className="mx-auto mb-8 grid h-20 w-20 place-items-center rounded-2xl bg-white/15 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
|
||||||
|
Yimaru Academy
|
||||||
|
</h2>
|
||||||
|
<p className="text-base leading-relaxed text-white/70">
|
||||||
|
Manage your academy, track student progress, and streamline
|
||||||
|
operations — all from one powerful dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel – form */}
|
||||||
|
<div className="flex w-full flex-col items-center justify-center bg-white px-6 py-12 lg:w-1/2 xl:w-[45%]">
|
||||||
|
<div className="w-full max-w-[420px]">
|
||||||
|
{/* Mobile-only logo */}
|
||||||
|
<div className="mb-10 flex justify-center lg:hidden">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
{/* Back link */}
|
||||||
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600">Forgot Password</h1>
|
<Link
|
||||||
<p className="text-sm text-grayScale-400">
|
to="/login"
|
||||||
Enter your email address and we'll send you a reset link.
|
className="mb-8 inline-flex items-center gap-1.5 text-sm font-medium text-grayScale-400 transition-colors hover:text-grayScale-600"
|
||||||
</p>
|
>
|
||||||
</div>
|
<ArrowLeft size={16} />
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
{submitted ? (
|
||||||
<div>
|
/* Success state */
|
||||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-grayScale-600">
|
<div className="text-center">
|
||||||
Email Address
|
<div className="mx-auto mb-6 grid h-16 w-16 place-items-center rounded-full bg-brand-100/60">
|
||||||
</label>
|
<Mail className="h-7 w-7 text-brand-500" />
|
||||||
<Input
|
</div>
|
||||||
id="email"
|
<h1 className="mb-2 text-2xl font-bold tracking-tight text-grayScale-600">
|
||||||
type="email"
|
Check your email
|
||||||
placeholder="admin@yimaruacademy.com"
|
</h1>
|
||||||
value={email}
|
<p className="mb-8 text-sm leading-relaxed text-grayScale-400">
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
We've sent a password reset link to{" "}
|
||||||
required
|
<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>
|
</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>
|
||||||
|
|
||||||
<Button type="submit" className="w-full">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
Send Reset Link
|
<div>
|
||||||
</Button>
|
<label
|
||||||
</form>
|
htmlFor="email"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<Button
|
||||||
<Link
|
type="submit"
|
||||||
to="/login"
|
className="mt-2 h-11 w-full rounded-xl text-sm font-semibold tracking-wide"
|
||||||
className="text-sm font-medium text-brand-500 hover:text-brand-600"
|
>
|
||||||
>
|
Send reset link
|
||||||
Back to Login
|
</Button>
|
||||||
</Link>
|
</form>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -6,9 +6,60 @@ import { BrandLogo } from "../../components/brand/BrandLogo";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
|
||||||
import { login } from "../../api/auth.api";
|
import { login, loginWithGoogle } from "../../api/auth.api";
|
||||||
import type { LoginRequest } from "../../types/auth.types";
|
import type { LoginRequest } from "../../types/auth.types";
|
||||||
import type { LoginResult } from "../../api/auth.api";
|
import type { LoginResult } from "../../api/auth.api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
google?: {
|
||||||
|
accounts: {
|
||||||
|
id: {
|
||||||
|
initialize: (config: {
|
||||||
|
client_id: string;
|
||||||
|
callback: (response: { credential: string }) => void;
|
||||||
|
auto_select?: boolean;
|
||||||
|
}) => void;
|
||||||
|
renderButton: (
|
||||||
|
element: HTMLElement,
|
||||||
|
config: {
|
||||||
|
theme?: string;
|
||||||
|
size?: string;
|
||||||
|
width?: number;
|
||||||
|
text?: string;
|
||||||
|
shape?: string;
|
||||||
|
logo_alignment?: string;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18A10.96 10.96 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.84Z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -17,28 +68,102 @@ export function LoginPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [googleReady, setGoogleReady] = useState(false);
|
||||||
|
|
||||||
|
const googleBtnRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const storeTokensAndRedirect = useCallback(
|
||||||
|
(result: LoginResult) => {
|
||||||
|
localStorage.setItem("access_token", result.accessToken);
|
||||||
|
localStorage.setItem("refresh_token", result.refreshToken);
|
||||||
|
localStorage.setItem("role", result.role);
|
||||||
|
localStorage.setItem("member_id", result.memberId.toString());
|
||||||
|
toast.success("Welcome back!", {
|
||||||
|
description: "You have signed in successfully.",
|
||||||
|
});
|
||||||
|
navigate("/dashboard");
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGoogleCallback = useCallback(
|
||||||
|
async (response: { credential: string }) => {
|
||||||
|
setError(null);
|
||||||
|
setGoogleLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await loginWithGoogle(response.credential);
|
||||||
|
storeTokensAndRedirect(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(
|
||||||
|
err.response?.data?.message || "Google sign-in failed. Please try again."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setGoogleLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[storeTokensAndRedirect]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load Google Identity Services script
|
||||||
|
useEffect(() => {
|
||||||
|
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||||
|
if (!clientId) return;
|
||||||
|
|
||||||
|
// If already loaded
|
||||||
|
if (window.google?.accounts) {
|
||||||
|
setGoogleReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://accounts.google.com/gsi/client";
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => setGoogleReady(true);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup only if we added it
|
||||||
|
if (document.head.contains(script)) {
|
||||||
|
document.head.removeChild(script);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize Google button once ready
|
||||||
|
useEffect(() => {
|
||||||
|
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||||
|
if (!googleReady || !clientId || !window.google?.accounts) return;
|
||||||
|
|
||||||
|
window.google.accounts.id.initialize({
|
||||||
|
client_id: clientId,
|
||||||
|
callback: handleGoogleCallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (googleBtnRef.current) {
|
||||||
|
// Render the hidden native button (we use our own styled button)
|
||||||
|
window.google.accounts.id.renderButton(googleBtnRef.current, {
|
||||||
|
theme: "outline",
|
||||||
|
size: "large",
|
||||||
|
width: 400,
|
||||||
|
text: "signin_with",
|
||||||
|
shape: "rectangular",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [googleReady, handleGoogleCallback]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const payload: LoginRequest = {
|
const payload: LoginRequest = { email, password };
|
||||||
email,
|
|
||||||
password,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: LoginResult = await login(payload);
|
const res: LoginResult = await login(payload);
|
||||||
|
storeTokensAndRedirect(res);
|
||||||
// Store tokens
|
|
||||||
localStorage.setItem("access_token", res.accessToken);
|
|
||||||
localStorage.setItem("refresh_token", res.refreshToken);
|
|
||||||
localStorage.setItem("role", res.role);
|
|
||||||
localStorage.setItem("member_id", res.memberId.toString());
|
|
||||||
|
|
||||||
navigate("/dashboard");
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || "Invalid email or password");
|
setError(err.response?.data?.message || "Invalid email or password");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -46,72 +171,198 @@ export function LoginPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGoogleClick = () => {
|
||||||
|
// Click the hidden Google button to trigger the native popup
|
||||||
|
const iframe = googleBtnRef.current?.querySelector("iframe");
|
||||||
|
const innerBtn =
|
||||||
|
googleBtnRef.current?.querySelector('[role="button"]') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (innerBtn) {
|
||||||
|
innerBtn.click();
|
||||||
|
} else if (iframe) {
|
||||||
|
// Fallback: the native button may render as an iframe
|
||||||
|
iframe.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-grayScale-100 px-4 py-12">
|
<div className="relative flex min-h-screen overflow-hidden">
|
||||||
<div className="w-full max-w-md">
|
{/* Decorative left panel */}
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-soft">
|
<div className="hidden lg:flex lg:w-1/2 xl:w-[55%] items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 relative">
|
||||||
<div className="mb-8 flex justify-center">
|
{/* Abstract decorative shapes */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
|
||||||
|
<div className="absolute left-1/3 top-1/4 h-64 w-64 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute bottom-1/4 left-1/4 h-48 w-48 rotate-45 rounded-3xl bg-white/[0.03]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-md px-12 text-center">
|
||||||
|
{/* Large brand icon */}
|
||||||
|
<div className="mx-auto mb-8 grid h-20 w-20 place-items-center rounded-2xl bg-white/15 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
|
||||||
|
Yimaru Academy
|
||||||
|
</h2>
|
||||||
|
<p className="text-base leading-relaxed text-white/70">
|
||||||
|
Manage your academy, track student progress, and streamline
|
||||||
|
operations — all from one powerful dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel – login form */}
|
||||||
|
<div className="flex w-full flex-col items-center justify-center bg-white px-6 py-12 lg:w-1/2 xl:w-[45%]">
|
||||||
|
<div className="w-full max-w-[420px]">
|
||||||
|
{/* Mobile-only logo */}
|
||||||
|
<div className="mb-10 flex justify-center lg:hidden">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8 text-center">
|
{/* Header */}
|
||||||
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600">
|
<div className="mb-10">
|
||||||
Admin Login
|
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-400">
|
||||||
|
Admin Portal
|
||||||
|
</p>
|
||||||
|
<h1 className="mb-2 text-3xl font-bold tracking-tight text-grayScale-600">
|
||||||
|
Welcome back
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-grayScale-400">
|
<p className="text-sm leading-relaxed text-grayScale-400">
|
||||||
Please enter your details to continue
|
Sign in to your account to continue
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">
|
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3.5 text-sm text-red-600">
|
||||||
|
<svg
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6" autoComplete="on" method="post">
|
{/* Google Sign-In Button */}
|
||||||
|
{googleClientId && (
|
||||||
|
<>
|
||||||
|
{/* Hidden native Google button */}
|
||||||
|
<div
|
||||||
|
ref={googleBtnRef}
|
||||||
|
className="absolute h-0 w-0 overflow-hidden opacity-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoogleClick}
|
||||||
|
disabled={googleLoading || !googleReady}
|
||||||
|
className="group mb-6 flex w-full items-center justify-center gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm font-medium text-grayScale-600 transition-all duration-200 hover:border-grayScale-300 hover:bg-grayScale-100 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{googleLoading ? (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 animate-spin text-grayScale-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<GoogleIcon className="h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
|
||||||
|
)}
|
||||||
|
{googleLoading ? "Signing in…" : "Continue with Google"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-grayScale-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs font-medium uppercase tracking-wider">
|
||||||
|
<span className="bg-white px-4 text-grayScale-400">
|
||||||
|
or sign in with email
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5" autoComplete="on" method="post">
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-1.5 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Email
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
|
className="h-11 rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
htmlFor="password"
|
<label
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
htmlFor="password"
|
||||||
>
|
className="block text-sm font-medium text-grayScale-600"
|
||||||
Password
|
>
|
||||||
</label>
|
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">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
className="pr-10"
|
className="h-11 rounded-xl pr-10"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 transition-colors hover:text-grayScale-600"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
|
@ -119,16 +370,19 @@ export function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<Button
|
||||||
<Link to="/forgot-password" className="text-sm text-brand-500">
|
type="submit"
|
||||||
Forgot Password?
|
className="mt-2 h-11 w-full rounded-xl text-sm font-semibold tracking-wide"
|
||||||
</Link>
|
disabled={loading}
|
||||||
</div>
|
>
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
|
||||||
{loading ? "Signing in..." : "Login"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="mt-10 text-center text-xs text-grayScale-400">
|
||||||
|
© {new Date().getFullYear()} Yimaru Academy · All rights reserved
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -256,16 +256,16 @@ export function AddNewPracticePage() {
|
||||||
{/* Back Link */}
|
{/* Back Link */}
|
||||||
<Link
|
<Link
|
||||||
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`}
|
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`}
|
||||||
className="inline-flex items-center gap-2 text-sm text-grayScale-600 hover:text-grayScale-900"
|
className="group inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-grayScale-600 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
Back to Sub-course
|
Back to Sub-course
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grayScale-900">Add New Practice</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Add New Practice</h1>
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
<p className="mt-1.5 text-sm text-grayScale-500">
|
||||||
Create a new immersive practice session for students.
|
Create a new immersive practice session for students.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -274,24 +274,28 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
{/* Step Tracker */}
|
{/* Step Tracker */}
|
||||||
{currentStep !== 5 && (
|
{currentStep !== 5 && (
|
||||||
<div className="flex items-center justify-center py-6">
|
<div className="flex items-center justify-center py-8">
|
||||||
{STEPS.map((step, index) => (
|
{STEPS.map((step, index) => (
|
||||||
<div key={step.number} className="flex items-center">
|
<div key={step.number} className="flex items-center">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div
|
<div
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors ${
|
className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition-all duration-300 ${
|
||||||
currentStep === step.number
|
currentStep === step.number
|
||||||
? "bg-brand-500 text-white"
|
? "bg-brand-500 text-white ring-4 ring-brand-100"
|
||||||
: currentStep > step.number
|
: currentStep > step.number
|
||||||
? "bg-brand-500 text-white"
|
? "bg-brand-500 text-white"
|
||||||
: "border-2 border-grayScale-300 text-grayScale-400"
|
: "border-2 border-grayScale-300 bg-white text-grayScale-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
|
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`mt-2 text-xs font-medium ${
|
className={`mt-2.5 text-xs font-semibold tracking-wide ${
|
||||||
currentStep === step.number ? "text-brand-500" : "text-grayScale-400"
|
currentStep === step.number
|
||||||
|
? "text-brand-600"
|
||||||
|
: currentStep > step.number
|
||||||
|
? "text-brand-500"
|
||||||
|
: "text-grayScale-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step.label}
|
{step.label}
|
||||||
|
|
@ -299,7 +303,7 @@ export function AddNewPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
{index < STEPS.length - 1 && (
|
{index < STEPS.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`mx-4 h-0.5 w-32 ${
|
className={`mx-3 h-0.5 w-16 rounded-full transition-colors duration-300 sm:mx-5 sm:w-24 md:w-32 ${
|
||||||
currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200"
|
currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -311,13 +315,13 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
{/* Step Content */}
|
{/* Step Content */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<Card className="mx-auto max-w-2xl p-8">
|
<Card className="mx-auto max-w-2xl p-6 sm:p-10">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context Definition</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context Definition</h2>
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
|
||||||
Define the educational level and curriculum module for this practice.
|
Define the educational level and curriculum module for this practice.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 space-y-6">
|
<div className="mt-8 space-y-7">
|
||||||
{/* Practice Title */}
|
{/* Practice Title */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Practice Title</label>
|
<label className="text-sm font-medium text-grayScale-700">Practice Title</label>
|
||||||
|
|
@ -335,12 +339,12 @@ export function AddNewPracticePage() {
|
||||||
value={practiceDescription}
|
value={practiceDescription}
|
||||||
onChange={(e) => setPracticeDescription(e.target.value)}
|
onChange={(e) => setPracticeDescription(e.target.value)}
|
||||||
placeholder="Enter practice description"
|
placeholder="Enter practice description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
{/* Passing Score */}
|
{/* Passing Score */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Passing Score</label>
|
<label className="text-sm font-medium text-grayScale-700">Passing Score</label>
|
||||||
|
|
@ -368,16 +372,16 @@ export function AddNewPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shuffle Questions */}
|
{/* Shuffle Questions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 rounded-lg bg-grayScale-50 px-4 py-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShuffleQuestions(!shuffleQuestions)}
|
onClick={() => setShuffleQuestions(!shuffleQuestions)}
|
||||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out ${
|
||||||
shuffleQuestions ? "bg-brand-500" : "bg-grayScale-200"
|
shuffleQuestions ? "bg-brand-500" : "bg-grayScale-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-in-out ${
|
||||||
shuffleQuestions ? "translate-x-5" : "translate-x-0"
|
shuffleQuestions ? "translate-x-5" : "translate-x-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -388,34 +392,34 @@ export function AddNewPracticePage() {
|
||||||
{/* Program */}
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Program <span className="text-brand-500">(Auto-selected)</span>
|
Program <span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto-selected</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-grayScale-200 px-4 py-3">
|
<div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-300 bg-grayScale-50/50 px-4 py-3">
|
||||||
<Grid3X3 className="h-5 w-5 text-grayScale-400" />
|
<Grid3X3 className="h-5 w-5 text-grayScale-400" />
|
||||||
<span className="flex-1 text-sm">{selectedProgram}</span>
|
<span className="flex-1 text-sm font-medium text-grayScale-600">{selectedProgram}</span>
|
||||||
<ChevronDown className="h-5 w-5 text-grayScale-400" />
|
<ChevronDown className="h-5 w-5 text-grayScale-300" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Course */}
|
{/* Course */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Course <span className="text-brand-500">(Auto-selected)</span>
|
Course <span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto-selected</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-grayScale-200 px-4 py-3">
|
<div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-300 bg-grayScale-50/50 px-4 py-3">
|
||||||
<Grid3X3 className="h-5 w-5 text-grayScale-400" />
|
<Grid3X3 className="h-5 w-5 text-grayScale-400" />
|
||||||
<span className="flex-1 text-sm">{selectedCourse}</span>
|
<span className="flex-1 text-sm font-medium text-grayScale-600">{selectedCourse}</span>
|
||||||
<ChevronDown className="h-5 w-5 text-grayScale-400" />
|
<ChevronDown className="h-5 w-5 text-grayScale-300" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="mt-8 flex items-center justify-between border-t pt-6">
|
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 border-t border-grayScale-100 pt-6 sm:flex-row">
|
||||||
<Button variant="ghost" onClick={handleCancel}>
|
<Button variant="ghost" onClick={handleCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}>
|
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}>
|
||||||
{getNextButtonLabel()}
|
{getNextButtonLabel()}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -425,45 +429,49 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<h2 className="text-xl font-semibold text-grayScale-900">Select Personas</h2>
|
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Select Personas</h2>
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
|
||||||
Choose the characters that will participate in this practice scenario. Students will interact with these personas.
|
Choose the characters that will participate in this practice scenario. Students will interact with these personas.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-4 gap-4">
|
<div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{PERSONAS.map((persona) => (
|
{PERSONAS.map((persona) => (
|
||||||
<button
|
<button
|
||||||
key={persona.id}
|
key={persona.id}
|
||||||
onClick={() => setSelectedPersona(persona.id)}
|
onClick={() => setSelectedPersona(persona.id)}
|
||||||
className={`relative flex flex-col items-center rounded-xl border-2 p-6 transition-all ${
|
className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${
|
||||||
selectedPersona === persona.id
|
selectedPersona === persona.id
|
||||||
? "border-brand-500 bg-brand-50"
|
? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100"
|
||||||
: "border-grayScale-200 hover:border-grayScale-300"
|
: "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedPersona === persona.id && (
|
{selectedPersona === persona.id && (
|
||||||
<div className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
|
<div className="absolute right-2.5 top-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100">
|
<div className={`mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100 ring-2 transition-all duration-200 ${
|
||||||
|
selectedPersona === persona.id ? "ring-brand-300 ring-offset-2" : "ring-transparent group-hover:ring-grayScale-200"
|
||||||
|
}`}>
|
||||||
<img
|
<img
|
||||||
src={persona.avatar}
|
src={persona.avatar}
|
||||||
alt={persona.name}
|
alt={persona.name}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-grayScale-900">{persona.name}</span>
|
<span className={`text-sm font-semibold transition-colors ${
|
||||||
|
selectedPersona === persona.id ? "text-brand-600" : "text-grayScale-900"
|
||||||
|
}`}>{persona.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||||
<Button variant="outline" onClick={handleBack}>
|
<Button variant="outline" onClick={handleBack}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}>
|
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}>
|
||||||
{getNextButtonLabel()}
|
{getNextButtonLabel()}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -473,28 +481,28 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<h2 className="text-xl font-semibold text-grayScale-900">Create Practice Questions</h2>
|
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Create Practice Questions</h2>
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
|
||||||
Add questions to your practice. Support for MCQ, True/False, and Short Answer types.
|
Add questions to your practice. Support for MCQ, True/False, and Short Answer types.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-5">
|
||||||
{questions.map((question, index) => (
|
{questions.map((question, index) => (
|
||||||
<Card key={question.id} className="border-l-4 border-l-brand-500 p-6">
|
<Card key={question.id} className="border-l-4 border-l-brand-500 p-5 shadow-sm transition-shadow hover:shadow-md sm:p-7">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GripVertical className="h-5 w-5 cursor-grab text-grayScale-400" />
|
<GripVertical className="h-5 w-5 cursor-grab text-grayScale-300 transition-colors hover:text-grayScale-500" />
|
||||||
<span className="font-medium text-grayScale-900">Question {index + 1}</span>
|
<span className="text-base font-semibold text-grayScale-900">Question {index + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeQuestion(question.id)}
|
onClick={() => removeQuestion(question.id)}
|
||||||
className="rounded p-1 text-grayScale-400 hover:bg-red-50 hover:text-red-500"
|
className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-5 w-5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-5 space-y-5">
|
||||||
{/* Question Text */}
|
{/* Question Text */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
|
@ -504,12 +512,12 @@ export function AddNewPracticePage() {
|
||||||
value={question.questionText}
|
value={question.questionText}
|
||||||
onChange={(e) => updateQuestion(question.id, { questionText: e.target.value })}
|
onChange={(e) => updateQuestion(question.id, { questionText: e.target.value })}
|
||||||
placeholder="Enter your question..."
|
placeholder="Enter your question..."
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
{/* Question Type */}
|
{/* Question Type */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
|
@ -556,20 +564,22 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
{/* MCQ Options */}
|
{/* MCQ Options */}
|
||||||
{question.questionType === "MCQ" && (
|
{question.questionType === "MCQ" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
Options
|
Options
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2.5">
|
||||||
{question.options.map((option, optIdx) => (
|
{question.options.map((option, optIdx) => (
|
||||||
<div key={optIdx} className="flex items-center gap-2">
|
<div key={optIdx} className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors ${
|
||||||
|
option.isCorrect ? "border-green-200 bg-green-50/50" : "border-grayScale-200 bg-white"
|
||||||
|
}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCorrectOption(question.id, optIdx)}
|
onClick={() => setCorrectOption(question.id, optIdx)}
|
||||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
|
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||||
option.isCorrect
|
option.isCorrect
|
||||||
? "border-green-500 bg-green-500 text-white"
|
? "border-green-500 bg-green-500 text-white shadow-sm"
|
||||||
: "border-grayScale-300 hover:border-brand-400"
|
: "border-grayScale-300 hover:border-brand-400 hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option.isCorrect && <Check className="h-3 w-3" />}
|
{option.isCorrect && <Check className="h-3 w-3" />}
|
||||||
|
|
@ -578,12 +588,12 @@ export function AddNewPracticePage() {
|
||||||
value={option.text}
|
value={option.text}
|
||||||
onChange={(e) => updateOption(question.id, optIdx, { text: e.target.value })}
|
onChange={(e) => updateOption(question.id, optIdx, { text: e.target.value })}
|
||||||
placeholder={`Option ${optIdx + 1}`}
|
placeholder={`Option ${optIdx + 1}`}
|
||||||
className="flex-1"
|
className="flex-1 border-0 bg-transparent shadow-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
{question.options.length > 2 && (
|
{question.options.length > 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => removeOption(question.id, optIdx)}
|
onClick={() => removeOption(question.id, optIdx)}
|
||||||
className="rounded p-1 text-grayScale-400 hover:bg-red-50 hover:text-red-500"
|
className="rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -593,7 +603,7 @@ export function AddNewPracticePage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => addOption(question.id)}
|
onClick={() => addOption(question.id)}
|
||||||
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600"
|
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Option
|
Add Option
|
||||||
|
|
@ -633,7 +643,7 @@ export function AddNewPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
|
@ -659,7 +669,7 @@ export function AddNewPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{/* Voice Prompt */}
|
{/* Voice Prompt */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
|
@ -690,10 +700,10 @@ export function AddNewPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Button */}
|
{/* Add Button */}
|
||||||
<div className="mt-4">
|
<div className="mt-5">
|
||||||
<button
|
<button
|
||||||
onClick={addQuestion}
|
onClick={addQuestion}
|
||||||
className="flex items-center gap-2 text-sm font-medium text-brand-500 hover:text-brand-600"
|
className="inline-flex items-center gap-2 rounded-lg border-2 border-dashed border-brand-200 px-4 py-2.5 text-sm font-semibold text-brand-500 transition-all hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add New Question
|
Add New Question
|
||||||
|
|
@ -701,11 +711,11 @@ export function AddNewPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||||
<Button variant="outline" onClick={handleBack}>
|
<Button variant="outline" onClick={handleBack}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}>
|
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}>
|
||||||
{getNextButtonLabel()}
|
{getNextButtonLabel()}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -715,49 +725,49 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<h2 className="text-xl font-semibold text-grayScale-900">Review & Publish</h2>
|
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Review & Publish</h2>
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
|
||||||
Review your practice details before saving.
|
Review your practice details before saving.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Basic Information Card */}
|
{/* Basic Information Card */}
|
||||||
<Card className="mt-6 p-6">
|
<Card className="mt-6 overflow-hidden p-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h3 className="font-semibold text-grayScale-900">Basic Information</h3>
|
<h3 className="font-semibold text-grayScale-900">Basic Information</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentStep(1)}
|
onClick={() => setCurrentStep(1)}
|
||||||
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600"
|
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="divide-y divide-grayScale-100">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between px-6 py-3.5 odd:bg-grayScale-50/50">
|
||||||
<span className="text-sm text-grayScale-500">Title</span>
|
<span className="text-sm text-grayScale-500">Title</span>
|
||||||
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
|
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
||||||
<span className="text-sm text-grayScale-500">Description</span>
|
<span className="text-sm text-grayScale-500">Description</span>
|
||||||
<span className="max-w-sm text-right text-sm text-grayScale-700">{practiceDescription || "—"}</span>
|
<span className="max-w-sm text-right text-sm text-grayScale-700">{practiceDescription || "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between px-6 py-3.5">
|
||||||
<span className="text-sm text-grayScale-500">Passing Score</span>
|
<span className="text-sm text-grayScale-500">Passing Score</span>
|
||||||
<span className="text-sm font-medium text-grayScale-900">{passingScore}%</span>
|
<span className="text-sm font-medium text-grayScale-900">{passingScore}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
||||||
<span className="text-sm text-grayScale-500">Time Limit</span>
|
<span className="text-sm text-grayScale-500">Time Limit</span>
|
||||||
<span className="text-sm font-medium text-grayScale-900">{timeLimitMinutes} minutes</span>
|
<span className="text-sm font-medium text-grayScale-900">{timeLimitMinutes} minutes</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between px-6 py-3.5">
|
||||||
<span className="text-sm text-grayScale-500">Shuffle Questions</span>
|
<span className="text-sm text-grayScale-500">Shuffle Questions</span>
|
||||||
<span className="text-sm font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span>
|
<span className="text-sm font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
||||||
<span className="text-sm text-grayScale-500">Persona</span>
|
<span className="text-sm text-grayScale-500">Persona</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedPersona && (
|
{selectedPersona && (
|
||||||
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100">
|
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
|
||||||
<img
|
<img
|
||||||
src={PERSONAS.find(p => p.id === selectedPersona)?.avatar}
|
src={PERSONAS.find(p => p.id === selectedPersona)?.avatar}
|
||||||
alt="Persona"
|
alt="Persona"
|
||||||
|
|
@ -765,7 +775,7 @@ export function AddNewPracticePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-brand-500">
|
<span className="text-sm font-medium text-brand-600">
|
||||||
{PERSONAS.find(p => p.id === selectedPersona)?.name || "None selected"}
|
{PERSONAS.find(p => p.id === selectedPersona)?.name || "None selected"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -774,60 +784,60 @@ export function AddNewPracticePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Questions Review */}
|
{/* Questions Review */}
|
||||||
<Card className="mt-6 p-6">
|
<Card className="mt-6 overflow-hidden p-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<h3 className="font-semibold text-grayScale-900">Questions</h3>
|
<h3 className="font-semibold text-grayScale-900">Questions</h3>
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-grayScale-100 text-xs">
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-600">
|
||||||
{questions.length}
|
{questions.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentStep(3)}
|
onClick={() => setCurrentStep(3)}
|
||||||
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600"
|
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="divide-y divide-grayScale-100 px-6 py-4">
|
||||||
{questions.map((question, index) => (
|
{questions.map((question, index) => (
|
||||||
<div key={question.id} className="rounded-lg border border-grayScale-200 p-4">
|
<div key={question.id} className="rounded-lg border border-grayScale-200 p-4 transition-colors first:mt-0 [&:not(:first-child)]:mt-3 hover:border-grayScale-300">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-brand-100 text-xs font-semibold text-brand-600">
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2.5">
|
||||||
<p className="text-sm font-medium text-grayScale-900">{question.questionText}</p>
|
<p className="text-sm font-medium leading-relaxed text-grayScale-900">{question.questionText}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
|
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
|
||||||
{question.questionType === "MCQ" ? "Multiple Choice" : question.questionType === "TRUE_FALSE" ? "True/False" : "Short Answer"}
|
{question.questionType === "MCQ" ? "Multiple Choice" : question.questionType === "TRUE_FALSE" ? "True/False" : "Short Answer"}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-purple-50 px-2 py-0.5 text-xs text-purple-600">
|
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
|
||||||
{question.difficultyLevel}
|
{question.difficultyLevel}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-grayScale-500">{question.points} pt{question.points !== 1 ? "s" : ""}</span>
|
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-xs font-medium text-grayScale-600">{question.points} pt{question.points !== 1 ? "s" : ""}</span>
|
||||||
</div>
|
</div>
|
||||||
{question.questionType === "MCQ" && question.options.length > 0 && (
|
{question.questionType === "MCQ" && question.options.length > 0 && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{question.options.map((opt, i) => (
|
{question.options.map((opt, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`flex items-center gap-2 rounded px-2 py-1 text-sm ${
|
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
|
||||||
opt.isCorrect ? "bg-green-50 text-green-700 font-medium" : "text-grayScale-600"
|
opt.isCorrect ? "bg-green-50 font-medium text-green-700" : "text-grayScale-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.isCorrect && <Check className="h-3 w-3" />}
|
{opt.isCorrect && <Check className="h-3.5 w-3.5" />}
|
||||||
{opt.text || `Option ${i + 1}`}
|
{opt.text || `Option ${i + 1}`}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{question.tips && (
|
{question.tips && (
|
||||||
<p className="text-xs text-amber-600">Tip: {question.tips}</p>
|
<p className="rounded-md bg-amber-50 px-2.5 py-1.5 text-xs text-amber-600">💡 Tip: {question.tips}</p>
|
||||||
)}
|
)}
|
||||||
{question.explanation && (
|
{question.explanation && (
|
||||||
<p className="text-xs text-grayScale-500">Explanation: {question.explanation}</p>
|
<p className="rounded-md bg-grayScale-50 px-2.5 py-1.5 text-xs text-grayScale-500">Explanation: {question.explanation}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -837,15 +847,17 @@ export function AddNewPracticePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<p className="mt-4 text-sm text-red-500">{saveError}</p>
|
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-red-600">{saveError}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||||
<Button variant="outline" onClick={handleBack}>
|
<Button variant="outline" onClick={handleBack}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-3">
|
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
|
||||||
<Button variant="outline" onClick={handleSaveAsDraft} disabled={saving}>
|
<Button variant="outline" onClick={handleSaveAsDraft} disabled={saving}>
|
||||||
{saving ? "Saving..." : "Save as Draft"}
|
{saving ? "Saving..." : "Save as Draft"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -860,18 +872,18 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
{/* Step 5: Result */}
|
{/* Step 5: Result */}
|
||||||
{currentStep === 5 && resultStatus && (
|
{currentStep === 5 && resultStatus && (
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="flex flex-col items-center justify-center px-4 py-20">
|
||||||
{resultStatus === "success" ? (
|
{resultStatus === "success" ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-brand-100">
|
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200 shadow-lg shadow-brand-100/50">
|
||||||
<svg viewBox="0 0 24 24" className="h-14 w-14 text-brand-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" className="h-16 w-16 text-brand-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M20 6 9 17l-5-5" />
|
<path d="M20 6 9 17l-5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-8 text-2xl font-bold text-grayScale-900">
|
<h2 className="mt-8 text-center text-2xl font-bold tracking-tight text-grayScale-900">
|
||||||
Practice Published Successfully!
|
Practice Published Successfully!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-grayScale-500">{resultMessage}</p>
|
<p className="mt-3 text-center text-sm text-grayScale-500">{resultMessage}</p>
|
||||||
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
|
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600"
|
className="w-full bg-brand-500 hover:bg-brand-600"
|
||||||
|
|
@ -902,17 +914,17 @@ export function AddNewPracticePage() {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-amber-100">
|
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-gradient-to-br from-amber-100 to-amber-200 shadow-lg shadow-amber-100/50">
|
||||||
<svg viewBox="0 0 24 24" className="h-14 w-14 text-amber-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" className="h-16 w-16 text-amber-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-8 text-2xl font-bold text-grayScale-900">
|
<h2 className="mt-8 text-center text-2xl font-bold tracking-tight text-grayScale-900">
|
||||||
Publish Error!
|
Publish Error!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-grayScale-500">{resultMessage}</p>
|
<p className="mt-3 max-w-md text-center text-sm text-grayScale-500">{resultMessage}</p>
|
||||||
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
|
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600"
|
className="w-full bg-brand-500 hover:bg-brand-600"
|
||||||
|
|
|
||||||
|
|
@ -136,36 +136,43 @@ export function AddPracticePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate("/content/speaking")}
|
onClick={() => navigate("/content/speaking")}
|
||||||
className="h-8 w-8"
|
className="h-9 w-9 rounded-lg border border-grayScale-200 bg-white shadow-sm transition-colors hover:bg-grayScale-50 hover:border-grayScale-300"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4 text-grayScale-500" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Add New Practice</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Add New Practice</h1>
|
||||||
|
<p className="text-sm text-grayScale-400">Create a new practice session with questions</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600">
|
<Button className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="p-6">
|
{/* Stepper */}
|
||||||
|
<Card className="border-grayScale-200 bg-white/80 p-5 shadow-sm sm:p-6">
|
||||||
<Stepper steps={STEPS} currentStep={currentStep} />
|
<Stepper steps={STEPS} currentStep={currentStep} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Step 1: Details */}
|
{/* Step 1: Details */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<Card className="p-6">
|
<Card className="mx-auto max-w-3xl border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||||
<h2 className="mb-6 text-lg font-semibold text-grayScale-900">Practice Details</h2>
|
<h2 className="mb-6 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
<div className="space-y-4">
|
Practice Details
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Practice Title
|
Practice Title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -177,7 +184,7 @@ export function AddPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -189,9 +196,9 @@ export function AddPracticePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Category
|
Category
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -208,7 +215,7 @@ export function AddPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Difficulty
|
Difficulty
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -224,9 +231,9 @@ export function AddPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Duration (minutes)
|
Duration (minutes)
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -239,7 +246,7 @@ export function AddPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">Tags</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tags</label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.tags}
|
value={formData.tags}
|
||||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||||
|
|
@ -249,11 +256,11 @@ export function AddPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-8 flex justify-end border-t border-grayScale-100 pt-6">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCurrentStep(2)}
|
onClick={() => setCurrentStep(2)}
|
||||||
disabled={!canProceedToStep2()}
|
disabled={!canProceedToStep2()}
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -265,31 +272,43 @@ export function AddPracticePage() {
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Select Participants Section */}
|
{/* Select Participants Section */}
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Select Participants</h2>
|
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
<div className="grid grid-cols-4 gap-4">
|
Select Participants
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-5 sm:grid-cols-4 lg:grid-cols-8">
|
||||||
{mockParticipants.map((participant) => {
|
{mockParticipants.map((participant) => {
|
||||||
const isSelected = formData.participants.includes(participant.id)
|
const isSelected = formData.participants.includes(participant.id)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
className="relative flex flex-col items-center"
|
className="group relative flex cursor-pointer flex-col items-center"
|
||||||
onClick={() => toggleParticipant(participant.id)}
|
onClick={() => toggleParticipant(participant.id)}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar className="h-16 w-16 cursor-pointer border-2 border-grayScale-200 transition-all hover:border-brand-500">
|
<Avatar
|
||||||
|
className={`h-16 w-16 border-2 transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? "border-brand-500 ring-2 ring-brand-500/20 scale-105"
|
||||||
|
: "border-grayScale-200 group-hover:border-brand-400 group-hover:shadow-md group-hover:scale-105"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<AvatarImage src={participant.avatar} />
|
<AvatarImage src={participant.avatar} />
|
||||||
<AvatarFallback className="bg-brand-100 text-brand-600">
|
<AvatarFallback className="bg-brand-100 text-brand-600 font-medium">
|
||||||
{participant.name[0]}
|
{participant.name[0]}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute -right-1 -top-1 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white">
|
<div className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-brand-500 text-white shadow-sm ring-2 ring-white">
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-2 text-sm font-medium text-grayScale-700">
|
<span
|
||||||
|
className={`mt-2 text-xs font-medium transition-colors ${
|
||||||
|
isSelected ? "text-brand-600" : "text-grayScale-500 group-hover:text-grayScale-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{participant.name}
|
{participant.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -299,28 +318,28 @@ export function AddPracticePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add Questions Section */}
|
{/* Add Questions Section */}
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">
|
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
General Practice Questions
|
General Practice Questions
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Existing Questions */}
|
{/* Existing Questions */}
|
||||||
{formData.questions.map((q) => (
|
{formData.questions.map((q) => (
|
||||||
<div key={q.id} className="mb-4 rounded-lg border bg-grayScale-50 p-4">
|
<div key={q.id} className="mb-4 rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-4 transition-colors hover:bg-grayScale-50">
|
||||||
<div className="mb-2 flex items-start justify-between">
|
<div className="mb-2 flex items-start justify-between">
|
||||||
<p className="font-medium text-grayScale-900">{q.question}</p>
|
<p className="font-medium text-grayScale-600">{q.question}</p>
|
||||||
<Badge variant="secondary">{q.points} points</Badge>
|
<Badge variant="secondary">{q.points} points</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm text-grayScale-400">
|
||||||
Type: {q.type} | Correct Answer: {q.correctAnswer}
|
Type: {q.type} | Correct Answer: {q.correctAnswer}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Add New Question Form */}
|
{/* Add New Question Form */}
|
||||||
<div className="space-y-4 rounded-lg border bg-white p-4">
|
<div className="space-y-5 rounded-xl border border-dashed border-grayScale-300 bg-white p-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Question
|
Question
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -333,9 +352,9 @@ export function AddPracticePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Question Type
|
Question Type
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -354,7 +373,7 @@ export function AddPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">Points</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Points</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={currentQuestion.points}
|
value={currentQuestion.points}
|
||||||
|
|
@ -371,7 +390,7 @@ export function AddPracticePage() {
|
||||||
|
|
||||||
{currentQuestion.type === "multiple-choice" && (
|
{currentQuestion.type === "multiple-choice" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Options
|
Options
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -389,7 +408,7 @@ export function AddPracticePage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={addOption}
|
onClick={addOption}
|
||||||
className="w-full"
|
className="mt-1 w-full border-dashed"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Option
|
Add Option
|
||||||
|
|
@ -399,7 +418,7 @@ export function AddPracticePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Correct Answer
|
Correct Answer
|
||||||
</label>
|
</label>
|
||||||
{currentQuestion.type === "multiple-choice" ? (
|
{currentQuestion.type === "multiple-choice" ? (
|
||||||
|
|
@ -434,7 +453,7 @@ export function AddPracticePage() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addQuestion}
|
onClick={addQuestion}
|
||||||
disabled={!currentQuestion.question || !currentQuestion.correctAnswer}
|
disabled={!currentQuestion.question || !currentQuestion.correctAnswer}
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600"
|
className="w-full bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add New Question
|
Add New Question
|
||||||
|
|
@ -442,14 +461,14 @@ export function AddPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={() => setCurrentStep(1)}>
|
<Button variant="outline" onClick={() => setCurrentStep(1)} className="px-6">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCurrentStep(3)}
|
onClick={() => setCurrentStep(3)}
|
||||||
disabled={!canProceedToStep3()}
|
disabled={!canProceedToStep3()}
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -459,90 +478,84 @@ export function AddPracticePage() {
|
||||||
|
|
||||||
{/* Step 3: Review */}
|
{/* Step 3: Review */}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div className="space-y-6">
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Practice Details</h2>
|
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
<div className="space-y-3">
|
Practice Details
|
||||||
<div className="flex justify-between">
|
</h2>
|
||||||
<span className="text-sm text-grayScale-600">Title:</span>
|
<div className="divide-y divide-grayScale-100 overflow-hidden rounded-lg border border-grayScale-200">
|
||||||
<span className="text-sm font-medium text-grayScale-900">{formData.title}</span>
|
{[
|
||||||
</div>
|
{ label: "Title", value: formData.title },
|
||||||
<div className="flex justify-between">
|
{ label: "Description", value: formData.description },
|
||||||
<span className="text-sm text-grayScale-600">Description:</span>
|
{ label: "Category", value: formData.category },
|
||||||
<span className="text-sm font-medium text-grayScale-900">
|
{ label: "Difficulty", value: formData.difficulty },
|
||||||
{formData.description}
|
{ label: "Duration", value: `${formData.duration} minutes` },
|
||||||
</span>
|
{ label: "Tags", value: formData.tags },
|
||||||
</div>
|
].map((row, idx) => (
|
||||||
<div className="flex justify-between">
|
<div
|
||||||
<span className="text-sm text-grayScale-600">Category:</span>
|
key={row.label}
|
||||||
<span className="text-sm font-medium text-grayScale-900">{formData.category}</span>
|
className={`flex items-baseline justify-between px-4 py-3 ${
|
||||||
</div>
|
idx % 2 === 0 ? "bg-grayScale-50/50" : "bg-white"
|
||||||
<div className="flex justify-between">
|
}`}
|
||||||
<span className="text-sm text-grayScale-600">Difficulty:</span>
|
>
|
||||||
<span className="text-sm font-medium text-grayScale-900">
|
<span className="text-sm font-medium text-grayScale-400">{row.label}</span>
|
||||||
{formData.difficulty}
|
<span className="text-right text-sm font-medium text-grayScale-600">
|
||||||
</span>
|
{row.value}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex justify-between">
|
</div>
|
||||||
<span className="text-sm text-grayScale-600">Duration:</span>
|
))}
|
||||||
<span className="text-sm font-medium text-grayScale-900">
|
|
||||||
{formData.duration} minutes
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-grayScale-600">Tags:</span>
|
|
||||||
<span className="text-sm font-medium text-grayScale-900">{formData.tags}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Questions</h2>
|
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Questions
|
||||||
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{formData.questions.map((q, index) => (
|
{formData.questions.map((q, index) => (
|
||||||
<div key={q.id} className="rounded-lg border bg-grayScale-50 p-4">
|
<div key={q.id} className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-5 transition-colors hover:bg-grayScale-50">
|
||||||
<div className="mb-2 flex items-start justify-between">
|
<div className="mb-2 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-grayScale-900">
|
<p className="font-medium text-grayScale-600">
|
||||||
{index + 1}. {q.question}
|
{index + 1}. {q.question}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-grayScale-600">
|
<p className="mt-1.5 text-sm text-grayScale-400">
|
||||||
Type: {q.type} | Points: {q.points}
|
Type: {q.type} | Points: {q.points}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{q.type === "multiple-choice" && q.options.length > 0 && (
|
{q.type === "multiple-choice" && q.options.length > 0 && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-3 space-y-1.5">
|
||||||
{q.options.map((opt, optIdx) => (
|
{q.options.map((opt, optIdx) => (
|
||||||
<div
|
<div
|
||||||
key={optIdx}
|
key={optIdx}
|
||||||
className={`rounded px-2 py-1 text-sm ${
|
className={`rounded-lg px-3 py-1.5 text-sm ${
|
||||||
opt === q.correctAnswer
|
opt === q.correctAnswer
|
||||||
? "bg-brand-100 text-brand-700 font-medium"
|
? "bg-brand-100 text-brand-700 font-medium ring-1 ring-brand-200"
|
||||||
: "bg-white text-grayScale-600"
|
: "bg-white text-grayScale-500 ring-1 ring-grayScale-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt}
|
{opt}
|
||||||
{opt === q.correctAnswer && (
|
{opt === q.correctAnswer && (
|
||||||
<Check className="ml-2 inline h-3 w-3" />
|
<Check className="ml-2 inline h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 text-sm text-grayScale-600">
|
<div className="mt-3 border-t border-grayScale-100 pt-2 text-sm text-grayScale-400">
|
||||||
Correct Answer: <span className="font-medium">{q.correctAnswer}</span>
|
Correct Answer: <span className="font-medium text-grayScale-600">{q.correctAnswer}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={() => setCurrentStep(2)}>
|
<Button variant="outline" onClick={() => setCurrentStep(2)} className="px-6">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} className="bg-brand-500 hover:bg-brand-600">
|
<Button onClick={handleSubmit} className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
Create Practice
|
Create Practice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -551,4 +564,3 @@ export function AddPracticePage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,182 +135,207 @@ export function AddQuestionPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center gap-4">
|
{/* Page Header */}
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/content/questions")}>
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate("/content/questions")}
|
||||||
|
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors"
|
||||||
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">
|
<div>
|
||||||
{isEditing ? "Edit Question" : "Add New Question"}
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
|
||||||
</h1>
|
{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>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<div className="max-w-3xl mx-auto">
|
||||||
<Card className="shadow-none">
|
<form onSubmit={handleSubmit}>
|
||||||
<CardHeader>
|
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
||||||
<CardTitle>Question Details</CardTitle>
|
<CardHeader className="pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle>
|
||||||
<CardContent className="space-y-6">
|
</CardHeader>
|
||||||
{/* Question Type */}
|
<CardContent className="space-y-7">
|
||||||
<div>
|
{/* Question Type */}
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-600">
|
|
||||||
Question Type
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={formData.type}
|
|
||||||
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
|
||||||
>
|
|
||||||
<option value="multiple-choice">Multiple Choice</option>
|
|
||||||
<option value="short-answer">Short Answer</option>
|
|
||||||
<option value="true-false">True/False</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Question Text */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="question" className="mb-2 block text-sm font-medium text-grayScale-600">
|
|
||||||
Question
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
id="question"
|
|
||||||
placeholder="Enter your question here..."
|
|
||||||
value={formData.question}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
|
|
||||||
rows={3}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options for Multiple Choice */}
|
|
||||||
{(formData.type === "multiple-choice" || formData.type === "true-false") && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Options
|
Question Type
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
|
||||||
{formData.options.map((option, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={option}
|
|
||||||
onChange={(e) => handleOptionChange(index, e.target.value)}
|
|
||||||
placeholder={`Option ${index + 1}`}
|
|
||||||
disabled={formData.type === "true-false"}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{formData.type === "multiple-choice" && formData.options.length > 2 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeOption(index)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{formData.type === "multiple-choice" && (
|
|
||||||
<Button type="button" variant="outline" onClick={addOption} className="w-full">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Add Option
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Correct Answer */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-600">
|
|
||||||
Correct Answer
|
|
||||||
</label>
|
|
||||||
{formData.type === "multiple-choice" || formData.type === "true-false" ? (
|
|
||||||
<Select
|
<Select
|
||||||
value={formData.correctAnswer}
|
value={formData.type}
|
||||||
onChange={(e) =>
|
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
||||||
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
|
||||||
}
|
|
||||||
required
|
|
||||||
>
|
>
|
||||||
<option value="">Select correct answer</option>
|
<option value="multiple-choice">Multiple Choice</option>
|
||||||
{formData.options.map((option, index) => (
|
<option value="short-answer">Short Answer</option>
|
||||||
<option key={index} value={option}>
|
<option value="true-false">True/False</option>
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-grayScale-100" />
|
||||||
|
|
||||||
|
{/* Question Text */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Question
|
||||||
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Enter the correct answer..."
|
id="question"
|
||||||
value={formData.correctAnswer}
|
placeholder="Enter your question here..."
|
||||||
onChange={(e) =>
|
value={formData.question}
|
||||||
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
|
||||||
}
|
rows={3}
|
||||||
rows={2}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options for Multiple Choice */}
|
||||||
|
{(formData.type === "multiple-choice" || formData.type === "true-false") && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Options
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{formData.options.map((option, index) => (
|
||||||
|
<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)}
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
disabled={formData.type === "true-false"}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{formData.type === "multiple-choice" && formData.options.length > 2 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{formData.type === "multiple-choice" && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Points */}
|
<hr className="border-grayScale-100" />
|
||||||
<div>
|
|
||||||
<label htmlFor="points" className="mb-2 block text-sm font-medium text-grayScale-600">
|
|
||||||
Points
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="points"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={formData.points}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 0 }))
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category */}
|
{/* Correct Answer */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="category" className="mb-2 block text-sm font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Category (Optional)
|
Correct Answer
|
||||||
</label>
|
</label>
|
||||||
<Input
|
{formData.type === "multiple-choice" || formData.type === "true-false" ? (
|
||||||
id="category"
|
<Select
|
||||||
placeholder="e.g., Programming, Geography"
|
value={formData.correctAnswer}
|
||||||
value={formData.category || ""}
|
onChange={(e) =>
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, category: e.target.value }))}
|
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
||||||
/>
|
}
|
||||||
</div>
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select correct answer</option>
|
||||||
|
{formData.options.map((option, index) => (
|
||||||
|
<option key={index} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter the correct answer..."
|
||||||
|
value={formData.correctAnswer}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Difficulty */}
|
<hr className="border-grayScale-100" />
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-600">
|
|
||||||
Difficulty (Optional)
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={formData.difficulty || ""}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value }))}
|
|
||||||
>
|
|
||||||
<option value="">Select difficulty</option>
|
|
||||||
<option value="Easy">Easy</option>
|
|
||||||
<option value="Medium">Medium</option>
|
|
||||||
<option value="Hard">Hard</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Points and Difficulty side by side */}
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")}>
|
{/* Points */}
|
||||||
Cancel
|
<div>
|
||||||
</Button>
|
<label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
<Button type="submit" className="bg-brand-500 hover:bg-brand-600">
|
Points
|
||||||
{isEditing ? "Update Question" : "Create Question"}
|
</label>
|
||||||
</Button>
|
<Input
|
||||||
</div>
|
id="points"
|
||||||
</CardContent>
|
type="number"
|
||||||
</Card>
|
min="1"
|
||||||
</form>
|
value={formData.points}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 0 }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Difficulty */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Difficulty (Optional)
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={formData.difficulty || ""}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Select difficulty</option>
|
||||||
|
<option value="Easy">Easy</option>
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<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 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 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
|
||||||
|
{isEditing ? "Update Question" : "Create Question"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,55 +27,72 @@ export function AddVideoPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate("/content/courses")}
|
onClick={() => navigate("/content/courses")}
|
||||||
className="h-8 w-8"
|
className="h-9 w-9 rounded-lg border border-grayScale-200 bg-white shadow-sm transition-colors hover:bg-grayScale-50 hover:border-grayScale-300"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4 text-grayScale-500" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Add New Video</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Add New Video</h1>
|
||||||
|
<p className="text-sm text-grayScale-400">Upload and configure a new video</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleSubmit} className="bg-brand-500 hover:bg-brand-600">
|
<Button onClick={handleSubmit} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="mx-auto max-w-3xl space-y-6">
|
||||||
<Card className="p-6">
|
{/* Upload Card */}
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Upload</h2>
|
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||||
|
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Video Upload
|
||||||
|
</h2>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
onFileSelect={setVideoFile}
|
onFileSelect={setVideoFile}
|
||||||
label="Drag & Drop Video Here"
|
label="Drag & Drop Video Here"
|
||||||
description="or click to browse files"
|
description="or click to browse files"
|
||||||
className="min-h-[200px]"
|
className="min-h-[200px] rounded-xl border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Preview Card */}
|
||||||
{videoFile && (
|
{videoFile && (
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 overflow-hidden p-0 shadow-sm">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Preview</h2>
|
<div className="border-b border-grayScale-100 px-6 py-4 sm:px-8">
|
||||||
<div className="aspect-video w-full overflow-hidden rounded-lg bg-grayScale-900">
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
<video
|
Video Preview
|
||||||
src={URL.createObjectURL(videoFile)}
|
</h2>
|
||||||
controls
|
</div>
|
||||||
className="h-full w-full object-contain"
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="p-6">
|
{/* Details Card */}
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Video Details</h2>
|
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||||
<div className="space-y-4">
|
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Video Details
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Video Title
|
Video Title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -87,7 +104,7 @@ export function AddVideoPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -99,9 +116,9 @@ export function AddVideoPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">Tags</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tags</label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.tags}
|
value={formData.tags}
|
||||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||||
|
|
@ -110,7 +127,7 @@ export function AddVideoPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Category
|
Category
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -127,9 +144,9 @@ export function AddVideoPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Visibility
|
Visibility
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -145,7 +162,7 @@ export function AddVideoPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
</label>
|
</label>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
|
|
@ -153,15 +170,15 @@ export function AddVideoPage() {
|
||||||
onFileSelect={(file) => setFormData({ ...formData, thumbnail: file })}
|
onFileSelect={(file) => setFormData({ ...formData, thumbnail: file })}
|
||||||
label="Upload Thumbnail"
|
label="Upload Thumbnail"
|
||||||
description="or click to browse"
|
description="or click to browse"
|
||||||
className="min-h-[100px]"
|
className="min-h-[100px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end pb-4">
|
||||||
<Button type="submit" className="bg-brand-500 hover:bg-brand-600">
|
<Button type="submit" className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
Save Video
|
Save Video
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -169,4 +186,3 @@ export function AddVideoPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,28 @@ const tabs = [
|
||||||
|
|
||||||
export function ContentManagementLayout() {
|
export function ContentManagementLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Content Management</div>
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||||
|
Content Management
|
||||||
|
</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
|
Manage courses, speaking exercises, practices, and questions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 flex items-center gap-2 rounded-xl border bg-white p-1">
|
{/* Tab bar */}
|
||||||
|
<div
|
||||||
|
className="scroll-hide mb-8 flex items-center gap-1 overflow-x-auto rounded-2xl border border-grayScale-100 bg-grayScale-50/60 p-1.5 shadow-sm backdrop-blur"
|
||||||
|
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||||
|
>
|
||||||
|
<style>{`.scroll-hide::-webkit-scrollbar { display: none; }`}</style>
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={t.to}
|
key={t.to}
|
||||||
|
|
@ -22,9 +40,10 @@ export function ContentManagementLayout() {
|
||||||
end={t.to === "/content"}
|
end={t.to === "/content"}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"rounded-lg px-4 py-2 text-sm font-semibold text-grayScale-500 transition",
|
"relative whitespace-nowrap rounded-xl px-5 py-2 text-sm font-semibold transition-all duration-200 ease-in-out",
|
||||||
"hover:text-brand-600",
|
"text-grayScale-500 hover:bg-white/80 hover:text-brand-600 hover:shadow-sm",
|
||||||
isActive && "bg-brand-500 text-white hover:text-white",
|
isActive &&
|
||||||
|
"bg-brand-500 text-white shadow-md shadow-brand-500/25 hover:bg-brand-600 hover:text-white",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -33,9 +52,8 @@ export function ContentManagementLayout() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,62 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Link, useParams } from "react-router-dom"
|
import { Link, useParams } from "react-router-dom"
|
||||||
import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft } from "lucide-react"
|
import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft, ArrowRight, ChevronRight } from "lucide-react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { getCourseCategories } from "../../api/courses.api"
|
import { getCourseCategories } from "../../api/courses.api"
|
||||||
import type { CourseCategory } from "../../types/course.types"
|
import type { CourseCategory } from "../../types/course.types"
|
||||||
|
|
||||||
|
const contentSections = [
|
||||||
|
{
|
||||||
|
key: "courses",
|
||||||
|
pathFn: (categoryId: string | undefined) => `/content/category/${categoryId}/courses`,
|
||||||
|
icon: BookOpen,
|
||||||
|
title: "Courses",
|
||||||
|
description: "Manage course videos and educational content",
|
||||||
|
action: "Manage Courses",
|
||||||
|
count: 12,
|
||||||
|
countLabel: "courses",
|
||||||
|
gradient: "from-brand-500/10 via-brand-400/5 to-transparent",
|
||||||
|
accentBorder: "group-hover:border-brand-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "speaking",
|
||||||
|
pathFn: () => "/content/speaking",
|
||||||
|
icon: Mic,
|
||||||
|
title: "Speaking",
|
||||||
|
description: "Manage speaking practice sessions and exercises",
|
||||||
|
action: "Manage Speaking",
|
||||||
|
count: 8,
|
||||||
|
countLabel: "sessions",
|
||||||
|
gradient: "from-purple-500/10 via-purple-400/5 to-transparent",
|
||||||
|
accentBorder: "group-hover:border-purple-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "practices",
|
||||||
|
pathFn: () => "/content/practices",
|
||||||
|
icon: Briefcase,
|
||||||
|
title: "Practice",
|
||||||
|
description: "Manage practice details, members, and leadership",
|
||||||
|
action: "Manage Practice",
|
||||||
|
count: 5,
|
||||||
|
countLabel: "practices",
|
||||||
|
gradient: "from-indigo-500/10 via-indigo-400/5 to-transparent",
|
||||||
|
accentBorder: "group-hover:border-indigo-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "questions",
|
||||||
|
pathFn: () => "/content/questions",
|
||||||
|
icon: HelpCircle,
|
||||||
|
title: "Questions",
|
||||||
|
description: "Manage questions, quizzes, and assessments",
|
||||||
|
action: "Manage Questions",
|
||||||
|
count: 34,
|
||||||
|
countLabel: "questions",
|
||||||
|
gradient: "from-rose-500/10 via-rose-400/5 to-transparent",
|
||||||
|
accentBorder: "group-hover:border-rose-400",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
export function ContentOverviewPage() {
|
export function ContentOverviewPage() {
|
||||||
const { categoryId } = useParams<{ categoryId: string }>()
|
const { categoryId } = useParams<{ categoryId: string }>()
|
||||||
const [category, setCategory] = useState<CourseCategory | null>(null)
|
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||||
|
|
@ -27,81 +78,114 @@ export function ContentOverviewPage() {
|
||||||
}, [categoryId])
|
}, [categoryId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center gap-3">
|
{/* Header & Breadcrumb */}
|
||||||
<Link
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
to="/content"
|
<div className="flex items-center gap-3">
|
||||||
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"
|
<Link
|
||||||
>
|
to="/content"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
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"
|
||||||
</Link>
|
>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-grayScale-400">
|
||||||
|
<Link to="/content" className="transition-colors hover:text-brand-500">
|
||||||
|
Content
|
||||||
|
</Link>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-medium text-grayScale-600">
|
||||||
|
{category?.name ?? "Overview"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-grayScale-700">
|
||||||
{category?.name ?? "Content Management"}
|
{category?.name ?? "Content Management"}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
|
||||||
<BookOpen className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-lg">Courses</CardTitle>
|
|
||||||
<CardDescription>Manage course videos and educational content</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Link to={`/content/category/${categoryId}/courses`}>
|
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Courses</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
{/* Gradient Divider */}
|
||||||
<CardHeader>
|
<div className="relative">
|
||||||
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
<Mic className="h-6 w-6" />
|
<div className="w-full border-t border-grayScale-100" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">Speaking</CardTitle>
|
<div className="relative flex justify-center">
|
||||||
<CardDescription>Manage speaking practice sessions and exercises</CardDescription>
|
<div
|
||||||
</CardHeader>
|
className="h-1 w-24 rounded-full"
|
||||||
<CardContent>
|
style={{
|
||||||
<Link to="/content/speaking">
|
background: "linear-gradient(90deg, #9E2891 0%, #6A1B9A 100%)",
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Speaking</Button>
|
}}
|
||||||
</Link>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
{/* Cards Grid */}
|
||||||
<CardHeader>
|
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
{contentSections.map((section) => {
|
||||||
<Briefcase className="h-6 w-6" />
|
const Icon = section.icon
|
||||||
</div>
|
return (
|
||||||
<CardTitle className="text-lg">Practice</CardTitle>
|
<Link
|
||||||
<CardDescription>Manage practice details, members, and leadership</CardDescription>
|
key={section.key}
|
||||||
</CardHeader>
|
to={section.pathFn(categoryId)}
|
||||||
<CardContent>
|
className="group"
|
||||||
<Link to="/content/practices">
|
>
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Practice</Button>
|
<Card
|
||||||
</Link>
|
className={`relative h-full overflow-hidden border border-grayScale-100 bg-white transition-all duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] ${section.accentBorder} group-hover:shadow-lg`}
|
||||||
</CardContent>
|
style={{
|
||||||
</Card>
|
boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Subtle gradient background on icon area */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-x-0 top-0 h-28 bg-gradient-to-b ${section.gradient} pointer-events-none`}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
<CardHeader className="relative pb-2">
|
||||||
<CardHeader>
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
{/* Icon with gradient ring */}
|
||||||
<HelpCircle className="h-6 w-6" />
|
<div className="relative">
|
||||||
</div>
|
<div
|
||||||
<CardTitle className="text-lg">Questions</CardTitle>
|
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"
|
||||||
<CardDescription>Manage questions, quizzes, and assessments</CardDescription>
|
style={{
|
||||||
</CardHeader>
|
background:
|
||||||
<CardContent>
|
"linear-gradient(135deg, rgba(158,40,145,0.08) 0%, rgba(106,27,154,0.04) 100%)",
|
||||||
<Link to="/content/questions">
|
}}
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Questions</Button>
|
>
|
||||||
|
<Icon className="h-5.5 w-5.5 transition-transform duration-300 group-hover:scale-110" />
|
||||||
|
</div>
|
||||||
|
{/* 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 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>
|
</Link>
|
||||||
</CardContent>
|
)
|
||||||
</Card>
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { FolderOpen } from "lucide-react"
|
import { FolderOpen, RefreshCw, AlertCircle, BookOpen } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { getCourseCategories } from "../../api/courses.api"
|
import { getCourseCategories } from "../../api/courses.api"
|
||||||
import type { CourseCategory } from "../../types/course.types"
|
import type { CourseCategory } from "../../types/course.types"
|
||||||
|
|
@ -10,63 +10,121 @@ export function CourseCategoryPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchCategories = async () => {
|
||||||
const fetchCategories = async () => {
|
setLoading(true)
|
||||||
try {
|
setError(null)
|
||||||
const res = await getCourseCategories()
|
try {
|
||||||
setCategories(res.data.data.categories)
|
const res = await getCourseCategories()
|
||||||
} catch (err) {
|
setCategories(res.data.data.categories)
|
||||||
console.error("Failed to fetch categories:", err)
|
} catch (err) {
|
||||||
setError("Failed to load categories")
|
console.error("Failed to fetch categories:", err)
|
||||||
} finally {
|
setError("Failed to load categories")
|
||||||
setLoading(false)
|
} finally {
|
||||||
}
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center gap-4 py-24">
|
||||||
<div className="text-sm text-grayScale-500">Loading categories...</div>
|
<div className="relative">
|
||||||
|
<div className="h-12 w-12 rounded-full border-4 border-brand-100" />
|
||||||
|
<div className="absolute inset-0 h-12 w-12 animate-spin rounded-full border-4 border-transparent border-t-brand-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-grayScale-400">Loading categories…</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-24">
|
||||||
<div className="text-sm text-red-500">{error}</div>
|
<div className="flex flex-col items-center gap-4 rounded-2xl border border-red-100 bg-red-50/60 px-10 py-8 text-center shadow-sm">
|
||||||
|
<div className="grid h-12 w-12 place-items-center rounded-full bg-red-100">
|
||||||
|
<AlertCircle className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-red-700">{error}</p>
|
||||||
|
<p className="mt-1 text-xs text-red-400">
|
||||||
|
Please check your connection and try again
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchCategories}
|
||||||
|
className="mt-1 inline-flex items-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Course Categories</h1>
|
{/* Page header */}
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div>
|
||||||
{categories.map((category) => (
|
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
|
||||||
<Link key={category.id} to={`/content/category/${category.id}/courses`} className="group">
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
<Card className="h-full shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-brand-200">
|
Browse and manage your course categories below
|
||||||
<CardHeader>
|
</p>
|
||||||
<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">
|
|
||||||
<FolderOpen className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-lg">{category.name}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<span className="text-sm font-medium text-brand-500 group-hover:text-brand-600">
|
|
||||||
View Courses →
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{categories.length === 0 && (
|
{categories.length === 0 ? (
|
||||||
<div className="text-center text-sm text-grayScale-500">No categories found</div>
|
<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="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 font-semibold text-grayScale-600 transition-colors group-hover:text-grayScale-700">
|
||||||
|
{category.name}
|
||||||
|
</CardTitle>
|
||||||
|
</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 Courses
|
||||||
|
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState, useRef } from "react"
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { Plus, ArrowLeft, BookOpen, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit } from "lucide-react"
|
import { Plus, ArrowLeft, BookOpen, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit, RefreshCw, AlertCircle } from "lucide-react"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
|
@ -237,55 +237,73 @@ export function CoursesPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<div className="text-sm text-grayScale-500">Loading courses...</div>
|
<div className="rounded-2xl bg-brand-50/50 p-6">
|
||||||
|
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-32">
|
||||||
<div className="text-sm text-red-500">{error}</div>
|
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
|
||||||
|
<div className="rounded-full bg-red-100 p-2">
|
||||||
|
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="rounded-2xl border border-grayScale-100 bg-white px-5 py-4 shadow-sm sm:px-6 sm:py-5">
|
||||||
<Link
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
to="/content"
|
<div className="flex items-center gap-3.5">
|
||||||
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"
|
<Link
|
||||||
>
|
to="/content"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
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"
|
||||||
</Link>
|
>
|
||||||
<div>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">
|
</Link>
|
||||||
{category?.name} Courses
|
<div>
|
||||||
</h1>
|
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">
|
||||||
<p className="text-sm text-grayScale-500">{courses.length} courses available</p>
|
{category?.name} Courses
|
||||||
|
</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
|
<span className="font-medium text-grayScale-500">{courses.length}</span> courses available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleOpenModal}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add New Course
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Course grid or empty state */}
|
||||||
{courses.length === 0 ? (
|
{courses.length === 0 ? (
|
||||||
<Card className="shadow-none">
|
<Card className="border-dashed border-grayScale-200 shadow-none">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-20">
|
||||||
<BookOpen className="mb-4 h-12 w-12 text-grayScale-300" />
|
<div className="rounded-2xl bg-grayScale-50 p-5">
|
||||||
<p className="text-sm text-grayScale-500">No courses found in this category</p>
|
<BookOpen className="h-14 w-14 text-grayScale-300" />
|
||||||
<Button variant="outline" className="mt-4" onClick={handleOpenModal}>
|
</div>
|
||||||
|
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No courses yet</h3>
|
||||||
|
<p className="mt-1.5 text-sm text-grayScale-400">No courses found in this category</p>
|
||||||
|
<Button variant="outline" className="mt-6 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50" onClick={handleOpenModal}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add your first course
|
Add your first course
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{courses.map((course, index) => {
|
{courses.map((course, index) => {
|
||||||
const gradients = [
|
const gradients = [
|
||||||
"bg-gradient-to-br from-blue-100 to-blue-200",
|
"bg-gradient-to-br from-blue-100 to-blue-200",
|
||||||
|
|
@ -296,48 +314,49 @@ export function CoursesPage() {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={course.id}
|
key={course.id}
|
||||||
className="cursor-pointer overflow-hidden border-0 bg-white shadow-sm transition hover:shadow-md"
|
className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-grayScale-200"
|
||||||
onClick={() => handleCourseClick(course.id)}
|
onClick={() => handleCourseClick(course.id)}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="relative aspect-video w-full">
|
<div className="relative aspect-video w-full overflow-hidden">
|
||||||
<CourseThumbnail
|
<CourseThumbnail
|
||||||
src={course.thumbnail}
|
src={course.thumbnail}
|
||||||
alt={course.title}
|
alt={course.title}
|
||||||
gradient={gradients[index % gradients.length]}
|
gradient={gradients[index % gradients.length]}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-4 space-y-3">
|
<div className="space-y-3 border-t border-grayScale-50 p-4">
|
||||||
{/* Status and menu */}
|
{/* Status and menu */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Badge
|
<Badge
|
||||||
className={`text-xs font-medium ${
|
className={`rounded-full px-2.5 py-0.5 text-[11px] font-semibold tracking-wide ${
|
||||||
course.is_active
|
course.is_active
|
||||||
? "bg-transparent text-green-600 border border-green-200"
|
? "border-0 bg-emerald-50 text-emerald-700"
|
||||||
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
|
: "border-0 bg-grayScale-100 text-grayScale-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-green-500" : "bg-grayScale-400"}`} />
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-emerald-500" : "bg-grayScale-400"}`} />
|
||||||
{course.is_active ? "ACTIVE" : "INACTIVE"}
|
{course.is_active ? "ACTIVE" : "INACTIVE"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}>
|
<div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenMenuId(openMenuId === course.id ? null : course.id)}
|
onClick={() => setOpenMenuId(openMenuId === course.id ? null : course.id)}
|
||||||
className="text-grayScale-400 hover:text-grayScale-600"
|
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{openMenuId === course.id && (
|
{openMenuId === course.id && (
|
||||||
<div className="absolute right-0 top-full z-10 mt-1 w-40 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">
|
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 animate-in fade-in slide-in-from-top-1 rounded-xl border border-grayScale-100 bg-white py-1.5 shadow-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleToggleStatus(course)
|
handleToggleStatus(course)
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null)
|
||||||
}}
|
}}
|
||||||
disabled={togglingId === course.id}
|
disabled={togglingId === course.id}
|
||||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-grayScale-700 hover:bg-grayScale-100 disabled:opacity-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{course.is_active ? (
|
{course.is_active ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -351,12 +370,13 @@ export function CoursesPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="mx-3 my-1 border-t border-grayScale-100" />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDeleteClick(course)
|
handleDeleteClick(course)
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null)
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-red-500 transition-colors hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -367,15 +387,15 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3 className="font-medium text-grayScale-900">{course.title}</h3>
|
<h3 className="font-semibold text-grayScale-700 line-clamp-1">{course.title}</h3>
|
||||||
<p className="text-sm text-grayScale-500 line-clamp-2">
|
<p className="text-sm leading-relaxed text-grayScale-400 line-clamp-2">
|
||||||
{course.description || "No description available"}
|
{course.description || "No description available"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Edit button */}
|
{/* Edit button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-grayScale-200 text-grayScale-700"
|
className="w-full border-grayScale-200 text-grayScale-600 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleEditClick(course)
|
handleEditClick(course)
|
||||||
|
|
@ -391,22 +411,24 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add Course Modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Add New Course</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCloseModal}
|
onClick={handleCloseModal}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">
|
<div className="flex items-center gap-2.5 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
{saveError}
|
{saveError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -416,7 +438,7 @@ export function CoursesPage() {
|
||||||
htmlFor="course-title"
|
htmlFor="course-title"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Title
|
Title <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="course-title"
|
id="course-title"
|
||||||
|
|
@ -431,7 +453,7 @@ export function CoursesPage() {
|
||||||
htmlFor="course-description"
|
htmlFor="course-description"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Description
|
Description <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="course-description"
|
id="course-description"
|
||||||
|
|
@ -439,21 +461,21 @@ export function CoursesPage() {
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="flex w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex w-full rounded-xl border border-grayScale-200 bg-white px-3.5 py-2.5 text-sm transition-colors ring-offset-background placeholder:text-grayScale-400 focus-visible:border-brand-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-grayScale-500">
|
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
|
||||||
Category: <span className="font-medium">{category?.name}</span>
|
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={handleCloseModal} disabled={saving}>
|
<Button variant="outline" onClick={handleCloseModal} disabled={saving} className="w-full sm:w-auto">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
|
|
@ -464,22 +486,24 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Course Modal */}
|
||||||
{showEditModal && courseToEdit && (
|
{showEditModal && courseToEdit && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Edit Course</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Edit Course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCloseEditModal}
|
onClick={handleCloseEditModal}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
{updateError && (
|
{updateError && (
|
||||||
<div className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">
|
<div className="flex items-center gap-2.5 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
{updateError}
|
{updateError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -489,7 +513,7 @@ export function CoursesPage() {
|
||||||
htmlFor="edit-course-title"
|
htmlFor="edit-course-title"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Title
|
Title <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-course-title"
|
id="edit-course-title"
|
||||||
|
|
@ -504,7 +528,7 @@ export function CoursesPage() {
|
||||||
htmlFor="edit-course-description"
|
htmlFor="edit-course-description"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Description
|
Description <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-course-description"
|
id="edit-course-description"
|
||||||
|
|
@ -512,7 +536,7 @@ export function CoursesPage() {
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="flex w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex w-full rounded-xl border border-grayScale-200 bg-white px-3.5 py-2.5 text-sm transition-colors ring-offset-background placeholder:text-grayScale-400 focus-visible:border-brand-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -532,12 +556,12 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating}>
|
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating} className="w-full sm:w-auto">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto"
|
||||||
onClick={handleUpdate}
|
onClick={handleUpdate}
|
||||||
disabled={updating}
|
disabled={updating}
|
||||||
>
|
>
|
||||||
|
|
@ -548,37 +572,42 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Course Modal */}
|
||||||
{showDeleteModal && courseToDelete && (
|
{showDeleteModal && courseToDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Delete Course</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Delete Course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-6">
|
||||||
<p className="text-sm text-grayScale-600">
|
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||||
|
<Trash2 className="h-5 w-5 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-semibold">{courseToDelete.title}</span>? This action cannot
|
<span className="font-semibold text-grayScale-700">{courseToDelete.title}</span>? This action cannot
|
||||||
be undone.
|
be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-red-500 hover:bg-red-600"
|
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
||||||
onClick={handleConfirmDelete}
|
onClick={handleConfirmDelete}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -50,38 +50,47 @@ export function PracticeDetailsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Practice Management</h1>
|
{/* Page Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">Manage your practice details, leadership, and members</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
|
||||||
{/* Practice Leadership */}
|
{/* Practice Leadership */}
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Practice Leadership</h2>
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Leadership</h2>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsLeaderModalOpen(true)}
|
onClick={() => setIsLeaderModalOpen(true)}
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add New Leader
|
Add New Leader
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{mockLeaders.map((leader) => (
|
{mockLeaders.map((leader) => (
|
||||||
<div
|
<div
|
||||||
key={leader.id}
|
key={leader.id}
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<p className="font-medium text-grayScale-900">{leader.name}</p>
|
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
||||||
<p className="text-sm text-grayScale-600">{leader.role}</p>
|
{leader.name[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-grayScale-600">{leader.name}</p>
|
||||||
|
<p className="text-xs text-grayScale-400">{leader.role}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -91,11 +100,11 @@ export function PracticeDetailsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Practice Details */}
|
{/* Practice Details */}
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-900">Practice Details</h2>
|
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">Practice Details</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Practice Name
|
Practice Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -106,7 +115,7 @@ export function PracticeDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Practice Description
|
Practice Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -118,7 +127,7 @@ export function PracticeDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Practice Type
|
Practice Type
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -133,7 +142,7 @@ export function PracticeDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Practice Address
|
Practice Address
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -142,7 +151,7 @@ export function PracticeDetailsPage() {
|
||||||
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
|
||||||
placeholder="Street"
|
placeholder="Street"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={formData.city}
|
value={formData.city}
|
||||||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||||
|
|
@ -162,39 +171,46 @@ export function PracticeDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600">Save Changes</Button>
|
<Button className="w-full bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Practice Members */}
|
{/* Practice Members */}
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Practice Members</h2>
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Members</h2>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsMemberModalOpen(true)}
|
onClick={() => setIsMemberModalOpen(true)}
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add New Member
|
Add New Member
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{mockMembers.map((member) => (
|
{mockMembers.map((member) => (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<p className="font-medium text-grayScale-900">{member.name}</p>
|
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
||||||
<p className="text-sm text-grayScale-600">{member.role}</p>
|
{member.name[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-grayScale-600">{member.name}</p>
|
||||||
|
<p className="text-xs text-grayScale-400">{member.role}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,13 +221,13 @@ export function PracticeDetailsPage() {
|
||||||
|
|
||||||
{/* Add Member Modal */}
|
{/* Add Member Modal */}
|
||||||
<Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}>
|
<Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:rounded-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Member</DialogTitle>
|
<DialogTitle>Add New Member</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-5 py-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Member Name
|
Member Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -221,7 +237,7 @@ export function PracticeDetailsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Member Role
|
Member Role
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -235,7 +251,7 @@ export function PracticeDetailsPage() {
|
||||||
<Button variant="outline" onClick={() => setIsMemberModalOpen(false)}>
|
<Button variant="outline" onClick={() => setIsMemberModalOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAddMember} className="bg-brand-500 hover:bg-brand-600">
|
<Button onClick={handleAddMember} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
Add Member
|
Add Member
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -244,13 +260,13 @@ export function PracticeDetailsPage() {
|
||||||
|
|
||||||
{/* Add Leader Modal */}
|
{/* Add Leader Modal */}
|
||||||
<Dialog open={isLeaderModalOpen} onOpenChange={setIsLeaderModalOpen}>
|
<Dialog open={isLeaderModalOpen} onOpenChange={setIsLeaderModalOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:rounded-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Leader</DialogTitle>
|
<DialogTitle>Add New Leader</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-5 py-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Leader Name
|
Leader Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -260,7 +276,7 @@ export function PracticeDetailsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Leader Role
|
Leader Role
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -274,7 +290,7 @@ export function PracticeDetailsPage() {
|
||||||
<Button variant="outline" onClick={() => setIsLeaderModalOpen(false)}>
|
<Button variant="outline" onClick={() => setIsLeaderModalOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAddLeader} className="bg-brand-500 hover:bg-brand-600">
|
<Button onClick={handleAddLeader} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
Add Leader
|
Add Leader
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -283,4 +299,3 @@ export function PracticeDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,43 +28,52 @@ export function PracticeMembersPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Practice Management</h1>
|
{/* Page Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">View and manage your practice members</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Current Members</h2>
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Current Members</h2>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Members
|
Add Members
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4 md:grid-cols-6">
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-6">
|
||||||
{mockMembers.map((member) => (
|
{mockMembers.map((member) => (
|
||||||
<div key={member.id} className="flex flex-col items-center">
|
<div
|
||||||
<Avatar className="h-16 w-16 border-2 border-grayScale-200">
|
key={member.id}
|
||||||
|
className="group flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<Avatar className="h-16 w-16 border-2 border-grayScale-200 transition-all duration-200 group-hover:border-brand-400 group-hover:shadow-md group-hover:scale-105">
|
||||||
<AvatarImage src={member.avatar} />
|
<AvatarImage src={member.avatar} />
|
||||||
<AvatarFallback className="bg-brand-100 text-brand-600">
|
<AvatarFallback className="bg-brand-100 text-brand-600 font-medium">
|
||||||
{member.name[0]}
|
{member.name[0]}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="mt-2 text-sm font-medium text-grayScale-700">{member.name}</span>
|
<span className="mt-2.5 text-sm font-medium text-grayScale-500 transition-colors group-hover:text-grayScale-600">
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:rounded-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Member</DialogTitle>
|
<DialogTitle>Add New Member</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-5 py-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Member Name
|
Member Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -74,7 +83,7 @@ export function PracticeMembersPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Member Role
|
Member Role
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -88,7 +97,7 @@ export function PracticeMembersPage() {
|
||||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAddMember} className="bg-brand-500 hover:bg-brand-600">
|
<Button onClick={handleAddMember} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
Add Member
|
Add Member
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -97,4 +106,3 @@ export function PracticeMembersPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,33 +170,37 @@ export function PracticeQuestionsPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<div className="text-sm text-grayScale-500">Loading questions...</div>
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-grayScale-200 border-t-brand-500" />
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading questions...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<div className="text-sm text-red-500">{error}</div>
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
|
<X className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-medium text-red-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
to={backLink}
|
to={backLink}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600"
|
className="group grid h-9 w-9 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition-all hover:bg-brand-100 hover:text-brand-600 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Practice Questions</h1>
|
<h1 className="text-xl font-bold tracking-tight text-grayScale-900">Practice Questions</h1>
|
||||||
<p className="text-sm text-grayScale-500">{questions.length} questions available</p>
|
<p className="mt-0.5 text-sm text-grayScale-500">{questions.length} questions available</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}>
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}>
|
||||||
|
|
@ -206,11 +210,15 @@ export function PracticeQuestionsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{questions.length === 0 ? (
|
{questions.length === 0 ? (
|
||||||
<Card className="shadow-none">
|
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
<HelpCircle className="mb-4 h-12 w-12 text-grayScale-300" />
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-grayScale-100">
|
||||||
<p className="text-sm text-grayScale-500">No questions found for this practice</p>
|
<HelpCircle className="h-8 w-8 text-grayScale-300" />
|
||||||
<Button variant="outline" className="mt-4" onClick={handleAddQuestion}>
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-600">No questions found for this practice</p>
|
||||||
|
<p className="mt-1 text-xs text-grayScale-400">Get started by adding your first question</p>
|
||||||
|
<Button variant="outline" className="mt-6 border-brand-200 text-brand-600 hover:bg-brand-50" onClick={handleAddQuestion}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add your first question
|
Add your first question
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -218,22 +226,22 @@ export function PracticeQuestionsPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{questions.map((question, index) => (
|
{questions.map((question, index) => (
|
||||||
<Card key={question.id} className="shadow-sm">
|
<Card key={question.id} className="shadow-sm transition-shadow hover:shadow-md">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-100 text-sm font-bold text-brand-600 shadow-sm">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<Badge className={typeColors[question.type]}>
|
<Badge className={`${typeColors[question.type]} font-medium`}>
|
||||||
{typeLabels[question.type]}
|
{typeLabels[question.type]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8 rounded-lg text-grayScale-400 hover:text-brand-600"
|
||||||
onClick={() => handleEditClick(question)}
|
onClick={() => handleEditClick(question)}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
|
|
@ -241,24 +249,24 @@ export function PracticeQuestionsPage() {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-red-500 hover:text-red-600"
|
className="h-8 w-8 rounded-lg text-grayScale-400 hover:bg-red-50 hover:text-red-500"
|
||||||
onClick={() => handleDeleteClick(question)}
|
onClick={() => handleDeleteClick(question)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="mt-3 text-base font-medium">{question.question}</CardTitle>
|
<CardTitle className="mt-3 text-base font-medium leading-relaxed">{question.question}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-0">
|
<CardContent className="space-y-3 pt-0">
|
||||||
<div className="rounded-lg bg-grayScale-50 p-3">
|
<div className="rounded-lg border border-grayScale-100 bg-grayScale-50 p-4">
|
||||||
<p className="text-xs font-medium text-grayScale-500">Sample Answer</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400">Sample Answer</p>
|
||||||
<p className="mt-1 text-sm text-grayScale-700">{question.sample_answer}</p>
|
<p className="mt-2 text-sm leading-relaxed text-grayScale-700">{question.sample_answer}</p>
|
||||||
</div>
|
</div>
|
||||||
{question.tips && (
|
{question.tips && (
|
||||||
<div className="rounded-lg bg-amber-50 p-3">
|
<div className="rounded-lg border border-amber-100 bg-amber-50 p-4">
|
||||||
<p className="text-xs font-medium text-amber-600">Tips</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-amber-500">💡 Tips</p>
|
||||||
<p className="mt-1 text-sm text-amber-700">{question.tips}</p>
|
<p className="mt-2 text-sm leading-relaxed text-amber-700">{question.tips}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -269,23 +277,23 @@ export function PracticeQuestionsPage() {
|
||||||
|
|
||||||
{/* Delete Modal */}
|
{/* Delete Modal */}
|
||||||
{showDeleteModal && questionToDelete && (
|
{showDeleteModal && questionToDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-sm rounded-xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Delete Question</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Delete Question</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-6">
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm leading-relaxed text-grayScale-600">
|
||||||
Are you sure you want to delete this question? This action cannot be undone.
|
Are you sure you want to delete this question? This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
|
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -299,19 +307,19 @@ export function PracticeQuestionsPage() {
|
||||||
|
|
||||||
{/* Add Modal */}
|
{/* Add Modal */}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-xl">
|
<div className="mx-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Add New Question</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Add New Question</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(false)}
|
onClick={() => setShowAddModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Question Type</label>
|
<label className="text-sm font-medium text-grayScale-700">Question Type</label>
|
||||||
<Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}>
|
<Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}>
|
||||||
|
|
@ -326,7 +334,7 @@ export function PracticeQuestionsPage() {
|
||||||
value={questionText}
|
value={questionText}
|
||||||
onChange={(e) => setQuestionText(e.target.value)}
|
onChange={(e) => setQuestionText(e.target.value)}
|
||||||
placeholder="Enter your question"
|
placeholder="Enter your question"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -336,7 +344,7 @@ export function PracticeQuestionsPage() {
|
||||||
value={sampleAnswer}
|
value={sampleAnswer}
|
||||||
onChange={(e) => setSampleAnswer(e.target.value)}
|
onChange={(e) => setSampleAnswer(e.target.value)}
|
||||||
placeholder="Enter the sample answer"
|
placeholder="Enter the sample answer"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -346,7 +354,7 @@ export function PracticeQuestionsPage() {
|
||||||
value={tips}
|
value={tips}
|
||||||
onChange={(e) => setTips(e.target.value)}
|
onChange={(e) => setTips(e.target.value)}
|
||||||
placeholder="Enter helpful tips"
|
placeholder="Enter helpful tips"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -366,10 +374,14 @@ export function PracticeQuestionsPage() {
|
||||||
placeholder="Voice prompt for sample answer"
|
placeholder="Voice prompt for sample answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
{saveError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2">
|
||||||
|
<p className="text-sm font-medium text-red-600">{saveError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={() => setShowAddModal(false)} disabled={saving}>
|
<Button variant="outline" onClick={() => setShowAddModal(false)} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -387,19 +399,19 @@ export function PracticeQuestionsPage() {
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{showEditModal && questionToEdit && (
|
{showEditModal && questionToEdit && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-xl">
|
<div className="mx-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Edit Question</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Edit Question</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditModal(false)}
|
onClick={() => setShowEditModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Question Type</label>
|
<label className="text-sm font-medium text-grayScale-700">Question Type</label>
|
||||||
<Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}>
|
<Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}>
|
||||||
|
|
@ -414,7 +426,7 @@ export function PracticeQuestionsPage() {
|
||||||
value={questionText}
|
value={questionText}
|
||||||
onChange={(e) => setQuestionText(e.target.value)}
|
onChange={(e) => setQuestionText(e.target.value)}
|
||||||
placeholder="Enter your question"
|
placeholder="Enter your question"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -424,7 +436,7 @@ export function PracticeQuestionsPage() {
|
||||||
value={sampleAnswer}
|
value={sampleAnswer}
|
||||||
onChange={(e) => setSampleAnswer(e.target.value)}
|
onChange={(e) => setSampleAnswer(e.target.value)}
|
||||||
placeholder="Enter the sample answer"
|
placeholder="Enter the sample answer"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -434,7 +446,7 @@ export function PracticeQuestionsPage() {
|
||||||
value={tips}
|
value={tips}
|
||||||
onChange={(e) => setTips(e.target.value)}
|
onChange={(e) => setTips(e.target.value)}
|
||||||
placeholder="Enter helpful tips"
|
placeholder="Enter helpful tips"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -454,10 +466,14 @@ export function PracticeQuestionsPage() {
|
||||||
placeholder="Voice prompt for sample answer"
|
placeholder="Voice prompt for sample answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
{saveError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2">
|
||||||
|
<p className="text-sm font-medium text-red-600">{saveError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={() => setShowEditModal(false)} disabled={saving}>
|
<Button variant="outline" onClick={() => setShowEditModal(false)} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { Plus, Search, Edit, Trash2 } from "lucide-react"
|
import { Plus, Search, Edit, Trash2, HelpCircle } from "lucide-react"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
|
@ -95,9 +95,9 @@ const typeLabels: Record<QuestionType, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeColors: Record<QuestionType, string> = {
|
const typeColors: Record<QuestionType, string> = {
|
||||||
"multiple-choice": "bg-blue-100 text-blue-700",
|
"multiple-choice": "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
|
||||||
"short-answer": "bg-green-100 text-green-700",
|
"short-answer": "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
|
||||||
"true-false": "bg-purple-100 text-purple-700",
|
"true-false": "bg-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionsPage() {
|
export function QuestionsPage() {
|
||||||
|
|
@ -126,35 +126,45 @@ export function QuestionsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
{/* Page Header */}
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Questions</h1>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Link to="/content/questions/add">
|
<div>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
|
||||||
|
Questions
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-400">
|
||||||
|
Create and manage your question bank
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/content/questions/add" className="w-full sm:w-auto">
|
||||||
|
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add New Question
|
Add New Question
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-soft">
|
||||||
<CardHeader>
|
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||||
<CardTitle>Question Management</CardTitle>
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
|
Question Management
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-5 pt-5">
|
||||||
{/* Search and Filters */}
|
{/* Search and Filters */}
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search questions..."
|
placeholder="Search questions..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-10 transition-colors focus:border-brand-300 focus:ring-brand-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
|
<Select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
|
||||||
<option value="all">All Types</option>
|
<option value="all">All Types</option>
|
||||||
<option value="multiple-choice">Multiple Choice</option>
|
<option value="multiple-choice">Multiple Choice</option>
|
||||||
|
|
@ -190,42 +200,63 @@ export function QuestionsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<div className="text-sm text-grayScale-500">
|
<div className="text-xs font-medium text-grayScale-400">
|
||||||
Showing {filteredQuestions.length} of {questions.length} questions
|
Showing {filteredQuestions.length} of {questions.length} questions
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Questions Table */}
|
{/* Questions Table */}
|
||||||
{filteredQuestions.length > 0 ? (
|
{filteredQuestions.length > 0 ? (
|
||||||
<div className="rounded-lg border">
|
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||||
<TableHead>Question</TableHead>
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
<TableHead>Type</TableHead>
|
Question
|
||||||
<TableHead>Category</TableHead>
|
</TableHead>
|
||||||
<TableHead>Difficulty</TableHead>
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
<TableHead>Points</TableHead>
|
Type
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
|
Category
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
|
Difficulty
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Points
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredQuestions.map((question) => (
|
{filteredQuestions.map((question, index) => (
|
||||||
<TableRow key={question.id}>
|
<TableRow
|
||||||
<TableCell className="max-w-md">
|
key={question.id}
|
||||||
<div className="truncate font-medium">{question.question}</div>
|
className={`transition-colors hover:bg-brand-100/30 ${
|
||||||
|
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-md py-3.5">
|
||||||
|
<div className="truncate text-sm font-medium text-grayScale-600">
|
||||||
|
{question.question}
|
||||||
|
</div>
|
||||||
{question.type === "multiple-choice" && question.options.length > 0 && (
|
{question.type === "multiple-choice" && question.options.length > 0 && (
|
||||||
<div className="mt-1 text-xs text-grayScale-400">
|
<div className="mt-1 truncate text-xs text-grayScale-400">
|
||||||
Options: {question.options.join(", ")}
|
Options: {question.options.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="py-3.5">
|
||||||
<Badge className={typeColors[question.type]}>
|
<Badge className={`text-xs font-medium ${typeColors[question.type]}`}>
|
||||||
{typeLabels[question.type]}
|
{typeLabels[question.type]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{question.category || "-"}</TableCell>
|
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
||||||
<TableCell>
|
{question.category || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden py-3.5 md:table-cell">
|
||||||
{question.difficulty && (
|
{question.difficulty && (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
|
|
@ -240,20 +271,27 @@ export function QuestionsPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{question.points}</TableCell>
|
<TableCell className="py-3.5 text-sm font-semibold text-grayScale-600">
|
||||||
<TableCell className="text-right">
|
{question.points}
|
||||||
<div className="flex justify-end gap-2">
|
</TableCell>
|
||||||
|
<TableCell className="py-3.5 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Link to={`/content/questions/edit/${question.id}`}>
|
<Link to={`/content/questions/edit/${question.id}`}>
|
||||||
<Button variant="ghost" size="icon">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
|
||||||
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
className="h-8 w-8 text-grayScale-400 hover:bg-red-50 hover:text-destructive"
|
||||||
onClick={() => handleDelete(question.id)}
|
onClick={() => handleDelete(question.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -263,8 +301,17 @@ export function QuestionsPage() {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-12 text-center text-grayScale-400">
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">
|
||||||
<p>No questions found matching your criteria.</p>
|
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
|
||||||
|
<HelpCircle className="h-8 w-8 text-grayScale-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-base font-semibold text-grayScale-600">
|
||||||
|
No questions found
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-grayScale-400">
|
||||||
|
Try adjusting your search or filter criteria to find what you're
|
||||||
|
looking for.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -272,4 +319,3 @@ export function QuestionsPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,49 @@
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { Plus } from "lucide-react"
|
import { Plus, Mic } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
|
|
||||||
export function SpeakingPage() {
|
export function SpeakingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Speaking</h1>
|
<div>
|
||||||
<Link to="/content/speaking/add-practice">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600">
|
Speaking
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-400">
|
||||||
|
Create and manage speaking practice sessions for your learners.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/content/speaking/add-practice" className="w-full sm:w-auto">
|
||||||
|
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add New Practice
|
Add New Practice
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Card className="shadow-none">
|
|
||||||
<CardHeader>
|
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
|
||||||
<CardTitle>Speaking Practice Management</CardTitle>
|
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
</CardHeader>
|
<div className="mb-6 grid h-20 w-20 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<Mic className="h-10 w-10 text-brand-500" />
|
||||||
Manage speaking practice sessions and exercises here.
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-grayScale-600">
|
||||||
|
No speaking practices yet
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
|
||||||
|
Get started by adding your first speaking practice session. Your
|
||||||
|
learners will be able to practice pronunciation and conversation
|
||||||
|
skills.
|
||||||
|
</p>
|
||||||
|
<Link to="/content/speaking/add-practice" className="mt-8">
|
||||||
|
<Button className="bg-brand-500 px-6 hover:bg-brand-600">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Your First Practice
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -285,16 +285,20 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<div className="text-sm text-grayScale-500">Loading sub-course...</div>
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading sub-course…</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<div className="text-sm text-red-500">{error}</div>
|
<div className="rounded-full bg-red-50 p-3">
|
||||||
|
<X className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-medium text-red-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -304,37 +308,37 @@ export function SubCourseContentPage() {
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Link
|
<Link
|
||||||
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses`}
|
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses`}
|
||||||
className="inline-flex items-center gap-2 text-sm text-grayScale-600 hover:text-grayScale-900"
|
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-grayScale-500 transition-all hover:bg-grayScale-50 hover:text-grayScale-900"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
Back to Sub-courses
|
Back to Sub-courses
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* SubCourse Header */}
|
{/* SubCourse Header */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<h1 className="text-2xl font-semibold text-grayScale-900">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">
|
||||||
{subCourse?.title}
|
{subCourse?.title}
|
||||||
</h1>
|
</h1>
|
||||||
{subCourse?.level && (
|
{subCourse?.level && (
|
||||||
<Badge className="bg-purple-100 text-purple-700">{subCourse.level}</Badge>
|
<Badge className="rounded-full bg-purple-50 px-2.5 py-0.5 text-xs font-semibold text-purple-700 ring-1 ring-inset ring-purple-200">{subCourse.level}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-grayScale-500">
|
<p className="text-sm leading-relaxed text-grayScale-500">
|
||||||
{subCourse?.description || "No description available"}
|
{subCourse?.description || "No description available"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col gap-2.5 sm:flex-row sm:gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-brand-500 text-brand-500 hover:bg-brand-50"
|
className="border-brand-200 text-brand-600 transition-colors hover:border-brand-500 hover:bg-brand-50"
|
||||||
onClick={handleAddPractice}
|
onClick={handleAddPractice}
|
||||||
>
|
>
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
Add Practice
|
Add Practice
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddVideo}>
|
<Button className="bg-brand-500 shadow-sm transition-colors hover:bg-brand-600" onClick={handleAddVideo}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Video
|
Add Video
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -343,26 +347,32 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-grayScale-200">
|
<div className="border-b border-grayScale-200">
|
||||||
<div className="flex gap-8">
|
<div className="-mb-px flex gap-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("video")}
|
onClick={() => setActiveTab("video")}
|
||||||
className={`pb-3 text-sm font-medium transition-colors ${
|
className={`relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all ${
|
||||||
activeTab === "video"
|
activeTab === "video"
|
||||||
? "border-b-2 border-brand-500 text-brand-500"
|
? "text-brand-600"
|
||||||
: "text-grayScale-500 hover:text-grayScale-700"
|
: "text-grayScale-400 hover:text-grayScale-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Video
|
Video
|
||||||
|
{activeTab === "video" && (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("practice")}
|
onClick={() => setActiveTab("practice")}
|
||||||
className={`pb-3 text-sm font-medium transition-colors ${
|
className={`relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all ${
|
||||||
activeTab === "practice"
|
activeTab === "practice"
|
||||||
? "border-b-2 border-brand-500 text-brand-500"
|
? "text-brand-600"
|
||||||
: "text-grayScale-500 hover:text-grayScale-700"
|
: "text-grayScale-400 hover:text-grayScale-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Practice
|
Practice
|
||||||
|
{activeTab === "practice" && (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -373,83 +383,88 @@ export function SubCourseContentPage() {
|
||||||
{activeTab === "practice" && (
|
{activeTab === "practice" && (
|
||||||
<>
|
<>
|
||||||
{practicesLoading ? (
|
{practicesLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<div className="text-sm text-grayScale-500">Loading practices...</div>
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading practices…</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredPractices.length === 0 ? (
|
) : filteredPractices.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
|
||||||
<FileText className="mb-4 h-12 w-12 text-grayScale-300" />
|
<div className="rounded-full bg-brand-50 p-4">
|
||||||
<p className="text-sm text-grayScale-500">No practices found</p>
|
<FileText className="h-8 w-8 text-brand-400" />
|
||||||
<Button variant="outline" className="mt-4" onClick={handleAddPractice}>
|
</div>
|
||||||
Add your first practice
|
<p className="mt-4 text-sm font-semibold text-grayScale-700">No practices yet</p>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">Create your first practice to get started</p>
|
||||||
|
<Button variant="outline" className="mt-5 border-brand-200 text-brand-600 hover:bg-brand-50" onClick={handleAddPractice}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Practice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{filteredPractices.map((practice) => {
|
{filteredPractices.map((practice) => {
|
||||||
const statusConfig: Record<string, { bg: string; dot: string; text: string }> = {
|
const statusConfig: Record<string, { bg: string; dot: string; text: string }> = {
|
||||||
PUBLISHED: { bg: "bg-transparent border border-green-200 text-green-600", dot: "bg-green-500", text: "Published" },
|
PUBLISHED: { bg: "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200", dot: "bg-green-500", text: "Published" },
|
||||||
DRAFT: { bg: "bg-grayScale-100 border border-grayScale-200 text-grayScale-600", dot: "bg-grayScale-400", text: "Draft" },
|
DRAFT: { bg: "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200", dot: "bg-grayScale-400", text: "Draft" },
|
||||||
ARCHIVED: { bg: "bg-transparent border border-amber-200 text-amber-600", dot: "bg-amber-500", text: "Archived" },
|
ARCHIVED: { bg: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200", dot: "bg-amber-500", text: "Archived" },
|
||||||
}
|
}
|
||||||
const status = statusConfig[practice.status] ?? statusConfig.DRAFT
|
const status = statusConfig[practice.status] ?? statusConfig.DRAFT
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={practice.id}
|
key={practice.id}
|
||||||
className="cursor-pointer overflow-hidden border border-grayScale-200 shadow-sm transition hover:shadow-md hover:border-brand-200"
|
className="group cursor-pointer overflow-hidden rounded-xl border border-grayScale-200 bg-white shadow-sm transition-all duration-200 hover:border-brand-300 hover:shadow-md hover:ring-1 hover:ring-brand-100"
|
||||||
onClick={() => handlePracticeClick(practice.id)}
|
onClick={() => handlePracticeClick(practice.id)}
|
||||||
>
|
>
|
||||||
<div className="p-4 space-y-3">
|
<div className="flex h-full flex-col p-5 space-y-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h3 className="font-semibold text-grayScale-900 line-clamp-2">{practice.title}</h3>
|
<h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{practice.title}</h3>
|
||||||
<Badge className={`shrink-0 text-xs font-medium ${status.bg}`}>
|
<Badge className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ${status.bg}`}>
|
||||||
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${status.dot}`} />
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${status.dot}`} />
|
||||||
{status.text}
|
{status.text}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-grayScale-500 line-clamp-2">{practice.description}</p>
|
<p className="text-sm leading-relaxed text-grayScale-500 line-clamp-2">{practice.description}</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge className="bg-brand-50 text-brand-600 text-xs px-2 py-0.5 border border-brand-200">
|
<Badge className="rounded-full bg-brand-50 text-brand-600 text-[11px] font-medium px-2.5 py-0.5 ring-1 ring-inset ring-brand-200">
|
||||||
{practice.set_type}
|
{practice.set_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
{practice.persona && (
|
{practice.persona && (
|
||||||
<Badge className="bg-purple-50 text-purple-600 text-xs px-2 py-0.5 border border-purple-200">
|
<Badge className="rounded-full bg-purple-50 text-purple-600 text-[11px] font-medium px-2.5 py-0.5 ring-1 ring-inset ring-purple-200">
|
||||||
{practice.persona}
|
{practice.persona}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-grayScale-400">
|
<div className="flex items-center gap-3 text-xs text-grayScale-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<Layers className="h-3.5 w-3.5" />
|
<Layers className="h-3.5 w-3.5" />
|
||||||
<span>{practice.owner_type.replace("_", " ")}</span>
|
<span>{practice.owner_type.replace("_", " ")}</span>
|
||||||
</div>
|
</div>
|
||||||
{practice.shuffle_questions && (
|
{practice.shuffle_questions && (
|
||||||
<span className="text-amber-500">Shuffle ON</span>
|
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 pt-3">
|
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
|
||||||
<span className="text-xs text-grayScale-400">
|
<span className="text-xs font-medium text-grayScale-400">
|
||||||
{new Date(practice.created_at).toLocaleDateString("en-US", {
|
{new Date(practice.created_at).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditClick(practice)}
|
onClick={() => handleEditClick(practice)}
|
||||||
className="rounded p-1.5 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteClick(practice)}
|
onClick={() => handleDeleteClick(practice)}
|
||||||
className="rounded p-1.5 text-grayScale-400 hover:bg-red-50 hover:text-red-500"
|
className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -467,25 +482,30 @@ export function SubCourseContentPage() {
|
||||||
{activeTab === "video" && (
|
{activeTab === "video" && (
|
||||||
<>
|
<>
|
||||||
{videosLoading ? (
|
{videosLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<div className="text-sm text-grayScale-500">Loading videos...</div>
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading videos…</p>
|
||||||
</div>
|
</div>
|
||||||
) : videos.length === 0 ? (
|
) : videos.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
|
||||||
<Video className="mb-4 h-12 w-12 text-grayScale-300" />
|
<div className="rounded-full bg-brand-50 p-4">
|
||||||
<p className="text-sm text-grayScale-500">No videos found</p>
|
<Video className="h-8 w-8 text-brand-400" />
|
||||||
<Button variant="outline" className="mt-4" onClick={handleAddVideo}>
|
</div>
|
||||||
Add your first video
|
<p className="mt-4 text-sm font-semibold text-grayScale-700">No videos yet</p>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">Upload your first video to get started</p>
|
||||||
|
<Button variant="outline" className="mt-5 border-brand-200 text-brand-600 hover:bg-brand-50" onClick={handleAddVideo}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Video
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{videos.map((video, index) => {
|
{videos.map((video, index) => {
|
||||||
const gradients = [
|
const gradients = [
|
||||||
"bg-gradient-to-br from-blue-100 to-blue-200",
|
"bg-gradient-to-br from-blue-100 via-blue-50 to-indigo-100",
|
||||||
"bg-gradient-to-br from-yellow-100 to-yellow-200",
|
"bg-gradient-to-br from-amber-100 via-yellow-50 to-orange-100",
|
||||||
"bg-gradient-to-br from-purple-100 to-purple-200",
|
"bg-gradient-to-br from-purple-100 via-fuchsia-50 to-pink-100",
|
||||||
"bg-gradient-to-br from-green-100 to-green-200",
|
"bg-gradient-to-br from-emerald-100 via-green-50 to-teal-100",
|
||||||
]
|
]
|
||||||
const formatDuration = (seconds: number) => {
|
const formatDuration = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
|
|
@ -493,15 +513,17 @@ export function SubCourseContentPage() {
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Card key={video.id} className="overflow-hidden border-0 bg-white shadow-sm">
|
<Card key={video.id} className="group overflow-hidden rounded-xl border border-grayScale-200 bg-white shadow-sm transition-all duration-200 hover:border-brand-300 hover:shadow-md hover:ring-1 hover:ring-brand-100">
|
||||||
{/* Thumbnail with duration */}
|
{/* Thumbnail with duration */}
|
||||||
<div className="relative aspect-video w-full">
|
<div className="relative aspect-video w-full overflow-hidden">
|
||||||
{video.thumbnail ? (
|
{video.thumbnail ? (
|
||||||
<img src={video.thumbnail} alt={video.title} className="h-full w-full object-cover rounded-t-lg" />
|
<img src={video.thumbnail} alt={video.title} className="h-full w-full rounded-t-xl object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||||
) : (
|
) : (
|
||||||
<div className={`h-full w-full rounded-t-lg ${gradients[index % gradients.length]}`} />
|
<div className={`flex h-full w-full items-center justify-center rounded-t-xl ${gradients[index % gradients.length]}`}>
|
||||||
|
<Video className="h-10 w-10 text-white/40" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-2 right-2 rounded bg-grayScale-900/80 px-2 py-0.5 text-xs font-medium text-white">
|
<div className="absolute bottom-2.5 right-2.5 rounded-md bg-grayScale-900/75 px-2 py-0.5 text-xs font-semibold tabular-nums text-white backdrop-blur-sm">
|
||||||
{formatDuration(video.duration || 0)}
|
{formatDuration(video.duration || 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -511,10 +533,10 @@ export function SubCourseContentPage() {
|
||||||
{/* Status and menu */}
|
{/* Status and menu */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Badge
|
<Badge
|
||||||
className={`text-xs font-medium ${
|
className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${
|
||||||
video.is_published
|
video.is_published
|
||||||
? "bg-transparent text-green-600 border border-green-200"
|
? "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200"
|
||||||
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
|
: "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${video.is_published ? "bg-green-500" : "bg-grayScale-400"}`} />
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${video.is_published ? "bg-green-500" : "bg-grayScale-400"}`} />
|
||||||
|
|
@ -523,18 +545,18 @@ export function SubCourseContentPage() {
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenVideoMenuId(openVideoMenuId === video.id ? null : video.id)}
|
onClick={() => setOpenVideoMenuId(openVideoMenuId === video.id ? null : video.id)}
|
||||||
className="text-grayScale-400 hover:text-grayScale-600"
|
className="rounded-lg p-1.5 text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{openVideoMenuId === video.id && (
|
{openVideoMenuId === video.id && (
|
||||||
<div className="absolute right-0 top-full z-10 mt-1 w-32 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">
|
<div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-xl bg-white py-1.5 shadow-lg ring-1 ring-grayScale-200">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDeleteVideoClick(video)
|
handleDeleteVideoClick(video)
|
||||||
setOpenVideoMenuId(null)
|
setOpenVideoMenuId(null)
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50"
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm font-medium text-red-500 transition-colors hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -545,12 +567,12 @@ export function SubCourseContentPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3 className="font-medium text-grayScale-900">{video.title}</h3>
|
<h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{video.title}</h3>
|
||||||
|
|
||||||
{/* Edit button */}
|
{/* Edit button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-grayScale-200 text-grayScale-700"
|
className="w-full border-grayScale-200 text-grayScale-700 transition-colors hover:border-grayScale-300 hover:bg-grayScale-50"
|
||||||
onClick={() => handleEditVideoClick(video)}
|
onClick={() => handleEditVideoClick(video)}
|
||||||
>
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -559,7 +581,7 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
{/* Publish button */}
|
{/* Publish button */}
|
||||||
<Button
|
<Button
|
||||||
className={`w-full ${
|
className={`w-full shadow-sm transition-colors ${
|
||||||
video.is_published
|
video.is_published
|
||||||
? "bg-green-500 hover:bg-green-600"
|
? "bg-green-500 hover:bg-green-600"
|
||||||
: "bg-brand-500 hover:bg-brand-600"
|
: "bg-brand-500 hover:bg-brand-600"
|
||||||
|
|
@ -578,28 +600,28 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
{/* Delete Modal */}
|
{/* Delete Modal */}
|
||||||
{showDeleteModal && practiceToDelete && (
|
{showDeleteModal && practiceToDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Delete Practice</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Delete Practice</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm leading-relaxed text-grayScale-600">
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-semibold">{practiceToDelete.title}</span>? This action cannot be undone.
|
<span className="font-semibold text-grayScale-900">{practiceToDelete.title}</span>? This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
|
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-red-500 hover:bg-red-600" onClick={handleConfirmDelete} disabled={deleting}>
|
<Button className="bg-red-500 shadow-sm hover:bg-red-600" onClick={handleConfirmDelete} disabled={deleting}>
|
||||||
{deleting ? "Deleting..." : "Delete"}
|
{deleting ? "Deleting..." : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -609,19 +631,19 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
{/* Edit Practice Modal */}
|
{/* Edit Practice Modal */}
|
||||||
{showEditPracticeModal && practiceToEdit && (
|
{showEditPracticeModal && practiceToEdit && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Edit Practice</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Edit Practice</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditPracticeModal(false)}
|
onClick={() => setShowEditPracticeModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
|
|
@ -629,17 +651,17 @@ export function SubCourseContentPage() {
|
||||||
placeholder="Enter practice title"
|
placeholder="Enter practice title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter practice description"
|
placeholder="Enter practice description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Persona (Optional)</label>
|
<label className="text-sm font-medium text-grayScale-700">Persona (Optional)</label>
|
||||||
<Input
|
<Input
|
||||||
value={persona}
|
value={persona}
|
||||||
|
|
@ -647,14 +669,14 @@ export function SubCourseContentPage() {
|
||||||
placeholder="Enter persona"
|
placeholder="Enter persona"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
{saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowEditPracticeModal(false)} disabled={saving}>
|
<Button variant="outline" onClick={() => setShowEditPracticeModal(false)} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 shadow-sm hover:bg-brand-600"
|
||||||
onClick={handleSaveEditPractice}
|
onClick={handleSaveEditPractice}
|
||||||
disabled={saving || !title.trim()}
|
disabled={saving || !title.trim()}
|
||||||
>
|
>
|
||||||
|
|
@ -667,19 +689,19 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
{/* Add Video Modal */}
|
{/* Add Video Modal */}
|
||||||
{showAddVideoModal && (
|
{showAddVideoModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddVideoModal(false)}
|
onClick={() => setShowAddVideoModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
<Input
|
<Input
|
||||||
value={videoTitle}
|
value={videoTitle}
|
||||||
|
|
@ -687,17 +709,17 @@ export function SubCourseContentPage() {
|
||||||
placeholder="Enter video title"
|
placeholder="Enter video title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={videoDescription}
|
value={videoDescription}
|
||||||
onChange={(e) => setVideoDescription(e.target.value)}
|
onChange={(e) => setVideoDescription(e.target.value)}
|
||||||
placeholder="Enter video description"
|
placeholder="Enter video description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Source URL</label>
|
<label className="text-sm font-medium text-grayScale-700">Source URL</label>
|
||||||
<Input
|
<Input
|
||||||
value={videoUrl}
|
value={videoUrl}
|
||||||
|
|
@ -706,7 +728,7 @@ export function SubCourseContentPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">File Size (bytes)</label>
|
<label className="text-sm font-medium text-grayScale-700">File Size (bytes)</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -716,7 +738,7 @@ export function SubCourseContentPage() {
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Duration (seconds)</label>
|
<label className="text-sm font-medium text-grayScale-700">Duration (seconds)</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -727,14 +749,14 @@ export function SubCourseContentPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
{saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowAddVideoModal(false)} disabled={saving}>
|
<Button variant="outline" onClick={() => setShowAddVideoModal(false)} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 shadow-sm hover:bg-brand-600"
|
||||||
onClick={handleSaveNewVideo}
|
onClick={handleSaveNewVideo}
|
||||||
disabled={saving || !videoTitle.trim() || !videoUrl.trim()}
|
disabled={saving || !videoTitle.trim() || !videoUrl.trim()}
|
||||||
>
|
>
|
||||||
|
|
@ -747,19 +769,19 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
{/* Edit Video Modal */}
|
{/* Edit Video Modal */}
|
||||||
{showEditVideoModal && videoToEdit && (
|
{showEditVideoModal && videoToEdit && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Edit Video</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Edit Video</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditVideoModal(false)}
|
onClick={() => setShowEditVideoModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
<Input
|
<Input
|
||||||
value={videoTitle}
|
value={videoTitle}
|
||||||
|
|
@ -767,17 +789,17 @@ export function SubCourseContentPage() {
|
||||||
placeholder="Enter video title"
|
placeholder="Enter video title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={videoDescription}
|
value={videoDescription}
|
||||||
onChange={(e) => setVideoDescription(e.target.value)}
|
onChange={(e) => setVideoDescription(e.target.value)}
|
||||||
placeholder="Enter video description"
|
placeholder="Enter video description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Video URL</label>
|
<label className="text-sm font-medium text-grayScale-700">Video URL</label>
|
||||||
<Input
|
<Input
|
||||||
value={videoUrl}
|
value={videoUrl}
|
||||||
|
|
@ -785,14 +807,14 @@ export function SubCourseContentPage() {
|
||||||
placeholder="Enter video URL"
|
placeholder="Enter video URL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
{saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowEditVideoModal(false)} disabled={saving}>
|
<Button variant="outline" onClick={() => setShowEditVideoModal(false)} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 shadow-sm hover:bg-brand-600"
|
||||||
onClick={handleSaveEditVideo}
|
onClick={handleSaveEditVideo}
|
||||||
disabled={saving || !videoTitle.trim()}
|
disabled={saving || !videoTitle.trim()}
|
||||||
>
|
>
|
||||||
|
|
@ -805,28 +827,28 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
{/* Delete Video Modal */}
|
{/* Delete Video Modal */}
|
||||||
{showDeleteVideoModal && videoToDelete && (
|
{showDeleteVideoModal && videoToDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Delete Video</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Delete Video</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteVideoModal(false)}
|
onClick={() => setShowDeleteVideoModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm leading-relaxed text-grayScale-600">
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-semibold">{videoToDelete.title}</span>? This action cannot be undone.
|
<span className="font-semibold text-grayScale-900">{videoToDelete.title}</span>? This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowDeleteVideoModal(false)} disabled={deletingVideo}>
|
<Button variant="outline" onClick={() => setShowDeleteVideoModal(false)} disabled={deletingVideo}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-red-500 hover:bg-red-600" onClick={handleConfirmDeleteVideo} disabled={deletingVideo}>
|
<Button className="bg-red-500 shadow-sm hover:bg-red-600" onClick={handleConfirmDeleteVideo} disabled={deletingVideo}>
|
||||||
{deletingVideo ? "Deleting..." : "Delete"}
|
{deletingVideo ? "Deleting..." : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState, useRef } from "react"
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { ArrowLeft, Layers, ToggleLeft, ToggleRight, MoreVertical, X, Trash2 } from "lucide-react"
|
import { ArrowLeft, Layers, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, RefreshCw, AlertCircle, Edit } from "lucide-react"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
|
|
@ -198,57 +198,70 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
<div className="text-sm text-grayScale-500">Loading sub-courses...</div>
|
<div className="rounded-full bg-brand-50 p-4">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-24">
|
||||||
<div className="text-sm text-red-500">{error}</div>
|
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-xl border border-red-100 bg-red-50 px-5 py-4 shadow-md">
|
||||||
|
<div className="rounded-full bg-red-100 p-2">
|
||||||
|
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/content/category/${categoryId}/courses`}
|
to={`/content/category/${categoryId}/courses`}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600"
|
className="mt-1 grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-grayScale-200 bg-white text-grayScale-500 shadow-sm transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 text-xs text-grayScale-500">
|
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-grayScale-400">
|
||||||
<span>{category?.name}</span>
|
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{category?.name}</span>
|
||||||
<span>→</span>
|
<span className="shrink-0 text-grayScale-300">→</span>
|
||||||
<span>{course?.title}</span>
|
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{course?.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Sub-courses</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Sub-courses</h1>
|
||||||
<p className="text-sm text-grayScale-500">{subCourses.length} sub-courses available</p>
|
<p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddSubCourse}>
|
<Button className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleAddSubCourse}>
|
||||||
Add New Sub-course
|
Add New Sub-course
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-course grid or empty state */}
|
||||||
{subCourses.length === 0 ? (
|
{subCourses.length === 0 ? (
|
||||||
<Card className="shadow-none">
|
<Card className="border border-dashed border-grayScale-200 shadow-none">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
<Layers className="mb-4 h-12 w-12 text-grayScale-300" />
|
<div className="rounded-2xl bg-brand-50 p-5">
|
||||||
<p className="text-sm text-grayScale-500">No sub-courses found for this course</p>
|
<Layers className="h-10 w-10 text-brand-400" />
|
||||||
<Button variant="outline" className="mt-4" onClick={handleAddSubCourse}>
|
</div>
|
||||||
|
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No sub-courses yet</h3>
|
||||||
|
<p className="mt-1.5 max-w-xs text-center text-sm text-grayScale-400">Get started by adding your first sub-course to this course</p>
|
||||||
|
<Button className="mt-5 rounded-xl bg-brand-500 px-5 shadow-sm hover:bg-brand-600 hover:shadow-md" onClick={handleAddSubCourse}>
|
||||||
Add your first sub-course
|
Add your first sub-course
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{subCourses.map((subCourse, index) => {
|
{subCourses.map((subCourse, index) => {
|
||||||
const gradients = [
|
const gradients = [
|
||||||
"bg-gradient-to-br from-blue-100 to-blue-200",
|
"bg-gradient-to-br from-blue-100 to-blue-200",
|
||||||
|
|
@ -259,40 +272,40 @@ export function SubCoursesPage() {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={subCourse.id}
|
key={subCourse.id}
|
||||||
className="cursor-pointer overflow-hidden border-0 bg-white shadow-sm transition hover:shadow-md"
|
className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-soft hover:-translate-y-1 hover:border-brand-100"
|
||||||
onClick={() => handleSubCourseClick(subCourse.id)}
|
onClick={() => handleSubCourseClick(subCourse.id)}
|
||||||
>
|
>
|
||||||
{/* Thumbnail with level badge */}
|
{/* Thumbnail with level badge */}
|
||||||
<div className="relative aspect-video w-full">
|
<div className="relative aspect-video w-full overflow-hidden">
|
||||||
{subCourse.thumbnail ? (
|
{subCourse.thumbnail ? (
|
||||||
<img
|
<img
|
||||||
src={subCourse.thumbnail}
|
src={subCourse.thumbnail}
|
||||||
alt={subCourse.title}
|
alt={subCourse.title}
|
||||||
className="h-full w-full object-cover rounded-t-lg"
|
className="h-full w-full object-cover rounded-t-lg transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={`h-full w-full rounded-t-lg ${gradients[index % gradients.length]}`} />
|
<div className={`h-full w-full rounded-t-lg transition-transform duration-300 group-hover:scale-105 ${gradients[index % gradients.length]}`} />
|
||||||
)}
|
)}
|
||||||
{subCourse.level && (
|
{subCourse.level && (
|
||||||
<div className="absolute bottom-2 right-2 rounded bg-purple-600 px-3 py-1 text-sm font-semibold text-white">
|
<div className="absolute bottom-2.5 right-2.5 rounded-md bg-brand-600/90 px-2.5 py-1 text-xs font-semibold tracking-wide text-white shadow-sm backdrop-blur-sm">
|
||||||
{subCourse.level}
|
{subCourse.level}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-4 space-y-3">
|
<div className="flex flex-col gap-3 p-4">
|
||||||
{/* Status and menu */}
|
{/* Status and menu */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Badge
|
<Badge
|
||||||
className={`text-xs font-medium ${
|
className={`rounded-full px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${
|
||||||
subCourse.is_active
|
subCourse.is_active
|
||||||
? "bg-transparent text-green-600 border border-green-200"
|
? "border border-green-200 bg-green-50 text-green-700"
|
||||||
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
|
: "border border-grayScale-200 bg-grayScale-50 text-grayScale-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`} />
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`} />
|
||||||
{subCourse.is_active ? "ACTIVE" : "INACTIVE"}
|
{subCourse.is_active ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
|
|
@ -301,19 +314,19 @@ export function SubCoursesPage() {
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenMenuId(openMenuId === subCourse.id ? null : subCourse.id)}
|
onClick={() => setOpenMenuId(openMenuId === subCourse.id ? null : subCourse.id)}
|
||||||
className="text-grayScale-400 hover:text-grayScale-600"
|
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{openMenuId === subCourse.id && (
|
{openMenuId === subCourse.id && (
|
||||||
<div className="absolute right-0 top-full z-10 mt-1 w-40 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">
|
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleToggleStatus(subCourse)
|
handleToggleStatus(subCourse)
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null)
|
||||||
}}
|
}}
|
||||||
disabled={togglingId === subCourse.id}
|
disabled={togglingId === subCourse.id}
|
||||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-grayScale-700 hover:bg-grayScale-100 disabled:opacity-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{subCourse.is_active ? (
|
{subCourse.is_active ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -327,12 +340,13 @@ export function SubCoursesPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="mx-3 border-t border-grayScale-100" />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDeleteClick(subCourse)
|
handleDeleteClick(subCourse)
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null)
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-500 transition-colors hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -343,20 +357,23 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3 className="font-medium text-grayScale-900">{subCourse.title}</h3>
|
<div>
|
||||||
<p className="text-sm text-grayScale-500 line-clamp-2">
|
<h3 className="font-semibold text-grayScale-700 group-hover:text-brand-600 transition-colors">{subCourse.title}</h3>
|
||||||
{subCourse.description || "No description available"}
|
<p className="mt-1 text-sm leading-relaxed text-grayScale-400 line-clamp-2">
|
||||||
</p>
|
{subCourse.description || "No description available"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Edit button */}
|
{/* Edit button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-grayScale-200 text-grayScale-700"
|
className="mt-auto w-full rounded-lg border-grayScale-200 text-grayScale-500 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleEditClick(subCourse)
|
handleEditClick(subCourse)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Edit className="mr-2 h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -366,37 +383,42 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
{showDeleteModal && subCourseToDelete && (
|
{showDeleteModal && subCourseToDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Delete Sub-course</h2>
|
<h2 className="text-lg font-semibold text-grayScale-700">Delete Sub-course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-6">
|
||||||
<p className="text-sm text-grayScale-600">
|
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||||
|
<Trash2 className="h-5 w-5 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-semibold">{subCourseToDelete.title}</span>? This action cannot
|
<span className="font-semibold text-grayScale-700">{subCourseToDelete.title}</span>? This action cannot
|
||||||
be undone.
|
be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
|
className="w-full rounded-lg sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-red-500 hover:bg-red-600"
|
className="w-full rounded-lg bg-red-500 shadow-sm hover:bg-red-600 sm:w-auto"
|
||||||
onClick={handleConfirmDelete}
|
onClick={handleConfirmDelete}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
|
|
@ -407,59 +429,66 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add Sub-course Modal */}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Add New Sub-course</h2>
|
<h2 className="text-lg font-semibold text-grayScale-700">Add New Sub-course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(false)}
|
onClick={() => setShowAddModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
<label className="text-sm font-semibold text-grayScale-600">Title</label>
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Enter sub-course title"
|
placeholder="Enter sub-course title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
<label className="text-sm font-semibold text-grayScale-600">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter sub-course description"
|
placeholder="Enter sub-course description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Level</label>
|
<label className="text-sm font-semibold text-grayScale-600">Level</label>
|
||||||
<Input
|
<Input
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(e) => setLevel(e.target.value)}
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
placeholder="e.g., Beginner, Intermediate, Advanced"
|
placeholder="e.g., Beginner, Intermediate, Advanced"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
{saveError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowAddModal(false)}
|
onClick={() => setShowAddModal(false)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
className="w-full rounded-lg sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="w-full rounded-lg bg-brand-500 shadow-sm hover:bg-brand-600 sm:w-auto"
|
||||||
onClick={handleSaveNewSubCourse}
|
onClick={handleSaveNewSubCourse}
|
||||||
disabled={saving || !title.trim()}
|
disabled={saving || !title.trim()}
|
||||||
>
|
>
|
||||||
|
|
@ -470,59 +499,66 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Sub-course Modal */}
|
||||||
{showEditModal && subCourseToEdit && (
|
{showEditModal && subCourseToEdit && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Edit Sub-course</h2>
|
<h2 className="text-lg font-semibold text-grayScale-700">Edit Sub-course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditModal(false)}
|
onClick={() => setShowEditModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
<label className="text-sm font-semibold text-grayScale-600">Title</label>
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Enter sub-course title"
|
placeholder="Enter sub-course title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
<label className="text-sm font-semibold text-grayScale-600">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter sub-course description"
|
placeholder="Enter sub-course description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Level</label>
|
<label className="text-sm font-semibold text-grayScale-600">Level</label>
|
||||||
<Input
|
<Input
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(e) => setLevel(e.target.value)}
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
placeholder="e.g., Beginner, Intermediate, Advanced"
|
placeholder="e.g., Beginner, Intermediate, Advanced"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
{saveError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowEditModal(false)}
|
onClick={() => setShowEditModal(false)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
className="w-full rounded-lg sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="w-full rounded-lg bg-brand-500 shadow-sm hover:bg-brand-600 sm:w-auto"
|
||||||
onClick={handleSaveEditSubCourse}
|
onClick={handleSaveEditSubCourse}
|
||||||
disabled={saving || !title.trim()}
|
disabled={saving || !title.trim()}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
917
src/pages/issues/IssuesPage.tsx
Normal file
917
src/pages/issues/IssuesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -138,21 +138,21 @@ export function TeamManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grayScale-600">Team Management</h1>
|
<h1 className="text-2xl font-bold text-grayScale-600">Team Management</h1>
|
||||||
<p className="text-sm text-grayScale-400">
|
<p className="text-sm text-grayScale-400">
|
||||||
Manage user access, roles, and platform permissions.
|
Manage user access, roles, and platform permissions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button className="bg-brand-600 hover:bg-brand-500 text-white">
|
<Button className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Team Member
|
Add Team Member
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 rounded-lg border bg-white p-3">
|
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-white p-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative w-full sm:flex-1 sm:w-auto">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by name or email address..."
|
placeholder="Search by name or email address..."
|
||||||
|
|
@ -206,7 +206,7 @@ export function TeamManagementPage() {
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>USER</TableHead>
|
<TableHead>USER</TableHead>
|
||||||
<TableHead>ROLE</TableHead>
|
<TableHead>ROLE</TableHead>
|
||||||
<TableHead>LAST LOGIN</TableHead>
|
<TableHead className="hidden sm:table-cell">LAST LOGIN</TableHead>
|
||||||
<TableHead>STATUS</TableHead>
|
<TableHead>STATUS</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -258,7 +258,7 @@ export function TeamManagementPage() {
|
||||||
{formatRoleLabel(member.team_role)}
|
{formatRoleLabel(member.team_role)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden sm:table-cell">
|
||||||
{member.last_login ? (
|
{member.last_login ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-grayScale-600">
|
<div className="text-sm text-grayScale-600">
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ export function TeamMemberDetailPage() {
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="h-28 bg-gradient-to-r from-brand-600 via-brand-400 to-mint-500" />
|
<div className="h-28 bg-gradient-to-r from-brand-600 via-brand-400 to-mint-500" />
|
||||||
<CardContent className="-mt-12 px-8 pb-8 pt-0">
|
<CardContent className="-mt-12 px-4 sm:px-8 pb-4 sm:pb-8 pt-0">
|
||||||
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end">
|
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end">
|
||||||
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
|
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
|
||||||
<AvatarImage src={undefined} alt={fullName} />
|
<AvatarImage src={undefined} alt={fullName} />
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,676 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Activity,
|
||||||
|
Eye,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Globe,
|
||||||
|
Monitor,
|
||||||
|
FileText,
|
||||||
|
X,
|
||||||
|
Info,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
|
||||||
|
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
|
||||||
|
|
||||||
export function UserLogPage() {
|
// ── Action type configuration ──────────────────────────────────────
|
||||||
return (
|
const ACTION_TYPES = [
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
"TEAM_MEMBER_CREATED",
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Log</div>
|
"TEAM_MEMBER_UPDATED",
|
||||||
<Card className="shadow-none">
|
"TEAM_MEMBER_DELETED",
|
||||||
<CardHeader>
|
"TEAM_MEMBER_DEACTIVATED",
|
||||||
<CardTitle>User Log</CardTitle>
|
"TEAM_MEMBER_REACTIVATED",
|
||||||
</CardHeader>
|
"VIDEO_UPLOADED",
|
||||||
<CardContent className="text-sm text-muted-foreground">User Log module placeholder.</CardContent>
|
"VIDEO_DELETED",
|
||||||
</Card>
|
"COURSE_CREATED",
|
||||||
</div>
|
"COURSE_UPDATED",
|
||||||
)
|
"COURSE_DELETED",
|
||||||
|
"USER_REGISTERED",
|
||||||
|
"USER_UPDATED",
|
||||||
|
"USER_DELETED",
|
||||||
|
"ROLE_CREATED",
|
||||||
|
"ROLE_UPDATED",
|
||||||
|
"ROLE_DELETED",
|
||||||
|
"LOGIN",
|
||||||
|
"LOGOUT",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function getActionBadgeClasses(action: string): string {
|
||||||
|
if (action.includes("CREATED") || action.includes("REGISTERED") || action.includes("UPLOADED"))
|
||||||
|
return "bg-emerald-50 text-emerald-700 border-emerald-200";
|
||||||
|
if (action.includes("UPDATED") || action.includes("REACTIVATED"))
|
||||||
|
return "bg-blue-50 text-blue-700 border-blue-200";
|
||||||
|
if (action.includes("DELETED") || action.includes("DEACTIVATED"))
|
||||||
|
return "bg-red-50 text-red-700 border-red-200";
|
||||||
|
if (action.includes("LOGIN") || action.includes("LOGOUT"))
|
||||||
|
return "bg-amber-50 text-amber-700 border-amber-200";
|
||||||
|
return "bg-grayScale-100 text-grayScale-600 border-grayScale-200";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActionIcon(action: string) {
|
||||||
|
if (action.includes("TEAM_MEMBER")) return User;
|
||||||
|
if (action.includes("VIDEO")) return FileText;
|
||||||
|
if (action.includes("COURSE")) return FileText;
|
||||||
|
if (action.includes("ROLE")) return Shield;
|
||||||
|
return Activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActionLabel(action: string): string {
|
||||||
|
return action
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelativeTime(dateStr: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "Just now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return formatDate(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoleLabel(role: string): string {
|
||||||
|
return role
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ─────────────────────────────────────────────────
|
||||||
|
export function UserLogPage() {
|
||||||
|
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [actionFilter, setActionFilter] = useState("");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [dateAfter, setDateAfter] = useState("");
|
||||||
|
const [dateBefore, setDateBefore] = useState("");
|
||||||
|
|
||||||
|
// Detail dialog
|
||||||
|
const [selectedLog, setSelectedLog] = useState<ActivityLog | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: ActivityLogFilters = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: (page - 1) * pageSize,
|
||||||
|
};
|
||||||
|
if (actionFilter) filters.action = actionFilter;
|
||||||
|
if (dateAfter) filters.after = new Date(dateAfter).toISOString();
|
||||||
|
if (dateBefore) filters.before = new Date(dateBefore).toISOString();
|
||||||
|
|
||||||
|
const res = await getActivityLogs(filters);
|
||||||
|
setLogs(res.data.data.logs);
|
||||||
|
setTotalCount(res.data.data.total_count);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch activity logs:", error);
|
||||||
|
setLogs([]);
|
||||||
|
setTotalCount(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, actionFilter, dateAfter, dateBefore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
const handleViewDetail = async (logId: number) => {
|
||||||
|
setDialogOpen(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getActivityLogById(logId);
|
||||||
|
setSelectedLog(res.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch log detail:", error);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setSelectedLog(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setActionFilter("");
|
||||||
|
setSearchQuery("");
|
||||||
|
setDateAfter("");
|
||||||
|
setDateBefore("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = actionFilter || dateAfter || dateBefore;
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
const safePage = Math.min(page, pageCount);
|
||||||
|
|
||||||
|
const handlePrev = () => safePage > 1 && setPage(safePage - 1);
|
||||||
|
const handleNext = () => safePage < pageCount && setPage(safePage + 1);
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: (number | string)[] = [];
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
for (let i = 1; i <= pageCount; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1, 2, 3);
|
||||||
|
if (safePage > 4) pages.push("...");
|
||||||
|
if (safePage > 3 && safePage < pageCount - 2) pages.push(safePage);
|
||||||
|
if (safePage < pageCount - 3) pages.push("...");
|
||||||
|
pages.push(pageCount);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter logs by search (client-side on the message field)
|
||||||
|
const filteredLogs = searchQuery
|
||||||
|
? logs.filter(
|
||||||
|
(log) =>
|
||||||
|
log.message?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
log.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
log.actor_role?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
: logs;
|
||||||
|
|
||||||
|
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
||||||
|
const endEntry = Math.min(safePage * pageSize, totalCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grayScale-600">Activity Log</h1>
|
||||||
|
<p className="text-sm text-grayScale-400">
|
||||||
|
Track all actions and changes made across the platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
|
fetchLogs();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||||
|
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-grayScale-600">{totalCount}</p>
|
||||||
|
<p className="text-xs text-grayScale-400">Total Logs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||||
|
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-grayScale-600">
|
||||||
|
{logs.length > 0 ? getRelativeTime(logs[0].created_at) : "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-grayScale-400">Latest Activity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||||
|
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-grayScale-600">{filteredLogs.length}</p>
|
||||||
|
<p className="text-xs text-grayScale-400">Showing Results</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-white p-4">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by message, action, or role..."
|
||||||
|
className="pl-9"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setActionFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="h-10 appearance-none rounded-lg border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Action: All</option>
|
||||||
|
{ACTION_TYPES.map((action) => (
|
||||||
|
<option key={action} value={action}>
|
||||||
|
{formatActionLabel(action)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs font-medium text-grayScale-400">From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateAfter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDateAfter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="h-10 rounded-lg border bg-white px-3 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs font-medium text-grayScale-400">To</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateBefore}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDateBefore(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="h-10 rounded-lg border bg-white px-3 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="gap-1 text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-xl border bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ACTION</TableHead>
|
||||||
|
<TableHead>MESSAGE</TableHead>
|
||||||
|
<TableHead>ACTOR</TableHead>
|
||||||
|
<TableHead>RESOURCE</TableHead>
|
||||||
|
<TableHead>TIMESTAMP</TableHead>
|
||||||
|
<TableHead className="text-right">DETAILS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
|
||||||
|
<span className="text-sm text-grayScale-400">Loading activity logs...</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredLogs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Activity className="h-8 w-8 text-grayScale-200" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-grayScale-500">No activity logs found</p>
|
||||||
|
<p className="text-xs text-grayScale-400 mt-1">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters"
|
||||||
|
: "Activity will appear here once actions are performed"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredLogs.map((log) => {
|
||||||
|
const ActionIcon = getActionIcon(log.action);
|
||||||
|
return (
|
||||||
|
<TableRow key={log.id} className="group">
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
|
||||||
|
<ActionIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap",
|
||||||
|
getActionBadgeClasses(log.action)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatActionLabel(log.action)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<p className="text-sm text-grayScale-600 max-w-[280px] truncate">
|
||||||
|
{log.message || "—"}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
|
||||||
|
<User className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-grayScale-600">
|
||||||
|
ID: {log.actor_id ?? "System"}
|
||||||
|
</p>
|
||||||
|
{log.actor_role && (
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
{formatRoleLabel(log.actor_role)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-grayScale-600">{log.resource_type}</p>
|
||||||
|
{log.resource_id !== null && (
|
||||||
|
<p className="text-xs text-grayScale-400">#{log.resource_id}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-grayScale-600">{formatDate(log.created_at)}</p>
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
{getRelativeTime(log.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => handleViewDetail(log.id)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 text-grayScale-400" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Showing</span>
|
||||||
|
<span className="font-medium text-grayScale-600">
|
||||||
|
{startEntry}–{endEntry}
|
||||||
|
</span>
|
||||||
|
<span>of</span>
|
||||||
|
<span className="font-medium text-grayScale-600">{totalCount}</span>
|
||||||
|
<span className="mr-4">entries</span>
|
||||||
|
<span className="border-l pl-4">Rows per page</span>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
{[5, 10, 20, 30, 50].map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||||
|
safePage === 1 && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{getPageNumbers().map((n, idx) =>
|
||||||
|
typeof n === "string" ? (
|
||||||
|
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(n)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-md border text-sm font-medium",
|
||||||
|
n === safePage
|
||||||
|
? "border-brand-500 bg-brand-500 text-white"
|
||||||
|
: "bg-white text-grayScale-600 hover:bg-grayScale-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={safePage === pageCount}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||||
|
safePage === pageCount && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Info className="h-5 w-5 text-brand-500" />
|
||||||
|
Activity Log Detail
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Full details for this activity log entry.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
|
||||||
|
</div>
|
||||||
|
) : selectedLog ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Action badge */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold",
|
||||||
|
getActionBadgeClasses(selectedLog.action)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatActionLabel(selectedLog.action)}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
ID #{selectedLog.id}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{selectedLog.message && (
|
||||||
|
<div className="rounded-lg bg-grayScale-50 p-3">
|
||||||
|
<p className="text-sm text-grayScale-600">{selectedLog.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<DetailItem
|
||||||
|
icon={<User className="h-4 w-4" />}
|
||||||
|
label="Actor"
|
||||||
|
value={`ID: ${selectedLog.actor_id ?? "System"}`}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
icon={<Shield className="h-4 w-4" />}
|
||||||
|
label="Role"
|
||||||
|
value={selectedLog.actor_role ? formatRoleLabel(selectedLog.actor_role) : "—"}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
icon={<FileText className="h-4 w-4" />}
|
||||||
|
label="Resource"
|
||||||
|
value={`${selectedLog.resource_type}${selectedLog.resource_id !== null ? ` #${selectedLog.resource_id}` : ""}`}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
icon={<Clock className="h-4 w-4" />}
|
||||||
|
label="Time"
|
||||||
|
value={`${formatDate(selectedLog.created_at)} ${formatTime(selectedLog.created_at)}`}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
icon={<Globe className="h-4 w-4" />}
|
||||||
|
label="IP Address"
|
||||||
|
value={selectedLog.ip_address || "—"}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
icon={<Monitor className="h-4 w-4" />}
|
||||||
|
label="User Agent"
|
||||||
|
value={selectedLog.user_agent ? truncateUA(selectedLog.user_agent) : "—"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
{selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400 mb-2">
|
||||||
|
Metadata
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border bg-grayScale-50 p-3">
|
||||||
|
<pre className="text-xs text-grayScale-600 whitespace-pre-wrap break-words font-mono">
|
||||||
|
{JSON.stringify(selectedLog.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-components ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border bg-white p-2.5">
|
||||||
|
<div className="mt-0.5 text-grayScale-400">{icon}</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-grayScale-400">{label}</p>
|
||||||
|
<p className="text-sm font-medium text-grayScale-600 truncate" title={value}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateUA(ua: string): string {
|
||||||
|
if (ua.length <= 40) return ua;
|
||||||
|
return ua.substring(0, 37) + "...";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card } from "../../components/ui/card"
|
import { Card } from "../../components/ui/card"
|
||||||
|
|
@ -15,20 +15,25 @@ export function RegisterUserPage() {
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/users")} className="h-8 w-8">
|
<Button variant="ghost" size="icon" onClick={() => navigate("/users")} className="h-8 w-8">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Register New User</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grayScale-600">Register New User</h1>
|
||||||
|
<p className="text-sm text-grayScale-400">Add a new user to the system</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="mx-auto max-w-2xl p-6">
|
||||||
<form className="space-y-4">
|
<form className="space-y-5">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
First Name
|
First Name
|
||||||
</label>
|
</label>
|
||||||
<Input placeholder="Enter first name" required />
|
<Input placeholder="Enter first name" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
Last Name
|
Last Name
|
||||||
</label>
|
</label>
|
||||||
<Input placeholder="Enter last name" required />
|
<Input placeholder="Enter last name" required />
|
||||||
|
|
@ -36,17 +41,26 @@ export function RegisterUserPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">Email</label>
|
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
<Input type="email" placeholder="Enter email address" required />
|
<Input type="email" placeholder="Enter email address" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">Phone</label>
|
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
Phone
|
||||||
|
</label>
|
||||||
<Input type="tel" placeholder="Enter phone number" required />
|
<Input type="tel" placeholder="Enter phone number" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">Role</label>
|
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
<Select required>
|
<Select required>
|
||||||
<option value="">Select role</option>
|
<option value="">Select role</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
|
|
@ -55,15 +69,18 @@ export function RegisterUserPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">Notes</label>
|
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Notes
|
||||||
|
</label>
|
||||||
<Textarea placeholder="Enter any additional notes" rows={3} />
|
<Textarea placeholder="Enter any additional notes" rows={3} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={() => navigate("/users")}>
|
<Button variant="outline" className="w-full sm:w-auto" onClick={() => navigate("/users")}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="bg-brand-500 hover:bg-brand-600">
|
<Button type="submit" className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||||
Register User
|
Register User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -72,4 +89,3 @@ export function RegisterUserPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export function UserDetailPage() {
|
||||||
{/* Profile card */}
|
{/* Profile card */}
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="h-24 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400" />
|
<div className="h-24 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400" />
|
||||||
<CardContent className="-mt-12 space-y-5 px-6 pb-6 pt-0">
|
<CardContent className="-mt-12 space-y-5 px-4 sm:px-6 pb-6 pt-0">
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<Avatar className="h-20 w-20 ring-4 ring-white shadow-soft">
|
<Avatar className="h-20 w-20 ring-4 ring-white shadow-soft">
|
||||||
<AvatarImage src={user.profile_picture_url ?? undefined} alt={fullName} />
|
<AvatarImage src={user.profile_picture_url ?? undefined} alt={fullName} />
|
||||||
|
|
@ -197,7 +197,7 @@ export function UserDetailPage() {
|
||||||
<span className="text-sm font-semibold text-grayScale-600">6-Month</span>
|
<span className="text-sm font-semibold text-grayScale-600">6-Month</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-1">
|
||||||
<span className="text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
<span className="text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||||
Expires
|
Expires
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -215,7 +215,7 @@ export function UserDetailPage() {
|
||||||
Extend Subscription
|
Extend Subscription
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Button variant="outline" className="w-full text-sm">
|
<Button variant="outline" className="w-full text-sm">
|
||||||
Mark as Paid
|
Mark as Paid
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Plus, Edit } from "lucide-react"
|
import { Edit, FolderOpen, Plus, Users } from "lucide-react"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../../components/ui/dialog"
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../components/ui/dialog"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
|
||||||
const mockGroups = [
|
const mockGroups = [
|
||||||
{ id: "1", name: "Big 10", userCount: 10 },
|
{ id: "1", name: "Big 10", userCount: 10 },
|
||||||
|
|
@ -26,29 +33,68 @@ export function UserGroupsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">User Groups</h1>
|
<div>
|
||||||
<Button onClick={() => setIsModalOpen(true)} className="bg-brand-500 hover:bg-brand-600">
|
<h1 className="text-2xl font-bold text-grayScale-600">User Groups</h1>
|
||||||
<Plus className="h-4 w-4" />
|
<p className="text-sm text-grayScale-400">
|
||||||
|
Organize users into groups for easier management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
Add New Group
|
Add New Group
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
{mockGroups.length === 0 ? (
|
||||||
{mockGroups.map((group) => (
|
<Card className="flex flex-col items-center justify-center px-6 py-16 text-center shadow-sm">
|
||||||
<Card key={group.id} className="overflow-hidden shadow-sm">
|
<div className="mb-4 grid h-14 w-14 place-items-center rounded-full bg-grayScale-100">
|
||||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
<FolderOpen className="h-7 w-7 text-grayScale-400" />
|
||||||
<CardContent className="p-6">
|
</div>
|
||||||
<h3 className="mb-2 text-lg font-semibold text-grayScale-900">{group.name}</h3>
|
<h3 className="text-lg font-semibold text-grayScale-600">No groups found</h3>
|
||||||
<p className="mb-4 text-sm text-grayScale-600">{group.userCount} Users</p>
|
<p className="mt-1 max-w-sm text-sm text-grayScale-400">
|
||||||
<Button variant="outline" className="w-full">
|
Get started by creating your first user group to organize users and manage permissions
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
more efficiently.
|
||||||
Edit Role
|
</p>
|
||||||
</Button>
|
<Button
|
||||||
</CardContent>
|
onClick={() => setIsModalOpen(true)}
|
||||||
</Card>
|
className="mt-6 bg-brand-500 hover:bg-brand-600"
|
||||||
))}
|
>
|
||||||
</div>
|
<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="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">
|
||||||
|
<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
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|
@ -57,7 +103,7 @@ export function UserGroupsPage() {
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-2 block text-sm font-medium text-grayScale-600">
|
||||||
Group Name
|
Group Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -67,7 +113,7 @@ export function UserGroupsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-2 block text-sm font-medium text-grayScale-600">
|
||||||
Group Description
|
Group Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -91,4 +137,3 @@ export function UserGroupsPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,136 @@
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { UserPlus, Users } from "lucide-react"
|
import {
|
||||||
|
Users,
|
||||||
|
UserPlus,
|
||||||
|
UserCheck,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowRight,
|
||||||
|
List,
|
||||||
|
UsersRound,
|
||||||
|
} from "lucide-react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
|
||||||
|
|
||||||
export function UserManagementDashboard() {
|
export function UserManagementDashboard() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">User Management</h1>
|
{/* Page Header */}
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div>
|
||||||
<Card className="shadow-sm">
|
<h1 className="text-2xl font-bold text-grayScale-600">User Management</h1>
|
||||||
<CardHeader>
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
Manage users, groups, and registrations.
|
||||||
<UserPlus className="h-6 w-6" />
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">User Register</CardTitle>
|
|
||||||
<CardDescription>Register new users to the system</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Link to="/users/register">
|
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600">Register User</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
{/* Stat Cards */}
|
||||||
<CardHeader>
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
<Card className="border-none bg-brand-50 shadow-sm">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
||||||
<Users className="h-6 w-6" />
|
<Users className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">User Groups</CardTitle>
|
<div className="min-w-0">
|
||||||
<CardDescription>Manage user groups and permissions</CardDescription>
|
<p className="text-sm font-medium text-grayScale-400">Total Users</p>
|
||||||
</CardHeader>
|
<p className="text-2xl font-bold text-grayScale-600">1,248</p>
|
||||||
<CardContent>
|
</div>
|
||||||
<Link to="/users/groups">
|
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600">View Groups</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="shadow-sm md:col-span-2">
|
<Card className="border-none bg-mint-50 shadow-sm">
|
||||||
<CardHeader>
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
<CardTitle className="text-lg">User List</CardTitle>
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-mint-100 text-mint-600">
|
||||||
<CardDescription>View and manage all users in the system</CardDescription>
|
<UserCheck className="h-6 w-6" />
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="min-w-0">
|
||||||
<Link to="/users/list">
|
<p className="text-sm font-medium text-grayScale-400">Active Users</p>
|
||||||
<Button variant="outline" className="w-full">
|
<p className="text-2xl font-bold text-grayScale-600">1,180</p>
|
||||||
View All Users
|
</div>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none bg-gold-50 shadow-sm sm:col-span-2 lg:col-span-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-gold-100 text-gold-600">
|
||||||
|
<TrendingUp className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-grayScale-400">New This Month</p>
|
||||||
|
<p className="text-2xl font-bold text-grayScale-600">64</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Cards */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-grayScale-600">Quick Actions</h2>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Link to="/users/register" className="group">
|
||||||
|
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
|
Register User
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-grayScale-400">
|
||||||
|
Add new users to the system with role assignment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||||
|
Get started
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/users/groups" className="group">
|
||||||
|
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
|
||||||
|
<UsersRound className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
|
User Groups
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-grayScale-400">
|
||||||
|
Manage groups, roles, and permission settings.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||||
|
Manage groups
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/users/list" className="group sm:col-span-2 lg:col-span-1">
|
||||||
|
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
|
||||||
|
<List className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
|
User List
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-grayScale-400">
|
||||||
|
Browse, search, and manage all registered users.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||||
|
View all users
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { Outlet } from "react-router-dom"
|
||||||
export function UserManagementLayout() {
|
export function UserManagementLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Management</div>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
|
import { ChevronDown, ChevronLeft, ChevronRight, Search, Users } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
|
@ -110,219 +110,238 @@ export function UsersListPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg border">
|
<div className="space-y-4">
|
||||||
<div className="p-4 border-b">
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div>
|
||||||
<div className="relative w-full md:max-w-sm">
|
<h1 className="text-2xl font-bold text-grayScale-600">Users List</h1>
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
<p className="text-sm text-grayScale-400">View and manage all registered users.</p>
|
||||||
<Input
|
|
||||||
placeholder="Search by name, phone number"
|
|
||||||
className="pl-9"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<option value="">Country</option>
|
|
||||||
<option value="USA">USA</option>
|
|
||||||
<option value="UK">UK</option>
|
|
||||||
<option value="Canada">Canada</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={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"
|
|
||||||
>
|
|
||||||
<option value="">Region</option>
|
|
||||||
<option value="North">North</option>
|
|
||||||
<option value="South">South</option>
|
|
||||||
<option value="East">East</option>
|
|
||||||
<option value="West">West</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={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"
|
|
||||||
>
|
|
||||||
<option value="">Subscription</option>
|
|
||||||
<option value="Monthly">Monthly</option>
|
|
||||||
<option value="Free">Free</option>
|
|
||||||
<option value="3-Month">3-Month</option>
|
|
||||||
<option value="6-Month">6-Month</option>
|
|
||||||
<option value="Expired">Expired</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
<div className="bg-white rounded-xl border">
|
||||||
<TableHeader>
|
{/* Search & Filters */}
|
||||||
<TableRow>
|
<div className="p-4 border-b">
|
||||||
<TableHead className="w-12">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<input
|
<div className="relative w-full md:max-w-sm">
|
||||||
type="checkbox"
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
checked={allSelected}
|
<Input
|
||||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
placeholder="Search by name, phone number"
|
||||||
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</div>
|
||||||
<TableHead>USER</TableHead>
|
|
||||||
<TableHead>Phone</TableHead>
|
|
||||||
<TableHead>Country</TableHead>
|
|
||||||
<TableHead>Region</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{users.length === 0 ? (
|
<div className="relative w-full sm:w-auto">
|
||||||
<TableRow>
|
<select
|
||||||
<TableCell colSpan={6} className="text-center text-grayScale-400">
|
value={countryFilter}
|
||||||
No users found
|
onChange={(e) => setCountryFilter(e.target.value)}
|
||||||
</TableCell>
|
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"
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
users.map((u) => {
|
|
||||||
const isActive = toggledStatuses[u.id] ?? false
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={u.id}
|
|
||||||
className="cursor-pointer hover:bg-grayScale-50"
|
|
||||||
onClick={() => handleRowClick(u.id)}
|
|
||||||
>
|
>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<option value="">Country</option>
|
||||||
<input
|
<option value="USA">USA</option>
|
||||||
type="checkbox"
|
<option value="UK">UK</option>
|
||||||
checked={selectedIds.has(u.id)}
|
<option value="Canada">Canada</option>
|
||||||
onChange={(e) => handleSelectOne(u.id, e.target.checked)}
|
</select>
|
||||||
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
/>
|
</div>
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar className="h-10 w-10">
|
|
||||||
<AvatarImage src={undefined} alt={`${u.firstName} ${u.lastName}`} />
|
|
||||||
<AvatarFallback className="bg-grayScale-200 text-grayScale-500">
|
|
||||||
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-grayScale-600">{u.firstName} {u.lastName}</div>
|
|
||||||
<div className="text-xs text-grayScale-400">{u.email || u.phoneNumber || "-"}</div>
|
|
||||||
</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 onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggle(u.id)}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
|
||||||
isActive ? "bg-brand-500" : "bg-grayScale-200"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform",
|
|
||||||
isActive ? "translate-x-5" : "translate-x-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
|
<div className="relative w-full sm:w-auto">
|
||||||
<div className="flex items-center gap-2">
|
<select
|
||||||
<span>Row Per Page</span>
|
value={regionFilter}
|
||||||
<div className="relative">
|
onChange={(e) => setRegionFilter(e.target.value)}
|
||||||
<select
|
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"
|
||||||
value={pageSize}
|
>
|
||||||
onChange={(e) => {
|
<option value="">Region</option>
|
||||||
setPageSize(Number(e.target.value))
|
<option value="North">North</option>
|
||||||
setPage(1)
|
<option value="South">South</option>
|
||||||
}}
|
<option value="East">East</option>
|
||||||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
<option value="West">West</option>
|
||||||
>
|
</select>
|
||||||
{[5, 10, 20, 30, 50].map((size) => (
|
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
<option key={size} value={size}>
|
</div>
|
||||||
{size}
|
|
||||||
</option>
|
<div className="relative w-full sm:w-auto">
|
||||||
))}
|
<select
|
||||||
</select>
|
value={subscriptionFilter}
|
||||||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
onChange={(e) => setSubscriptionFilter(e.target.value)}
|
||||||
|
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>
|
||||||
|
<option value="Free">Free</option>
|
||||||
|
<option value="3-Month">3-Month</option>
|
||||||
|
<option value="6-Month">6-Month</option>
|
||||||
|
<option value="Expired">Expired</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>
|
||||||
</div>
|
</div>
|
||||||
<span>Entries</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{/* Table */}
|
||||||
<button
|
<Table>
|
||||||
onClick={handlePrev}
|
<TableHeader>
|
||||||
disabled={safePage === 1}
|
<TableRow>
|
||||||
className={cn(
|
<TableHead className="w-12">
|
||||||
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
<input
|
||||||
safePage === 1 && "opacity-50 cursor-not-allowed"
|
type="checkbox"
|
||||||
)}
|
checked={allSelected}
|
||||||
>
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
<ChevronLeft className="h-4 w-4" />
|
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
||||||
</button>
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>USER</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>
|
||||||
|
|
||||||
{getPageNumbers().map((n, idx) =>
|
<TableBody>
|
||||||
typeof n === "string" ? (
|
{users.length === 0 ? (
|
||||||
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
|
<TableRow>
|
||||||
...
|
<TableCell colSpan={6} className="py-16 text-center">
|
||||||
</span>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<button
|
users.map((u) => {
|
||||||
key={n}
|
const isActive = toggledStatuses[u.id] ?? false
|
||||||
type="button"
|
return (
|
||||||
onClick={() => setPage(n)}
|
<TableRow
|
||||||
className={cn(
|
key={u.id}
|
||||||
"h-8 w-8 rounded-md border text-sm font-medium",
|
className="cursor-pointer hover:bg-grayScale-50"
|
||||||
n === safePage
|
onClick={() => handleRowClick(u.id)}
|
||||||
? "border-brand-500 bg-brand-500 text-white"
|
>
|
||||||
: "bg-white text-grayScale-600 hover:bg-grayScale-50"
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
)}
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
{n}
|
checked={selectedIds.has(u.id)}
|
||||||
</button>
|
onChange={(e) => handleSelectOne(u.id, e.target.checked)}
|
||||||
)
|
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
||||||
)}
|
/>
|
||||||
|
</TableCell>
|
||||||
<button
|
<TableCell>
|
||||||
onClick={handleNext}
|
<div className="flex items-center gap-3">
|
||||||
disabled={safePage === pageCount}
|
<Avatar className="h-10 w-10">
|
||||||
className={cn(
|
<AvatarImage src={undefined} alt={`${u.firstName} ${u.lastName}`} />
|
||||||
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
<AvatarFallback className="bg-grayScale-200 text-grayScale-500">
|
||||||
safePage === pageCount && "opacity-50 cursor-not-allowed"
|
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-grayScale-600">{u.firstName} {u.lastName}</div>
|
||||||
|
<div className="text-xs text-grayScale-400">{u.email || u.phoneNumber || "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
onClick={() => handleToggle(u.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||||
|
isActive ? "bg-brand-500" : "bg-grayScale-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform",
|
||||||
|
isActive ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
>
|
</TableBody>
|
||||||
<ChevronRight className="h-4 w-4" />
|
</Table>
|
||||||
</button>
|
|
||||||
|
{/* 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">
|
||||||
|
<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>
|
||||||
|
<span>Entries</span>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
47
src/types/activity-log.types.ts
Normal file
47
src/types/activity-log.types.ts
Normal 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
52
src/types/issue.types.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user