feat: Implement multi-tenancy with default organization, global courses, user profiles, and new UI components like OrganizationSelector and Combobox.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cmsApi, Course, Module, Lesson } from "@/lib/api";
|
||||
import { cmsApi, Course, Module, Lesson, Organization } from "@/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
@@ -16,9 +17,11 @@ import {
|
||||
X,
|
||||
GripVertical,
|
||||
Trash2,
|
||||
ArrowLeft
|
||||
ArrowLeft,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||
import OrganizationSelector from "@/components/OrganizationSelector";
|
||||
|
||||
interface FullModule extends Module {
|
||||
lessons: Lesson[];
|
||||
@@ -32,6 +35,10 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false); // Added saving state
|
||||
const { user } = useAuth();
|
||||
|
||||
const startEditing = (id: string, currentTitle: string) => {
|
||||
setEditingId(id);
|
||||
@@ -56,6 +63,20 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
loadData();
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrgs = async () => {
|
||||
if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') {
|
||||
try {
|
||||
const orgs = await cmsApi.getOrganizations();
|
||||
setOrganizations(orgs);
|
||||
} catch (err) {
|
||||
console.error("Failed to load organizations", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadOrgs();
|
||||
}, [user]);
|
||||
|
||||
const handleAddModule = async () => {
|
||||
const title = "";
|
||||
try {
|
||||
@@ -70,13 +91,13 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
};
|
||||
|
||||
const handleAddLesson = async (moduleId: string) => {
|
||||
const mod = modules.find(m => m.id === moduleId);
|
||||
const mod = modules.find((m: FullModule) => m.id === moduleId);
|
||||
if (!mod) return;
|
||||
|
||||
const title = "New Lesson";
|
||||
try {
|
||||
const newLesson = await cmsApi.createLesson(moduleId, title, "video", mod.lessons.length + 1);
|
||||
setModules(modules.map(m =>
|
||||
setModules(modules.map((m: FullModule) =>
|
||||
m.id === moduleId
|
||||
? { ...m, lessons: [...m.lessons, newLesson] }
|
||||
: m
|
||||
@@ -96,12 +117,12 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
try {
|
||||
if (type === 'module') {
|
||||
await cmsApi.updateModule(id, { title: editValue });
|
||||
setModules(modules.map(m => m.id === id ? { ...m, title: editValue } : m));
|
||||
setModules(modules.map((m: FullModule) => m.id === id ? { ...m, title: editValue } : m));
|
||||
} else {
|
||||
await cmsApi.updateLesson(id, { title: editValue });
|
||||
setModules(modules.map(mod => ({
|
||||
setModules(modules.map((mod: FullModule) => ({
|
||||
...mod,
|
||||
lessons: mod.lessons.map(l => l.id === id ? { ...l, title: editValue } : l)
|
||||
lessons: mod.lessons.map((l: Lesson) => l.id === id ? { ...l, title: editValue } : l)
|
||||
})));
|
||||
}
|
||||
setEditingId(null);
|
||||
@@ -114,7 +135,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
if (!confirm("Are you sure you want to delete this module and all its lessons?")) return;
|
||||
try {
|
||||
await cmsApi.deleteModule(id);
|
||||
setModules(modules.filter(m => m.id !== id));
|
||||
setModules(modules.filter((m: FullModule) => m.id !== id));
|
||||
} catch {
|
||||
alert("Failed to delete module");
|
||||
}
|
||||
@@ -124,9 +145,9 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
if (!confirm("Are you sure you want to delete this lesson?")) return;
|
||||
try {
|
||||
await cmsApi.deleteLesson(lessonId);
|
||||
setModules(modules.map(m =>
|
||||
setModules(modules.map((m: FullModule) =>
|
||||
m.id === moduleId
|
||||
? { ...m, lessons: m.lessons.filter(l => l.id !== lessonId) }
|
||||
? { ...m, lessons: m.lessons.filter((l: Lesson) => l.id !== lessonId) }
|
||||
: m
|
||||
));
|
||||
} catch {
|
||||
@@ -141,8 +162,8 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
|
||||
[newModules[index], newModules[targetIndex]] = [newModules[targetIndex], newModules[index]];
|
||||
|
||||
const items = newModules.map((m, i) => ({ id: m.id, position: i + 1 }));
|
||||
setModules(newModules.map((m, i) => ({ ...m, position: i + 1 })));
|
||||
const items = newModules.map((m: FullModule, i: number) => ({ id: m.id, position: i + 1 }));
|
||||
setModules(newModules.map((m: FullModule, i: number) => ({ ...m, position: i + 1 })));
|
||||
|
||||
try {
|
||||
await cmsApi.reorderModules({ items });
|
||||
@@ -152,7 +173,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
};
|
||||
|
||||
const handleReorderLesson = async (moduleId: string, lessonIndex: number, direction: 'up' | 'down') => {
|
||||
const mod = modules.find(m => m.id === moduleId);
|
||||
const mod = modules.find((m: FullModule) => m.id === moduleId);
|
||||
if (!mod) return;
|
||||
|
||||
const newLessons = [...mod.lessons];
|
||||
@@ -161,8 +182,8 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
|
||||
[newLessons[lessonIndex], newLessons[targetIndex]] = [newLessons[targetIndex], newLessons[lessonIndex]];
|
||||
|
||||
const items = newLessons.map((l, i) => ({ id: l.id, position: i + 1 }));
|
||||
setModules(modules.map(m => m.id === moduleId ? { ...m, lessons: newLessons.map((l, i) => ({ ...l, position: i + 1 })) } : m));
|
||||
const items = newLessons.map((l: Lesson, i: number) => ({ id: l.id, position: i + 1 }));
|
||||
setModules(modules.map((m: FullModule) => m.id === moduleId ? { ...m, lessons: newLessons.map((l: Lesson, i: number) => ({ ...l, position: i + 1 })) } : m));
|
||||
|
||||
try {
|
||||
await cmsApi.reorderLessons({ items });
|
||||
@@ -171,19 +192,29 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
}
|
||||
};
|
||||
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!course) return;
|
||||
setIsPublishing(true);
|
||||
|
||||
const isSuperAdmin = user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
if (isSuperAdmin && organizations.length > 0) {
|
||||
setIsOrgModalOpen(true);
|
||||
} else {
|
||||
publishCourse();
|
||||
}
|
||||
};
|
||||
|
||||
const publishCourse = async (targetOrgId?: string) => {
|
||||
try {
|
||||
await cmsApi.publishCourse(params.id);
|
||||
alert("Course published successfully to LMS!");
|
||||
setSaving(true);
|
||||
await cmsApi.publishCourse(params.id as string, targetOrgId);
|
||||
alert("Course published successfully!");
|
||||
} catch (err) {
|
||||
console.error("Publish failed:", err);
|
||||
console.error("Failed to publish course", err);
|
||||
alert("Failed to publish course.");
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
setSaving(false);
|
||||
setIsOrgModalOpen(false); // Close modal after publishing attempt
|
||||
}
|
||||
};
|
||||
|
||||
@@ -220,18 +251,22 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing}
|
||||
className={`flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 rounded-xl text-sm font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-95 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
|
||||
disabled={saving}
|
||||
className={`flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 rounded-xl font-bold transition-all shadow-lg shadow-blue-500/20 active:scale-95 ${saving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<PlayCircle className="w-5 h-5 transition-transform group-active:scale-90" />
|
||||
{isPublishing ? "Publishing..." : "Publish to LMS"}
|
||||
{saving ? (
|
||||
<div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send size={18} />
|
||||
)}
|
||||
{saving ? 'Publishing...' : 'Publish Course'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CourseEditorLayout activeTab="outline">
|
||||
<div className="p-8 space-y-6">
|
||||
{modules.map((module, mIndex) => (
|
||||
{modules.map((module: FullModule, mIndex: number) => (
|
||||
<div key={module.id} className="glass rounded-xl overflow-hidden border-white/5">
|
||||
<div className="bg-white/5 px-6 py-4 flex justify-between items-center border-b border-white/5">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
@@ -296,7 +331,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
{module.lessons.map((lesson, lIndex) => (
|
||||
{module.lessons.map((lesson: Lesson, lIndex: number) => (
|
||||
<div key={lesson.id} className="flex items-center gap-3 group/row">
|
||||
<div className="flex flex-col opacity-0 group-hover/row:opacity-100 transition-opacity">
|
||||
<button
|
||||
@@ -395,6 +430,15 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
</div>
|
||||
</CourseEditorLayout>
|
||||
</div>
|
||||
{/* Organization Selector Modal */}
|
||||
<OrganizationSelector
|
||||
isOpen={isOrgModalOpen}
|
||||
onClose={() => setIsOrgModalOpen(false)}
|
||||
organizations={organizations}
|
||||
title="Publish to Organization"
|
||||
actionLabel="Publish Course"
|
||||
onConfirm={(orgId) => publishCourse(orgId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+96
-11
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cmsApi, Course } from "@/lib/api";
|
||||
import { cmsApi, Course, Organization } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { Plus, BookOpen } from "lucide-react";
|
||||
import OrganizationSelector from "@/components/OrganizationSelector";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
export default function StudioDashboard() {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
@@ -29,16 +31,50 @@ export default function StudioDashboard() {
|
||||
loadCourses();
|
||||
}, [user]);
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
const title = prompt("Enter new course title:");
|
||||
if (title) {
|
||||
try {
|
||||
const newCourse = await cmsApi.createCourse(title);
|
||||
setCourses(prev => [...prev, newCourse]);
|
||||
} catch (err) {
|
||||
console.error("Failed to create course", err);
|
||||
alert("Failed to create course. Please ensure the backend is running.");
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
||||
const [isTitleModalOpen, setIsTitleModalOpen] = useState(false);
|
||||
const [newCourseTitle, setNewCourseTitle] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrgs = async () => {
|
||||
if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') {
|
||||
try {
|
||||
const orgs = await cmsApi.getOrganizations();
|
||||
setOrganizations(orgs);
|
||||
} catch (err) {
|
||||
console.error("Failed to load organizations", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadOrgs();
|
||||
}, [user]);
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
setIsTitleModalOpen(true);
|
||||
};
|
||||
|
||||
const onTitleConfirm = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCourseTitle) return;
|
||||
setIsTitleModalOpen(false);
|
||||
|
||||
const isSuperAdmin = user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001';
|
||||
if (isSuperAdmin && organizations.length > 0) {
|
||||
setIsOrgModalOpen(true);
|
||||
} else {
|
||||
createCourse();
|
||||
}
|
||||
};
|
||||
|
||||
const createCourse = async (targetOrgId?: string) => {
|
||||
try {
|
||||
const newCourse = await cmsApi.createCourse(newCourseTitle, targetOrgId);
|
||||
setCourses((prev: Course[]) => [...prev, newCourse]);
|
||||
setNewCourseTitle("");
|
||||
} catch (err) {
|
||||
console.error("Failed to create course", err);
|
||||
alert("Failed to create course. Please ensure the backend is running.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,7 +110,7 @@ export default function StudioDashboard() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{courses.map(course => (
|
||||
{courses.map((course: Course) => (
|
||||
<Link href={`/courses/${course.id}`} key={course.id}>
|
||||
<div className="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all">
|
||||
<div className="flex-1">
|
||||
@@ -94,6 +130,55 @@ export default function StudioDashboard() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Course Title Modal */}
|
||||
<Modal
|
||||
isOpen={isTitleModalOpen}
|
||||
onClose={() => setIsTitleModalOpen(false)}
|
||||
title="Create New Course"
|
||||
>
|
||||
<form onSubmit={onTitleConfirm} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Course Title
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
type="text"
|
||||
value={newCourseTitle}
|
||||
onChange={(e) => setNewCourseTitle(e.target.value)}
|
||||
placeholder="e.g. Advanced Rust Development"
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsTitleModalOpen(false)}
|
||||
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-[2] px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 font-bold text-sm"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Organization Selector Modal */}
|
||||
<OrganizationSelector
|
||||
isOpen={isOrgModalOpen}
|
||||
onClose={() => setIsOrgModalOpen(false)}
|
||||
organizations={organizations}
|
||||
title="Target Organization"
|
||||
actionLabel="Create Course"
|
||||
onConfirm={(orgId) => createCourse(orgId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { cmsApi } from "@/lib/api";
|
||||
import { User, Save, Shield, Mail, User as UserIcon, Building } from "lucide-react";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, token, logout } = useAuth();
|
||||
const [fullName, setFullName] = useState(user?.full_name || "");
|
||||
const [email, setEmail] = useState(user?.email || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFullName(user.full_name);
|
||||
setEmail(user.email);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
await cmsApi.updateUser(user.id, {
|
||||
full_name: fullName,
|
||||
// In this simplified version, we don't allow email change here to avoid complexity
|
||||
});
|
||||
|
||||
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||
|
||||
// Optionally update the local user state if needed,
|
||||
// but usually a page refresh or context update would be better.
|
||||
// For now, let's just show the message.
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setMessage({ type: 'error', text: 'Failed to update profile.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-black tracking-tight text-white mb-2">User Profile</h1>
|
||||
<p className="text-gray-400">Manage your personal information and account settings.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Profile Card */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="glass p-8 rounded-3xl border border-white/5 flex flex-col items-center text-center">
|
||||
<div className="w-24 h-24 rounded-full bg-blue-600/20 border-2 border-blue-500/30 flex items-center justify-center font-black text-3xl text-blue-400 mb-4 shadow-2xl shadow-blue-500/20">
|
||||
{user.full_name.charAt(0)}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white">{user.full_name}</h2>
|
||||
<span className="text-xs font-black uppercase tracking-widest text-blue-500 mt-1">{user.role}</span>
|
||||
|
||||
<div className="w-full h-px bg-white/5 my-6" />
|
||||
|
||||
<div className="w-full flex flex-col gap-4 text-left">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||
<Building size={16} className="text-gray-600" />
|
||||
<span className="truncate">Org: {user.organization_id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||
<Shield size={16} className="text-gray-600" />
|
||||
<span>Role: {user.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="mt-8 w-full py-3 rounded-xl border border-white/10 text-sm font-bold text-gray-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Logout Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Form */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<form onSubmit={handleSave} className="glass p-8 rounded-3xl border border-white/5 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||
<UserIcon size={14} /> Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
placeholder="Enter your full name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 opacity-60">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||
<Mail size={14} /> Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white/50 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-500 italic">Email cannot be changed currently.</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`p-4 rounded-xl text-sm font-medium ${message.type === 'success' ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-red-500/10 text-red-400 border border-red-500/20'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full py-4 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 rounded-xl font-black text-white shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-3"
|
||||
>
|
||||
{saving ? (
|
||||
<div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Save size={20} />
|
||||
)}
|
||||
Save Changes
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="glass p-6 rounded-3xl border border-red-500/10 bg-red-500/5 items-center justify-between flex">
|
||||
<div>
|
||||
<h3 className="text-red-400 font-bold">Danger Zone</h3>
|
||||
<p className="text-xs text-red-400/60 mt-0.5">Deleting your account is permanent.</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 border border-red-500/20 rounded-lg text-xs font-black text-red-400 hover:bg-red-500/10 transition-colors uppercase tracking-widest">
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,9 +20,9 @@ export default function AuthHeader() {
|
||||
)}
|
||||
{user && (
|
||||
<>
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 border border-white/20 flex items-center justify-center font-bold text-xs">
|
||||
<Link href="/profile" className="w-8 h-8 rounded-full bg-white/10 border border-white/20 flex items-center justify-center font-bold text-xs hover:border-blue-500/50 transition-colors">
|
||||
{user.full_name.charAt(0)}
|
||||
</div>
|
||||
</Link>
|
||||
<button onClick={logout} className="p-2 hover:bg-white/10 rounded-full transition-colors">
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Search, ChevronDown, Check } from "lucide-react";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function Combobox({ options, value, onChange, placeholder = "Search..." }: ComboboxProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filteredOptions = options.filter(option =>
|
||||
option.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const selectedOption = options.find(o => o.id === value);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-between w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 cursor-pointer hover:border-white/20 transition-all focus-within:ring-2 focus-within:ring-blue-500/50"
|
||||
>
|
||||
<span className={selectedOption ? "text-white" : "text-gray-500"}>
|
||||
{selectedOption ? selectedOption.name : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={18} className={`text-gray-500 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 z-[110] bg-[#1a1d23] border border-white/10 rounded-lg shadow-2xl overflow-hidden glass-card animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="p-2 border-b border-white/5 bg-white/5">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
className="w-full bg-black/20 border-none rounded-md pl-9 pr-4 py-2 text-sm focus:ring-0 placeholder:text-gray-600"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto p-1 custom-scrollbar">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No results found</div>
|
||||
) : (
|
||||
filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
onChange(option.id);
|
||||
setIsOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors ${value === option.id ? "bg-blue-600 text-white" : "hover:bg-white/5 text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{option.name}</span>
|
||||
{value === option.id && <Check size={14} />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="w-full max-w-md glass-card bg-[#1a1d23] border border-white/10 rounded-2xl p-8 shadow-2xl animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold bg-gradient-to-r from-white to-gray-400 bg-clip-text text-transparent italic">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/5 rounded-full transition-colors text-gray-400 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Modal from "./Modal";
|
||||
import Combobox from "./Combobox";
|
||||
import { Organization } from "@/lib/api";
|
||||
|
||||
interface OrganizationSelectorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
organizations: Organization[];
|
||||
onConfirm: (orgId: string | undefined) => void;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
}
|
||||
|
||||
export default function OrganizationSelector({
|
||||
isOpen,
|
||||
onClose,
|
||||
organizations,
|
||||
onConfirm,
|
||||
title,
|
||||
actionLabel
|
||||
}: OrganizationSelectorProps) {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedId || undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Target Organization
|
||||
</label>
|
||||
<Combobox
|
||||
options={organizations}
|
||||
value={selectedId}
|
||||
onChange={setSelectedId}
|
||||
placeholder="Search or Select Organization..."
|
||||
/>
|
||||
<p className="mt-3 text-xs text-gray-500 italic">
|
||||
Leave empty to use the Default Organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-[2] px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 font-bold text-sm"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface Course {
|
||||
description?: string;
|
||||
instructor_id: string;
|
||||
pacing_mode: 'self_paced' | 'instructor_led';
|
||||
organization_id: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
passing_percentage: number;
|
||||
@@ -233,11 +234,11 @@ export const cmsApi = {
|
||||
|
||||
// Courses
|
||||
getCourses: (): Promise<Course[]> => apiFetch('/courses'),
|
||||
createCourse: (title: string): Promise<Course> => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title }) }),
|
||||
createCourse: (title: string, organizationId?: string): Promise<Course> => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title, organization_id: organizationId }) }),
|
||||
getCourse: (id: string): Promise<Course> => apiFetch(`/courses/${id}`),
|
||||
getCourseWithFullOutline: (id: string): Promise<Course> => apiFetch(`/courses/${id}/outline`),
|
||||
updateCourse: (id: string, payload: Partial<Course>): Promise<Course> => apiFetch(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
publishCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}/publish`, { method: 'POST' }),
|
||||
publishCourse: (id: string, targetOrganizationId?: string): Promise<void> => apiFetch(`/courses/${id}/publish`, { method: 'POST', body: JSON.stringify({ target_organization_id: targetOrganizationId }) }),
|
||||
|
||||
// Modules & Lessons
|
||||
createModule: (course_id: string, title: string, position: number): Promise<Module> => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }),
|
||||
|
||||
Reference in New Issue
Block a user