setting
This commit is contained in:
parent
df4820eba9
commit
aa998e5599
|
|
@ -12,6 +12,7 @@ import {
|
||||||
UserCircle2,
|
UserCircle2,
|
||||||
Users,
|
Users,
|
||||||
Users2,
|
Users2,
|
||||||
|
Settings,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentType, useEffect, useState } from "react";
|
import { type ComponentType, useEffect, useState } from "react";
|
||||||
|
|
@ -39,6 +40,7 @@ const navItems: NavItem[] = [
|
||||||
{ 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 },
|
||||||
|
{ label: "Settings", to: "/settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
Eye,
|
Eye,
|
||||||
|
|
@ -13,21 +13,43 @@ import {
|
||||||
Shield,
|
Shield,
|
||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
|
CreditCard,
|
||||||
|
AlertTriangle,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
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";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Select } from "../components/ui/select";
|
import { Select } from "../components/ui/select";
|
||||||
import { Separator } from "../components/ui/separator";
|
import { Separator } from "../components/ui/separator";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../components/ui/dialog";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
||||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
import { getMyProfile, updateProfile } from "../api/users.api";
|
||||||
import type { UserProfileData } from "../types/user.types";
|
import type { UserProfileData } from "../types/user.types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type SettingsTab = "profile" | "security" | "notifications" | "appearance";
|
type SettingsTab =
|
||||||
|
| "subscription"
|
||||||
|
| "profile"
|
||||||
|
| "security"
|
||||||
|
| "notifications"
|
||||||
|
| "appearance";
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
||||||
|
{ id: "subscription", label: "Subscription", icon: CreditCard },
|
||||||
{ id: "profile", label: "Profile", icon: User },
|
{ id: "profile", label: "Profile", icon: User },
|
||||||
{ id: "security", label: "Security", icon: Shield },
|
{ id: "security", label: "Security", icon: Shield },
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||||
|
|
@ -48,14 +70,14 @@ function Toggle({
|
||||||
aria-checked={enabled}
|
aria-checked={enabled}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2",
|
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none",
|
||||||
enabled ? "bg-brand-500" : "bg-grayScale-200"
|
enabled ? "bg-brand-500" : "bg-grayScale-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
"pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
|
||||||
enabled ? "translate-x-6" : "translate-x-1"
|
enabled ? "translate-x-5" : "translate-x-0.5",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -68,20 +90,20 @@ function SettingRow({
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
icon: typeof User;
|
icon: any;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition-colors hover:bg-grayScale-100/50">
|
<div className="flex items-center justify-between gap-4 rounded-[6px] px-3 py-4 transition-colors hover:bg-grayScale-100/50">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400">
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-grayScale-100 text-grayScale-400">
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-grayScale-600">{title}</p>
|
<p className="text-sm font-medium text-grayScale-800">{title}</p>
|
||||||
<p className="mt-0.5 text-xs text-grayScale-400">{description}</p>
|
<p className="mt-0.5 text-xs text-grayScale-500">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">{children}</div>
|
<div className="shrink-0">{children}</div>
|
||||||
|
|
@ -89,34 +111,143 @@ function SettingRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingSkeleton() {
|
// --- Subscription Tab ---
|
||||||
|
|
||||||
|
function SubscriptionTab() {
|
||||||
|
const [subs, setSubs] = useState([
|
||||||
|
{
|
||||||
|
id: "auto_renew",
|
||||||
|
name: "Auto-renewal",
|
||||||
|
desc: "Automatically renew your subscription when it expires",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "marketing_emails",
|
||||||
|
name: "Marketing Emails",
|
||||||
|
desc: "Receive updates about new features and promotions",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "priority_support",
|
||||||
|
name: "Priority Support",
|
||||||
|
desc: "Access 24/7 priority customer support",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [pendingToggle, setPendingToggle] = useState<string | null>(null);
|
||||||
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = (id: string) => {
|
||||||
|
const item = subs.find((s) => s.id === id);
|
||||||
|
if (item?.enabled) {
|
||||||
|
setPendingToggle(id);
|
||||||
|
setShowWarning(true);
|
||||||
|
} else {
|
||||||
|
setSubs((prev) =>
|
||||||
|
prev.map((s) => (s.id === id ? { ...s, enabled: true } : s)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmToggleOff = () => {
|
||||||
|
if (pendingToggle) {
|
||||||
|
setSubs((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === pendingToggle ? { ...s, enabled: false } : s,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setShowWarning(false);
|
||||||
|
setPendingToggle(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
<div className="animate-pulse space-y-6">
|
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
||||||
<div className="h-7 w-32 rounded-lg bg-grayScale-100" />
|
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||||
<div className="flex gap-2">
|
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
Subscription Features
|
||||||
<div key={i} className="h-10 w-28 rounded-lg bg-grayScale-100" />
|
</CardTitle>
|
||||||
|
<p className="text-[11px] text-grayScale-500">
|
||||||
|
Customize your subscription experience and management preferences
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-0 p-0">
|
||||||
|
{subs.map((sub, idx) => (
|
||||||
|
<React.Fragment key={sub.id}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-2",
|
||||||
|
idx < subs.length - 1 && "border-b border-grayScale-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
icon={CreditCard}
|
||||||
|
title={sub.name}
|
||||||
|
description={sub.desc}
|
||||||
|
>
|
||||||
|
<Toggle
|
||||||
|
enabled={sub.enabled}
|
||||||
|
onToggle={() => handleToggle(sub.id)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={showWarning} onOpenChange={setShowWarning}>
|
||||||
|
<DialogContent className="max-w-md p-0 overflow-hidden border border-grayScale-100 rounded-[12px] shadow-2xl">
|
||||||
|
<div className="relative p-8">
|
||||||
|
<div className="flex items-start gap-5 mb-6">
|
||||||
|
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-500 border border-red-100">
|
||||||
|
<AlertTriangle className="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-grayScale-100 p-6">
|
<div className="pt-1">
|
||||||
<div className="space-y-6">
|
<h3 className="text-xl font-bold text-grayScale-900 tracking-tight">
|
||||||
{[1, 2, 3, 4].map((j) => (
|
Are you absolutely sure?
|
||||||
<div key={j} className="flex items-center justify-between">
|
</h3>
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-grayScale-500 mt-1">
|
||||||
<div className="h-4 w-32 rounded bg-grayScale-100" />
|
Disabling this feature might limit your experience.
|
||||||
<div className="h-3 w-48 rounded bg-grayScale-100" />
|
</p>
|
||||||
</div>
|
|
||||||
<div className="h-10 w-48 rounded-lg bg-grayScale-100" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-grayScale-50/80 border border-grayScale-100 p-5 rounded-[8px] mb-8">
|
||||||
|
<p className="text-sm text-grayScale-600 leading-relaxed font-medium">
|
||||||
|
By turning this off, you will no longer receive the benefits
|
||||||
|
associated with this feature. Some changes might take up to 24
|
||||||
|
hours to reflect.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmToggleOff}
|
||||||
|
className="w-full rounded-[8px] py-6 text-sm font-bold bg-red-500 hover:bg-red-600 text-white border-none shadow-sm transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Yes, Disable Feature
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowWarning(false)}
|
||||||
|
className="w-full rounded-[8px] py-6 text-sm font-bold border-grayScale-200 text-grayScale-600 hover:bg-grayScale-50 transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Other Tabs (Existing, but with sidebar layout updates) ---
|
||||||
|
|
||||||
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
const [firstName, setFirstName] = useState(profile.first_name);
|
const [firstName, setFirstName] = useState(profile.first_name);
|
||||||
const [lastName, setLastName] = useState(profile.last_name);
|
const [lastName, setLastName] = useState(profile.last_name);
|
||||||
|
|
@ -142,79 +273,88 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
<Card className="border border-grayScale-100">
|
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
|
<div className="h-1 w-full bg-brand-500" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||||
<div className="flex items-center gap-3">
|
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||||
<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
|
Personal Information
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pb-6">
|
<CardContent className="space-y-5 pb-6">
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-grayScale-500">First Name</label>
|
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
First Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="rounded-[6px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-grayScale-500">Last Name</label>
|
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
Last Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="rounded-[6px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-grayScale-500">Nickname</label>
|
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
<Input value={nickName} onChange={(e) => setNickName(e.target.value)} />
|
Nickname
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={nickName}
|
||||||
|
onChange={(e) => setNickName(e.target.value)}
|
||||||
|
className="rounded-[6px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border border-grayScale-100">
|
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
|
<div className="h-1 w-full bg-brand-400" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||||
<div className="flex items-center gap-3">
|
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||||
<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">
|
|
||||||
<Languages className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
|
||||||
Preferences
|
Preferences
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pb-6">
|
<CardContent className="space-y-5 pb-6">
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-grayScale-500">Preferred Language</label>
|
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
<Select value={language} onChange={(e) => setLanguage(e.target.value)}>
|
Preferred Language
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
|
className="rounded-[6px]"
|
||||||
|
>
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="am">Amharic</option>
|
<option value="am">Amharic</option>
|
||||||
<option value="or">Afan Oromo</option>
|
<option value="or">Afan Oromo</option>
|
||||||
<option value="ti">Tigrinya</option>
|
<option value="ti">Tigrinya</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium text-grayScale-500">Timezone</label>
|
|
||||||
<Select defaultValue="eat">
|
|
||||||
<option value="eat">East Africa Time (UTC+3)</option>
|
|
||||||
<option value="utc">UTC</option>
|
|
||||||
<option value="est">Eastern Time (UTC-5)</option>
|
|
||||||
<option value="pst">Pacific Time (UTC-8)</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="min-w-[140px] rounded-[6px] font-bold"
|
||||||
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<SpinnerIcon className="h-4 w-4" />
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
{saving ? "Saving…" : "Save Changes"}
|
{saving ? "Saving…" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -240,96 +380,102 @@ function SecurityTab() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
<Card className="border border-grayScale-100">
|
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
|
<div className="h-1 w-full bg-brand-600" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||||
<div className="flex items-center gap-3">
|
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||||
<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">
|
|
||||||
<KeyRound className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
|
||||||
Change Password
|
Change Password
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pb-6">
|
<CardContent className="space-y-5 pb-6">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-grayScale-500">Current Password</label>
|
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
|
Current Password
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type={showCurrent ? "text" : "password"} placeholder="Enter current password" />
|
<Input
|
||||||
|
type={showCurrent ? "text" : "password"}
|
||||||
|
placeholder="Enter current password"
|
||||||
|
className="rounded-[6px]"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCurrent(!showCurrent)}
|
onClick={() => setShowCurrent(!showCurrent)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showCurrent ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-grayScale-500">New Password</label>
|
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type={showNew ? "text" : "password"} placeholder="Enter new password" />
|
<Input
|
||||||
|
type={showNew ? "text" : "password"}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
className="rounded-[6px]"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNew(!showNew)}
|
onClick={() => setShowNew(!showNew)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showNew ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-grayScale-500">Confirm New Password</label>
|
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type={showConfirm ? "text" : "password"} placeholder="Confirm new password" />
|
<Input
|
||||||
|
type={showConfirm ? "text" : "password"}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
className="rounded-[6px]"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowConfirm(!showConfirm)}
|
onClick={() => setShowConfirm(!showConfirm)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showConfirm ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
|
<Button
|
||||||
|
onClick={handleChangePassword}
|
||||||
|
disabled={saving}
|
||||||
|
className="min-w-[160px] rounded-[6px] font-bold"
|
||||||
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<SpinnerIcon className="h-4 w-4" />
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Lock className="h-4 w-4" />
|
<Lock className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
{saving ? "Updating…" : "Update Password"}
|
{saving ? "Updating…" : "Update Password"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border border-grayScale-100">
|
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-400" />
|
|
||||||
<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-400 text-white shadow-sm">
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
|
||||||
Two-Factor Authentication
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-6">
|
|
||||||
<SettingRow
|
|
||||||
icon={Shield}
|
|
||||||
title="Enable 2FA"
|
|
||||||
description="Add an extra layer of security to your account"
|
|
||||||
>
|
|
||||||
<Toggle enabled={false} onToggle={() => toast.info("2FA coming soon")} />
|
|
||||||
</SettingRow>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -338,20 +484,14 @@ function NotificationsTab() {
|
||||||
const [emailNotifs, setEmailNotifs] = useState(true);
|
const [emailNotifs, setEmailNotifs] = useState(true);
|
||||||
const [pushNotifs, setPushNotifs] = useState(true);
|
const [pushNotifs, setPushNotifs] = useState(true);
|
||||||
const [loginAlerts, setLoginAlerts] = useState(true);
|
const [loginAlerts, setLoginAlerts] = useState(true);
|
||||||
const [weeklyDigest, setWeeklyDigest] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border border-grayScale-100">
|
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
|
<div className="h-1 w-full bg-brand-500" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||||
<div className="flex items-center gap-3">
|
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||||
<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">
|
|
||||||
<Bell className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
|
||||||
Notification Preferences
|
Notification Preferences
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1 pb-6">
|
<CardContent className="space-y-1 pb-6">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
|
|
@ -359,31 +499,32 @@ function NotificationsTab() {
|
||||||
title="Email Notifications"
|
title="Email Notifications"
|
||||||
description="Receive important updates via email"
|
description="Receive important updates via email"
|
||||||
>
|
>
|
||||||
<Toggle enabled={emailNotifs} onToggle={() => setEmailNotifs(!emailNotifs)} />
|
<Toggle
|
||||||
|
enabled={emailNotifs}
|
||||||
|
onToggle={() => setEmailNotifs(!emailNotifs)}
|
||||||
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<Separator />
|
<Separator className="bg-grayScale-50" />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
title="Push Notifications"
|
title="Push Notifications"
|
||||||
description="Get notified in the browser"
|
description="Get notified in the browser"
|
||||||
>
|
>
|
||||||
<Toggle enabled={pushNotifs} onToggle={() => setPushNotifs(!pushNotifs)} />
|
<Toggle
|
||||||
|
enabled={pushNotifs}
|
||||||
|
onToggle={() => setPushNotifs(!pushNotifs)}
|
||||||
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<Separator />
|
<Separator className="bg-grayScale-50" />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon={Shield}
|
icon={Shield}
|
||||||
title="Login Alerts"
|
title="Login Alerts"
|
||||||
description="Get notified when someone logs into your account"
|
description="Get notified when someone logs into your account"
|
||||||
>
|
>
|
||||||
<Toggle enabled={loginAlerts} onToggle={() => setLoginAlerts(!loginAlerts)} />
|
<Toggle
|
||||||
</SettingRow>
|
enabled={loginAlerts}
|
||||||
<Separator />
|
onToggle={() => setLoginAlerts(!loginAlerts)}
|
||||||
<SettingRow
|
/>
|
||||||
icon={Globe}
|
|
||||||
title="Weekly Digest"
|
|
||||||
description="Receive a weekly summary of activity"
|
|
||||||
>
|
|
||||||
<Toggle enabled={weeklyDigest} onToggle={() => setWeeklyDigest(!weeklyDigest)} />
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -394,17 +535,12 @@ function AppearanceTab() {
|
||||||
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
|
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border border-grayScale-100">
|
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-600" />
|
<div className="h-1 w-full bg-brand-400" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||||
<div className="flex items-center gap-3">
|
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-sm">
|
|
||||||
<Palette className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
|
||||||
Theme
|
Theme
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-6">
|
<CardContent className="pb-6">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
|
@ -420,16 +556,18 @@ function AppearanceTab() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme(id)}
|
onClick={() => setTheme(id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center gap-2.5 rounded-xl border-2 px-4 py-5 transition-all",
|
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
|
||||||
theme === id
|
theme === id
|
||||||
? "border-brand-500 bg-brand-100/30 text-brand-600 shadow-sm"
|
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
|
||||||
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-100/40"
|
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
"flex h-10 w-10 items-center justify-center rounded-[6px]",
|
||||||
theme === id ? "bg-brand-500 text-white" : "bg-grayScale-100 text-grayScale-400"
|
theme === id
|
||||||
|
? "bg-brand-500 text-white"
|
||||||
|
: "bg-grayScale-100 text-grayScale-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
|
|
@ -444,7 +582,7 @@ function AppearanceTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
|
const [activeTab, setActiveTab] = useState<SettingsTab>("subscription");
|
||||||
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -464,21 +602,27 @@ export function SettingsPage() {
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <LoadingSkeleton />;
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[400px] items-center justify-center">
|
||||||
|
<SpinnerIcon className="h-8 w-8 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (error || !profile) {
|
if (error || !profile) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
|
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
|
||||||
<Card className="border-dashed">
|
<Card className="border-dashed border-grayScale-200 rounded-[6px]">
|
||||||
<CardContent className="flex flex-col items-center gap-5 p-12">
|
<CardContent className="flex flex-col items-center gap-5 p-12">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
|
||||||
<User className="h-10 w-10 text-grayScale-300" />
|
<User className="h-10 w-10 text-grayScale-300" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
|
<p className="text-lg font-bold tracking-tight text-grayScale-900">
|
||||||
{error || "Settings not available"}
|
{error || "Settings not available"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
Please check your connection and try again.
|
Please check your connection and try again.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -489,40 +633,23 @@ export function SettingsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
|
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6">
|
||||||
{/* Page header */}
|
<div className="mb-10 ">
|
||||||
<div>
|
<h1 className="text-2xl font-black tracking-tight text-grayScale-700">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Settings</h1>
|
Settings
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
</h1>
|
||||||
Manage your account preferences and configuration
|
<p className="mt-2 text-sm text-grayScale-500 ">
|
||||||
|
Manage your account preferences, subscriptions, and system
|
||||||
|
configurations with ease
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab navigation */}
|
<div className="flex flex-col gap-8">
|
||||||
<div className="flex gap-1 rounded-xl border border-grayScale-100 bg-grayScale-100/50 p-1">
|
{/* Content Area */}
|
||||||
{tabs.map(({ id, label, icon: Icon }) => (
|
<main className="min-h-[400px]">
|
||||||
<button
|
{activeTab === "subscription" && <SubscriptionTab />}
|
||||||
key={id}
|
</main>
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(id)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all",
|
|
||||||
activeTab === id
|
|
||||||
? "bg-white text-brand-600 shadow-sm"
|
|
||||||
: "text-grayScale-400 hover:text-grayScale-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">{label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
{activeTab === "profile" && <ProfileTab profile={profile} />}
|
|
||||||
{activeTab === "security" && <SecurityTab />}
|
|
||||||
{activeTab === "notifications" && <NotificationsTab />}
|
|
||||||
{activeTab === "appearance" && <AppearanceTab />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Outlet } from "react-router-dom"
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { ContentHierarchyList } from "./components/ContentHierarchyList";
|
||||||
|
|
||||||
export function ContentManagementLayout() {
|
export function ContentManagementLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 mb-8">
|
||||||
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
||||||
|
|
@ -15,9 +16,11 @@ export function ContentManagementLayout() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContentHierarchyList />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
539
src/pages/content-management/components/ContentHierarchyList.tsx
Normal file
539
src/pages/content-management/components/ContentHierarchyList.tsx
Normal file
|
|
@ -0,0 +1,539 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragOverlay,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import type {
|
||||||
|
DragEndEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
UniqueIdentifier,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
GripVertical,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
LayoutGrid,
|
||||||
|
BookOpen,
|
||||||
|
Layers,
|
||||||
|
PlayCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Image as ImageIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
export type ItemType = "program" | "course" | "module" | "lesson";
|
||||||
|
|
||||||
|
export interface BaseItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Program extends BaseItem {}
|
||||||
|
export interface Course extends BaseItem {
|
||||||
|
programId: string;
|
||||||
|
}
|
||||||
|
export interface Module extends BaseItem {
|
||||||
|
courseId: string;
|
||||||
|
}
|
||||||
|
export interface Lesson extends BaseItem {
|
||||||
|
moduleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock Data ---
|
||||||
|
const initialPrograms: Program[] = [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "Web Development Masterclass",
|
||||||
|
thumbnail:
|
||||||
|
"https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=100&h=100&fit=crop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p2",
|
||||||
|
name: "Mobile App Development",
|
||||||
|
thumbnail:
|
||||||
|
"https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?w=100&h=100&fit=crop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p3",
|
||||||
|
name: "UI/UX Design Fundamentals",
|
||||||
|
thumbnail:
|
||||||
|
"https://images.unsplash.com/photo-1586717791821-3f44a563eb4c?w=100&h=100&fit=crop",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialCourses: Course[] = [
|
||||||
|
{ id: "c1", name: "React for Beginners", programId: "p1" },
|
||||||
|
{ id: "c2", name: "Advanced Node.js", programId: "p1" },
|
||||||
|
{ id: "c3", name: "Swift UI Intro", programId: "p2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialModules: Module[] = [
|
||||||
|
{ id: "m1", name: "Introduction to Hooks", courseId: "c1" },
|
||||||
|
{ id: "m2", name: "State Management", courseId: "c1" },
|
||||||
|
{ id: "m3", name: "Backend Architecture", courseId: "c2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialLessons: Lesson[] = [
|
||||||
|
{ id: "l1", name: "What is useState?", moduleId: "m1" },
|
||||||
|
{ id: "l2", name: "useEffect deep dive", moduleId: "m1" },
|
||||||
|
{ id: "l3", name: "Redux Setup", moduleId: "m2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Components ---
|
||||||
|
|
||||||
|
interface SortableItemProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
thumbnail?: string;
|
||||||
|
onEdit?: (id: string) => void;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableItem({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
thumbnail,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: SortableItemProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between px-4 py-3 border border-grayScale-200 rounded-[6px] mb-2 bg-white transition-all duration-200 group/item",
|
||||||
|
isDragging && "opacity-50 border-dashed z-50 shadow-sm",
|
||||||
|
!isDragging && "hover:border-brand-200 hover:shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing p-1 text-grayScale-300 hover:text-brand-500 transition-colors"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Thumbnail/Icon Container */}
|
||||||
|
<div className="h-10 w-10 shrink-0 rounded-[4px] bg-grayScale-50 border border-grayScale-100 flex items-center justify-center overflow-hidden">
|
||||||
|
{thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt={name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-grayScale-400 group-hover/item:text-brand-500 transition-colors">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[14px] font-bold text-grayScale-800 leading-tight">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit?.(id)}
|
||||||
|
className="p-2 text-grayScale-400 rounded-[4px] transition-all"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete?.(id)}
|
||||||
|
className="p-2 text-grayScale-400 rounded-[4px] transition-all"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggableListProps {
|
||||||
|
items: BaseItem[];
|
||||||
|
onReorder: (activeId: string, overId: string) => void;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onEdit?: (id: string) => void;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableList({
|
||||||
|
items,
|
||||||
|
onReorder,
|
||||||
|
icon,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: DraggableListProps) {
|
||||||
|
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) =>
|
||||||
|
setActiveId(event.active.id);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
onReorder(active.id as string, over.id as string);
|
||||||
|
}
|
||||||
|
setActiveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeItem = items.find((i) => i.id === activeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<SortableItem
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
thumbnail={item.thumbnail}
|
||||||
|
icon={icon}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay>
|
||||||
|
{activeItem ? (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-white border border-brand-300 shadow-lg rounded-[6px] opacity-90 cursor-grabbing">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-1 ">
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 shrink-0 rounded-[4px] bg-grayScale-50 border border-grayScale-100 flex items-center justify-center overflow-hidden">
|
||||||
|
{activeItem.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={activeItem.thumbnail}
|
||||||
|
alt={activeItem.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-brand-500">{icon}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[14px] font-bold text-grayScale-800">
|
||||||
|
{activeItem.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HierarchySection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: SectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="border border-grayScale-100 rounded-xl mb-3 overflow-hidden transition-all duration-300 bg-white">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center justify-between px-5 py-4 transition-colors",
|
||||||
|
isOpen ? "bg-grayScale-50" : "hover:bg-grayScale-25",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-lg transition-colors",
|
||||||
|
isOpen
|
||||||
|
? "bg-brand-300 text-white"
|
||||||
|
: "bg-grayScale-50 text-grayScale-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[15px] font-bold",
|
||||||
|
isOpen ? "text-grayScale-900" : "text-grayScale-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-5 w-5 text-grayScale-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-5 w-5 text-grayScale-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
|
isOpen ? "max-h-[1000px] opacity-100 p-5 pt-0" : "max-h-0 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="pt-4 border-t border-grayScale-200">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentHierarchyList() {
|
||||||
|
const [programs, setPrograms] = useState<Program[]>(initialPrograms);
|
||||||
|
const [courses, setCourses] = useState<Course[]>(initialCourses);
|
||||||
|
const [modules, setModules] = useState<Module[]>(initialModules);
|
||||||
|
const [lessons, setLessons] = useState<Lesson[]>(initialLessons);
|
||||||
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||||
|
program: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSection = (id: string) => {
|
||||||
|
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorder = <T extends BaseItem>(
|
||||||
|
list: T[],
|
||||||
|
setList: React.Dispatch<React.SetStateAction<T[]>>,
|
||||||
|
activeId: string,
|
||||||
|
overId: string,
|
||||||
|
) => {
|
||||||
|
const oldIndex = list.findIndex((i) => i.id === activeId);
|
||||||
|
const newIndex = list.findIndex((i) => i.id === overId);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
setList(arrayMove(list, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (type: ItemType, id: string) => {
|
||||||
|
console.log(`Edit ${type}: ${id}`);
|
||||||
|
// Logic for opening edit modal would go here
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (type: ItemType, id: string) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete this ${type}?`))
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "program":
|
||||||
|
setPrograms((prev) => prev.filter((p) => p.id !== id));
|
||||||
|
break;
|
||||||
|
case "course":
|
||||||
|
setCourses((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
break;
|
||||||
|
case "module":
|
||||||
|
setModules((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
break;
|
||||||
|
case "lesson":
|
||||||
|
setLessons((prev) => prev.filter((l) => l.id !== id));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setPrograms(initialPrograms);
|
||||||
|
setCourses(initialCourses);
|
||||||
|
setModules(initialModules);
|
||||||
|
setLessons(initialLessons);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#ffffff] rounded-2xl p-6 border border-grayScale-100 mb-8 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[16px] font-bold text-grayScale-900">
|
||||||
|
Content Hierarchy
|
||||||
|
</h3>
|
||||||
|
<p className="text-[12px] text-grayScale-500 mt-1">
|
||||||
|
Manage the ordering and structure of your educational content
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="text-[13px] font-bold text-brand-300 hover:text-brand-400 transition-colors flex items-center gap-2 group"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 transition-transform group-hover:rotate-[-45deg]" />
|
||||||
|
Reset All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Program Section */}
|
||||||
|
<HierarchySection
|
||||||
|
title="Programs"
|
||||||
|
icon={<LayoutGrid className="h-5 w-5" />}
|
||||||
|
isOpen={openSections.program}
|
||||||
|
onToggle={() => toggleSection("program")}
|
||||||
|
>
|
||||||
|
<DraggableList
|
||||||
|
items={programs}
|
||||||
|
onReorder={(active, over) =>
|
||||||
|
reorder(programs, setPrograms, active, over)
|
||||||
|
}
|
||||||
|
icon={<LayoutGrid className="h-4 w-4" />}
|
||||||
|
onEdit={(id) => handleEdit("program", id)}
|
||||||
|
onDelete={(id) => handleDelete("program", id)}
|
||||||
|
/>
|
||||||
|
</HierarchySection>
|
||||||
|
|
||||||
|
{/* Course Section */}
|
||||||
|
<HierarchySection
|
||||||
|
title="Courses"
|
||||||
|
icon={<BookOpen className="h-5 w-5" />}
|
||||||
|
isOpen={openSections.course}
|
||||||
|
onToggle={() => toggleSection("course")}
|
||||||
|
>
|
||||||
|
{programs.map((program) => {
|
||||||
|
const programCourses = courses.filter(
|
||||||
|
(c) => c.programId === program.id,
|
||||||
|
);
|
||||||
|
if (programCourses.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={program.id} className="mb-4 last:mb-0">
|
||||||
|
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
|
||||||
|
{program.name}
|
||||||
|
</h4>
|
||||||
|
<DraggableList
|
||||||
|
items={programCourses}
|
||||||
|
onReorder={(active, over) =>
|
||||||
|
reorder(courses, setCourses, active, over)
|
||||||
|
}
|
||||||
|
icon={<BookOpen className="h-4 w-4" />}
|
||||||
|
onEdit={(id) => handleEdit("course", id)}
|
||||||
|
onDelete={(id) => handleDelete("course", id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HierarchySection>
|
||||||
|
|
||||||
|
{/* Module Section */}
|
||||||
|
<HierarchySection
|
||||||
|
title="Modules"
|
||||||
|
icon={<Layers className="h-5 w-5" />}
|
||||||
|
isOpen={openSections.module}
|
||||||
|
onToggle={() => toggleSection("module")}
|
||||||
|
>
|
||||||
|
{courses.map((course) => {
|
||||||
|
const courseModules = modules.filter(
|
||||||
|
(m) => m.courseId === course.id,
|
||||||
|
);
|
||||||
|
if (courseModules.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={course.id} className="mb-4 last:mb-0">
|
||||||
|
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
|
||||||
|
{course.name}
|
||||||
|
</h4>
|
||||||
|
<DraggableList
|
||||||
|
items={courseModules}
|
||||||
|
onReorder={(active, over) =>
|
||||||
|
reorder(modules, setModules, active, over)
|
||||||
|
}
|
||||||
|
icon={<Layers className="h-4 w-4" />}
|
||||||
|
onEdit={(id) => handleEdit("module", id)}
|
||||||
|
onDelete={(id) => handleDelete("module", id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HierarchySection>
|
||||||
|
|
||||||
|
{/* Lesson Section */}
|
||||||
|
<HierarchySection
|
||||||
|
title="Lessons"
|
||||||
|
icon={<PlayCircle className="h-5 w-5" />}
|
||||||
|
isOpen={openSections.lesson}
|
||||||
|
onToggle={() => toggleSection("lesson")}
|
||||||
|
>
|
||||||
|
{modules.map((module) => {
|
||||||
|
const moduleLessons = lessons.filter(
|
||||||
|
(l) => l.moduleId === module.id,
|
||||||
|
);
|
||||||
|
if (moduleLessons.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={module.id} className="mb-4 last:mb-0">
|
||||||
|
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
|
||||||
|
{module.name}
|
||||||
|
</h4>
|
||||||
|
<DraggableList
|
||||||
|
items={moduleLessons}
|
||||||
|
onReorder={(active, over) =>
|
||||||
|
reorder(lessons, setLessons, active, over)
|
||||||
|
}
|
||||||
|
icon={<PlayCircle className="h-4 w-4" />}
|
||||||
|
onEdit={(id) => handleEdit("lesson", id)}
|
||||||
|
onDelete={(id) => handleDelete("lesson", id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HierarchySection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user