content manager api integrated

This commit is contained in:
priyanshuvish
2025-10-29 19:21:35 +05:30
parent 241b5a2770
commit 729d1d8f50
67 changed files with 8458 additions and 2543 deletions

100
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/postcss": "^4.1.12", "@tailwindcss/postcss": "^4.1.12",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "*", "clsx": "*",
@@ -47,6 +48,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^7.8.2", "react-router-dom": "^7.8.2",
"recharts": "^2.15.2", "recharts": "^2.15.2",
@@ -1941,6 +1943,32 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2242,6 +2270,18 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/core": { "node_modules/@swc/core": {
"version": "1.13.5", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
@@ -2829,6 +2869,12 @@
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react-swc": { "node_modules/@vitejs/plugin-react-swc": {
"version": "3.11.0", "version": "3.11.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
@@ -3225,6 +3271,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/input-otp": { "node_modules/input-otp": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -3721,6 +3777,29 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.1", "version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
@@ -3901,6 +3980,27 @@
"decimal.js-light": "^2.4.1" "decimal.js-light": "^2.4.1"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.50.0", "version": "4.50.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz",

View File

@@ -29,6 +29,7 @@
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/postcss": "^4.1.12", "@tailwindcss/postcss": "^4.1.12",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "*", "clsx": "*",
@@ -42,6 +43,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^7.8.2", "react-router-dom": "^7.8.2",
"recharts": "^2.15.2", "recharts": "^2.15.2",

View File

