content manager api integrated
This commit is contained in:
100
package-lock.json
generated
100
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
126
src/App.tsx
126
src/App.tsx
@@ -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} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
597
src/components/pages/EditBlog.tsx
Normal file
597
src/components/pages/EditBlog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
397
src/components/pages/EditCaseStudy.tsx
Normal file
397
src/components/pages/EditCaseStudy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
431
src/components/pages/EditFAQ.tsx
Normal file
431
src/components/pages/EditFAQ.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
383
src/components/pages/EditKlcArchiveContent.tsx
Normal file
383
src/components/pages/EditKlcArchiveContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
397
src/components/pages/EditPodcast.tsx
Normal file
397
src/components/pages/EditPodcast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
374
src/components/pages/EditReadingMaterial.tsx
Normal file
374
src/components/pages/EditReadingMaterial.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
src/components/pages/EditTrainingMaterial.tsx
Normal file
381
src/components/pages/EditTrainingMaterial.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
380
src/components/pages/EditWebcast.tsx
Normal file
380
src/components/pages/EditWebcast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
253
src/components/pages/ViewBlog.tsx
Normal file
253
src/components/pages/ViewBlog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
477
src/components/pages/ViewFAQ.tsx
Normal file
477
src/components/pages/ViewFAQ.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
300
src/components/pages/shared/BulkUploadDrawer.tsx
Normal file
300
src/components/pages/shared/BulkUploadDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
708
src/components/pages/shared/ContentTable.tsx
Normal file
708
src/components/pages/shared/ContentTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
489
src/components/pages/shared/UploadDrawer.tsx
Normal file
489
src/components/pages/shared/UploadDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/components/pages/tabs/BlogsTab.tsx
Normal file
204
src/components/pages/tabs/BlogsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/components/pages/tabs/CaseStudiesTab.tsx
Normal file
158
src/components/pages/tabs/CaseStudiesTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/components/pages/tabs/FAQTab.tsx
Normal file
206
src/components/pages/tabs/FAQTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/components/pages/tabs/KLCContentArchiveTab.tsx
Normal file
218
src/components/pages/tabs/KLCContentArchiveTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/components/pages/tabs/PodcastsTab.tsx
Normal file
156
src/components/pages/tabs/PodcastsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/pages/tabs/ProfilerTab.tsx
Normal file
215
src/components/pages/tabs/ProfilerTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/components/pages/tabs/ReadingMaterialsTab.tsx
Normal file
153
src/components/pages/tabs/ReadingMaterialsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
src/components/pages/tabs/TrainingMaterialsTab.tsx
Normal file
255
src/components/pages/tabs/TrainingMaterialsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/components/pages/tabs/WebcastsTab.tsx
Normal file
160
src/components/pages/tabs/WebcastsTab.tsx
Normal 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
12
src/global.d.ts
vendored
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
16
src/main.tsx
16
src/main.tsx
@@ -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
17
src/store/baseQuery.ts
Normal 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;
|
||||||
429
src/store/services/contentManager.service.ts
Normal file
429
src/store/services/contentManager.service.ts
Normal 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
20
src/store/store.ts
Normal 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;
|
||||||
@@ -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
44
src/types/routes.ts
Normal 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";
|
||||||
Reference in New Issue
Block a user