@@ -39,44 +39,17 @@ import { Analytics } from "./components/pages/Analytics";
import { Toaster } from "./components/ui/sonner"; import { Toaster } from "./components/ui/sonner";
import { SESSION_CONFIG, AutoSaveData, mockNotifications, Notification, mockApprovalTasks, ApprovalTask } from "./data/mockData"; import { SESSION_CONFIG, AutoSaveData, mockNotifications, Notification, mockApprovalTasks, ApprovalTask } from "./data/mockData";
type Route = import { Route } from "./types/routes";
| "/login" import { ViewFAQ } from "./components/pages/ViewFAQ";
| "/login/forget-password" import { ViewBlog } from "./components/pages/ViewBlog";
| "/login/2fa" import { EditFAQ } from "./components/pages/EditFAQ";
| "/dashboard" import { EditBlog } from "./components/pages/EditBlog";
| "/profile" import { EditWebcast, WebcastEditPage } from "./components/pages/EditWebcast";
| "/profile/reset-password" import { EditTrainingMaterial } from "./components/pages/EditTrainingMaterial";
| "/users/individual" import { EditReadingMaterial } from "./components/pages/EditReadingMaterial";
| "/users/organizations" import { EditPodcast } from "./components/pages/EditPodcast";
| "/users/organizations/new" import { EditCaseStudy } from "./components/pages/EditCaseStudy";
| "/content" import { EditKlcArchiveContent } from "./components/pages/EditKlcArchiveContent";
| "/content/blogs/new"
| "/content/faqs/new"
| "/courses"
| "/courses/course-builder"
| "/courses/new"
| `/courses/${string}/assign`
| "/profilers"
| "/profilers/new"
| "/profilers/preview"
| "/profiler-approval"
| "/landing-pages"
| "/landing-pages/edit/home"
| "/landing-pages/edit/services"
| "/landing-pages/edit/about-us"
| "/webinars"
| "/programmes"
| "/programmes/new"
| `/programmes/${string}/assign`
| "/class-scheduler"
| "/open-programme"
| "/facilities-360"
| "/facilities-360/new"
| `/facilities-360/${string}`
| "/admin/leads"
| "/admin/roles"
| "/admin/analytics"
| "/admin/profiler-master";
export default function App() { export default function App() {
const [currentRoute, setCurrentRoute] = useState<Route>("/login"); const [currentRoute, setCurrentRoute] = useState<Route>("/login");
@@ -271,6 +244,8 @@ export default function App() {
navigate("/login"); navigate("/login");
}; };
// In your App.tsx, update the renderPage function:
const renderPage = () => { const renderPage = () => {
if (!isAuthenticated && !currentRoute.startsWith("/login")) { if (!isAuthenticated && !currentRoute.startsWith("/login")) {
return <Login onLogin={login} onNavigate={navigate} />; return <Login onLogin={login} onNavigate={navigate} />;
@@ -314,6 +289,22 @@ export default function App() {
return <NewBlog {...commonProps} />; return <NewBlog {...commonProps} />;
case "/content/faqs/new": case "/content/faqs/new":
return <NewFAQ {...commonProps} />; return <NewFAQ {...commonProps} />;
case "/content/faqs":
return <ContentManager {...commonProps} />;
case "/content/blogs":
return <ContentManager {...commonProps} />;
// ADD THESE ROUTES FOR WEBCASTS AND TRAINING MATERIALS
case "/content/webcasts":
return <ContentManager {...commonProps} />;
case "/content/training-materials":
return <ContentManager {...commonProps} />;
case "/content/reading-materials":
return <ContentManager {...commonProps} />;
case "/content/podcasts":
return <ContentManager {...commonProps} />;
case "/courses": case "/courses":
return <Courses {...commonProps} />; return <Courses {...commonProps} />;
case "/courses/course-builder": case "/courses/course-builder":
@@ -326,7 +317,6 @@ export default function App() {
case "/profilers/preview": case "/profilers/preview":
return <ProfilerPreview onBack={() => navigate("/dashboard")} />; return <ProfilerPreview onBack={() => navigate("/dashboard")} />;
case "/profiler-approval": case "/profiler-approval":
// Get the approval task from sessionStorage
const taskData = sessionStorage.getItem('currentApprovalTask'); const taskData = sessionStorage.getItem('currentApprovalTask');
const approvalTask = taskData ? JSON.parse(taskData) : approvalTasks[0]; const approvalTask = taskData ? JSON.parse(taskData) : approvalTasks[0];
return ( return (
@@ -371,7 +361,62 @@ export default function App() {
case "/admin/analytics": case "/admin/analytics":
return <Analytics {...commonProps} />; return <Analytics {...commonProps} />;
default: default:
// Handle dynamic routes // Handle dynamic routes - UPDATE THIS SECTION:
if (currentRoute.startsWith("/content/faqs/view/")) {
const faqId = currentRoute.split("/").pop();
console.log('🔄 Rendering ViewFAQ with ID:', faqId);
return <ViewFAQ {...commonProps} faqId={faqId} />;
}
if (currentRoute.startsWith("/content/blogs/view/")) {
const blogId = currentRoute.split("/").pop();
console.log('🔄 Rendering ViewBlog with ID:', blogId);
return <ViewBlog {...commonProps} blogId={blogId} />;
}
if (currentRoute.startsWith("/content/faqs/edit/")) {
const faqId = currentRoute.split("/").pop();
return <EditFAQ {...commonProps} faqId={faqId} />;
}
if (currentRoute.startsWith("/content/blogs/edit/")) {
const blogId = currentRoute.split("/").pop();
return <EditBlog {...commonProps} blogId={blogId} />;
}
if (currentRoute.startsWith("/content/case-studies/edit/")) {
const caseStudyId = currentRoute.split("/").pop();
return <EditCaseStudy {...commonProps} caseStudyId={caseStudyId} />;
}
if (currentRoute.startsWith("/content/klc-archive/edit/")) {
const archiveId = currentRoute.split("/").pop();
return <EditKlcArchiveContent {...commonProps} archiveId={archiveId} />;
}
if (currentRoute.startsWith("/content/webcasts/edit/")) {
const webcastId = currentRoute.split("/").pop();
return <EditWebcast {...commonProps} webcastId={webcastId} />;
}
if (currentRoute.startsWith("/content/podcasts/edit/")) {
const podcastId = currentRoute.split("/").pop();
return <EditPodcast {...commonProps} podcastId={podcastId} />;
}
if (currentRoute.startsWith("/content/training-materials/edit/")) {
const trainingMaterialId = currentRoute.split("/").pop();
return <EditTrainingMaterial {...commonProps} trainingMaterialId={trainingMaterialId} />;
}
if (currentRoute.startsWith("/content/reading-materials/edit/")) {
const readingMaterialId = currentRoute.split("/").pop();
return <EditReadingMaterial {...commonProps} readingMaterialId={readingMaterialId} />;
}
if (currentRoute.startsWith("/content/faqs/edit/")) {
const faqId = currentRoute.split("/").pop();
return <NewFAQ {...commonProps} formData={{ editMode: true, faqId }} />;
}
if (currentRoute.startsWith("/content/blogs/edit/")) {
const blogId = currentRoute.split("/").pop();
return <NewBlog {...commonProps} formData={{ editMode: true, blogId }} />;
}
if (currentRoute.startsWith("/programmes/") && currentRoute.endsWith("/assign")) { if (currentRoute.startsWith("/programmes/") && currentRoute.endsWith("/assign")) {
const programmeId = currentRoute.split("/")[2]; const programmeId = currentRoute.split("/")[2];
return <ProgrammeAssignment programmeId={programmeId} {...commonProps} />; return <ProgrammeAssignment programmeId={programmeId} {...commonProps} />;
@@ -384,6 +429,7 @@ export default function App() {
const tourId = currentRoute.split("/")[2]; const tourId = currentRoute.split("/")[2];
return <Facilities360Detail tourId={tourId} {...commonProps} />; return <Facilities360Detail tourId={tourId} {...commonProps} />;
} }
console.log('🚨 Route not found, falling back to dashboard:', currentRoute);
return <Dashboard {...commonProps} />; return <Dashboard {...commonProps} />;
} }
}; };

View File

@@ -6,9 +6,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Alert, AlertDescription } from '../ui/alert'; import { Alert, AlertDescription } from '../ui/alert';
import { Eye, EyeOff, CheckCircle2, Loader2, AlertCircle } from 'lucide-react'; import { Eye, EyeOff, CheckCircle2, Loader2, AlertCircle } from 'lucide-react';
import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png'; import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
import { Route } from '../../types/routes';
interface ForgotPasswordProps { interface ForgotPasswordProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
} }
type Step = 'request' | 'verify' | 'newPassword' | 'done'; type Step = 'request' | 'verify' | 'newPassword' | 'done';

View File

@@ -6,10 +6,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Alert, AlertDescription } from '../ui/alert'; import { Alert, AlertDescription } from '../ui/alert';
import { Eye, EyeOff, AlertCircle, Loader2 } from 'lucide-react'; import { Eye, EyeOff, AlertCircle, Loader2 } from 'lucide-react';
import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png'; import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
import { Route } from '../../types/routes';
interface LoginProps { interface LoginProps {
onLogin: () => void; onLogin: () => void;
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
} }
interface FormErrors { interface FormErrors {

View File

@@ -6,10 +6,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Alert, AlertDescription } from '../ui/alert'; import { Alert, AlertDescription } from '../ui/alert';
import { AlertCircle, Loader2 } from 'lucide-react'; import { AlertCircle, Loader2 } from 'lucide-react';
import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png'; import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
import { Route } from '../../types/routes';
interface TwoFactorAuthProps { interface TwoFactorAuthProps {
onLogin: () => void; onLogin: () => void;
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
} }
export function TwoFactorAuth({ onLogin, onNavigate }: TwoFactorAuthProps) { export function TwoFactorAuth({ onLogin, onNavigate }: TwoFactorAuthProps) {

View File

@@ -38,7 +38,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../ui/dropdown-menu'; } from '../ui/dropdown-menu';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import klcLogoDark from 'figma:asset/af520440d0fb3ca587ea6a7b2e63956e028f6f37.png'; import klcLogoDark from 'figma:asset/af520440d0fb3ca587ea6a7b2e63956e028f6f37.png';
import { SESSION_CONFIG, AutoSaveData, mockNotifications } from '../../data/mockData'; import { SESSION_CONFIG, AutoSaveData, mockNotifications } from '../../data/mockData';

View File

@@ -22,9 +22,10 @@ import { MediaPicker } from '../landing-pages/MediaPicker';
import { PreviewModal } from '../landing-pages/PreviewModal'; import { PreviewModal } from '../landing-pages/PreviewModal';
import { VersionHistory } from '../landing-pages/VersionHistory'; import { VersionHistory } from '../landing-pages/VersionHistory';
import { AuditDrawer } from '../landing-pages/AuditDrawer'; import { AuditDrawer } from '../landing-pages/AuditDrawer';
import { Route } from '../../types/routes';
interface AboutUsEditorProps { interface AboutUsEditorProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -67,9 +67,10 @@ import {
Clock, Clock,
CheckCircle2 CheckCircle2
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface AnalyticsProps { interface AnalyticsProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -46,7 +46,7 @@ import {
} from '../ui/dialog'; } from '../ui/dialog';
import { Checkbox } from '../ui/checkbox'; import { Checkbox } from '../ui/checkbox';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { import {
Calendar, Calendar,
Download, Download,
@@ -66,9 +66,10 @@ import {
Check, Check,
Eye Eye
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface ClassSchedulerProps { interface ClassSchedulerProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
pickerMode?: boolean; pickerMode?: boolean;

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Progress } from '../ui/progress'; import { Progress } from '../ui/progress';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -34,10 +34,11 @@ import {
CheckCircle CheckCircle
} from 'lucide-react'; } from 'lucide-react';
import { klcMockData } from '../../data/mockData'; import { klcMockData } from '../../data/mockData';
import { Route } from '../../types/routes';
interface CourseAssignmentProps { interface CourseAssignmentProps {
courseId: string; courseId: string;
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -68,9 +68,10 @@ import {
CheckCircle, CheckCircle,
AlertCircle AlertCircle
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface CoursesProps { interface CoursesProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -64,9 +64,10 @@ import {
mockApprovalTasks, mockApprovalTasks,
ApprovalTask ApprovalTask
} from '../../data/mockData'; } from '../../data/mockData';
import { Route } from '../../types/routes';
interface DashboardProps { interface DashboardProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -0,0 +1,597 @@
// src/components/pages/EditBlog.tsx
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Save, Plus, X, Upload, Image } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetBlogsByIdQuery, useUpdateBlogMutation } from '../../store/services/contentManager.service';
interface EditBlogProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
blogId?: string;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function EditBlog({
onNavigate,
onLogout,
user,
blogId,
formData,
onAutoSave,
onClearAutoSave
}: EditBlogProps) {
const { data: existingBlog, isLoading, error } = useGetBlogsByIdQuery(blogId!, {
skip: !blogId,
});
const [updateBlog, { isLoading: isUpdating }] = useUpdateBlogMutation();
const [blogData, setBlogData] = useState({
title: '',
urlSlug: '',
content: '',
bannerImage: '',
category: '',
tags: [] as string[],
metaTitle: '',
metaDesc: '',
publishedAt: '',
});
const [newTag, setNewTag] = useState('');
const [isUploading, setIsUploading] = useState(false);
// Load existing blog data when it's fetched
useEffect(() => {
if (existingBlog) {
setBlogData({
title: existingBlog.title || '',
urlSlug: existingBlog.urlSlug || '',
content: existingBlog.content || '',
bannerImage: existingBlog.bannerImage || '',
category: existingBlog.category || '',
tags: existingBlog.tags || [],
metaTitle: existingBlog.metaTitle || '',
metaDesc: existingBlog.metaDesc || '',
publishedAt: existingBlog.publishedAt || '',
});
}
}, [existingBlog]);
// Auto-save functionality
useEffect(() => {
if (onAutoSave && blogId) {
const timer = setTimeout(() => {
onAutoSave({ ...blogData, id: blogId });
}, 1000);
return () => clearTimeout(timer);
}
}, [blogData, blogId, onAutoSave]);
const categories = ['Technology', 'Business', 'Marketing', 'Design', 'Development', 'Personal Development', 'Leadership', 'Other'];
const handleInputChange = (field: string, value: any) => {
setBlogData(prev => ({
...prev,
[field]: value
}));
// Auto-generate URL slug from title
if (field === 'title' && !blogData.urlSlug) {
const slug = value.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
setBlogData(prev => ({
...prev,
urlSlug: slug
}));
}
};
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select a valid image file');
return;
}
setIsUploading(true);
try {
// Simulate upload - replace with actual image upload API
await new Promise(resolve => setTimeout(resolve, 1500));
// For now, we'll use a placeholder. In production, you'd upload to your server/CDN
const imageUrl = URL.createObjectURL(file);
handleInputChange('bannerImage', imageUrl);
toast.success('Banner image uploaded successfully');
} catch (error) {
toast.error('Failed to upload image');
} finally {
setIsUploading(false);
}
};
const addTag = () => {
if (newTag.trim() && !blogData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...blogData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', blogData.tags.filter(tag => tag !== tagToRemove));
};
const handleSave = async (status: 'draft' | 'published' = 'draft') => {
if (!blogData.title.trim()) {
toast.error('Please enter a blog title');
return;
}
if (!blogData.content.trim()) {
toast.error('Please enter blog content');
return;
}
if (!blogId) {
toast.error('Blog ID is missing');
return;
}
try {
// Create update payload with only changed fields (PATCH style)
const updatePayload: any = { id: blogId };
// Only include fields that have changed from the original
if (existingBlog) {
if (blogData.title !== existingBlog.title) {
updatePayload.title = blogData.title.trim();
}
if (blogData.urlSlug !== existingBlog.urlSlug) {
updatePayload.urlSlug = blogData.urlSlug.trim();
}
if (blogData.content !== existingBlog.content) {
updatePayload.content = blogData.content.trim();
}
if (blogData.bannerImage !== existingBlog.bannerImage) {
updatePayload.bannerImage = blogData.bannerImage;
}
if (blogData.category !== existingBlog.category) {
updatePayload.category = blogData.category;
}
if (JSON.stringify(blogData.tags) !== JSON.stringify(existingBlog.tags)) {
updatePayload.tags = blogData.tags;
}
if (blogData.metaTitle !== existingBlog.metaTitle) {
updatePayload.metaTitle = blogData.metaTitle;
}
if (blogData.metaDesc !== existingBlog.metaDesc) {
updatePayload.metaDesc = blogData.metaDesc;
}
// Handle publishedAt based on status
if (status === 'published' && !existingBlog.publishedAt) {
updatePayload.publishedAt = new Date().toISOString();
}
} else {
// Fallback: include all fields if we don't have original data
updatePayload.title = blogData.title.trim();
updatePayload.urlSlug = blogData.urlSlug.trim();
updatePayload.content = blogData.content.trim();
updatePayload.bannerImage = blogData.bannerImage;
updatePayload.category = blogData.category;
updatePayload.tags = blogData.tags;
updatePayload.metaTitle = blogData.metaTitle;
updatePayload.metaDesc = blogData.metaDesc;
if (status === 'published') {
updatePayload.publishedAt = new Date().toISOString();
}
}
console.log('PATCH payload:', updatePayload);
await updateBlog(updatePayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success(`Blog ${status === 'draft' ? 'updated' : 'published'} successfully`);
onNavigate('/content');
} catch (error: any) {
console.error('Error updating blog:', error);
toast.error(error.data?.message || 'Failed to update blog. Please try again.');
}
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/blogs/edit/${blogId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
{/* Loading skeleton */}
</div>
</AuthenticatedLayout>
);
}
if (error || !existingBlog) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/blogs/edit/${blogId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading Blog' : 'Blog Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/blogs/edit/${blogId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Edit Blog</h1>
<p className="text-muted-foreground mt-1">
Update the blog post
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => handleSave('draft')}
variant="outline"
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Save className="h-4 w-4 mr-2" />
{isUpdating ? 'Saving...' : 'Save Draft'}
</Button>
<Button
onClick={() => handleSave('published')}
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
{isUpdating ? 'Publishing...' : 'Publish Blog'}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Blog Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={blogData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter blog title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="urlSlug">URL Slug</Label>
<Input
id="urlSlug"
value={blogData.urlSlug}
onChange={(e) => handleInputChange('urlSlug', e.target.value)}
placeholder="blog-url-slug"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<p className="text-sm text-muted-foreground">
URL: /blog/{blogData.urlSlug || 'blog-url-slug'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="content">Content *</Label>
<Textarea
id="content"
value={blogData.content}
onChange={(e) => handleInputChange('content', e.target.value)}
placeholder="Write your blog content here..."
rows={15}
className="min-h-[300px] resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
{/* SEO Settings */}
<Card>
<CardHeader>
<CardTitle>SEO Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="metaTitle">Meta Title</Label>
<Input
id="metaTitle"
value={blogData.metaTitle}
onChange={(e) => handleInputChange('metaTitle', e.target.value)}
placeholder="SEO optimized title"
maxLength={60}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<p className="text-sm text-muted-foreground">
{blogData.metaTitle.length}/60 characters
</p>
</div>
<div className="space-y-2">
<Label htmlFor="metaDesc">Meta Description</Label>
<Textarea
id="metaDesc"
value={blogData.metaDesc}
onChange={(e) => handleInputChange('metaDesc', e.target.value)}
placeholder="Brief description for search engines"
maxLength={160}
rows={3}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<p className="text-sm text-muted-foreground">
{blogData.metaDesc.length}/160 characters
</p>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Banner Image */}
<Card>
<CardHeader>
<CardTitle>Banner Image</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{blogData.bannerImage ? (
<div className="space-y-2">
<div className="relative">
<img
src={blogData.bannerImage}
alt="Banner preview"
className="w-full h-32 object-cover rounded border"
/>
<Button
variant="destructive"
size="sm"
onClick={() => handleInputChange('bannerImage', '')}
className="absolute top-2 right-2 min-h-[32px] h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Image URL: {blogData.bannerImage}
</p>
</div>
) : (
<div className="space-y-2">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
id="banner-upload"
/>
<Label
htmlFor="banner-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-muted-foreground/25 rounded cursor-pointer hover:border-muted-foreground/50 transition-colors"
>
{isUploading ? (
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto"></div>
<p className="text-sm text-muted-foreground mt-2">Uploading...</p>
</div>
) : (
<div className="text-center">
<Upload className="h-6 w-6 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground mt-2">Click to upload banner</p>
<p className="text-xs text-muted-foreground mt-1">Or enter URL in field below</p>
</div>
)}
</Label>
</div>
)}
<div className="space-y-2">
<Label htmlFor="bannerImageUrl">Banner Image URL</Label>
<Input
id="bannerImageUrl"
value={blogData.bannerImage}
onChange={(e) => handleInputChange('bannerImage', e.target.value)}
placeholder="https://example.com/image.jpg"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
{/* Category & Tags */}
<Card>
<CardHeader>
<CardTitle>Classification</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<select
id="category"
value={blogData.category}
onChange={(e) => handleInputChange('category', e.target.value)}
className="w-full min-h-[44px] px-3 py-2 border border-input bg-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<option value="">Select category</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{blogData.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{blogData.tags.map((tag: string) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Blog Info */}
<Card>
<CardHeader>
<CardTitle>Blog Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Blog ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{blogId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant={existingBlog.publishedAt ? 'default' : 'secondary'}>
{existingBlog.publishedAt ? 'Published' : 'Draft'}
</Badge>
</div>
{existingBlog.publishedAt && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Published</span>
<span className="text-sm">
{new Date(existingBlog.publishedAt).toLocaleDateString()}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Last Updated</span>
<span className="text-sm">
{existingBlog.updatedAt ? new Date(existingBlog.updatedAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,397 @@
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Save, Plus, X, FileText } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetCaseStudyByIdQuery, useUpdateCaseStudyMutation } from '../../store/services/contentManager.service';
interface EditCaseStudyProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
caseStudyId?: string;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function EditCaseStudy({
onNavigate,
onLogout,
user,
caseStudyId,
formData,
onAutoSave,
onClearAutoSave
}: EditCaseStudyProps) {
const { data: existingCaseStudy, isLoading, error } = useGetCaseStudyByIdQuery(caseStudyId!, {
skip: !caseStudyId,
});
const [updateCaseStudy, { isLoading: isUpdating }] = useUpdateCaseStudyMutation();
const [caseStudyData, setCaseStudyData] = useState({
title: '',
description: '',
fileUrl: '',
tags: [] as string[],
});
const [newTag, setNewTag] = useState('');
// Load existing case study data when it's fetched
useEffect(() => {
if (existingCaseStudy) {
const caseStudy = existingCaseStudy.data || existingCaseStudy;
setCaseStudyData({
title: caseStudy.title || '',
description: caseStudy.description || '',
fileUrl: caseStudy.fileUrl || '',
tags: caseStudy.tags || [],
});
}
}, [existingCaseStudy]);
// Auto-save functionality
useEffect(() => {
if (onAutoSave && caseStudyId) {
const timer = setTimeout(() => {
onAutoSave({ ...caseStudyData, id: caseStudyId });
}, 1000);
return () => clearTimeout(timer);
}
}, [caseStudyData, caseStudyId, onAutoSave]);
const handleInputChange = (field: string, value: any) => {
setCaseStudyData(prev => ({
...prev,
[field]: value
}));
};
const addTag = () => {
if (newTag.trim() && !caseStudyData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...caseStudyData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', caseStudyData.tags.filter(tag => tag !== tagToRemove));
};
const handleSave = async () => {
if (!caseStudyData.title.trim()) {
toast.error('Please enter a title');
return;
}
if (!caseStudyData.fileUrl.trim()) {
toast.error('Please enter a file URL');
return;
}
if (!caseStudyId) {
toast.error('Case study ID is missing');
return;
}
try {
const updatePayload: any = { id: caseStudyId };
if (existingCaseStudy) {
const originalCaseStudy = existingCaseStudy.data || existingCaseStudy;
if (caseStudyData.title !== originalCaseStudy.title) {
updatePayload.title = caseStudyData.title.trim();
}
if (caseStudyData.description !== originalCaseStudy.description) {
updatePayload.description = caseStudyData.description.trim();
}
if (caseStudyData.fileUrl !== originalCaseStudy.fileUrl) {
updatePayload.fileUrl = caseStudyData.fileUrl.trim();
}
if (JSON.stringify(caseStudyData.tags) !== JSON.stringify(originalCaseStudy.tags)) {
updatePayload.tags = caseStudyData.tags;
}
} else {
updatePayload.title = caseStudyData.title.trim();
updatePayload.description = caseStudyData.description.trim();
updatePayload.fileUrl = caseStudyData.fileUrl.trim();
updatePayload.tags = caseStudyData.tags;
}
await updateCaseStudy(updatePayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success('Case study updated successfully');
onNavigate('/content');
} catch (error: any) {
console.error('Error updating case study:', error);
toast.error(error.data?.message || 'Failed to update case study. Please try again.');
}
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/case-studies/edit/${caseStudyId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
if (error || !existingCaseStudy) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/case-studies/edit/${caseStudyId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading Case Study' : 'Case Study Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
const originalCaseStudy = existingCaseStudy.data || existingCaseStudy;
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/case-studies/edit/${caseStudyId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Edit Case Study</h1>
<p className="text-muted-foreground mt-1">
Update case study details and metadata
</p>
</div>
<Button
onClick={handleSave}
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Save className="h-4 w-4 mr-2" />
{isUpdating ? 'Updating...' : 'Update Case Study'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Case Study Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={caseStudyData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter case study title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={caseStudyData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Enter case study description"
rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fileUrl">File URL *</Label>
<Input
id="fileUrl"
value={caseStudyData.fileUrl}
onChange={(e) => handleInputChange('fileUrl', e.target.value)}
placeholder="https://example.com/case-studies/document.pdf"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Tags */}
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{caseStudyData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{caseStudyData.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Preview */}
<Card>
<CardHeader>
<CardTitle>Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{caseStudyData.fileUrl ? (
<div className="space-y-3">
<div className="aspect-square bg-muted rounded-lg flex items-center justify-center">
<FileText className="h-12 w-12 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium text-sm">{caseStudyData.title || "Case Study Title"}</p>
<p className="text-xs text-muted-foreground">Case Study Document</p>
</div>
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<FileText className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm">Add file URL to see preview</p>
</div>
)}
</CardContent>
</Card>
{/* Case Study Info */}
<Card>
<CardHeader>
<CardTitle>Case Study Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Case Study ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{caseStudyId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Last Updated</span>
<span className="text-sm">
{originalCaseStudy.updatedAt ? new Date(originalCaseStudy.updatedAt).toLocaleDateString() : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">
{originalCaseStudy.createdAt ? new Date(originalCaseStudy.createdAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,431 @@
// src/components/pages/EditFAQ.tsx
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Save, Plus, X } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetFAQByIdQuery, useUpdateFAQMutation } from '../../store/services/contentManager.service';
interface EditFAQProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
faqId?: string;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function EditFAQ({
onNavigate,
onLogout,
user,
faqId,
formData,
onAutoSave,
onClearAutoSave
}: EditFAQProps) {
const { data: existingFAQ, isLoading, error } = useGetFAQByIdQuery(faqId!, {
skip: !faqId,
});
const [updateFAQ, { isLoading: isUpdating }] = useUpdateFAQMutation();
const [faqData, setFaqData] = useState({
question: '',
answer: '',
category: '',
tags: [] as string[],
globalTag: [] as string[],
});
const [newTag, setNewTag] = useState('');
// Load existing FAQ data when it's fetched
useEffect(() => {
if (existingFAQ) {
setFaqData({
question: existingFAQ.question || '',
answer: existingFAQ.answer || '',
category: existingFAQ.category || '',
tags: existingFAQ.tags || [],
globalTag: existingFAQ.globalTag || [],
});
}
}, [existingFAQ]);
// Auto-save functionality
useEffect(() => {
if (onAutoSave && faqId) {
const timer = setTimeout(() => {
onAutoSave({ ...faqData, id: faqId });
}, 1000);
return () => clearTimeout(timer);
}
}, [faqData, faqId, onAutoSave]);
const categories = ['General', 'Technical', 'Account', 'Billing', 'Support', 'Features', 'Other'];
const handleInputChange = (field: string, value: any) => {
setFaqData(prev => ({
...prev,
[field]: value
}));
};
const addTag = () => {
if (newTag.trim() && !faqData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...faqData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', faqData.tags.filter(tag => tag !== tagToRemove));
};
const addGlobalTag = () => {
const tag = prompt('Enter global tag:');
if (tag && tag.trim() && !faqData.globalTag.includes(tag.trim())) {
handleInputChange('globalTag', [...faqData.globalTag, tag.trim()]);
}
};
const removeGlobalTag = (tagToRemove: string) => {
handleInputChange('globalTag', faqData.globalTag.filter(tag => tag !== tagToRemove));
};
const handleSave = async () => {
if (!faqData.question.trim()) {
toast.error('Please enter a question');
return;
}
if (!faqData.answer.trim()) {
toast.error('Please enter an answer');
return;
}
if (!faqId) {
toast.error('FAQ ID is missing');
return;
}
try {
// Create update payload with only changed fields (PATCH style)
const updatePayload: any = { id: faqId };
// Only include fields that have changed from the original
if (existingFAQ) {
if (faqData.question !== existingFAQ.question) {
updatePayload.question = faqData.question.trim();
}
if (faqData.answer !== existingFAQ.answer) {
updatePayload.answer = faqData.answer.trim();
}
if (faqData.category !== existingFAQ.category) {
updatePayload.category = faqData.category;
}
if (JSON.stringify(faqData.tags) !== JSON.stringify(existingFAQ.tags)) {
updatePayload.tags = faqData.tags;
}
if (JSON.stringify(faqData.globalTag) !== JSON.stringify(existingFAQ.globalTag)) {
updatePayload.globalTag = faqData.globalTag;
}
} else {
// Fallback: include all fields if we don't have original data
updatePayload.question = faqData.question.trim();
updatePayload.answer = faqData.answer.trim();
updatePayload.category = faqData.category;
updatePayload.tags = faqData.tags;
updatePayload.globalTag = faqData.globalTag;
}
console.log('PATCH payload:', updatePayload);
await updateFAQ(updatePayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success('FAQ updated successfully');
onNavigate('/content');
} catch (error: any) {
console.error('Error updating FAQ:', error);
toast.error(error.data?.message || 'Failed to update FAQ. Please try again.');
}
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/faqs/edit/${faqId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
{/* Loading skeleton */}
</div>
</AuthenticatedLayout>
);
}
if (error || !existingFAQ) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/faqs/edit/${faqId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading FAQ' : 'FAQ Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/faqs/edit/${faqId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Edit FAQ</h1>
<p className="text-muted-foreground mt-1">
Update the frequently asked question
</p>
</div>
<Button
onClick={handleSave}
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Save className="h-4 w-4 mr-2" />
{isUpdating ? 'Updating...' : 'Update FAQ'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>FAQ Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="question">Question *</Label>
<Input
id="question"
value={faqData.question}
onChange={(e) => handleInputChange('question', e.target.value)}
placeholder="Enter the question"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="answer">Answer *</Label>
<Textarea
id="answer"
value={faqData.answer}
onChange={(e) => handleInputChange('answer', e.target.value)}
placeholder="Enter the answer"
rows={6}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Category */}
<Card>
<CardHeader>
<CardTitle>Category</CardTitle>
</CardHeader>
<CardContent>
<select
value={faqData.category}
onChange={(e) => handleInputChange('category', e.target.value)}
className="w-full min-h-[44px] px-3 py-2 border border-input bg-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<option value="">Select category</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</CardContent>
</Card>
{/* Tags */}
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{faqData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{faqData.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Global Tags */}
<Card>
<CardHeader>
<CardTitle>Global Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={addGlobalTag}
variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4 mr-2" />
Add Global Tag
</Button>
{faqData.globalTag.length > 0 && (
<div className="flex flex-wrap gap-2">
{faqData.globalTag.map((tag) => (
<Badge
key={tag}
variant="default"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeGlobalTag(tag)}
/>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* FAQ Info */}
<Card>
<CardHeader>
<CardTitle>FAQ Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">FAQ ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{faqId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Last Updated</span>
<span className="text-sm">
{existingFAQ.updatedAt ? new Date(existingFAQ.updatedAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,383 @@
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Save, Plus, X, FolderOpen } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetKlcArchiveByIdQuery, useUpdateKlcArchiveMutation } from '../../store/services/contentManager.service';
interface EditKlcArchiveContentProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
archiveId?: string;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function EditKlcArchiveContent({
onNavigate,
onLogout,
user,
archiveId,
formData,
onAutoSave,
onClearAutoSave
}: EditKlcArchiveContentProps) {
const { data: existingArchive, isLoading, error } = useGetKlcArchiveByIdQuery(archiveId!, {
skip: !archiveId,
});
const [updateKlcArchive, { isLoading: isUpdating }] = useUpdateKlcArchiveMutation();
const [archiveData, setArchiveData] = useState({
title: '',
description: '',
fileUrl: '',
tags: [] as string[],
});
const [newTag, setNewTag] = useState('');
const categories = [
"Leadership Lego Blocks",
"Management Development Lego blocks",
"Consulting Lego Blocks",
"Business Development",
"KLC - facility-related",
"Photos",
"Videos",
"Client details & Contracts"
];
// Load existing archive data when it's fetched
useEffect(() => {
if (existingArchive) {
const archive = existingArchive.data || existingArchive;
setArchiveData({
title: archive.title || '',
description: archive.description || '',
fileUrl: archive.fileUrl || '',
tags: archive.tags || [],
});
}
}, [existingArchive]);
// Auto-save functionality
useEffect(() => {
if (onAutoSave && archiveId) {
const timer = setTimeout(() => {
onAutoSave({ ...archiveData, id: archiveId });
}, 1000);
return () => clearTimeout(timer);
}
}, [archiveData, archiveId, onAutoSave]);
const handleInputChange = (field: string, value: any) => {
setArchiveData(prev => ({
...prev,
[field]: value
}));
};
const addTag = () => {
if (newTag.trim() && !archiveData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...archiveData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', archiveData.tags.filter(tag => tag !== tagToRemove));
};
const handleSave = async () => {
if (!archiveData.title.trim()) {
toast.error('Please enter a title');
return;
}
if (!archiveData.fileUrl.trim()) {
toast.error('Please enter a file URL');
return;
}
if (!archiveId) {
toast.error('Archive content ID is missing');
return;
}
try {
const updatePayload: any = { id: archiveId };
if (existingArchive) {
const originalArchive = existingArchive.data || existingArchive;
if (archiveData.title !== originalArchive.title) {
updatePayload.title = archiveData.title.trim();
}
if (archiveData.description !== originalArchive.description) {
updatePayload.description = archiveData.description.trim();
}
if (archiveData.fileUrl !== originalArchive.fileUrl) {
updatePayload.fileUrl = archiveData.fileUrl.trim();
}
if (JSON.stringify(archiveData.tags) !== JSON.stringify(originalArchive.tags)) {
updatePayload.tags = archiveData.tags;
}
} else {
updatePayload.title = archiveData.title.trim();
updatePayload.description = archiveData.description.trim();
updatePayload.fileUrl = archiveData.fileUrl.trim();
updatePayload.tags = archiveData.tags;
}
await updateKlcArchive(updatePayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success('Archive content updated successfully');
onNavigate('/content');
} catch (error: any) {
console.error('Error updating archive content:', error);
toast.error(error.data?.message || 'Failed to update archive content. Please try again.');
}
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/archive/edit/${archiveId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
if (error || !existingArchive) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/archive/edit/${archiveId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading Archive Content' : 'Archive Content Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
const originalArchive = existingArchive.data || existingArchive;
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/archive/edit/${archiveId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Edit Archive Content</h1>
<p className="text-muted-foreground mt-1">
Update archive content details and metadata
</p>
</div>
<Button
onClick={handleSave}
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Save className="h-4 w-4 mr-2" />
{isUpdating ? 'Updating...' : 'Update Archive Content'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Archive Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={archiveData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter archive content title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={archiveData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Enter archive content description"
rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fileUrl">File URL *</Label>
<Input
id="fileUrl"
value={archiveData.fileUrl}
onChange={(e) => handleInputChange('fileUrl', e.target.value)}
placeholder="https://example.com/archive/document.pdf"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Tags */}
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{archiveData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{archiveData.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Archive Content Info */}
<Card>
<CardHeader>
<CardTitle>Archive Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Content ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{archiveId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Last Updated</span>
<span className="text-sm">
{originalArchive.updatedAt ? new Date(originalArchive.updatedAt).toLocaleDateString() : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">
{originalArchive.createdAt ? new Date(originalArchive.createdAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,397 @@
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Save, Plus, X, Mic } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetPodcastByIdQuery, useUpdatePodcastMutation } from '../../store/services/contentManager.service';
interface EditPodcastProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
podcastId?: string;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function EditPodcast({
onNavigate,
onLogout,
user,
podcastId,
formData,
onAutoSave,
onClearAutoSave
}: EditPodcastProps) {
const { data: existingPodcast, isLoading, error } = useGetPodcastByIdQuery(podcastId!, {
skip: !podcastId,
});
const [updatePodcast, { isLoading: isUpdating }] = useUpdatePodcastMutation();
const [podcastData, setPodcastData] = useState({
title: '',
description: '',
fileUrl: '',
tags: [] as string[],
});
const [newTag, setNewTag] = useState('');
// Load existing podcast data when it's fetched
useEffect(() => {
if (existingPodcast) {
const podcast = existingPodcast.data || existingPodcast;
setPodcastData({
title: podcast.title || '',
description: podcast.description || '',
fileUrl: podcast.fileUrl || '',
tags: podcast.tags || [],
});
}
}, [existingPodcast]);
// Auto-save functionality
useEffect(() => {
if (onAutoSave && podcastId) {
const timer = setTimeout(() => {
onAutoSave({ ...podcastData, id: podcastId });
}, 1000);
return () => clearTimeout(timer);
}
}, [podcastData, podcastId, onAutoSave]);
const handleInputChange = (field: string, value: any) => {
setPodcastData(prev => ({
...prev,
[field]: value
}));
};
const addTag = () => {
if (newTag.trim() && !podcastData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...podcastData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', podcastData.tags.filter(tag => tag !== tagToRemove));
};
const handleSave = async () => {
if (!podcastData.title.trim()) {
toast.error('Please enter a title');
return;
}
if (!podcastData.fileUrl.trim()) {
toast.error('Please enter a file URL');
return;
}
if (!podcastId) {
toast.error('Podcast ID is missing');
return;
}
try {
const updatePayload: any = { id: podcastId };
if (existingPodcast) {
const originalPodcast = existingPodcast.data || existingPodcast;
if (podcastData.title !== originalPodcast.title) {
updatePayload.title = podcastData.title.trim();
}
if (podcastData.description !== originalPodcast.description) {
updatePayload.description = podcastData.description.trim();
}
if (podcastData.fileUrl !== originalPodcast.fileUrl) {
updatePayload.fileUrl = podcastData.fileUrl.trim();
}
if (JSON.stringify(podcastData.tags) !== JSON.stringify(originalPodcast.tags)) {
updatePayload.tags = podcastData.tags;
}
} else {
updatePayload.title = podcastData.title.trim();
updatePayload.description = podcastData.description.trim();
updatePayload.fileUrl = podcastData.fileUrl.trim();
updatePayload.tags = podcastData.tags;
}
await updatePodcast(updatePayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success('Podcast updated successfully');
onNavigate('/content');
} catch (error: any) {
console.error('Error updating podcast:', error);
toast.error(error.data?.message || 'Failed to update podcast. Please try again.');
}
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/podcasts/edit/${podcastId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
if (error || !existingPodcast) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/podcasts/edit/${podcastId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading Podcast' : 'Podcast Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
const originalPodcast = existingPodcast.data || existingPodcast;
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/podcasts/edit/${podcastId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Edit Podcast</h1>
<p className="text-muted-foreground mt-1">
Update podcast details and metadata
</p>
</div>
<Button
onClick={handleSave}
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Save className="h-4 w-4 mr-2" />
{isUpdating ? 'Updating...' : 'Update Podcast'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Podcast Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={podcastData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter podcast title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={podcastData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Enter podcast description"
rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fileUrl">File URL *</Label>
<Input
id="fileUrl"
value={podcastData.fileUrl}
onChange={(e) => handleInputChange('fileUrl', e.target.value)}
placeholder="https://example.com/podcasts/episode.mp3"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Tags */}
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{podcastData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{podcastData.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Preview */}
<Card>
<CardHeader>
<CardTitle>Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{podcastData.fileUrl ? (
<div className="space-y-3">
<div className="aspect-square bg-muted rounded-lg flex items-center justify-center">
<Mic className="h-12 w-12 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium text-sm">{podcastData.title || "Podcast Title"}</p>
<p className="text-xs text-muted-foreground">Audio Podcast</p>
</div>
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Mic className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm">Add file URL to see preview</p>
</div>
)}
</CardContent>
</Card>
{/* Podcast Info */}
<Card>
<CardHeader>
<CardTitle>Podcast Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Podcast ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{podcastId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Last Updated</span>
<span className="text-sm">
{originalPodcast.updatedAt ? new Date(originalPodcast.updatedAt).toLocaleDateString() : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">
{originalPodcast.createdAt ? new Date(originalPodcast.createdAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,374 @@
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Save, Plus, X, FileText } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetReadingMaterialByIdQuery, useUpdateReadingMaterialMutation } from '../../store/services/contentManager.service';
interface EditReadingMaterialProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
readingMaterialId?: string;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function EditReadingMaterial({
onNavigate,
onLogout,
user,
readingMaterialId,
formData,
onAutoSave,
onClearAutoSave
}: EditReadingMaterialProps) {
const { data: existingReadingMaterial, isLoading, error } = useGetReadingMaterialByIdQuery(readingMaterialId!, {
skip: !readingMaterialId,
});
const [updateReadingMaterial, { isLoading: isUpdating }] = useUpdateReadingMaterialMutation();
const [readingMaterialData, setReadingMaterialData] = useState({
title: '',
description: '',
fileUrl: '',
tags: [] as string[],
});
const [newTag, setNewTag] = useState('');
// Load existing reading material data when it's fetched
useEffect(() => {
if (existingReadingMaterial) {
const material = existingReadingMaterial.data || existingReadingMaterial;
setReadingMaterialData({
title: material.title || '',
description: material.description || '',
fileUrl: material.fileUrl || '',
tags: material.tags || [],
});
}
}, [existingReadingMaterial]);
// Auto-save functionality
useEffect(() => {
if (onAutoSave && readingMaterialId) {
const timer = setTimeout(() => {
onAutoSave({ ...readingMaterialData, id: readingMaterialId });
}, 1000);
return () => clearTimeout(timer);
}
}, [readingMaterialData, readingMaterialId, onAutoSave]);
const categories = ['Leadership', 'Management', 'Technical', 'Professional Development', 'Reference'];
const handleInputChange = (field: string, value: any) => {
setReadingMaterialData(prev => ({
...prev,
[field]: value
}));
};
const addTag = () => {
if (newTag.trim() && !readingMaterialData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...readingMaterialData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', readingMaterialData.tags.filter(tag => tag !== tagToRemove));
};
const handleSave = async () => {
if (!readingMaterialData.title.trim()) {
toast.error('Please enter a title');
return;
}
if (!readingMaterialData.fileUrl.trim()) {
toast.error('Please enter a file URL');
return;
}
if (!readingMaterialId) {
toast.error('Reading Material ID is missing');
return;
}
try {
const updatePayload: any = { id: readingMaterialId };
if (existingReadingMaterial) {
const originalMaterial = existingReadingMaterial.data || existingReadingMaterial;
if (readingMaterialData.title !== originalMaterial.title) {
updatePayload.title = readingMaterialData.title.trim();
}
if (readingMaterialData.description !== originalMaterial.description) {
updatePayload.description = readingMaterialData.description.trim();
}
if (readingMaterialData.fileUrl !== originalMaterial.fileUrl) {
updatePayload.fileUrl = readingMaterialData.fileUrl.trim();
}
if (JSON.stringify(readingMaterialData.tags) !== JSON.stringify(originalMaterial.tags)) {
updatePayload.tags = readingMaterialData.tags;
}
} else {
updatePayload.title = readingMaterialData.title.trim();
updatePayload.description = readingMaterialData.description.trim();
updatePayload.fileUrl = readingMaterialData.fileUrl.trim();
updatePayload.tags = readingMaterialData.tags;
}
await updateReadingMaterial(updatePayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success('Reading material updated successfully');
onNavigate('/content');
} catch (error: any) {
console.error('Error updating reading material:', error);
toast.error(error.data?.message || 'Failed to update reading material. Please try again.');
}
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/reading-materials/edit/${readingMaterialId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
if (error || !existingReadingMaterial) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/reading-materials/edit/${readingMaterialId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading Reading Material' : 'Reading Material Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
const originalMaterial = existingReadingMaterial.data || existingReadingMaterial;
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/reading-materials/edit/${readingMaterialId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Edit Reading Material</h1>
<p className="text-muted-foreground mt-1">
Update reading material details and metadata
</p>
</div>
<Button
onClick={handleSave}
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Save className="h-4 w-4 mr-2" />
{isUpdating ? 'Updating...' : 'Update Reading Material'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Reading Material Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={readingMaterialData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter reading material title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={readingMaterialData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Enter reading material description"
rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fileUrl">File URL *</Label>
<Input
id="fileUrl"
value={readingMaterialData.fileUrl}
onChange={(e) => handleInputChange('fileUrl', e.target.value)}
placeholder="https://example.com/reading-materials/document.pdf"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Tags */}
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{readingMaterialData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{readingMaterialData.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Reading Material Info */}
<Card>
<CardHeader>
<CardTitle>Reading Material Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Material ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{readingMaterialId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Last Updated</span>
<span className="text-sm">
{originalMaterial.updatedAt ? new Date(originalMaterial.updatedAt).toLocaleDateString() : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">
{originalMaterial.createdAt ? new Date(originalMaterial.createdAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,381 @@
// src/components/pages/EditTrainingMaterial.tsx
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Save, Plus, X, FileText } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetTrainingMaterialByIdQuery, useUpdateTrainingMaterialMutation } from '../../store/services/contentManager.service';
interface EditTrainingMaterialProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
trainingMaterialId?: string;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function EditTrainingMaterial({
onNavigate,
onLogout,
user,
trainingMaterialId,
formData,
onAutoSave,
onClearAutoSave
}: EditTrainingMaterialProps) {
const { data: existingTrainingMaterial, isLoading, error } = useGetTrainingMaterialByIdQuery(trainingMaterialId!, {
skip: !trainingMaterialId,
});
const [updateTrainingMaterial, { isLoading: isUpdating }] = useUpdateTrainingMaterialMutation();
const [trainingMaterialData, setTrainingMaterialData] = useState({
title: '',
description: '',
fileUrl: '',
tags: [] as string[],
});
const [newTag, setNewTag] = useState('');
// Load existing training material data when it's fetched
useEffect(() => {
if (existingTrainingMaterial) {
const material = existingTrainingMaterial.data || existingTrainingMaterial;
setTrainingMaterialData({
title: material.title || '',
description: material.description || '',
fileUrl: material.fileUrl || '',
tags: material.tags || [],
});
}
}, [existingTrainingMaterial]);
// Auto-save functionality
useEffect(() => {
if (onAutoSave && trainingMaterialId) {
const timer = setTimeout(() => {
onAutoSave({ ...trainingMaterialData, id: trainingMaterialId });
}, 1000);
return () => clearTimeout(timer);
}
}, [trainingMaterialData, trainingMaterialId, onAutoSave]);
const categories = ['Facilitator Manual', 'Participant Handouts', 'To be printed (for KLC team)', 'Reference Material', 'Activity Sheets'];
const handleInputChange = (field: string, value: any) => {
setTrainingMaterialData(prev => ({
...prev,
[field]: value
}));
};
const addTag = () => {
if (newTag.trim() && !trainingMaterialData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...trainingMaterialData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', trainingMaterialData.tags.filter(tag => tag !== tagToRemove));
};
const handleSave = async () => {
if (!trainingMaterialData.title.trim()) {
toast.error('Please enter a title');
return;
}
if (!trainingMaterialData.fileUrl.trim()) {
toast.error('Please enter a file URL');
return;
}
if (!trainingMaterialId) {
toast.error('Training Material ID is missing');
return;
}
try {
// Create update payload with only changed fields (PATCH style)
const updatePayload: any = { id: trainingMaterialId };
// Only include fields that have changed from the original
if (existingTrainingMaterial) {
const originalMaterial = existingTrainingMaterial.data || existingTrainingMaterial;
if (trainingMaterialData.title !== originalMaterial.title) {
updatePayload.title = trainingMaterialData.title.trim();
}
if (trainingMaterialData.description !== originalMaterial.description) {
updatePayload.description = trainingMaterialData.description.trim();
}
if (trainingMaterialData.fileUrl !== originalMaterial.fileUrl) {
updatePayload.fileUrl = trainingMaterialData.fileUrl.trim();
}
if (JSON.stringify(trainingMaterialData.tags) !== JSON.stringify(originalMaterial.tags)) {
updatePayload.tags = trainingMaterialData.tags;
}
} else {
// Fallback: include all fields if we don't have original data
updatePayload.title = trainingMaterialData.title.trim();
updatePayload.description = trainingMaterialData.description.trim();
updatePayload.fileUrl = trainingMaterialData.fileUrl.trim();
updatePayload.tags = trainingMaterialData.tags;
}
console.log('PATCH payload:', updatePayload);
await updateTrainingMaterial(updatePayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success('Training Material updated successfully');
onNavigate('/content');
} catch (error: any) {
console.error('Error updating training material:', error);
toast.error(error.data?.message || 'Failed to update training material. Please try again.');
}
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/training-materials/edit/${trainingMaterialId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
{/* Loading skeleton */}
</div>
</AuthenticatedLayout>
);
}
if (error || !existingTrainingMaterial) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/training-materials/edit/${trainingMaterialId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading Training Material' : 'Training Material Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
const originalMaterial = existingTrainingMaterial.data || existingTrainingMaterial;
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/training-materials/edit/${trainingMaterialId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Edit Training Material</h1>
<p className="text-muted-foreground mt-1">
Update training material details and metadata
</p>
</div>
<Button
onClick={handleSave}
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Save className="h-4 w-4 mr-2" />
{isUpdating ? 'Updating...' : 'Update Training Material'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Training Material Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={trainingMaterialData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter training material title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={trainingMaterialData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Enter training material description"
rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fileUrl">File URL *</Label>
<Input
id="fileUrl"
value={trainingMaterialData.fileUrl}
onChange={(e) => handleInputChange('fileUrl', e.target.value)}
placeholder="https://example.com/training-materials/document.pdf"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Tags */}
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{trainingMaterialData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{trainingMaterialData.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Training Material Info */}
<Card>
<CardHeader>
<CardTitle>Training Material Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Material ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{trainingMaterialId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Last Updated</span>
<span className="text-sm">
{originalMaterial.updatedAt ? new Date(originalMaterial.updatedAt).toLocaleDateString() : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">
{originalMaterial.createdAt ? new Date(originalMaterial.createdAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,380 @@
// src/components/pages/EditWebcast.tsx
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Save, Plus, X, Play } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetWebcastByIdQuery, useUpdateWebcastMutation } from '../../store/services/contentManager.service';
interface EditWebcastProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
webcastId?: string;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function EditWebcast({
onNavigate,
onLogout,
user,
webcastId,
formData,
onAutoSave,
onClearAutoSave
}: EditWebcastProps) {
const { data: existingWebcast, isLoading, error } = useGetWebcastByIdQuery(webcastId!, {
skip: !webcastId,
});
const [updateWebcast, { isLoading: isUpdating }] = useUpdateWebcastMutation();
const [webcastData, setWebcastData] = useState({
title: '',
description: '',
fileUrl: '',
tags: [] as string[],
});
const [newTag, setNewTag] = useState('');
// Load existing webcast data when it's fetched
useEffect(() => {
if (existingWebcast) {
const webcast = existingWebcast.data || existingWebcast;
setWebcastData({
title: webcast.title || '',
description: webcast.description || '',
fileUrl: webcast.fileUrl || '',
tags: webcast.tags || [],
});
}
}, [existingWebcast]);
// Auto-save functionality
useEffect(() => {
if (onAutoSave && webcastId) {
const timer = setTimeout(() => {
onAutoSave({ ...webcastData, id: webcastId });
}, 1000);
return () => clearTimeout(timer);
}
}, [webcastData, webcastId, onAutoSave]);
const handleInputChange = (field: string, value: any) => {
setWebcastData(prev => ({
...prev,
[field]: value
}));
};
const addTag = () => {
if (newTag.trim() && !webcastData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...webcastData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', webcastData.tags.filter(tag => tag !== tagToRemove));
};
const handleSave = async () => {
if (!webcastData.title.trim()) {
toast.error('Please enter a title');
return;
}
if (!webcastData.fileUrl.trim()) {
toast.error('Please enter a file URL');
return;
}
if (!webcastId) {
toast.error('Webcast ID is missing');
return;
}
try {
// Create update payload with only changed fields (PATCH style)
const updatePayload: any = { id: webcastId };
// Only include fields that have changed from the original
if (existingWebcast) {
const originalWebcast = existingWebcast.data || existingWebcast;
if (webcastData.title !== originalWebcast.title) {
updatePayload.title = webcastData.title.trim();
}
if (webcastData.description !== originalWebcast.description) {
updatePayload.description = webcastData.description.trim();
}
if (webcastData.fileUrl !== originalWebcast.fileUrl) {
updatePayload.fileUrl = webcastData.fileUrl.trim();
}
if (JSON.stringify(webcastData.tags) !== JSON.stringify(originalWebcast.tags)) {
updatePayload.tags = webcastData.tags;
}
} else {
// Fallback: include all fields if we don't have original data
updatePayload.title = webcastData.title.trim();
updatePayload.description = webcastData.description.trim();
updatePayload.fileUrl = webcastData.fileUrl.trim();
updatePayload.tags = webcastData.tags;
}
console.log('PATCH payload:', updatePayload);
await updateWebcast(updatePayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success('Webcast updated successfully');
onNavigate('/content');
} catch (error: any) {
console.error('Error updating webcast:', error);
toast.error(error.data?.message || 'Failed to update webcast. Please try again.');
}
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/webcasts/edit/${webcastId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
{/* Loading skeleton */}
</div>
</AuthenticatedLayout>
);
}
if (error || !existingWebcast) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/webcasts/edit/${webcastId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading Webcast' : 'Webcast Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
const originalWebcast = existingWebcast.data || existingWebcast;
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/webcasts/edit/${webcastId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Edit Webcast</h1>
<p className="text-muted-foreground mt-1">
Update webcast details and metadata
</p>
</div>
<Button
onClick={handleSave}
disabled={isUpdating}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Save className="h-4 w-4 mr-2" />
{isUpdating ? 'Updating...' : 'Update Webcast'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Webcast Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={webcastData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter webcast title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={webcastData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Enter webcast description"
rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fileUrl">File URL *</Label>
<Input
id="fileUrl"
value={webcastData.fileUrl}
onChange={(e) => handleInputChange('fileUrl', e.target.value)}
placeholder="https://example.com/webcasts/video.mp4"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Tags */}
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{webcastData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{webcastData.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Webcast Info */}
<Card>
<CardHeader>
<CardTitle>Webcast Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Webcast ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{webcastId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Last Updated</span>
<span className="text-sm">
{originalWebcast.updatedAt ? new Date(originalWebcast.updatedAt).toLocaleDateString() : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">
{originalWebcast.createdAt ? new Date(originalWebcast.createdAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -37,7 +37,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '../ui/alert-dialog'; } from '../ui/alert-dialog';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { import {
Plus, Plus,
Download, Download,
@@ -52,9 +52,10 @@ import {
Activity, Activity,
Filter Filter
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface Facilities360Props { interface Facilities360Props {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -41,7 +41,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '../ui/alert-dialog'; } from '../ui/alert-dialog';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { import {
Eye, Eye,
Upload, Upload,
@@ -63,10 +63,11 @@ import {
Link, Link,
Info Info
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface Facilities360DetailProps { interface Facilities360DetailProps {
tourId: string; tourId: string;
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -42,9 +42,10 @@ import {
Copy, Copy,
Eye Eye
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface Facilities360NewProps { interface Facilities360NewProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -25,9 +25,10 @@ import { MediaPicker } from '../landing-pages/MediaPicker';
import { PreviewModal } from '../landing-pages/PreviewModal'; import { PreviewModal } from '../landing-pages/PreviewModal';
import { VersionHistory } from '../landing-pages/VersionHistory'; import { VersionHistory } from '../landing-pages/VersionHistory';
import { AuditDrawer } from '../landing-pages/AuditDrawer'; import { AuditDrawer } from '../landing-pages/AuditDrawer';
import { Route } from '../../types/routes';
interface HomeEditorProps { interface HomeEditorProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -74,9 +74,10 @@ import {
User, User,
Filter Filter
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface IndividualLearnersProps { interface IndividualLearnersProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -4,9 +4,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Edit, Eye } from 'lucide-react'; import { Edit, Eye } from 'lucide-react';
import { Route } from '../../types/routes';
interface LandingPagesProps { interface LandingPagesProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -46,7 +46,7 @@ import {
} from '../ui/alert-dialog'; } from '../ui/alert-dialog';
import { Checkbox } from '../ui/checkbox'; import { Checkbox } from '../ui/checkbox';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { import {
Search, Search,
Upload, Upload,
@@ -78,9 +78,10 @@ import {
UserPlus, UserPlus,
ExternalLink ExternalLink
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface LeadsProps { interface LeadsProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -6,11 +6,13 @@ import { Input } from '../ui/input';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea'; import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { ArrowLeft, Upload, X, Plus } from 'lucide-react'; import { ArrowLeft, Upload, X, Plus } from 'lucide-react';
import { Route } from '../../types/routes';
import { useCreateBlogMutation } from '../../store/services/contentManager.service';
interface NewBlogProps { interface NewBlogProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
formData?: any; formData?: any;
@@ -28,20 +30,22 @@ export function NewBlog({
}: NewBlogProps) { }: NewBlogProps) {
const [blogData, setBlogData] = useState(() => ({ const [blogData, setBlogData] = useState(() => ({
title: formData?.title || '', title: formData?.title || '',
slug: formData?.slug || '', urlSlug: formData?.urlSlug || '',
metaTitle: formData?.metaTitle || '', content: formData?.content || '',
metaDescription: formData?.metaDescription || '', bannerImage: formData?.bannerImage || '',
body: formData?.body || '',
bannerImage: formData?.bannerImage || null,
bannerAltText: formData?.bannerAltText || '',
tags: formData?.tags || [],
category: formData?.category || '', category: formData?.category || '',
status: formData?.status || 'draft' tags: formData?.tags || [],
metaTitle: formData?.metaTitle || '',
metaDesc: formData?.metaDesc || '',
publishedAt: formData?.publishedAt || new Date().toISOString()
})); }));
const [newTag, setNewTag] = useState(''); const [newTag, setNewTag] = useState('');
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
// Use the RTK Query mutation hook
const [createBlog, { isLoading: isSubmitting }] = useCreateBlogMutation();
// Auto-save functionality // Auto-save functionality
React.useEffect(() => { React.useEffect(() => {
if (onAutoSave) { if (onAutoSave) {
@@ -58,8 +62,8 @@ export function NewBlog({
[field]: value [field]: value
})); }));
// Auto-generate slug from title // Auto-generate URL slug from title
if (field === 'title' && !blogData.slug) { if (field === 'title' && !blogData.urlSlug) {
const slug = value.toLowerCase() const slug = value.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') .replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
@@ -67,7 +71,7 @@ export function NewBlog({
.trim(); .trim();
setBlogData(prev => ({ setBlogData(prev => ({
...prev, ...prev,
slug urlSlug: slug
})); }));
} }
}; };
@@ -83,15 +87,12 @@ export function NewBlog({
setIsUploading(true); setIsUploading(true);
try { try {
// Simulate upload // Simulate upload - replace with actual image upload API
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise(resolve => setTimeout(resolve, 1500));
// For now, we'll use a placeholder. In production, you'd upload to your server/CDN
const imageUrl = URL.createObjectURL(file); const imageUrl = URL.createObjectURL(file);
handleInputChange('bannerImage', { handleInputChange('bannerImage', imageUrl);
url: imageUrl,
name: file.name,
size: file.size
});
toast.success('Banner image uploaded successfully'); toast.success('Banner image uploaded successfully');
} catch (error) { } catch (error) {
@@ -112,29 +113,37 @@ export function NewBlog({
handleInputChange('tags', blogData.tags.filter((tag: string) => tag !== tagToRemove)); handleInputChange('tags', blogData.tags.filter((tag: string) => tag !== tagToRemove));
}; };
const handleSave = async (status: 'draft' | 'published') => { const handleCreateBlog = async (status: 'draft' | 'published') => {
if (!blogData.title.trim()) { if (!blogData.title.trim()) {
toast.error('Please enter a blog title'); toast.error('Please enter a blog title');
return; return;
} }
if (!blogData.body.trim()) { if (!blogData.content.trim()) {
toast.error('Please enter blog content'); toast.error('Please enter blog content');
return; return;
} }
if (!blogData.category) {
toast.error('Please select a category');
return;
}
try { try {
const blogToSave = { const blogPayload = {
...blogData, title: blogData.title,
status, urlSlug: blogData.urlSlug,
id: Date.now().toString(), content: blogData.content,
createdAt: new Date().toISOString(), bannerImage: blogData.bannerImage,
updatedAt: new Date().toISOString(), category: blogData.category,
author: user.name tags: blogData.tags,
metaTitle: blogData.metaTitle,
metaDesc: blogData.metaDesc,
publishedAt: status === 'published' ? new Date().toISOString() : null
}; };
// Simulate API call // Use the RTK Query mutation
await new Promise(resolve => setTimeout(resolve, 1000)); await createBlog(blogPayload).unwrap();
if (onClearAutoSave) { if (onClearAutoSave) {
onClearAutoSave(); onClearAutoSave();
@@ -142,12 +151,23 @@ export function NewBlog({
toast.success(`Blog ${status === 'draft' ? 'saved as draft' : 'published'} successfully`); toast.success(`Blog ${status === 'draft' ? 'saved as draft' : 'published'} successfully`);
onNavigate('/content'); onNavigate('/content');
} catch (error) { } catch (error: any) {
toast.error('Failed to save blog'); console.error('Error creating blog:', error);
// Handle different error formats
if (error.data?.message) {
if (Array.isArray(error.data.message)) {
error.data.message.forEach((msg: string) => toast.error(msg));
} else {
toast.error(error.data.message);
}
} else {
toast.error(error.message || 'Failed to save blog. Please try again.');
}
} }
}; };
const categories = ['Technology', 'Business', 'Marketing', 'Design', 'Development', 'Other']; const categories = ['Technology', 'Business', 'Marketing', 'Design', 'Development', 'Personal Development', 'Leadership', 'Other'];
return ( return (
<AuthenticatedLayout <AuthenticatedLayout
@@ -161,6 +181,7 @@ export function NewBlog({
variant="ghost" variant="ghost"
onClick={() => onNavigate('/content')} onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Content Back to Content
@@ -189,32 +210,35 @@ export function NewBlog({
onChange={(e) => handleInputChange('title', e.target.value)} onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter blog title" placeholder="Enter blog title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="slug">URL Slug</Label> <Label htmlFor="urlSlug">URL Slug</Label>
<Input <Input
id="slug" id="urlSlug"
value={blogData.slug} value={blogData.urlSlug}
onChange={(e) => handleInputChange('slug', e.target.value)} onChange={(e) => handleInputChange('urlSlug', e.target.value)}
placeholder="blog-url-slug" placeholder="blog-url-slug"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
URL: /blog/{blogData.slug || 'blog-url-slug'} URL: /blog/{blogData.urlSlug || 'blog-url-slug'}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="body">Content *</Label> <Label htmlFor="content">Content *</Label>
<Textarea <Textarea
id="body" id="content"
value={blogData.body} value={blogData.content}
onChange={(e) => handleInputChange('body', e.target.value)} onChange={(e) => handleInputChange('content', e.target.value)}
placeholder="Write your blog content here..." placeholder="Write your blog content here..."
rows={15} rows={15}
className="min-h-[300px] resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[300px] resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
</div> </div>
</CardContent> </CardContent>
@@ -235,6 +259,7 @@ export function NewBlog({
placeholder="SEO optimized title" placeholder="SEO optimized title"
maxLength={60} maxLength={60}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{blogData.metaTitle.length}/60 characters {blogData.metaTitle.length}/60 characters
@@ -242,18 +267,19 @@ export function NewBlog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="metaDescription">Meta Description</Label> <Label htmlFor="metaDesc">Meta Description</Label>
<Textarea <Textarea
id="metaDescription" id="metaDesc"
value={blogData.metaDescription} value={blogData.metaDesc}
onChange={(e) => handleInputChange('metaDescription', e.target.value)} onChange={(e) => handleInputChange('metaDesc', e.target.value)}
placeholder="Brief description for search engines" placeholder="Brief description for search engines"
maxLength={160} maxLength={160}
rows={3} rows={3}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{blogData.metaDescription.length}/160 characters {blogData.metaDesc.length}/160 characters
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -272,29 +298,23 @@ export function NewBlog({
<div className="space-y-2"> <div className="space-y-2">
<div className="relative"> <div className="relative">
<img <img
src={blogData.bannerImage.url} src={blogData.bannerImage}
alt="Banner preview" alt="Banner preview"
className="w-full h-32 object-cover rounded border" className="w-full h-32 object-cover rounded border"
/> />
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => handleInputChange('bannerImage', null)} onClick={() => handleInputChange('bannerImage', '')}
className="absolute top-2 right-2 min-h-[32px] h-8 w-8 p-0" className="absolute top-2 right-2 min-h-[32px] h-8 w-8 p-0"
disabled={isSubmitting}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="space-y-2"> <p className="text-sm text-muted-foreground">
<Label htmlFor="bannerAltText">Alt Text</Label> Image URL: {blogData.bannerImage}
<Input </p>
id="bannerAltText"
value={blogData.bannerAltText}
onChange={(e) => handleInputChange('bannerAltText', e.target.value)}
placeholder="Describe the image"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -304,10 +324,13 @@ export function NewBlog({
onChange={handleImageUpload} onChange={handleImageUpload}
className="hidden" className="hidden"
id="banner-upload" id="banner-upload"
disabled={isSubmitting}
/> />
<Label <Label
htmlFor="banner-upload" htmlFor="banner-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-muted-foreground/25 rounded cursor-pointer hover:border-muted-foreground/50 transition-colors" className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-muted-foreground/25 rounded cursor-pointer hover:border-muted-foreground/50 transition-colors ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
}`}
> >
{isUploading ? ( {isUploading ? (
<div className="text-center"> <div className="text-center">
@@ -318,11 +341,24 @@ export function NewBlog({
<div className="text-center"> <div className="text-center">
<Upload className="h-6 w-6 text-muted-foreground mx-auto" /> <Upload className="h-6 w-6 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground mt-2">Click to upload banner</p> <p className="text-sm text-muted-foreground mt-2">Click to upload banner</p>
<p className="text-xs text-muted-foreground mt-1">Or enter URL in field below</p>
</div> </div>
)} )}
</Label> </Label>
</div> </div>
)} )}
<div className="space-y-2">
<Label htmlFor="bannerImageUrl">Banner Image URL</Label>
<Input
id="bannerImageUrl"
value={blogData.bannerImage}
onChange={(e) => handleInputChange('bannerImage', e.target.value)}
placeholder="https://example.com/image.jpg"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -333,12 +369,13 @@ export function NewBlog({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="category">Category</Label> <Label htmlFor="category">Category *</Label>
<select <select
id="category" id="category"
value={blogData.category} value={blogData.category}
onChange={(e) => handleInputChange('category', e.target.value)} onChange={(e) => handleInputChange('category', e.target.value)}
className="w-full min-h-[44px] px-3 py-2 border border-input bg-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="w-full min-h-[44px] px-3 py-2 border border-input bg-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<option value="">Select category</option> <option value="">Select category</option>
{categories.map(category => ( {categories.map(category => (
@@ -361,12 +398,14 @@ export function NewBlog({
} }
}} }}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
<Button <Button
type="button" type="button"
onClick={addTag} onClick={addTag}
variant="outline" variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
@@ -382,7 +421,7 @@ export function NewBlog({
{tag} {tag}
<X <X
className="h-3 w-3 cursor-pointer" className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)} onClick={() => !isSubmitting && removeTag(tag)}
/> />
</Badge> </Badge>
))} ))}
@@ -399,18 +438,20 @@ export function NewBlog({
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Button <Button
onClick={() => handleSave('draft')} onClick={() => handleCreateBlog('draft')}
variant="outline" variant="outline"
disabled={isSubmitting}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
> >
Save as Draft {isSubmitting ? 'Saving...' : 'Save as Draft'}
</Button> </Button>
<Button <Button
onClick={() => handleSave('published')} onClick={() => handleCreateBlog('published')}
disabled={isSubmitting}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }} style={{ backgroundColor: "var(--color-brand-primary)" }}
> >
Publish Blog {isSubmitting ? 'Publishing...' : 'Publish Blog'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -6,8 +6,10 @@ import { Input } from '../ui/input';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea'; import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { ArrowLeft, Plus, X, Save, GripVertical } from 'lucide-react'; import { ArrowLeft, Plus, X, Save, GripVertical } from 'lucide-react';
import { Route } from '../../types/routes';
import { useCreateFAQMutation } from '../../store/services/contentManager.service';
interface FAQ { interface FAQ {
id: string; id: string;
@@ -15,11 +17,12 @@ interface FAQ {
answer: string; answer: string;
category: string; category: string;
tags: string[]; tags: string[];
globalTag: string[];
order: number; order: number;
} }
interface NewFAQProps { interface NewFAQProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
formData?: any; formData?: any;
@@ -43,6 +46,7 @@ export function NewFAQ({
answer: '', answer: '',
category: '', category: '',
tags: [], tags: [],
globalTag: [],
order: 0 order: 0
} }
] ]
@@ -50,6 +54,10 @@ export function NewFAQ({
const [globalTags, setGlobalTags] = useState<string[]>(formData?.globalTags || []); const [globalTags, setGlobalTags] = useState<string[]>(formData?.globalTags || []);
const [newGlobalTag, setNewGlobalTag] = useState(''); const [newGlobalTag, setNewGlobalTag] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Use the RTK Query mutation hook
const [createFAQ] = useCreateFAQMutation();
// Auto-save functionality // Auto-save functionality
React.useEffect(() => { React.useEffect(() => {
@@ -70,6 +78,7 @@ export function NewFAQ({
answer: '', answer: '',
category: '', category: '',
tags: [], tags: [],
globalTag: [],
order: faqs.length order: faqs.length
}; };
setFaqs([...faqs, newFAQ]); setFaqs([...faqs, newFAQ]);
@@ -119,7 +128,7 @@ export function NewFAQ({
const applyGlobalTagsToAll = () => { const applyGlobalTagsToAll = () => {
setFaqs(faqs.map(faq => ({ setFaqs(faqs.map(faq => ({
...faq, ...faq,
tags: [...new Set([...faq.tags, ...globalTags])] globalTag: [...new Set([...faq.globalTag, ...globalTags])]
}))); })));
toast.success('Global tags applied to all FAQs'); toast.success('Global tags applied to all FAQs');
}; };
@@ -154,27 +163,91 @@ export function NewFAQ({
return; return;
} }
try { setIsSubmitting(true);
const faqsToSave = faqs.map((faq, index) => ({
...faq,
order: index,
id: faq.id.startsWith('temp_') ? undefined : faq.id, // Remove temp IDs
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: user.name
}));
// Simulate API call try {
await new Promise(resolve => setTimeout(resolve, 1500)); // Create each FAQ individually
const savePromises = faqs.map(async (faq) => {
const faqPayload = {
question: faq.question.trim(),
category: faq.category,
answer: faq.answer.trim(),
tags: faq.tags,
globalTag: faq.globalTag
};
// Use the RTK Query mutation for each FAQ
return await createFAQ(faqPayload).unwrap();
});
// Wait for all FAQs to be created
await Promise.all(savePromises);
if (onClearAutoSave) { if (onClearAutoSave) {
onClearAutoSave(); onClearAutoSave();
} }
toast.success(`${faqs.length} FAQ(s) saved successfully`); toast.success(`${faqs.length} FAQ(s) created successfully`);
onNavigate('/content'); onNavigate('/content');
} catch (error) { } catch (error: any) {
toast.error('Failed to save FAQs'); console.error('Error creating FAQs:', error);
// Handle different error formats
if (error.data?.message) {
if (Array.isArray(error.data.message)) {
error.data.message.forEach((msg: string) => toast.error(msg));
} else {
toast.error(error.data.message);
}
} else if (error.status === 500) {
toast.error('Server error. Please try again.');
} else {
toast.error(error.message || 'Failed to save FAQs. Please try again.');
}
} finally {
setIsSubmitting(false);
}
};
// Alternative: Save all FAQs in a single batch (if your API supports it)
const handleSaveBatch = async () => {
// Validate FAQs
const invalidFAQs = faqs.filter(faq => !faq.question.trim() || !faq.answer.trim());
if (invalidFAQs.length > 0) {
toast.error('All FAQs must have both question and answer');
return;
}
setIsSubmitting(true);
try {
const faqsPayload = faqs.map(faq => ({
question: faq.question.trim(),
category: faq.category,
answer: faq.answer.trim(),
tags: faq.tags,
globalTag: faq.globalTag
}));
// If your API supports batch creation, you can use:
// await createFAQBatch(faqsPayload).unwrap();
// For now, we'll create them individually
for (const faq of faqsPayload) {
await createFAQ(faq).unwrap();
}
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success(`${faqs.length} FAQ(s) created successfully`);
onNavigate('/content');
} catch (error: any) {
console.error('Error creating FAQs:', error);
toast.error(error.data?.message || 'Failed to save FAQs. Please try again.');
} finally {
setIsSubmitting(false);
} }
}; };
@@ -190,6 +263,7 @@ export function NewFAQ({
variant="ghost" variant="ghost"
onClick={() => onNavigate('/content')} onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Content Back to Content
@@ -202,11 +276,12 @@ export function NewFAQ({
</div> </div>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={isSubmitting}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }} style={{ backgroundColor: "var(--color-brand-primary)" }}
> >
<Save className="h-4 w-4 mr-2" /> <Save className="h-4 w-4 mr-2" />
Save All FAQs {isSubmitting ? 'Saving...' : 'Save All FAQs'}
</Button> </Button>
</div> </div>
@@ -234,12 +309,14 @@ export function NewFAQ({
} }
}} }}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
<Button <Button
type="button" type="button"
onClick={addGlobalTag} onClick={addGlobalTag}
variant="outline" variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
@@ -257,7 +334,7 @@ export function NewFAQ({
{tag} {tag}
<X <X
className="h-3 w-3 cursor-pointer" className="h-3 w-3 cursor-pointer"
onClick={() => removeGlobalTag(tag)} onClick={() => !isSubmitting && removeGlobalTag(tag)}
/> />
</Badge> </Badge>
))} ))}
@@ -267,6 +344,7 @@ export function NewFAQ({
variant="outline" variant="outline"
size="sm" size="sm"
className="min-h-[36px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[36px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
Apply to All FAQs Apply to All FAQs
</Button> </Button>
@@ -286,7 +364,7 @@ export function NewFAQ({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Button <Button
onClick={() => moveFAQ(faq.id, 'up')} onClick={() => moveFAQ(faq.id, 'up')}
disabled={index === 0} disabled={index === 0 || isSubmitting}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
@@ -295,7 +373,7 @@ export function NewFAQ({
</Button> </Button>
<Button <Button
onClick={() => moveFAQ(faq.id, 'down')} onClick={() => moveFAQ(faq.id, 'down')}
disabled={index === faqs.length - 1} disabled={index === faqs.length - 1 || isSubmitting}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
@@ -308,6 +386,7 @@ export function NewFAQ({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10" className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10"
disabled={isSubmitting}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@@ -325,6 +404,7 @@ export function NewFAQ({
placeholder="Enter the question" placeholder="Enter the question"
rows={3} rows={3}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
</div> </div>
@@ -335,6 +415,7 @@ export function NewFAQ({
value={faq.category} value={faq.category}
onChange={(e) => updateFAQ(faq.id, 'category', e.target.value)} onChange={(e) => updateFAQ(faq.id, 'category', e.target.value)}
className="w-full min-h-[44px] px-3 py-2 border border-input bg-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="w-full min-h-[44px] px-3 py-2 border border-input bg-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<option value="">Select category</option> <option value="">Select category</option>
{categories.map(category => ( {categories.map(category => (
@@ -353,6 +434,7 @@ export function NewFAQ({
placeholder="Enter the answer" placeholder="Enter the answer"
rows={4} rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
</div> </div>
@@ -369,6 +451,7 @@ export function NewFAQ({
} }
}} }}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/> />
<Button <Button
type="button" type="button"
@@ -381,6 +464,7 @@ export function NewFAQ({
}} }}
variant="outline" variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
@@ -396,13 +480,35 @@ export function NewFAQ({
{tag} {tag}
<X <X
className="h-3 w-3 cursor-pointer" className="h-3 w-3 cursor-pointer"
onClick={() => removeTagFromFAQ(faq.id, tag)} onClick={() => !isSubmitting && removeTagFromFAQ(faq.id, tag)}
/> />
</Badge> </Badge>
))} ))}
</div> </div>
)} )}
</div> </div>
<div className="space-y-2">
<Label>Global Tags</Label>
<div className="flex flex-wrap gap-2">
{faq.globalTag.map((tag) => (
<Badge
key={tag}
variant="default"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => !isSubmitting && updateFAQ(faq.id, 'globalTag', faq.globalTag.filter(t => t !== tag))}
/>
</Badge>
))}
{faq.globalTag.length === 0 && (
<span className="text-sm text-muted-foreground">No global tags applied</span>
)}
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@@ -412,6 +518,7 @@ export function NewFAQ({
onClick={addFAQ} onClick={addFAQ}
variant="outline" variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Another FAQ Add Another FAQ
@@ -457,6 +564,7 @@ export function NewFAQ({
onClick={addFAQ} onClick={addFAQ}
variant="outline" variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add FAQ Add FAQ
@@ -467,6 +575,7 @@ export function NewFAQ({
onClick={applyGlobalTagsToAll} onClick={applyGlobalTagsToAll}
variant="outline" variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
> >
Apply Global Tags Apply Global Tags
</Button> </Button>

View File

@@ -58,9 +58,10 @@ import {
Check, Check,
X X
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface NewOrganizationProps { interface NewOrganizationProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -81,9 +81,10 @@ import {
Clock, Clock,
MousePointerClick MousePointerClick
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface OpenProgrammeProps { interface OpenProgrammeProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -87,9 +87,10 @@ import {
UserCheck, UserCheck,
Move Move
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface OrganizationsProps { interface OrganizationsProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -41,9 +41,10 @@ import {
AlertCircle, AlertCircle,
Loader2 Loader2
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface ProfileProps { interface ProfileProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: { user: {
name: string; name: string;

View File

@@ -59,12 +59,13 @@ import {
ThumbsDown, ThumbsDown,
Send Send
} from 'lucide-react'; } from 'lucide-react';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { ApprovalTask } from '../../data/mockData'; import { ApprovalTask } from '../../data/mockData';
import { Route } from '../../types/routes';
interface ProfilerApprovalProps { interface ProfilerApprovalProps {
approvalTask: ApprovalTask; approvalTask: ApprovalTask;
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: { user: {
name: string; name: string;

View File

@@ -109,11 +109,12 @@ import {
Search, Search,
XCircle XCircle
} from 'lucide-react'; } from 'lucide-react';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { mockUsers, mockCourses, mockProgrammes, mockProfilerTypes } from '../../data/mockData'; import { mockUsers, mockCourses, mockProgrammes, mockProfilerTypes } from '../../data/mockData';
import { Route } from '../../types/routes';
interface ProfilerBuilderProps { interface ProfilerBuilderProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
formData?: any; formData?: any;

View File

@@ -45,9 +45,10 @@ import {
Lock Lock
} from 'lucide-react'; } from 'lucide-react';
import { mockProfilerTypes, ProfilerType, IpsativeSubDimension, LikertOption } from '../../data/mockData'; import { mockProfilerTypes, ProfilerType, IpsativeSubDimension, LikertOption } from '../../data/mockData';
import { Route } from '../../types/routes';
interface ProfilerMasterProps { interface ProfilerMasterProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -89,9 +89,10 @@ import {
Calendar, Calendar,
Briefcase Briefcase
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface ProfilersProps { interface ProfilersProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -18,7 +18,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Progress } from '../ui/progress'; import { Progress } from '../ui/progress';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -34,10 +34,11 @@ import {
CheckCircle CheckCircle
} from 'lucide-react'; } from 'lucide-react';
import { klcMockData } from '../../data/mockData'; import { klcMockData } from '../../data/mockData';
import { Route } from '../../types/routes';
interface ProgrammeAssignmentProps { interface ProgrammeAssignmentProps {
programmeId: string; programmeId: string;
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -97,10 +97,11 @@ import {
Layers, Layers,
Target Target
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface ProgrammeComposerProps { interface ProgrammeComposerProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
formData?: any; formData?: any;

View File

@@ -33,7 +33,7 @@ import {
SheetTitle, SheetTitle,
} from '../ui/sheet'; } from '../ui/sheet';
import { Checkbox } from '../ui/checkbox'; import { Checkbox } from '../ui/checkbox';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { import {
Plus, Plus,
Search, Search,
@@ -49,9 +49,10 @@ import {
Filter, Filter,
Users Users
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface ProgrammesProps { interface ProgrammesProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -16,9 +16,10 @@ import {
AlertCircle, AlertCircle,
Loader2 Loader2
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface ResetPasswordProps { interface ResetPasswordProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: { user: {
name: string; name: string;

View File

@@ -53,7 +53,7 @@ import {
import { Checkbox } from '../ui/checkbox'; import { Checkbox } from '../ui/checkbox';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { toast } from "sonner@2.0.3"; import { toast } from "sonner";
import { import {
Search, Search,
Plus, Plus,
@@ -74,9 +74,10 @@ import {
ExternalLink, ExternalLink,
RefreshCw RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface RolesProps { interface RolesProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -24,9 +24,10 @@ import { MediaPicker } from '../landing-pages/MediaPicker';
import { PreviewModal } from '../landing-pages/PreviewModal'; import { PreviewModal } from '../landing-pages/PreviewModal';
import { VersionHistory } from '../landing-pages/VersionHistory'; import { VersionHistory } from '../landing-pages/VersionHistory';
import { AuditDrawer } from '../landing-pages/AuditDrawer'; import { AuditDrawer } from '../landing-pages/AuditDrawer';
import { Route } from '../../types/routes';
interface ServicesEditorProps { interface ServicesEditorProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
} }

View File

@@ -0,0 +1,253 @@
import React from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Edit, Calendar, Image, Tag, Copy, Share2, ExternalLink } from 'lucide-react';
import { Route } from '../../types/routes';
import { useParams } from 'react-router-dom';
import { useGetBlogsByIdQuery } from '../../store/services/contentManager.service';
interface ViewBlogProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
blogId?: string;
}
export function ViewBlog({
onNavigate,
onLogout,
user,
blogId
}: ViewBlogProps) {
// const { id } = useParams<{ id: string }>();
const { data: blog, isLoading, error } = useGetBlogsByIdQuery(blogId!, {
skip: !blogId,
});
const handleEdit = () => {
if (blog) {
onNavigate(`/content/blogs/edit/${blog.id}`);
}
};
const handleCopyToClipboard = async () => {
if (blog) {
const text = `Title: ${blog.title}\n\n${blog.content}`;
try {
await navigator.clipboard.writeText(text);
toast.success('Blog content copied to clipboard');
} catch (err) {
toast.error('Failed to copy to clipboard');
}
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/blogs/view/${blogId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
{/* Loading skeleton */}
</div>
</AuthenticatedLayout>
);
}
if (error || !blog) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/blogs/view/${blogId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
{error ? 'Error Loading Blog' : 'Blog Not Found'}
</div>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/blogs/view/${blogId}`}
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div>
<h1>View Blog</h1>
<p className="text-muted-foreground mt-1">
Detailed view of the blog post
</p>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleEdit}
className="min-h-[36px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Edit className="h-4 w-4 mr-2" />
Edit Blog
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-2xl">{blog.title}</CardTitle>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>Slug: /{blog.urlSlug}</span>
{blog.publishedAt && (
<span>Published: {formatDate(blog.publishedAt)}</span>
)}
</div>
</CardHeader>
<CardContent>
{blog.bannerImage && (
<div className="mb-6">
<img
src={blog.bannerImage}
alt="Blog banner"
className="w-full h-64 object-cover rounded-lg"
/>
</div>
)}
<div className="prose max-w-none">
<p className="whitespace-pre-wrap leading-relaxed">
{blog.content}
</p>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Blog Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-muted-foreground">Category</span>
<Badge variant="outline">
{blog.category || 'Uncategorized'}
</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-muted-foreground">Status</span>
<Badge variant="default">
{blog.publishedAt ? 'Published' : 'Draft'}
</Badge>
</div>
{blog.metaTitle && (
<div>
<span className="text-sm font-medium text-muted-foreground">Meta Title</span>
<p className="text-sm mt-1">{blog.metaTitle}</p>
</div>
)}
{blog.metaDesc && (
<div>
<span className="text-sm font-medium text-muted-foreground">Meta Description</span>
<p className="text-sm mt-1">{blog.metaDesc}</p>
</div>
)}
</div>
</CardContent>
</Card>
{blog.tags && blog.tags.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{blog.tags.map((tag: string, index: number) => (
<Badge key={index} variant="secondary">
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,477 @@
import React from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { toast } from "sonner";
import { ArrowLeft, Edit, Calendar, User, Tag, Globe, Copy, Share2 } from 'lucide-react';
import { Route } from '../../types/routes';
import { useGetFAQByIdQuery } from '../../store/services/contentManager.service';
interface ViewFAQProps {
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
faqId?: string;
}
export function ViewFAQ({
onNavigate,
onLogout,
user,
faqId
}: ViewFAQProps) {
console.log('🔍 ViewFAQ component rendered with ID:', faqId);
// Use the FAQ by ID query
const { data: faq, isLoading, error } = useGetFAQByIdQuery(faqId!, {
skip: !faqId,
});
console.log('📊 FAQ Query Result:', { data: faq, isLoading, error });
const handleEdit = () => {
if (faq) {
onNavigate(`/content/faqs/edit/${faq.id}`);
}
};
const handleCopyToClipboard = async () => {
if (faq) {
const text = `Q: ${faq.question}\nA: ${faq.answer}`;
try {
await navigator.clipboard.writeText(text);
toast.success('FAQ copied to clipboard');
} catch (err) {
toast.error('Failed to copy to clipboard');
}
}
};
const handleShare = async () => {
if (faq) {
const shareData = {
title: faq.question,
text: `Q: ${faq.question}\nA: ${faq.answer}`,
url: window.location.href,
};
if (navigator.share) {
try {
await navigator.share(shareData);
} catch (err) {
console.log('Error sharing:', err);
}
} else {
// Fallback to clipboard
handleCopyToClipboard();
}
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Handle loading state
if (isLoading) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/faqs/view/${faqId}`} // ✅ Changed id to faqId
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
disabled
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<div className="h-8 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/2 mt-2 animate-pulse"></div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<div className="h-6 bg-muted rounded w-1/4 animate-pulse"></div>
</CardHeader>
<CardContent className="space-y-4">
<div className="h-20 bg-muted rounded animate-pulse"></div>
<div className="h-32 bg-muted rounded animate-pulse"></div>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<div className="h-6 bg-muted rounded w-1/3 animate-pulse"></div>
</CardHeader>
<CardContent className="space-y-3">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex justify-between">
<div className="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
<div className="h-4 bg-muted rounded w-1/4 animate-pulse"></div>
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
// Handle error state
if (error) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/faqs/view/${faqId}`} // ✅ Changed id to faqId
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-destructive text-lg font-semibold mb-2">
Error Loading FAQ
</div>
<p className="text-muted-foreground mb-4">
{error && 'status' in error
? `Error ${error.status}: Failed to load FAQ`
: 'Failed to load FAQ. Please try again.'}
</p>
<Button
onClick={() => window.location.reload()}
variant="outline"
>
Retry
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
// Handle FAQ not found
if (!faq) {
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/faqs/view/${faqId}`} // ✅ Changed id to faqId
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
</div>
<Card>
<CardContent className="py-12">
<div className="text-center">
<div className="text-lg font-semibold mb-2">
FAQ Not Found
</div>
<p className="text-muted-foreground mb-4">
The requested FAQ could not be found.
</p>
<Button
onClick={() => onNavigate('/content')}
variant="outline"
>
Back to Content
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath={`/content/faqs/view/${faqId}`} // ✅ Changed id to faqId
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div>
<h1>View FAQ</h1>
<p className="text-muted-foreground mt-1">
Detailed view of the frequently asked question
</p>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleCopyToClipboard}
variant="outline"
size="sm"
className="min-h-[36px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button
onClick={handleShare}
variant="outline"
size="sm"
className="min-h-[36px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
<Button
onClick={handleEdit}
className="min-h-[36px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Edit className="h-4 w-4 mr-2" />
Edit FAQ
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Question Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="w-2 h-6 bg-blue-500 rounded-full"></div>
Question
</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm max-w-none">
<p className="text-lg font-medium text-foreground leading-relaxed">
{faq.question}
</p>
</div>
</CardContent>
</Card>
{/* Answer Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="w-2 h-6 bg-green-500 rounded-full"></div>
Answer
</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm max-w-none">
<p className="text-foreground leading-relaxed whitespace-pre-wrap">
{faq.answer}
</p>
</div>
</CardContent>
</Card>
{/* Tags Card */}
{(faq.tags.length > 0 || faq.globalTag.length > 0) && (
<Card>
<CardHeader>
<CardTitle>Tags & Categories</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{faq.tags.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Tag className="h-4 w-4" />
Tags
</div>
<div className="flex flex-wrap gap-2">
{faq.tags.map((tag, index) => (
<Badge key={index} variant="secondary" className="text-sm">
{tag}
</Badge>
))}
</div>
</div>
)}
{faq.globalTag.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Globe className="h-4 w-4" />
Global Tags
</div>
<div className="flex flex-wrap gap-2">
{faq.globalTag.map((tag, index) => (
<Badge key={index} variant="default" className="text-sm">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* FAQ Details */}
<Card>
<CardHeader>
<CardTitle>FAQ Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-muted-foreground">Category</span>
<Badge variant="outline">
{faq.category || 'Uncategorized'}
</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-muted-foreground">FAQ ID</span>
<code className="text-xs bg-muted px-2 py-1 rounded">
{faq.id}
</code>
</div>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
Created
</span>
<span className="text-sm text-right">
{formatDate(faq.createdAt)}
</span>
</div>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
Last Updated
</span>
<span className="text-sm text-right">
{formatDate(faq.updatedAt)}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button
onClick={handleEdit}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Edit className="h-4 w-4 mr-2" />
Edit FAQ
</Button>
<Button
onClick={handleCopyToClipboard}
variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Copy className="h-4 w-4 mr-2" />
Copy Content
</Button>
<Button
onClick={handleShare}
variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Share2 className="h-4 w-4 mr-2" />
Share FAQ
</Button>
</CardContent>
</Card>
{/* Statistics */}
<Card>
<CardHeader>
<CardTitle>Content Info</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Question Length</span>
<span className="font-medium text-sm">{faq.question.length} chars</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Answer Length</span>
<span className="font-medium text-sm">{faq.answer.length} chars</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Total Tags</span>
<span className="font-medium text-sm">{faq.tags.length + faq.globalTag.length}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant="default" className="text-sm">
Published
</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -90,9 +90,10 @@ import {
Zap, Zap,
MessageSquare MessageSquare
} from 'lucide-react'; } from 'lucide-react';
import { Route } from '../../types/routes';
interface WebinarsProps { interface WebinarsProps {
onNavigate: (route: string) => void; onNavigate: (route: Route) => void;
onLogout: () => void; onLogout: () => void;
user: any; user: any;
pickerMode?: boolean; pickerMode?: boolean;

View File

@@ -0,0 +1,300 @@
import React, { useRef, useState } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "../../ui/sheet";
import { Button } from "../../ui/button";
import { Progress } from "../../ui/progress";
import { Separator } from "../../ui/separator";
import { Label } from "../../ui/label";
import { Checkbox } from "../../ui/checkbox";
import { Download, FileSpreadsheet, XCircle, CheckCircle } from "lucide-react";
import { toast } from "sonner";
interface BulkUploadDrawerProps {
isOpen: boolean;
onClose: () => void;
}
interface BulkUploadResult {
total: number;
success: number;
failed: number;
items: Array<{
code: string;
title: string;
sectionsCount: number;
questionsCount: number;
issues: string[];
}>;
errors: Array<{ row: number; error: string; }>;
}
export function BulkUploadDrawer({ isOpen, onClose }: BulkUploadDrawerProps) {
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [bulkUploadProgress, setBulkUploadProgress] = useState(0);
const [bulkUploadResult, setBulkUploadResult] = useState<BulkUploadResult | null>(null);
const [autoSubmitEnabled, setAutoSubmitEnabled] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleBulkUploadFile = () => {
fileInputRef.current?.click();
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.csv')) {
toast.error("Please upload a valid Excel (.xlsx) or CSV file");
return;
}
setUploadedFile(file);
setBulkUploadProgress(0);
setBulkUploadResult(null);
// Simulate file processing
const processFile = () => {
const interval = setInterval(() => {
setBulkUploadProgress(prev => {
if (prev >= 100) {
clearInterval(interval);
const mockResult: BulkUploadResult = {
total: 3,
success: 2,
failed: 1,
items: [
{
code: "LEAD001",
title: "Leadership Assessment 2024",
sectionsCount: 5,
questionsCount: 45,
issues: []
},
{
code: "TEAM002",
title: "Team Dynamics Profiler",
sectionsCount: 4,
questionsCount: 32,
issues: []
},
{
code: "COMM003",
title: "Communication Skills Assessment",
sectionsCount: 0,
questionsCount: 0,
issues: ["Missing required sections", "Invalid question types"]
}
],
errors: [
{ row: 45, error: "Missing required field: SectionName" },
{ row: 67, error: "Invalid QuestionType: MultipleSelect" }
]
};
setBulkUploadResult(mockResult);
toast.success(`Bulk upload completed. ${mockResult.success}/${mockResult.total} items processed successfully.`);
return 100;
}
return prev + 10;
});
}, 200);
};
processFile();
};
const downloadTemplate = () => {
toast.success("Bulk upload template downloaded");
};
const handleCreateItems = () => {
if (!bulkUploadResult) return;
const status = autoSubmitEnabled ? "In Review" : "Draft";
toast.success(`${bulkUploadResult.success} items created with ${status} status`);
onClose();
setBulkUploadResult(null);
setUploadedFile(null);
setBulkUploadProgress(0);
};
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent className="w-[480px]">
<SheetHeader>
<SheetTitle>Bulk Upload</SheetTitle>
<SheetDescription>
Upload multiple items using Excel or CSV files.
</SheetDescription>
</SheetHeader>
<div className="space-y-6 mt-6 px-4">
{/* Step 1: Download Template */}
<div className="space-y-4">
<h3 className="font-medium">Step 1: Download Template</h3>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<FileSpreadsheet className="h-8 w-8 text-green-600" />
<div>
<p className="font-medium">Bulk Upload Template</p>
<p className="text-sm text-muted-foreground">
CSV/XLSX with required columns
</p>
</div>
</div>
<Button variant="outline" onClick={downloadTemplate}>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
</div>
<Separator />
{/* Step 2: Upload File */}
<div className="space-y-4">
<h3 className="font-medium">Step 2: Upload File</h3>
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6 text-center">
{uploadedFile ? (
<div className="space-y-3">
<FileSpreadsheet className="h-8 w-8 mx-auto text-blue-600" />
<p className="font-medium">{uploadedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(uploadedFile.size / 1024).toFixed(1)} KB
</p>
{bulkUploadProgress > 0 && bulkUploadProgress < 100 && (
<div className="space-y-2">
<Progress value={bulkUploadProgress} className="w-full" />
<p className="text-sm text-muted-foreground">
Processing... {bulkUploadProgress}%
</p>
</div>
)}
</div>
) : (
<div className="space-y-3">
<Download className="h-8 w-8 mx-auto text-muted-foreground" />
<div>
<p className="font-medium">Upload your file</p>
<p className="text-sm text-muted-foreground">
Supports .xlsx and .csv files
</p>
</div>
<Button onClick={handleBulkUploadFile}>
Choose File
</Button>
</div>
)}
</div>
</div>
{/* Step 3: Validation Results */}
{bulkUploadResult && (
<>
<Separator />
<div className="space-y-4">
<h3 className="font-medium">Step 3: Validation Results</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{bulkUploadResult.total}</p>
<p className="text-sm text-blue-600">Total</p>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<p className="text-2xl font-bold text-green-600">{bulkUploadResult.success}</p>
<p className="text-sm text-green-600">Valid</p>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<p className="text-2xl font-bold text-red-600">{bulkUploadResult.failed}</p>
<p className="text-sm text-red-600">Issues</p>
</div>
</div>
{/* Items Groups */}
<div className="space-y-2 max-h-48 overflow-y-auto">
<h4 className="font-medium text-sm">Items:</h4>
{bulkUploadResult.items.map((item, index) => (
<div key={index} className={`p-3 rounded-lg border ${
item.issues.length > 0 ? 'bg-red-50 border-red-200' : 'bg-green-50 border-green-200'
}`}>
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-sm">{item.code}: {item.title}</p>
<p className="text-xs text-muted-foreground">
{item.sectionsCount} sections, {item.questionsCount} questions
</p>
</div>
{item.issues.length > 0 ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</div>
{item.issues.length > 0 && (
<ul className="mt-2 text-xs text-red-700 list-disc list-inside">
{item.issues.map((issue, i) => (
<li key={i}>{issue}</li>
))}
</ul>
)}
</div>
))}
</div>
</div>
</>
)}
{/* Step 4: Create Items */}
{bulkUploadResult && bulkUploadResult.success > 0 && (
<>
<Separator />
<div className="space-y-4">
<h3 className="font-medium">Step 4: Create Items</h3>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-submit"
checked={autoSubmitEnabled}
onCheckedChange={(checked: boolean | "indeterminate") => setAutoSubmitEnabled(checked === true)}
/>
<Label htmlFor="auto-submit" className="text-sm">
Auto-submit for approval
</Label>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleCreateItems}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
className="flex-1"
>
Create {bulkUploadResult.success} Items
</Button>
</div>
</div>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.csv"
onChange={handleFileUpload}
className="hidden"
/>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,708 @@
// src/components/shared/ContentTable.tsx
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../ui/table";
import { Checkbox } from "../../ui/checkbox";
import { Badge } from "../../ui/badge";
import { Button } from "../../ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../ui/dropdown-menu";
import { MoreHorizontal, Eye, Edit, Archive, ExternalLink, Image, Trash2, Play, FileText, BookOpen, Mic, Video } from "lucide-react";
import { toast } from "sonner";
import {
useDeleteBlogMutation,
useDeleteFAQMutation,
useDeleteWebcastMutation,
useDeleteTrainingMaterialMutation,
useDeleteReadingMaterialMutation,
useDeletePodcastMutation,
useDeleteCaseStudyMutation,
useDeleteKlcArchiveMutation
} from "../../../store/services/contentManager.service";
interface ContentTableProps {
data: any[];
type: string;
selectedItems: string[];
onSelectionChange: (items: string[]) => void;
onEdit: (item: any) => void;
onNavigate: (route: string) => void;
user: any;
pagination?: {
currentPage: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
onPageChange: (page: number) => void;
};
onItemDeleted?: () => void;
}
export function ContentTable({
data,
type,
selectedItems,
onSelectionChange,
onEdit,
onNavigate,
user,
pagination,
onItemDeleted
}: ContentTableProps) {
const [deleteFAQ, { isLoading: isDeletingFAQ }] = useDeleteFAQMutation();
const [deleteBlog, { isLoading: isDeletingBlog }] = useDeleteBlogMutation();
const [deleteWebcast, { isLoading: isDeletingWebcast }] = useDeleteWebcastMutation();
const [deleteTrainingMaterial, { isLoading: isDeletingTrainingMaterial }] = useDeleteTrainingMaterialMutation();
const [deleteReadingMaterial, { isLoading: isDeletingReadingMaterial }] = useDeleteReadingMaterialMutation();
const [deletePodcast, { isLoading: isDeletingPodcast }] = useDeletePodcastMutation();
const [deleteCaseStudy, { isLoading: isDeletingCaseStudy }] = useDeleteCaseStudyMutation();
const [deleteKlcArchive, { isLoading: isDeletingKlcArchive }] = useDeleteKlcArchiveMutation();
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleRowSelection = (id: string, checked: boolean) => {
if (checked) {
onSelectionChange([...selectedItems, id]);
} else {
onSelectionChange(selectedItems.filter(item => item !== id));
}
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allIds = data.map((item: any) => item.id);
onSelectionChange(allIds);
} else {
onSelectionChange([]);
}
};
const handleDelete = async (item: any) => {
if (!item.id) {
toast.error('Item ID is missing');
return;
}
const confirmed = window.confirm(
`Are you sure you want to delete this ${type}? This action cannot be undone.`
);
if (!confirmed) return;
setDeletingId(item.id);
try {
if (type === 'faq') {
await deleteFAQ(item.id).unwrap();
toast.success('FAQ deleted successfully');
} else if (type === 'blog') {
await deleteBlog(item.id).unwrap();
toast.success('Blog deleted successfully');
} else if (type === 'webcast') {
await deleteWebcast(item.id).unwrap();
toast.success('Webcast deleted successfully');
} else if (type === 'training-material') {
await deleteTrainingMaterial(item.id).unwrap();
toast.success('Training material deleted successfully');
} else if (type === 'reading-material') {
await deleteReadingMaterial(item.id).unwrap();
toast.success('Reading material deleted successfully');
} else if (type === 'podcast') {
await deletePodcast(item.id).unwrap();
toast.success('Podcast deleted successfully');
} else if (type === 'case-study') {
await deleteCaseStudy(item.id).unwrap();
toast.success('Case study deleted successfully');
} else if (type === 'klc-archive') {
await deleteKlcArchive(item.id).unwrap();
toast.success('KLC archive deleted successfully');
} else {
toast.error('Unknown type — delete action not supported.');
}
if (onItemDeleted) {
onItemDeleted();
}
} catch (error: any) {
console.error(`Error deleting ${type}:`, error);
toast.error(error.data?.message || `Failed to delete ${type}. Please try again.`);
} finally {
setDeletingId(null);
}
};
const handleBulkDelete = async () => {
if (selectedItems.length === 0) {
toast.error('Please select items to delete');
return;
}
const confirmed = window.confirm(
`Are you sure you want to delete ${selectedItems.length} ${type}(s)? This action cannot be undone.`
);
if (!confirmed) return;
try {
const deletePromises = selectedItems.map(id => {
if (type === 'faq') {
return deleteFAQ(id).unwrap();
} else if (type === 'blog') {
return deleteBlog(id).unwrap();
} else if (type === 'webcast') {
return deleteWebcast(id).unwrap();
} else if (type === 'training-material') {
return deleteTrainingMaterial(id).unwrap();
} else if (type === 'reading-material') {
return deleteReadingMaterial(id).unwrap();
} else if (type === 'podcast') {
return deletePodcast(id).unwrap();
} else if (type === 'case-study') {
return deleteCaseStudy(id).unwrap();
} else if (type === 'klc-archive') {
return deleteKlcArchive(id).unwrap();
}
return Promise.resolve();
});
await Promise.all(deletePromises);
toast.success(`${selectedItems.length} ${type}(s) deleted successfully`);
onSelectionChange([]);
if (onItemDeleted) {
onItemDeleted();
}
} catch (error: any) {
console.error(`Error bulk deleting ${type}s:`, error);
toast.error(`Failed to delete ${type}s. Please try again.`);
}
};
// Dynamic column configuration based on type
const getTableColumns = () => {
switch (type) {
case 'faq':
return [
{ key: 'question', label: 'Question', minWidth: 'min-w-[200px]' },
{ key: 'answer', label: 'Answer', minWidth: 'min-w-[250px]' },
{ key: 'category', label: 'Category', minWidth: 'min-w-[150px]' },
{ key: 'tags', label: 'Tags', minWidth: 'min-w-[150px]' },
{ key: 'globalTags', label: 'Global Tags', minWidth: 'min-w-[150px]' },
{ key: 'createdAt', label: 'Created Time', minWidth: 'min-w-[150px]' },
{ key: 'updatedAt', label: 'Updated Time', minWidth: 'min-w-[150px]' },
];
case 'blog':
return [
{ key: 'title', label: 'Title', minWidth: 'min-w-[200px]' },
{ key: 'content', label: 'Content', minWidth: 'min-w-[200px]' },
{ key: 'bannerImage', label: 'Banner Image', minWidth: 'min-w-[120px]' },
{ key: 'category', label: 'Category', minWidth: 'min-w-[120px]' },
{ key: 'tags', label: 'Tags', minWidth: 'min-w-[150px]' },
{ key: 'metaTitle', label: 'Meta Title', minWidth: 'min-w-[150px]' },
{ key: 'publishedAt', label: 'Published At', minWidth: 'min-w-[150px]' },
];
case 'webcast':
return [
{ key: 'title', label: 'Title', minWidth: 'min-w-[250px]' },
{ key: 'description', label: 'Description', minWidth: 'min-w-[200px]' },
{ key: 'fileUrl', label: 'File', minWidth: 'min-w-[150px]' },
{ key: 'tags', label: 'Tags', minWidth: 'min-w-[150px]' },
{ key: 'duration', label: 'Duration', minWidth: 'min-w-[100px]' },
{ key: 'views', label: 'Views', minWidth: 'min-w-[100px]' },
{ key: 'category', label: 'Category', minWidth: 'min-w-[120px]' },
{ key: 'status', label: 'Status', minWidth: 'min-w-[100px]' },
{ key: 'updated', label: 'Last Updated', minWidth: 'min-w-[150px]' },
{ key: 'owner', label: 'Owner', minWidth: 'min-w-[120px]' },
];
case 'training-material':
return [
{ key: 'title', label: 'Title', minWidth: 'min-w-[250px]' },
{ key: 'description', label: 'Description', minWidth: 'min-w-[200px]' },
{ key: 'fileUrl', label: 'File', minWidth: 'min-w-[150px]' },
{ key: 'tags', label: 'Tags', minWidth: 'min-w-[150px]' },
{ key: 'fileType', label: 'File Type', minWidth: 'min-w-[100px]' },
{ key: 'fileSize', label: 'File Size', minWidth: 'min-w-[100px]' },
{ key: 'status', label: 'Status', minWidth: 'min-w-[100px]' },
{ key: 'version', label: 'Version', minWidth: 'min-w-[80px]' },
{ key: 'updated', label: 'Last Updated', minWidth: 'min-w-[150px]' },
{ key: 'owner', label: 'Owner', minWidth: 'min-w-[120px]' },
];
case 'reading-material':
return [
{ key: 'title', label: 'Title', minWidth: 'min-w-[250px]' },
{ key: 'description', label: 'Description', minWidth: 'min-w-[200px]' },
{ key: 'fileUrl', label: 'File', minWidth: 'min-w-[150px]' },
{ key: 'tags', label: 'Tags', minWidth: 'min-w-[150px]' },
{ key: 'pages', label: 'Pages', minWidth: 'min-w-[80px]' },
{ key: 'category', label: 'Category', minWidth: 'min-w-[120px]' },
{ key: 'fileType', label: 'File Type', minWidth: 'min-w-[100px]' },
{ key: 'fileSize', label: 'File Size', minWidth: 'min-w-[100px]' },
{ key: 'status', label: 'Status', minWidth: 'min-w-[100px]' },
{ key: 'updated', label: 'Last Updated', minWidth: 'min-w-[150px]' },
];
case 'podcast':
return [
{ key: 'title', label: 'Title', minWidth: 'min-w-[250px]' },
{ key: 'description', label: 'Description', minWidth: 'min-w-[200px]' },
{ key: 'fileUrl', label: 'File', minWidth: 'min-w-[150px]' },
{ key: 'tags', label: 'Tags', minWidth: 'min-w-[150px]' },
{ key: 'duration', label: 'Duration', minWidth: 'min-w-[100px]' },
{ key: 'listens', label: 'Listens', minWidth: 'min-w-[100px]' },
{ key: 'status', label: 'Status', minWidth: 'min-w-[100px]' },
{ key: 'updated', label: 'Last Updated', minWidth: 'min-w-[150px]' },
{ key: 'owner', label: 'Owner', minWidth: 'min-w-[120px]' },
];
case 'case-study':
return [
{ key: 'title', label: 'Title', minWidth: 'min-w-[250px]' },
{ key: 'description', label: 'Description', minWidth: 'min-w-[200px]' },
{ key: 'fileUrl', label: 'File', minWidth: 'min-w-[150px]' },
{ key: 'tags', label: 'Tags', minWidth: 'min-w-[150px]' },
{ key: 'client', label: 'Client', minWidth: 'min-w-[120px]' },
{ key: 'industry', label: 'Industry', minWidth: 'min-w-[120px]' },
{ key: 'status', label: 'Status', minWidth: 'min-w-[100px]' },
{ key: 'updated', label: 'Last Updated', minWidth: 'min-w-[150px]' },
{ key: 'owner', label: 'Owner', minWidth: 'min-w-[120px]' },
];
case 'klc-archive':
return [
{ key: 'title', label: 'Title', minWidth: 'min-w-[250px]' },
{ key: 'description', label: 'Description', minWidth: 'min-w-[200px]' },
{ key: 'fileUrl', label: 'File', minWidth: 'min-w-[150px]' },
{ key: 'tags', label: 'Tags', minWidth: 'min-w-[150px]' },
{ key: 'category', label: 'Category', minWidth: 'min-w-[120px]' },
{ key: 'fileType', label: 'File Type', minWidth: 'min-w-[100px]' },
{ key: 'fileSize', label: 'File Size', minWidth: 'min-w-[100px]' },
{ key: 'status', label: 'Status', minWidth: 'min-w-[100px]' },
{ key: 'updated', label: 'Last Updated', minWidth: 'min-w-[150px]' },
];
default:
return [
{ key: 'title', label: 'Title', minWidth: 'min-w-[250px]' },
{ key: 'status', label: 'Status', minWidth: 'min-w-[120px]' },
{ key: 'updated', label: 'Updated', minWidth: 'min-w-[150px]' },
];
}
};
const columns = getTableColumns();
const isDeleting = deletingId !== null;
const isBulkDeleting = isDeletingFAQ || isDeletingBlog || isDeletingReadingMaterial;
// Handle file view - opens file in new tab for file-based content types
const handleViewFile = (item: any) => {
if (item.fileUrl) {
window.open(item.fileUrl, '_blank');
} else {
toast.error('No file available to view');
}
};
// Handle preview for Blogs and FAQs (opens in app view)
const handlePreview = (item: any) => {
if (type === 'blog') {
onNavigate(`/content/blogs/view/${item.id}`);
} else if (type === 'faq') {
onNavigate(`/content/faqs/view/${item.id}`);
} else {
console.log('Preview item:', item);
}
};
// Get appropriate action label and icon for each content type
const getViewActionConfig = () => {
switch (type) {
case 'webcast':
return { label: 'Watch', icon: Video, action: 'file' };
case 'training-material':
case 'case-study':
case 'klc-archive':
return { label: 'View', icon: FileText, action: 'file' };
case 'reading-material':
return { label: 'Read', icon: BookOpen, action: 'file' };
case 'podcast':
return { label: 'Listen', icon: Mic, action: 'file' };
case 'blog':
return { label: 'Preview', icon: Eye, action: 'preview' };
case 'faq':
return { label: 'Preview', icon: Eye, action: 'preview' };
default:
return { label: 'View', icon: Eye, action: 'file' };
}
};
const renderCellContent = (item: any, columnKey: string) => {
switch (columnKey) {
// Common fields for all content types
case 'title':
return (
<div className="font-medium text-sm">
{item.title}
</div>
);
case 'description':
return (
<div className="text-sm text-muted-foreground line-clamp-2">
{item.description}
</div>
);
case 'fileUrl':
const viewConfig = getViewActionConfig();
const IconComponent = viewConfig.icon;
// Only make file URLs clickable for file-based content types
if (viewConfig.action === 'file') {
return (
<div className="flex items-center gap-2">
<IconComponent className="h-4 w-4 text-muted-foreground" />
<div
className="text-xs text-muted-foreground truncate max-w-[120px] cursor-pointer hover:text-blue-600 transition-colors"
onClick={() => handleViewFile(item)}
title={`Click to ${viewConfig.label.toLowerCase()} file`}
>
{item.fileUrl ? `${viewConfig.label} File` : 'No file'}
</div>
</div>
);
} else {
// For blogs and FAQs, just show the file info without clickable link
return (
<div className="flex items-center gap-2">
<IconComponent className="h-4 w-4 text-muted-foreground" />
<div className="text-xs text-muted-foreground truncate max-w-[120px]">
{item.fileUrl ? 'View File' : 'No file'}
</div>
</div>
);
}
case 'tags':
return (
<div className="flex flex-wrap gap-1">
{item.tags?.map((tag: string) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{(!item.tags || item.tags.length === 0) && (
<span className="text-xs text-muted-foreground">No tags</span>
)}
</div>
);
case 'status':
return (
<Badge variant={item.status === 'Published' ? 'default' : 'secondary'}>
{item.status}
</Badge>
);
case 'updated':
return (
<div className="text-sm text-muted-foreground">
{item.updated}
</div>
);
case 'owner':
return (
<div className="text-sm">{item.owner}</div>
);
// Content type specific fields
case 'pages':
return (
<div className="text-sm text-muted-foreground">
{item.pages || '-'}
</div>
);
case 'category':
return (
<Badge variant="outline" className="text-xs">
{item.category || 'Uncategorized'}
</Badge>
);
case 'fileType':
return (
<Badge variant="outline" className="text-xs">
{item.fileType || '-'}
</Badge>
);
case 'fileSize':
return (
<div className="text-sm text-muted-foreground">
{item.fileSize || '-'}
</div>
);
case 'duration':
return (
<div className="text-sm text-muted-foreground">
{item.duration || '-'}
</div>
);
case 'views':
return (
<div className="text-sm text-muted-foreground">
{item.views?.toLocaleString() || '0'}
</div>
);
case 'listens':
return (
<div className="text-sm text-muted-foreground">
{item.listens?.toLocaleString() || '0'}
</div>
);
case 'client':
return (
<div className="text-sm text-muted-foreground">
{item.client || '-'}
</div>
);
case 'industry':
return (
<div className="text-sm text-muted-foreground">
{item.industry || '-'}
</div>
);
case 'version':
return (
<Badge variant="outline">{item.version}</Badge>
);
// FAQ and Blog columns
case 'question':
return (
<div className="font-medium text-sm">
{item.question}
</div>
);
case 'answer':
return (
<div className="text-sm text-muted-foreground line-clamp-2">
{item.answer}
</div>
);
case 'content':
return (
<div className="text-sm text-muted-foreground line-clamp-2">
{item.content}
</div>
);
case 'bannerImage':
return (
<div className="flex items-center gap-2">
{item.bannerImage ? (
<>
<Image className="h-4 w-4 text-muted-foreground" />
<div className="text-xs text-muted-foreground truncate max-w-[100px]">
Image
</div>
</>
) : (
<span className="text-xs text-muted-foreground">No image</span>
)}
</div>
);
case 'metaTitle':
return (
<div className="text-sm text-muted-foreground line-clamp-2">
{item.metaTitle || 'No meta title'}
</div>
);
case 'publishedAt':
return (
<div className="text-sm text-muted-foreground">
{item.publishedAt}
</div>
);
case 'globalTags':
return (
<div className="flex flex-wrap gap-1">
{item.globalTags?.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{(!item.globalTags || item.globalTags.length === 0) && (
<span className="text-xs text-muted-foreground">No global tags</span>
)}
</div>
);
case 'createdAt':
case 'updatedAt':
return (
<div className="text-sm text-muted-foreground">
{item[columnKey]}
</div>
);
case 'type':
return (
<Badge variant="outline" className="text-xs">
{item.type}
</Badge>
);
default:
return item[columnKey] || '-';
}
};
if (data.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">No content found</p>
</div>
);
}
const viewConfig = getViewActionConfig();
return (
<div className="border rounded-lg overflow-hidden">
{/* Bulk Actions Toolbar */}
{selectedItems.length > 0 && (
<div className="bg-muted/50 px-4 py-2 border-b flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedItems.length} {type}(s) selected
</div>
<Button
onClick={handleBulkDelete}
disabled={isBulkDeleting}
variant="destructive"
size="sm"
className="min-h-[36px]"
>
<Trash2 className="h-4 w-4 mr-2" />
{isBulkDeleting ? 'Deleting...' : `Delete ${selectedItems.length} ${type}(s)`}
</Button>
</div>
)}
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedItems.length === data.length && data.length > 0}
onCheckedChange={handleSelectAll}
/>
</TableHead>
{columns.map((column) => (
<TableHead key={column.key} className={column.minWidth}>
{column.label}
</TableHead>
))}
<TableHead className="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.id} className="group hover:bg-muted/50">
<TableCell>
<Checkbox
checked={selectedItems.includes(item.id)}
onCheckedChange={(checked: boolean | "indeterminate") =>
handleRowSelection(item.id, checked as boolean)
}
/>
</TableCell>
{columns.map((column) => (
<TableCell key={column.key} className="py-3" style={{
whiteSpace: "normal",
wordBreak: "break-word",
}}>
{renderCellContent(item, column.key)}
</TableCell>
))}
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(item)}>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
{/* Different action based on content type */}
{viewConfig.action === 'file' ? (
<DropdownMenuItem onClick={() => handleViewFile(item)}>
<viewConfig.icon className="h-4 w-4 mr-2" />
{viewConfig.label}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => handlePreview(item)}>
<viewConfig.icon className="h-4 w-4 mr-2" />
{viewConfig.label}
</DropdownMenuItem>
)}
<DropdownMenuItem>
<ExternalLink className="h-4 w-4 mr-2" />
Publish
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(item)}
disabled={isDeleting && deletingId === item.id}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting && deletingId === item.id ? 'Deleting...' : 'Delete'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
{pagination && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<div className="text-sm text-muted-foreground">
Page {pagination.currentPage} of {pagination.totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => pagination.onPageChange(pagination.currentPage - 1)}
disabled={!pagination.hasPrev}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => pagination.onPageChange(pagination.currentPage + 1)}
disabled={!pagination.hasNext}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,489 @@
import React, { useState } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "../../ui/sheet";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { Textarea } from "../../ui/textarea";
import { Badge } from "../../ui/badge";
import { X, Upload, FileText, Video, BookOpen, Mic, FolderOpen, Link } from "lucide-react";
import { toast } from "sonner";
interface UploadDrawerProps {
isOpen: boolean;
onClose: () => void;
contentType: "webcast" | "training-material" | "reading-material" | "podcast" | "case-study" | "archive";
onUploadComplete: (data: any) => void;
multiple?: boolean;
acceptedFileTypes?: string;
}
export interface UploadFormData {
title: string;
description: string;
files: File[];
fileUrl?: string;
tags: string[];
// REMOVED all extra fields - only using basic four
}
const contentTypeConfig = {
webcast: {
title: "Upload Webcast",
description: "Upload video webcast files",
icon: Video,
acceptedTypes: "video/*",
multiple: true
},
"training-material": {
title: "Upload Training Material",
description: "Upload training materials and resources",
icon: BookOpen,
acceptedTypes: ".pdf,.doc,.docx,.ppt,.pptx",
multiple: false
},
"reading-material": {
title: "Upload Reading Material",
description: "Upload reading materials and documents",
icon: FileText,
acceptedTypes: ".pdf,.doc,.docx,.epub",
multiple: false
},
podcast: {
title: "Upload Podcast",
description: "Upload audio podcast episodes",
icon: Mic,
acceptedTypes: "audio/*",
multiple: false
},
"case-study": {
title: "Upload Case Study",
description: "Upload case study documents",
icon: FileText,
acceptedTypes: ".pdf,.doc,.docx",
multiple: false
},
archive: {
title: "Upload to Archive",
description: "Upload files to KLC Content Archive",
icon: FolderOpen,
acceptedTypes: "*",
multiple: false
}
};
export function UploadDrawer({
isOpen,
onClose,
contentType,
onUploadComplete,
multiple = false,
acceptedFileTypes
}: UploadDrawerProps) {
const [formData, setFormData] = useState<UploadFormData>({
title: "",
description: "",
files: [],
fileUrl: "",
tags: []
// REMOVED all extra fields
});
const [tagInput, setTagInput] = useState("");
const [isUploading, setIsUploading] = useState(false);
const config = contentTypeConfig[contentType];
const Icon = config.icon;
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = event.target.files;
if (!selectedFiles) return;
const filesArray = Array.from(selectedFiles);
if (!multiple && filesArray.length > 1) {
toast.error("Please select only one file");
return;
}
setFormData(prev => ({
...prev,
files: multiple ? [...prev.files, ...filesArray] : [filesArray[0]]
}));
// Auto-fill title if empty
if (!formData.title && filesArray.length === 1) {
const fileName = filesArray[0].name.replace(/\.[^/.]+$/, "");
setFormData(prev => ({ ...prev, title: fileName }));
}
};
const handleFileUrlChange = (url: string) => {
setFormData(prev => ({
...prev,
fileUrl: url.trim()
}));
// Auto-fill title if empty and URL has a meaningful ending
if (!formData.title && url.trim()) {
const urlName = url.split('/').pop() || "Content";
if (urlName && urlName !== '') {
setFormData(prev => ({ ...prev, title: urlName }));
}
}
};
const handleRemoveFile = (index: number) => {
setFormData(prev => ({
...prev,
files: prev.files.filter((_, i) => i !== index)
}));
};
const handleAddTag = () => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData(prev => ({
...prev,
tags: [...prev.tags, tagInput.trim()]
}));
setTagInput("");
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData(prev => ({
...prev,
tags: prev.tags.filter(tag => tag !== tagToRemove)
}));
};
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
};
const handleUpload = async () => {
// Prevent double calls
if (isUploading) {
return;
}
if (!formData.title.trim()) {
toast.error("Title is required");
return;
}
// Check if either files or fileUrl are provided
if (formData.files.length === 0 && !formData.fileUrl) {
toast.error("Please select at least one file or add a file URL");
return;
}
// Validate fileUrl if provided
if (formData.fileUrl) {
try {
new URL(formData.fileUrl);
} catch {
toast.error("Please enter a valid file URL");
return;
}
}
setIsUploading(true);
try {
// Prepare the data - ONLY the four basic fields
let uploadData: any = {
title: formData.title.trim(),
description: formData.description.trim(),
tags: formData.tags,
};
// Add fileUrl
if (formData.fileUrl) {
// Use the provided URL directly
uploadData.fileUrl = formData.fileUrl.trim();
} else if (formData.files.length > 0) {
// If files are uploaded, simulate upload and get URL
const fileUrl = await uploadFileToServer(formData.files[0]);
uploadData.fileUrl = fileUrl;
}
// Validate that we have a fileUrl
if (!uploadData.fileUrl) {
toast.error("File URL is required");
return;
}
console.log("Prepared upload data:", uploadData);
// ONLY call the parent callback - let the parent handle the API call
onUploadComplete(uploadData);
// Reset form
setFormData({
title: "",
description: "",
files: [],
fileUrl: "",
tags: []
});
setTagInput("");
onClose();
} catch (error: any) {
console.error("Upload preparation failed:", error);
toast.error("Failed to prepare upload. Please try again.");
} finally {
setIsUploading(false);
}
};
// Function to upload file to your server and get back the file URL
const uploadFileToServer = async (file: File): Promise<string> => {
// TODO: Implement your actual file upload logic here
console.log("Uploading file:", file.name, file.type, file.size);
// Simulate upload delay
await new Promise(resolve => setTimeout(resolve, 1000));
// Return a mock URL - in production, this should be the actual URL from your server
const mockFileUrl = `https://example.com/uploads/${file.name}`;
toast.success(`File "${file.name}" uploaded successfully`);
return mockFileUrl;
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Reset form when drawer closes
React.useEffect(() => {
if (!isOpen) {
setFormData({
title: "",
description: "",
files: [],
fileUrl: "",
tags: []
});
setTagInput("");
}
}, [isOpen]);
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent className="w-[480px] sm:max-w-[540px]">
<SheetHeader>
<div className="flex items-center gap-2">
<Icon className="h-6 w-6" />
<SheetTitle>{config.title}</SheetTitle>
</div>
<SheetDescription>
{config.description}
</SheetDescription>
</SheetHeader>
<div className="my-6 space-y-6 max-h-[calc(100vh-200px)] px-4 overflow-y-auto">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">
Title <span className="text-destructive">*</span>
</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="Enter title"
className="focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)]"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Enter description"
rows={3}
className="resize-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)]"
/>
</div>
{/* File Upload Section */}
<div className="space-y-4">
<Label>
File URL <span className="text-destructive">*</span>
</Label>
{/* File URL Input */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Link className="h-4 w-4 text-green-600" />
<Input
value={formData.fileUrl}
onChange={(e) => handleFileUrlChange(e.target.value)}
placeholder="Paste file URL"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
Or upload a file below
</p>
</div>
{/* File Upload as alternative */}
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6 text-center">
{formData.files.length > 0 ? (
<div className="space-y-3">
{formData.files.map((file, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-blue-600" />
<div className="text-left">
<p className="font-medium text-sm">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveFile(index)}
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
{multiple && (
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('file')?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Add More Files
</Button>
)}
</div>
) : (
<div className="space-y-3">
<Upload className="h-8 w-8 mx-auto text-muted-foreground" />
<div>
<p className="font-medium">Or upload a file</p>
<p className="text-sm text-muted-foreground">
{acceptedFileTypes || config.acceptedTypes}
</p>
</div>
<Button
type="button"
onClick={() => document.getElementById('file')?.click()}
variant="outline"
>
Choose File{multiple ? "s" : ""}
</Button>
</div>
)}
<Input
id="file"
type="file"
multiple={multiple}
accept={acceptedFileTypes || config.acceptedTypes}
onChange={handleFileSelect}
className="hidden"
/>
</div>
</div>
{/* Tags */}
<div className="space-y-2">
<Label htmlFor="tags">Tags</Label>
<div className="space-y-2">
<div className="flex gap-2">
<Input
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
placeholder="Add tags..."
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleAddTag}
disabled={!tagInput.trim()}
>
Add
</Button>
</div>
{formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4">
<Button
variant="outline"
onClick={onClose}
className="flex-1"
disabled={isUploading}
>
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={isUploading || !formData.title.trim() || (!formData.fileUrl && formData.files.length === 0)}
className="flex-1"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
{isUploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
Creating...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Create Content
</>
)}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,204 @@
import React, { useState } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Search, Plus } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { useGetBlogsQuery } from "../../../store/services/contentManager.service";
interface BlogsTabProps {
onNavigate: (route: string) => void;
user: any;
activeInnerTab?: string;
setActiveInnerTab?: (tabs: any) => void;
}
export function BlogsTab({ onNavigate, user }: BlogsTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [limit] = useState(10);
// Use the actual API hook
const { data: blogsResponse, isLoading, error, refetch } = useGetBlogsQuery({
page,
limit,
});
const handleNewBlog = () => {
onNavigate("/content/blogs/new");
};
const handleEditBlog = (blog: any) => {
onNavigate(`/content/blogs/edit/${blog.id}`);
};
const handlePreviewBlog = (blog: any) => {
onNavigate(`/content/blogs/view/${blog.id}`);
};
const handleItemDeleted = () => {
// Refetch data after deletion
refetch();
};
// Transform API data to match ContentTable expected format
const transformBlogsData = (blogs: any[]) => {
return blogs.map(blog => ({
id: blog.id.toString(),
title: blog.title,
urlSlug: blog.urlSlug,
content: blog.content,
bannerImage: blog.bannerImage,
category: blog.category,
tags: blog.tags || [],
metaTitle: blog.metaTitle,
metaDesc: blog.metaDesc,
publishedAt: blog.publishedAt ? new Date(blog.publishedAt).toLocaleString() : "Not Published",
createdAt: blog.createdAt ? new Date(blog.createdAt).toLocaleString() : "N/A",
updatedAt: blog.updatedAt ? new Date(blog.updatedAt).toLocaleString() : "N/A",
// Include original data for any additional needs
originalData: blog
}));
};
// Filter blogs based on search term
const filteredBlogs = blogsResponse?.data ? blogsResponse.data.filter(blog =>
blog.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
blog.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
blog.category?.toLowerCase().includes(searchTerm.toLowerCase()) ||
blog.tags?.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase())) ||
blog.metaTitle?.toLowerCase().includes(searchTerm.toLowerCase()) ||
blog.metaDesc?.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
const displayBlogs = transformBlogsData(filteredBlogs);
// Handle loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search title, content, category, or tags"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
disabled
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={handleNewBlog}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
disabled
>
<Plus className="h-4 w-4 mr-2" />
New Blog
</Button>
</div>
</div>
<div className="flex justify-center items-center py-8">
<div className="text-muted-foreground">Loading blogs...</div>
</div>
</div>
);
}
// Handle error state
if (error) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search title, content, category, or tags"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
disabled
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={handleNewBlog}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Plus className="h-4 w-4 mr-2" />
New Blog
</Button>
</div>
</div>
<div className="flex justify-center items-center py-8">
<div className="text-destructive">Error loading blogs. Please try again.</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search title, content, category, or tags"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={handleNewBlog}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Plus className="h-4 w-4 mr-2" />
New Blog
</Button>
</div>
</div>
{/* Content Table */}
{displayBlogs.length > 0 ? (
<ContentTable
data={displayBlogs}
type="blog"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleEditBlog}
onPreview={handlePreviewBlog} // Add this
onNavigate={onNavigate}
onItemDeleted={handleItemDeleted}
user={user}
pagination={blogsResponse?.meta ? {
currentPage: blogsResponse.meta.page,
totalPages: blogsResponse.meta.totalPages,
hasNext: blogsResponse.meta.hasNext,
hasPrev: blogsResponse.meta.hasPrev,
onPageChange: setPage
} : undefined}
/>
) : (
<div className="flex justify-center items-center py-8">
<div className="text-muted-foreground">
{searchTerm ? "No blogs match your search criteria." : "No blogs found."}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import React, { useState, useEffect } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Search, Upload, Loader2 } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { UploadDrawer, UploadFormData } from "../shared/UploadDrawer";
import {
useGetCaseStudiesQuery,
useDeleteCaseStudyMutation,
useCreateCaseStudyMutation
} from "../../../store/services/contentManager.service";
import { toast } from "sonner";
interface CaseStudiesTabProps {
onNavigate: (route: string) => void;
user: any;
activeInnerTab?: string;
setActiveInnerTab?: (tabs: any) => void;
}
export function CaseStudiesTab({ onNavigate, user }: CaseStudiesTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
// Use the case studies API
const {
data: caseStudiesResponse,
isLoading,
error,
refetch
} = useGetCaseStudiesQuery({});
const [deleteCaseStudy, { isLoading: isDeleting }] = useDeleteCaseStudyMutation();
const [createCaseStudy, { isLoading: isCreating }] = useCreateCaseStudyMutation();
// Transform API data to match table structure
const caseStudies = caseStudiesResponse?.data || caseStudiesResponse || [];
const handleUploadComplete = async (data: UploadFormData) => {
try {
const caseStudyData = {
title: data.title,
description: data.description,
fileUrl: data.fileUrl,
tags: data.tags || []
};
await createCaseStudy(caseStudyData).unwrap();
toast.success("Case study uploaded successfully");
setIsUploadDrawerOpen(false);
refetch();
} catch (error: any) {
console.error("Failed to upload case study:", error);
toast.error(error.data?.message || "Failed to upload case study");
}
};
const handleEditCaseStudy = (caseStudy: any) => {
onNavigate(`/content/case-studies/edit/${caseStudy.id}`);
};
const filteredCaseStudies = caseStudies.filter((caseStudy: any) =>
caseStudy.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
caseStudy.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
caseStudy.tags?.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
// Add additional fields for table display
const enhancedCaseStudies = filteredCaseStudies.map((caseStudy: any) => ({
...caseStudy,
status: caseStudy.status || "Published",
updated: new Date(caseStudy.updatedAt || caseStudy.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
type: "Case Study",
fileType: caseStudy.fileUrl?.split('.').pop()?.toUpperCase() || "PDF",
fileSize: caseStudy.fileSize || "3.8 MB",
owner: caseStudy.owner || "System",
version: caseStudy.version || "v1",
industry: caseStudy.industry || "General",
client: caseStudy.client || "Confidential"
}));
if (error) {
return (
<div className="text-center py-12">
<p className="text-destructive">Failed to load case studies</p>
<Button onClick={refetch} variant="outline" className="mt-4">
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search case studies by title, client, or tags..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={() => setIsUploadDrawerOpen(true)}
className="min-h-[44px]"
disabled={isCreating}
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
{isCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isCreating ? "Uploading..." : "Upload Case Study"}
</Button>
</div>
</div>
{/* Content Table */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<ContentTable
data={enhancedCaseStudies}
type="case-study"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleEditCaseStudy}
onNavigate={onNavigate}
user={user}
onItemDeleted={refetch}
/>
)}
{/* Upload Drawer */}
<UploadDrawer
isOpen={isUploadDrawerOpen}
onClose={() => setIsUploadDrawerOpen(false)}
contentType="case-study"
onUploadComplete={handleUploadComplete}
/>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import React, { useState } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Search, Plus } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { useGetFAQsQuery } from "../../../store/services/contentManager.service";
import { Route } from "../../../types/routes";
interface FAQTabProps {
onNavigate: (route: Route) => void;
user: any;
activeInnerTab?: string;
setActiveInnerTab?: (tabs: any) => void;
}
export function FAQTab({ onNavigate, user }: FAQTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [limit] = useState(10);
// Use the FAQ API hook
const { data: faqsResponse, isLoading, error, refetch } = useGetFAQsQuery({
page,
limit,
});
const handleNewFAQ = () => {
onNavigate("/content/faqs/new");
};
const handleEditFAQ = (faq: any) => {
onNavigate(`/content/faqs/edit/${faq.id}`);
};
const handlePreviewFAQ = (faq: any) => {
console.log('🔍 Preview FAQ clicked');
console.log('FAQ ID:', faq.id);
console.log('Full FAQ object:', faq);
const route = `/content/faqs/view/${faq.id}`;
console.log('Navigating to:', route);
onNavigate(route as Route);
};
const handleItemDeleted = () => {
// Refetch data after deletion
refetch();
};
// Transform API data to match the required format
const transformFAQsData = (faqs: any[]) => {
return faqs.map(faq => ({
id: faq.id.toString(),
question: faq.question,
answer: faq.answer,
category: faq.category,
tags: faq.tags || [],
globalTags: faq.globalTag || [],
createdAt: faq.createdAt ? new Date(faq.createdAt).toLocaleString() : "N/A",
updatedAt: faq.updatedAt ? new Date(faq.updatedAt).toLocaleString() : "N/A",
// Include original data for any additional needs
originalData: faq
}));
};
// Filter FAQs based on search term
const filteredFAQs = faqsResponse?.data ? faqsResponse.data.filter(faq =>
faq.question.toLowerCase().includes(searchTerm.toLowerCase()) ||
faq.answer.toLowerCase().includes(searchTerm.toLowerCase()) ||
faq.category?.toLowerCase().includes(searchTerm.toLowerCase()) ||
faq.tags?.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase())) ||
faq.globalTag?.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase()))
) : [];
const displayFAQs = transformFAQsData(filteredFAQs);
// Handle loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search questions, answers, or tags"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
disabled
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={handleNewFAQ}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
disabled
>
<Plus className="h-4 w-4 mr-2" />
New FAQ
</Button>
</div>
</div>
<div className="flex justify-center items-center py-8">
<div className="text-muted-foreground">Loading FAQs...</div>
</div>
</div>
);
}
// Handle error state
if (error) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search questions, answers, or tags"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={handleNewFAQ}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Plus className="h-4 w-4 mr-2" />
New FAQ
</Button>
</div>
</div>
<div className="flex justify-center items-center py-8">
<div className="text-destructive">Error loading FAQs. Please try again.</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search questions, answers, category, or tags"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={handleNewFAQ}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Plus className="h-4 w-4 mr-2" />
New FAQ
</Button>
</div>
</div>
{/* Content Table */}
{displayFAQs.length > 0 ? (
<ContentTable
data={displayFAQs}
type="faq"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleEditFAQ}
onPreview={handlePreviewFAQ} // Add this
onNavigate={onNavigate}
onItemDeleted={handleItemDeleted}
user={user}
pagination={faqsResponse?.meta ? {
currentPage: faqsResponse.meta.page,
totalPages: faqsResponse.meta.totalPages,
hasNext: faqsResponse.meta.hasNext,
hasPrev: faqsResponse.meta.hasPrev,
onPageChange: setPage
} : undefined}
/>
) : (
<div className="flex justify-center items-center py-8">
<div className="text-muted-foreground">
{searchTerm ? "No FAQs match your search criteria." : "No FAQs found."}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,218 @@
import React, { useState, useEffect } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Search, Upload, Loader2 } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { UploadDrawer, UploadFormData } from "../shared/UploadDrawer";
import {
useGetKlcArchivesQuery,
useDeleteKlcArchiveMutation,
useCreateKlcArchiveMutation
} from "../../../store/services/contentManager.service";
import { toast } from "sonner";
interface KLCContentArchiveTabProps {
onNavigate: (route: string) => void;
user: any;
activeInnerTab: { [key: string]: string };
setActiveInnerTab: React.Dispatch<React.SetStateAction<{ [key: string]: string }>>;
}
type ArchiveCategory =
| "Leadership Lego Blocks"
| "Management Development Lego blocks"
| "Consulting Lego Blocks"
| "Business Development"
| "KLC - facility-related"
| "Photos"
| "Videos"
| "Client details & Contracts";
export function KLCContentArchiveTab({
onNavigate,
user,
activeInnerTab,
setActiveInnerTab
}: KLCContentArchiveTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
const innerTabs: ArchiveCategory[] = [
"Leadership Lego Blocks",
"Management Development Lego blocks",
"Consulting Lego Blocks",
"Business Development",
"KLC - facility-related",
"Photos",
"Videos",
"Client details & Contracts"
];
// Use the KLC archives API
const {
data: archiveResponse,
isLoading,
error,
refetch
} = useGetKlcArchivesQuery({});
const [createKlcArchive, { isLoading: isCreating }] = useCreateKlcArchiveMutation();
// Transform API data to match table structure
const archiveContent = archiveResponse?.data || archiveResponse || [];
// Ensure activeInnerTab is a valid key
const currentCategory = (activeInnerTab["klc-content-archive"] as ArchiveCategory) || innerTabs[0];
const handleUploadComplete = async (data: UploadFormData) => {
try {
const archiveData = {
title: data.title,
description: data.description,
fileUrl: data.fileUrl,
tags: data.tags || [],
};
await createKlcArchive(archiveData).unwrap();
toast.success("File uploaded to archive successfully");
setIsUploadDrawerOpen(false);
refetch();
} catch (error: any) {
console.error("Failed to upload archive content:", error);
toast.error(error.data?.message || "Failed to upload file to archive");
}
};
const handleEditArchive = (archive: any) => {
onNavigate(`/content/klc-archive/edit/${archive.id}`);
};
const handleTabChange = (tab: ArchiveCategory) => {
setActiveInnerTab((prev: any) => ({
...prev,
"klc-content-archive": tab
}));
setSearchTerm("");
setSelectedItems([]);
};
// REMOVED: Filter by category since API data doesn't have category field
// Use all data instead of filtering by category
const filteredArchive = archiveContent.filter((archive: any) =>
archive.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
archive.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
archive.tags?.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
// Add additional fields for table display
const enhancedArchive = filteredArchive.map((archive: any) => ({
...archive,
status: archive.status || "Published",
updated: new Date(archive.updatedAt || archive.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
type: "Archive Content",
fileType: archive.fileUrl?.split('.').pop()?.toUpperCase() || "PDF",
fileSize: archive.fileSize || "2.5 MB",
owner: archive.owner || "System",
version: archive.version || "v1",
category: archive.category || currentCategory // Add category for display
}));
if (error) {
return (
<div className="text-center py-12">
<p className="text-destructive">Failed to load archive content</p>
<Button onClick={refetch} variant="outline" className="mt-4">
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Inner Tabs */}
<div className="border-b mb-4">
<div className="flex space-x-8 overflow-x-auto">
{innerTabs.map((innerTab) => (
<button
key={innerTab}
onClick={() => handleTabChange(innerTab)}
className={`py-2 px-1 border-b-2 transition-colors text-sm whitespace-nowrap ${currentCategory === innerTab
? "border-[var(--color-brand-primary)] text-[var(--color-brand-primary)] font-medium"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{innerTab}
</button>
))}
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search in ${currentCategory.toLowerCase()}...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{filteredArchive.length} items</span>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={() => setIsUploadDrawerOpen(true)}
className="min-h-[44px]"
disabled={isCreating}
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
{isCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isCreating ? "Uploading..." : "Upload File"}
</Button>
</div>
</div>
{/* Content Table */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<ContentTable
data={enhancedArchive}
type="klc-archive"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleEditArchive}
onNavigate={onNavigate}
user={user}
onItemDeleted={refetch}
/>
)}
{/* Upload Drawer */}
<UploadDrawer
isOpen={isUploadDrawerOpen}
onClose={() => setIsUploadDrawerOpen(false)}
contentType="archive"
onUploadComplete={handleUploadComplete}
/>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Search, Upload, Loader2 } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { UploadDrawer, UploadFormData } from "../shared/UploadDrawer";
import {
useGetPodcastsQuery,
useDeletePodcastMutation,
useCreatePodcastMutation
} from "../../../store/services/contentManager.service";
import { toast } from "sonner";
interface PodcastsTabProps {
onNavigate: (route: string) => void;
user: any;
activeInnerTab?: string;
setActiveInnerTab?: (tabs: any) => void;
}
export function PodcastsTab({ onNavigate, user }: PodcastsTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
// Use the podcasts API
const {
data: podcastsResponse,
isLoading,
error,
refetch
} = useGetPodcastsQuery({});
const [deletePodcast, { isLoading: isDeleting }] = useDeletePodcastMutation();
const [createPodcast, { isLoading: isCreating }] = useCreatePodcastMutation();
// Transform API data to match table structure
const podcasts = podcastsResponse?.data || podcastsResponse || [];
const handleUploadComplete = async (data: UploadFormData) => {
try {
const podcastData = {
title: data.title,
description: data.description,
fileUrl: data.fileUrl,
tags: data.tags || []
};
await createPodcast(podcastData).unwrap();
toast.success("Podcast uploaded successfully");
setIsUploadDrawerOpen(false);
refetch();
} catch (error: any) {
console.error("Failed to upload podcast:", error);
toast.error(error.data?.message || "Failed to upload podcast");
}
};
const handleEditPodcast = (podcast: any) => {
onNavigate(`/content/podcasts/edit/${podcast.id}`);
};
const filteredPodcasts = podcasts.filter((podcast: any) =>
podcast.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
podcast.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
podcast.tags?.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
// Add additional fields for table display
const enhancedPodcasts = filteredPodcasts.map((podcast: any) => ({
...podcast,
status: "Published",
updated: new Date(podcast.updatedAt || podcast.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
type: "Podcast",
fileType: podcast.fileUrl?.split('.').pop()?.toUpperCase() || "MP3",
fileSize: "24.5 MB", // Default value or from API if available
owner: "System",
listens: podcast.listens || 0
}));
if (error) {
return (
<div className="text-center py-12">
<p className="text-destructive">Failed to load podcasts</p>
<Button onClick={refetch} variant="outline" className="mt-4">
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search podcast episodes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={() => setIsUploadDrawerOpen(true)}
className="min-h-[44px]"
disabled={isCreating}
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
{isCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isCreating ? "Uploading..." : "Upload Podcast"}
</Button>
</div>
</div>
{/* Content Table */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<ContentTable
data={enhancedPodcasts}
type="podcast"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleEditPodcast}
onNavigate={onNavigate}
user={user}
onItemDeleted={refetch}
/>
)}
{/* Upload Drawer */}
<UploadDrawer
isOpen={isUploadDrawerOpen}
onClose={() => setIsUploadDrawerOpen(false)}
contentType="podcast"
onUploadComplete={handleUploadComplete}
/>
</div>
);
}

View File

@@ -0,0 +1,215 @@
import React, { useState } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select";
import { Search, Plus, Upload, Download } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { mockProfilers } from "../../../data/mockData";
import { BulkUploadDrawer } from "../shared/BulkUploadDrawer";
// import { BulkUploadDrawer } from "../shared/BulkUploadDrawer";
// import { mockProfilers } from "../../data/mockData";
interface ProfilerTabProps {
onNavigate: (route: string) => void;
user: any;
activeInnerTab?: string;
setActiveInnerTab?: (tabs: any) => void;
}
// Create enhanced profiler data from mockProfilers
const createEnhancedProfilerData = () => {
return (mockProfilers || []).map((profiler, index) => ({
id: `prof_${profiler.id}`,
title: profiler.name || profiler.title || "Untitled Profiler",
type: index % 3 === 0 ? "Individual Self-Assessment" :
index % 3 === 1 ? "360 Feedback (self + multi-rater)" : "Group survey/poll",
status: index % 5 === 0 ? "Draft" :
index % 5 === 1 ? "In Review" :
index % 5 === 2 ? "Changes Requested" :
index % 5 === 3 ? "Approved" : "Published",
version: `v${Math.floor(Math.random() * 3) + 1}`,
updated: profiler.lastModified || new Date().toISOString(),
owner: profiler.createdBy || profiler.owner || "Unknown",
sectionsCount: Math.floor(Math.random() * 8) + 3,
questionsCount: {
likert: Math.floor(Math.random() * 20) + 5,
ipsative: Math.floor(Math.random() * 10) + 2,
trueFalse: Math.floor(Math.random() * 8),
matching: Math.floor(Math.random() * 5),
descriptive: Math.floor(Math.random() * 3)
},
tags: profiler.tags || ["Leadership", "Assessment", "360 Feedback"].slice(0, Math.floor(Math.random() * 3) + 1),
raterGroups: index % 3 === 1 ? ["Self", "Manager", "Peer", "Direct Report"] : [],
anonymousGroups: index % 3 === 1 ? ["Manager", "Peer", "Direct Report"] : [],
randomizeSections: Math.random() > 0.5,
randomizeQuestions: Math.random() > 0.5,
keepStemSetsTogether: Math.random() > 0.5
}));
};
export function ProfilerTab({ onNavigate, user }: ProfilerTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [ownerFilter, setOwnerFilter] = useState("all");
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false);
const [selectedItems, setSelectedItems] = useState<string[]>([]);
// Use the enhanced profiler data
const enhancedProfilerData = createEnhancedProfilerData();
const profilerTypeOptions = [
{ value: "all", label: "All Types" },
{ value: "individual", label: "Individual Self-Assessment" },
{ value: "360-feedback", label: "360 Feedback (self + multi-rater)" },
{ value: "group-survey", label: "Group survey/poll" }
];
const profilerStatusOptions = [
{ value: "all", label: "All Status" },
{ value: "draft", label: "Draft" },
{ value: "in-review", label: "In Review" },
{ value: "changes-requested", label: "Changes Requested" },
{ value: "approved", label: "Approved" },
{ value: "published", label: "Published" },
{ value: "archived", label: "Archived" }
];
const ownerOptions = [
{ value: "all", label: "All Owners" },
{ value: "dr-rajesh", label: "Dr. Rajesh Mehta" },
{ value: "prof-sunita", label: "Prof. Sunita Agarwal" },
{ value: "dr-amit", label: "Dr. Amit Sharma" }
];
const handleProfilerBuilder = (item?: any) => {
if (item) {
onNavigate(`/profilers/new?id=${item.id}`);
} else {
onNavigate("/profilers/new");
}
};
const filteredProfilers = enhancedProfilerData.filter(profiler => {
const searchLower = searchTerm.toLowerCase();
const matchesSearch = (profiler.title || '').toLowerCase().includes(searchLower) ||
(profiler.tags || []).some(tag => (tag || '').toLowerCase().includes(searchLower));
const matchesType = typeFilter === "all" ||
(typeFilter === "individual" && profiler.type === "Individual Self-Assessment") ||
(typeFilter === "360-feedback" && profiler.type === "360 Feedback (self + multi-rater)") ||
(typeFilter === "group-survey" && profiler.type === "Group survey/poll");
const matchesStatus = statusFilter === "all" ||
(profiler.status || '').toLowerCase().replace(" ", "-") === statusFilter;
const ownerLower = ownerFilter.replace("-", " ").toLowerCase();
const matchesOwner = ownerFilter === "all" ||
(profiler.owner || '').toLowerCase().includes(ownerLower);
return matchesSearch && matchesType && matchesStatus && matchesOwner;
});
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
{/* Search */}
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search title or tags"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
{/* Type Filter */}
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[200px] min-h-[44px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{profilerTypeOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status Filter */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[160px] min-h-[44px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{profilerStatusOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Owner Filter */}
<Select value={ownerFilter} onValueChange={setOwnerFilter}>
<SelectTrigger className="w-[160px] min-h-[44px]">
<SelectValue placeholder="Owner" />
</SelectTrigger>
<SelectContent>
{ownerOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Actions */}
<div className="ml-auto flex gap-2">
<Button
variant="outline"
onClick={() => setIsBulkUploadOpen(true)}
className="min-h-[44px]"
>
<Upload className="h-4 w-4 mr-2" />
Bulk Upload
</Button>
<Button
variant="outline"
className="min-h-[44px]"
>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
<Button
onClick={() => handleProfilerBuilder()}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Plus className="h-4 w-4 mr-2" />
New Profiler
</Button>
</div>
</div>
{/* Content Table */}
<ContentTable
data={filteredProfilers}
type="profiler"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleProfilerBuilder}
onNavigate={onNavigate}
user={user}
/>
{/* Bulk Upload Drawer */}
<BulkUploadDrawer
isOpen={isBulkUploadOpen}
onClose={() => setIsBulkUploadOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import React, { useState, useEffect } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Search, Upload, Loader2, BookOpen } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { UploadDrawer, UploadFormData } from "../shared/UploadDrawer";
import {
useGetReadingMaterialsQuery,
useDeleteReadingMaterialMutation,
useCreateReadingMaterialMutation
} from "../../../store/services/contentManager.service";
import { toast } from "sonner";
interface ReadingMaterialsTabProps {
onNavigate: (route: string) => void;
user: any;
activeInnerTab?: string;
setActiveInnerTab?: (tabs: any) => void;
}
export function ReadingMaterialsTab({ onNavigate, user }: ReadingMaterialsTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
const {
data: readingMaterialsResponse,
isLoading,
error,
refetch
} = useGetReadingMaterialsQuery({});
const [deleteReadingMaterial, { isLoading: isDeleting }] = useDeleteReadingMaterialMutation();
const [createReadingMaterial, { isLoading: isCreating }] = useCreateReadingMaterialMutation();
const readingMaterials = readingMaterialsResponse?.data || readingMaterialsResponse || [];
const handleUploadComplete = async (data: UploadFormData) => {
try {
const readingMaterialData = {
title: data.title,
description: data.description,
fileUrl: data.fileUrl,
tags: data.tags || [],
};
await createReadingMaterial(readingMaterialData).unwrap();
toast.success("Reading material uploaded successfully");
setIsUploadDrawerOpen(false);
refetch();
} catch (error: any) {
console.error("Failed to upload reading material:", error);
toast.error(error.data?.message || "Failed to upload reading material");
}
};
const handleEditReadingMaterial = (readingMaterial: any) => {
onNavigate(`/content/reading-materials/edit/${readingMaterial.id}`);
};
const filteredReadingMaterials = readingMaterials.filter((material: any) =>
material.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.tags?.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
const enhancedReadingMaterials = filteredReadingMaterials.map((material: any) => ({
...material,
status: material.status || "Published",
updated: new Date(material.updatedAt || material.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
type: "Reading Material",
fileType: material.fileUrl?.split('.').pop()?.toUpperCase() || "PDF",
fileSize: material.fileSize || "2.4 MB",
pages: material.pages || 0,
category: material.category || "Uncategorized"
}));
if (error) {
return (
<div className="text-center py-12">
<p className="text-destructive">Failed to load reading materials</p>
<Button onClick={refetch} variant="outline" className="mt-4">
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search reading materials..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={() => setIsUploadDrawerOpen(true)}
className="min-h-[44px]"
disabled={isCreating}
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
{isCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<BookOpen className="h-4 w-4 mr-2" />
)}
{isCreating ? "Uploading..." : "Upload Reading Material"}
</Button>
</div>
</div>
{/* Content Table */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<ContentTable
data={enhancedReadingMaterials}
type="reading-material"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleEditReadingMaterial}
onNavigate={onNavigate}
user={user}
onItemDeleted={refetch}
/>
)}
{/* Upload Drawer */}
<UploadDrawer
isOpen={isUploadDrawerOpen}
onClose={() => setIsUploadDrawerOpen(false)}
contentType="reading-material"
onUploadComplete={handleUploadComplete}
/>
</div>
);
}

View File

@@ -0,0 +1,255 @@
import React, { useState, useEffect } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Search, Upload, Loader2 } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { UploadDrawer, UploadFormData } from "../shared/UploadDrawer";
import {
useGetTrainingMaterialsQuery,
useDeleteTrainingMaterialMutation,
useCreateTrainingMaterialMutation
} from "../../../store/services/contentManager.service";
import { toast } from "sonner";
interface TrainingMaterialsTabProps {
onNavigate: (route: string) => void;
user: any;
activeInnerTab: { [key: string]: string };
setActiveInnerTab: (tabs: any) => void;
}
// Type for the categories
type TrainingMaterialCategory = "Facilitator Manual" | "Participant handouts" | "To be printed (for KLC team)";
export function TrainingMaterialsTab({
onNavigate,
user,
activeInnerTab,
setActiveInnerTab
}: TrainingMaterialsTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
// Use the training materials API
const {
data: trainingMaterialsResponse,
isLoading,
error,
refetch
} = useGetTrainingMaterialsQuery({});
const [deleteTrainingMaterial, { isLoading: isDeleting }] = useDeleteTrainingMaterialMutation();
const [createTrainingMaterial, { isLoading: isCreating }] = useCreateTrainingMaterialMutation();
const innerTabs: TrainingMaterialCategory[] = [
"Facilitator Manual",
"Participant handouts",
"To be printed (for KLC team)"
];
// Ensure activeInnerTab is a valid key, fallback to first tab
const currentCategory = activeInnerTab["training-materials"] as TrainingMaterialCategory || innerTabs[0];
const handleUploadComplete = async (data: UploadFormData) => {
try {
const trainingMaterialData = {
title: data.title,
description: data.description,
fileUrl: data.fileUrl,
tags: data.tags || [],
};
await createTrainingMaterial(trainingMaterialData).unwrap();
toast.success("Training material uploaded successfully");
setIsUploadDrawerOpen(false);
refetch();
} catch (error: any) {
console.error("Failed to upload training material:", error);
toast.error(error.data?.message || "Failed to upload training material");
}
};
const handleEditMaterial = (material: any) => {
onNavigate(`/content/training-materials/edit/${material.id}`);
};
const handleDeleteMaterial = async (material: any) => {
try {
await deleteTrainingMaterial(material.id).unwrap();
toast.success("Training material deleted successfully");
refetch();
} catch (error: any) {
console.error("Failed to delete training material:", error);
toast.error(error.data?.message || "Failed to delete training material");
}
};
// CORRECTED: Properly access the data from API response
const allTrainingMaterials = trainingMaterialsResponse?.data || [];
console.log("API Response:", trainingMaterialsResponse);
console.log("All Training Materials:", allTrainingMaterials);
// TEMPORARY FIX: Show all materials without category filtering
// Since your API doesn't have categories yet, we'll show all data in all tabs
const currentData = allTrainingMaterials;
const filteredMaterials = currentData.filter((material: any) =>
material.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.tags?.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
// Add additional fields for table display
const enhancedMaterials = filteredMaterials.map((material: any) => ({
...material,
id: material.id.toString(), // Ensure ID is string for selection
status: "Published",
updated: new Date(material.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
type: "Training Material",
fileType: material.fileUrl?.split('.').pop()?.toUpperCase() || "PDF",
fileSize: "2.4 MB", // Default value for now
version: "v1",
owner: "System",
}));
const handleTabChange = (tab: TrainingMaterialCategory) => {
setActiveInnerTab((prev: any) => ({
...prev,
"training-materials": tab
}));
setSearchTerm("");
setSelectedItems([]);
};
// Debug: Check what's being rendered
console.log("Current Category:", currentCategory);
console.log("Current Data length:", currentData.length);
console.log("Filtered Materials length:", filteredMaterials.length);
console.log("Enhanced Materials:", enhancedMaterials);
if (error) {
return (
<div className="text-center py-12">
<p className="text-destructive">Failed to load training materials</p>
<Button onClick={refetch} variant="outline" className="mt-4">
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Inner Tabs */}
<div className="border-b mb-4">
<div className="flex space-x-8">
{innerTabs.map((innerTab) => (
<button
key={innerTab}
onClick={() => handleTabChange(innerTab)}
className={`py-2 px-1 border-b-2 transition-colors text-sm ${
currentCategory === innerTab
? "border-[var(--color-brand-primary)] text-[var(--color-brand-primary)] font-medium"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{innerTab}
</button>
))}
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search training materials...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{enhancedMaterials.length} items</span>
{allTrainingMaterials.length > 0 && (
<span className="text-xs">(Total: {allTrainingMaterials.length})</span>
)}
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={() => setIsUploadDrawerOpen(true)}
className="min-h-[44px]"
disabled={isCreating}
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
{isCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isCreating ? "Uploading..." : "Upload File"}
</Button>
</div>
</div>
{/* Content Table */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-2">Loading training materials...</span>
</div>
) : (
<>
{enhancedMaterials.length === 0 ? (
<div className="text-center py-12 border rounded-lg">
{allTrainingMaterials.length === 0 ? (
<p className="text-muted-foreground">No training materials found</p>
) : (
<p className="text-muted-foreground">
No training materials match your search criteria
</p>
)}
<Button
onClick={() => setSearchTerm('')}
variant="outline"
className="mt-4"
>
Clear Search
</Button>
</div>
) : (
<ContentTable
data={enhancedMaterials}
type="training-material"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleEditMaterial}
onDelete={handleDeleteMaterial}
onNavigate={onNavigate}
user={user}
onItemDeleted={refetch}
/>
)}
</>
)}
{/* Upload Drawer */}
<UploadDrawer
isOpen={isUploadDrawerOpen}
onClose={() => setIsUploadDrawerOpen(false)}
contentType="training-material"
onUploadComplete={handleUploadComplete}
/>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from "react";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Search, Upload, Loader2 } from "lucide-react";
import { ContentTable } from "../shared/ContentTable";
import { UploadDrawer, UploadFormData } from "../shared/UploadDrawer";
import {
useGetWebcastsQuery,
useDeleteWebcastMutation,
useCreateWebcastMutation
} from "../../../store/services/contentManager.service";
import { toast } from "sonner";
interface WebcastsTabProps {
onNavigate: (route: string) => void;
user: any;
activeInnerTab?: string;
setActiveInnerTab?: (tabs: any) => void;
}
export function WebcastsTab({ onNavigate, user }: WebcastsTabProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
// Use the webcast API
const {
data: webcastsData,
isLoading,
error,
refetch
} = useGetWebcastsQuery({});
const [deleteWebcast, { isLoading: isDeleting }] = useDeleteWebcastMutation();
const [createWebcast, { isLoading: isCreating }] = useCreateWebcastMutation();
// Transform API data to match table structure
const webcasts = webcastsData?.data || webcastsData || [];
const handleUploadComplete = async (data: UploadFormData) => {
try {
const webcastData = {
title: data.title,
description: data.description,
fileUrl: data.fileUrl, // This should come from your file upload process
tags: data.tags || [],
};
await createWebcast(webcastData).unwrap();
toast.success("Webcast uploaded successfully");
setIsUploadDrawerOpen(false);
refetch();
} catch (error: any) {
console.error("Failed to upload webcast:", error);
toast.error(error.data?.message || "Failed to upload webcast");
}
};
const handleEditWebcast = (webcast: any) => {
onNavigate(`/content/webcasts/edit/${webcast.id}`);
};
const handleDeleteWebcast = async (webcast: any) => {
try {
await deleteWebcast(webcast.id).unwrap();
toast.success("Webcast deleted successfully");
refetch();
} catch (error: any) {
console.error("Failed to delete webcast:", error);
toast.error(error.data?.message || "Failed to delete webcast");
}
};
const filteredWebcasts = webcasts.filter((webcast: any) =>
webcast.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
webcast.tags.some((tag: string) => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
// Add status and other fields for table display
const enhancedWebcasts = filteredWebcasts.map((webcast: any) => ({
...webcast,
status: "Published", // You might want to add this field to your API
updated: new Date(webcast.updatedAt).toLocaleDateString(),
type: "Webcast",
}));
if (error) {
return (
<div className="text-center py-12">
<p className="text-destructive">Failed to load webcasts</p>
<Button onClick={refetch} variant="outline" className="mt-4">
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search webcast titles or tags"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={() => setIsUploadDrawerOpen(true)}
className="min-h-[44px]"
disabled={isCreating}
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
{isCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isCreating ? "Uploading..." : "Upload Webcast"}
</Button>
</div>
</div>
{/* Content Table */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<ContentTable
data={enhancedWebcasts}
type="webcast"
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
onEdit={handleEditWebcast}
onDelete={handleDeleteWebcast}
onNavigate={onNavigate}
user={user}
onItemDeleted={refetch}
/>
)}
{/* Upload Drawer */}
<UploadDrawer
isOpen={isUploadDrawerOpen}
onClose={() => setIsUploadDrawerOpen(false)}
contentType="webcast"
onUploadComplete={handleUploadComplete}
multiple={true}
/>
</div>
);
}

12
src/global.d.ts vendored
View File

@@ -27,3 +27,15 @@ declare module '*.gif' {
const src: string; const src: string;
export default src; export default src;
} }
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
// Add more env vars if needed:
// readonly VITE_SOME_OTHER_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,7 +1,11 @@
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import store from "./store/store";
import App from "./App";
import "./index.css";
import { createRoot } from "react-dom/client"; createRoot(document.getElementById("root")!).render(
import App from "./App"; <Provider store={store}>
import "./index.css"; <App />
</Provider>
createRoot(document.getElementById("root")!).render(<App />); );

17
src/store/baseQuery.ts Normal file
View File

@@ -0,0 +1,17 @@
// src/store/baseQuery.ts
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
const baseQuery = fetchBaseQuery({
baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
prepareHeaders: (headers) => {
// Add authentication headers if needed
const token = localStorage.getItem('token');
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
headers.set('Content-Type', 'application/json');
return headers;
},
});
export default baseQuery;

View File

@@ -0,0 +1,429 @@
// src/store/services/contentManager.service.ts
import { createApi } from "@reduxjs/toolkit/query/react";
import baseQuery from "../baseQuery";
export const contentManagerApi = createApi({
reducerPath: "contentManagerApi",
baseQuery,
tagTypes: [
"Blogs",
"FAQs",
"Webcasts",
"TrainingMaterials",
"Podcasts",
"ReadingMaterials",
"CaseStudies",
"KlcArchive",
],
endpoints: (builder) => ({
// Existing Blog endpoints
getBlogs: builder.query({
query: ({ page = 1, limit = 10 }) => `/blogs?page=${page}&limit=${limit}`,
providesTags: ["Blogs"],
}),
getBlogsById: builder.query({
query: (id: string) => `/blogs/${id}`,
providesTags: (_result, _error, id) => [{ type: "Blogs", id }],
}),
createBlog: builder.mutation({
query: (blogData) => ({
url: "/blogs",
method: "POST",
body: blogData,
}),
invalidatesTags: ["Blogs"],
}),
updateBlog: builder.mutation({
query: ({ id, ...blogData }) => ({
url: `/blogs/${id}`,
method: "PATCH",
body: blogData,
}),
invalidatesTags: (_result, _error, { id }) => [
{ type: "Blogs", id },
"Blogs",
],
}),
deleteBlog: builder.mutation({
query: (id: string) => ({
url: `/blogs/${id}`,
method: "DELETE",
}),
invalidatesTags: ["Blogs"],
}),
// Existing FAQ endpoints
getFAQs: builder.query({
query: ({ page = 1, limit = 10 }) => `/faq?page=${page}&limit=${limit}`,
providesTags: ["FAQs"],
}),
getFAQById: builder.query({
query: (id: string) => `/faq/${id}`,
providesTags: (_result, _error, id) => [{ type: "FAQs", id }],
}),
createFAQ: builder.mutation({
query: (faqData) => ({
url: "/faq",
method: "POST",
body: faqData,
}),
invalidatesTags: ["FAQs"],
}),
updateFAQ: builder.mutation({
query: ({ id, ...faqData }) => ({
url: `/faq/${id}`,
method: "PATCH",
body: faqData,
}),
invalidatesTags: (_result, _error, { id }) => [
{ type: "FAQs", id },
"FAQs",
],
}),
deleteFAQ: builder.mutation({
query: (id: string) => ({
url: `/faq/${id}`,
method: "DELETE",
}),
invalidatesTags: ["FAQs"],
}),
// Webcast endpoints
getWebcasts: builder.query({
query: ({ page = 1, limit = 10 }) =>
`/webcasts?page=${page}&limit=${limit}`,
providesTags: ["Webcasts"],
}),
getWebcastById: builder.query({
query: (id: string) => `/webcasts/${id}`,
providesTags: (_result, _error, id) => [{ type: "Webcasts", id }],
}),
createWebcast: builder.mutation({
query: (webcastData) => ({
url: "/webcasts",
method: "POST",
body: webcastData,
}),
invalidatesTags: ["Webcasts"],
}),
updateWebcast: builder.mutation({
query: ({ id, ...webcastData }) => ({
url: `/webcasts/${id}`,
method: "PATCH",
body: webcastData,
}),
invalidatesTags: (_result, _error, { id }) => [
{ type: "Webcasts", id },
"Webcasts",
],
}),
deleteWebcast: builder.mutation({
query: (id: string) => ({
url: `/webcasts/${id}`,
method: "DELETE",
}),
invalidatesTags: ["Webcasts"],
}),
// Training Materials endpoints
getTrainingMaterials: builder.query({
query: ({ page = 1, limit = 10 }) =>
`/training-materials?page=${page}&limit=${limit}`,
providesTags: ["TrainingMaterials"],
}),
getTrainingMaterialById: builder.query({
query: (id: string) => `/training-materials/${id}`,
providesTags: (_result, _error, id) => [
{ type: "TrainingMaterials", id },
],
}),
createTrainingMaterial: builder.mutation({
query: (trainingMaterialData) => ({
url: "/training-materials",
method: "POST",
body: trainingMaterialData,
}),
invalidatesTags: ["TrainingMaterials"],
}),
updateTrainingMaterial: builder.mutation({
query: ({ id, ...trainingMaterialData }) => ({
url: `/training-materials/${id}`,
method: "PATCH",
body: trainingMaterialData,
}),
invalidatesTags: (_result, _error, { id }) => [
{ type: "TrainingMaterials", id },
"TrainingMaterials",
],
}),
deleteTrainingMaterial: builder.mutation({
query: (id: string) => ({
url: `/training-materials/${id}`,
method: "DELETE",
}),
invalidatesTags: ["TrainingMaterials"],
}),
// Podcast endpoints
getPodcasts: builder.query({
query: ({ page = 1, limit = 10 }) =>
`/podcasts?page=${page}&limit=${limit}`,
providesTags: ["Podcasts"],
}),
getPodcastById: builder.query({
query: (id: string) => `/podcasts/${id}`,
providesTags: (_result, _error, id) => [{ type: "Podcasts", id }],
}),
createPodcast: builder.mutation({
query: (podcastData) => ({
url: "/podcasts",
method: "POST",
body: podcastData,
}),
invalidatesTags: ["Podcasts"],
}),
updatePodcast: builder.mutation({
query: ({ id, ...podcastData }) => ({
url: `/podcasts/${id}`,
method: "PATCH",
body: podcastData,
}),
invalidatesTags: (_result, _error, { id }) => [
{ type: "Podcasts", id },
"Podcasts",
],
}),
deletePodcast: builder.mutation({
query: (id: string) => ({
url: `/podcasts/${id}`,
method: "DELETE",
}),
invalidatesTags: ["Podcasts"],
}),
// Reading Materials endpoints
getReadingMaterials: builder.query({
query: ({ page = 1, limit = 10 }) =>
`/reading-materials?page=${page}&limit=${limit}`,
providesTags: ["ReadingMaterials"],
}),
getReadingMaterialById: builder.query({
query: (id: string) => `/reading-materials/${id}`,
providesTags: (_result, _error, id) => [{ type: "ReadingMaterials", id }],
}),
createReadingMaterial: builder.mutation({
query: (readingMaterialData) => ({
url: "/reading-materials",
method: "POST",
body: readingMaterialData,
}),
invalidatesTags: ["ReadingMaterials"],
}),
updateReadingMaterial: builder.mutation({
query: ({ id, ...readingMaterialData }) => ({
url: `/reading-materials/${id}`,
method: "PATCH",
body: readingMaterialData,
}),
invalidatesTags: (_result, _error, { id }) => [
{ type: "ReadingMaterials", id },
"ReadingMaterials",
],
}),
deleteReadingMaterial: builder.mutation({
query: (id: string) => ({
url: `/reading-materials/${id}`,
method: "DELETE",
}),
invalidatesTags: ["ReadingMaterials"],
}),
// Case Studies endpoints
getCaseStudies: builder.query({
query: ({ page = 1, limit = 10 }) =>
`/case-studies?page=${page}&limit=${limit}`,
providesTags: ["CaseStudies"],
}),
getCaseStudyById: builder.query({
query: (id: string) => `/case-studies/${id}`,
providesTags: (_result, _error, id) => [{ type: "CaseStudies", id }],
}),
searchCaseStudiesByTitle: builder.query({
query: (title: string) => `/case-studies/search/title?query=${title}`,
providesTags: ["CaseStudies"],
}),
searchCaseStudiesByDescription: builder.query({
query: (desc: string) => `/case-studies/search/description?query=${desc}`,
providesTags: ["CaseStudies"],
}),
getCaseStudiesByTag: builder.query({
query: (tag: string) => `/case-studies/tag/${tag}`,
providesTags: ["CaseStudies"],
}),
createCaseStudy: builder.mutation({
query: (data) => ({
url: "/case-studies",
method: "POST",
body: data,
}),
invalidatesTags: ["CaseStudies"],
}),
updateCaseStudy: builder.mutation({
query: ({ id, ...data }) => ({
url: `/case-studies/${id}`,
method: "PATCH",
body: data,
}),
invalidatesTags: (_r, _e, { id }) => [
{ type: "CaseStudies", id },
"CaseStudies",
],
}),
deleteCaseStudy: builder.mutation({
query: (id: string) => ({
url: `/case-studies/${id}`,
method: "DELETE",
}),
invalidatesTags: ["CaseStudies"],
}),
// KLC Archive endpoints
getKlcArchives: builder.query({
query: ({ page = 1, limit = 10 }) =>
`/klc-archive?page=${page}&limit=${limit}`,
providesTags: ["KlcArchive"],
}),
getKlcArchiveById: builder.query({
query: (id: string) => `/klc-archive/${id}`,
providesTags: (_result, _error, id) => [{ type: "KlcArchive", id }],
}),
searchKlcArchiveByTitle: builder.query({
query: (title: string) => `/klc-archive/search/title?query=${title}`,
providesTags: ["KlcArchive"],
}),
searchKlcArchiveByDescription: builder.query({
query: (desc: string) => `/klc-archive/search/description?query=${desc}`,
providesTags: ["KlcArchive"],
}),
getKlcArchiveByTag: builder.query({
query: (tag: string) => `/klc-archive/tag/${tag}`,
providesTags: ["KlcArchive"],
}),
createKlcArchive: builder.mutation({
query: (data) => ({
url: "/klc-archive",
method: "POST",
body: data,
}),
invalidatesTags: ["KlcArchive"],
}),
updateKlcArchive: builder.mutation({
query: ({ id, ...data }) => ({
url: `/klc-archive/${id}`,
method: "PATCH",
body: data,
}),
invalidatesTags: (_r, _e, { id }) => [
{ type: "KlcArchive", id },
"KlcArchive",
],
}),
deleteKlcArchive: builder.mutation({
query: (id: string) => ({
url: `/klc-archive/${id}`,
method: "DELETE",
}),
invalidatesTags: ["KlcArchive"],
}),
}),
});
export const {
useGetBlogsQuery,
useCreateBlogMutation,
useGetBlogsByIdQuery,
useGetFAQsQuery,
useGetFAQByIdQuery,
useCreateFAQMutation,
useUpdateFAQMutation,
useDeleteFAQMutation,
useUpdateBlogMutation,
useDeleteBlogMutation,
// New exports
useGetWebcastsQuery,
useGetWebcastByIdQuery,
useCreateWebcastMutation,
useUpdateWebcastMutation,
useDeleteWebcastMutation,
useGetTrainingMaterialsQuery,
useGetTrainingMaterialByIdQuery,
useCreateTrainingMaterialMutation,
useUpdateTrainingMaterialMutation,
useDeleteTrainingMaterialMutation,
useGetPodcastsQuery,
useGetPodcastByIdQuery,
useCreatePodcastMutation,
useUpdatePodcastMutation,
useDeletePodcastMutation,
useGetReadingMaterialsQuery,
useGetReadingMaterialByIdQuery,
useCreateReadingMaterialMutation,
useUpdateReadingMaterialMutation,
useDeleteReadingMaterialMutation,
useGetCaseStudiesQuery,
useGetCaseStudyByIdQuery,
useSearchCaseStudiesByTitleQuery,
useSearchCaseStudiesByDescriptionQuery,
useGetCaseStudiesByTagQuery,
useCreateCaseStudyMutation,
useUpdateCaseStudyMutation,
useDeleteCaseStudyMutation,
useGetKlcArchivesQuery,
useGetKlcArchiveByIdQuery,
useSearchKlcArchiveByTitleQuery,
useSearchKlcArchiveByDescriptionQuery,
useGetKlcArchiveByTagQuery,
useCreateKlcArchiveMutation,
useUpdateKlcArchiveMutation,
useDeleteKlcArchiveMutation,
} = contentManagerApi;

20
src/store/store.ts Normal file
View File

@@ -0,0 +1,20 @@
// src/store/store.ts
import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import { contentManagerApi } from "./services/contentManager.service";
export const store = configureStore({
reducer: {
[contentManagerApi.reducerPath]: contentManagerApi.reducer, // ✅ add new reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(contentManagerApi.middleware), // ✅ add new middleware
});
setupListeners(store.dispatch);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

View File

@@ -233,3 +233,8 @@ html {
height: 0; height: 0;
display: none; /* Safari and Chrome */ display: none; /* Safari and Chrome */
} }
.whitespace-nowrap {
/* white-space: wrap !important; */
}

44
src/types/routes.ts Normal file
View File

@@ -0,0 +1,44 @@
export type Route =
| "/login"
| "/login/forget-password"
| "/login/2fa"
| "/dashboard"
| "/profile"
| "/profile/reset-password"
| "/users/individual"
| "/users/organizations"
| "/users/organizations/new"
| "/content"
| "/content/blogs"
| "/content/blogs/new"
| `/content/blogs/edit/${string}`
| `/content/blogs/view/${string}` // Add blog view route
| "/content/faqs"
| "/content/faqs/new"
| `/content/faqs/edit/${string}`
| `/content/faqs/view/${string}` // Add FAQ view route
| "/courses"
| "/courses/course-builder"
| "/courses/new"
| `/courses/${string}/assign`
| "/profilers"
| "/profilers/new"
| "/profilers/preview"
| "/profiler-approval"
| "/landing-pages"
| "/landing-pages/edit/home"
| "/landing-pages/edit/services"
| "/landing-pages/edit/about-us"
| "/webinars"
| "/programmes"
| "/programmes/new"
| `/programmes/${string}/assign`
| "/class-scheduler"
| "/open-programme"
| "/facilities-360"
| "/facilities-360/new"
| `/facilities-360/${string}`
| "/admin/leads"
| "/admin/roles"
| "/admin/analytics"
| "/admin/profiler-master";