content manager api integrated

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,11 +6,13 @@ import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
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 { Route } from '../../types/routes';
import { useCreateBlogMutation } from '../../store/services/contentManager.service';
interface NewBlogProps {
onNavigate: (route: string) => void;
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
formData?: any;
@@ -28,19 +30,21 @@ export function NewBlog({
}: NewBlogProps) {
const [blogData, setBlogData] = useState(() => ({
title: formData?.title || '',
slug: formData?.slug || '',
metaTitle: formData?.metaTitle || '',
metaDescription: formData?.metaDescription || '',
body: formData?.body || '',
bannerImage: formData?.bannerImage || null,
bannerAltText: formData?.bannerAltText || '',
tags: formData?.tags || [],
urlSlug: formData?.urlSlug || '',
content: formData?.content || '',
bannerImage: formData?.bannerImage || '',
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 [isUploading, setIsUploading] = useState(false);
// Use the RTK Query mutation hook
const [createBlog, { isLoading: isSubmitting }] = useCreateBlogMutation();
// Auto-save functionality
React.useEffect(() => {
@@ -58,8 +62,8 @@ export function NewBlog({
[field]: value
}));
// Auto-generate slug from title
if (field === 'title' && !blogData.slug) {
// Auto-generate URL slug from title
if (field === 'title' && !blogData.urlSlug) {
const slug = value.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
@@ -67,7 +71,7 @@ export function NewBlog({
.trim();
setBlogData(prev => ({
...prev,
slug
urlSlug: slug
}));
}
};
@@ -83,15 +87,12 @@ export function NewBlog({
setIsUploading(true);
try {
// Simulate upload
// 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', {
url: imageUrl,
name: file.name,
size: file.size
});
handleInputChange('bannerImage', imageUrl);
toast.success('Banner image uploaded successfully');
} catch (error) {
@@ -112,42 +113,61 @@ export function NewBlog({
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()) {
toast.error('Please enter a blog title');
return;
}
if (!blogData.body.trim()) {
if (!blogData.content.trim()) {
toast.error('Please enter blog content');
return;
}
if (!blogData.category) {
toast.error('Please select a category');
return;
}
try {
const blogToSave = {
...blogData,
status,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: user.name
const blogPayload = {
title: blogData.title,
urlSlug: blogData.urlSlug,
content: blogData.content,
bannerImage: blogData.bannerImage,
category: blogData.category,
tags: blogData.tags,
metaTitle: blogData.metaTitle,
metaDesc: blogData.metaDesc,
publishedAt: status === 'published' ? new Date().toISOString() : null
};
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Use the RTK Query mutation
await createBlog(blogPayload).unwrap();
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success(`Blog ${status === 'draft' ? 'saved as draft' : 'published'} successfully`);
onNavigate('/content');
} catch (error) {
toast.error('Failed to save blog');
} catch (error: any) {
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 (
<AuthenticatedLayout
@@ -161,6 +181,7 @@ export function NewBlog({
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"
disabled={isSubmitting}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
@@ -189,32 +210,35 @@ export function NewBlog({
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"
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">URL Slug</Label>
<Label htmlFor="urlSlug">URL Slug</Label>
<Input
id="slug"
value={blogData.slug}
onChange={(e) => handleInputChange('slug', e.target.value)}
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"
disabled={isSubmitting}
/>
<p className="text-sm text-muted-foreground">
URL: /blog/{blogData.slug || 'blog-url-slug'}
URL: /blog/{blogData.urlSlug || 'blog-url-slug'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="body">Content *</Label>
<Label htmlFor="content">Content *</Label>
<Textarea
id="body"
value={blogData.body}
onChange={(e) => handleInputChange('body', e.target.value)}
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"
disabled={isSubmitting}
/>
</div>
</CardContent>
@@ -235,6 +259,7 @@ export function NewBlog({
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"
disabled={isSubmitting}
/>
<p className="text-sm text-muted-foreground">
{blogData.metaTitle.length}/60 characters
@@ -242,18 +267,19 @@ export function NewBlog({
</div>
<div className="space-y-2">
<Label htmlFor="metaDescription">Meta Description</Label>
<Label htmlFor="metaDesc">Meta Description</Label>
<Textarea
id="metaDescription"
value={blogData.metaDescription}
onChange={(e) => handleInputChange('metaDescription', e.target.value)}
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"
disabled={isSubmitting}
/>
<p className="text-sm text-muted-foreground">
{blogData.metaDescription.length}/160 characters
{blogData.metaDesc.length}/160 characters
</p>
</div>
</CardContent>
@@ -272,29 +298,23 @@ export function NewBlog({
<div className="space-y-2">
<div className="relative">
<img
src={blogData.bannerImage.url}
src={blogData.bannerImage}
alt="Banner preview"
className="w-full h-32 object-cover rounded border"
/>
<Button
variant="destructive"
size="sm"
onClick={() => handleInputChange('bannerImage', null)}
onClick={() => handleInputChange('bannerImage', '')}
className="absolute top-2 right-2 min-h-[32px] h-8 w-8 p-0"
disabled={isSubmitting}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="bannerAltText">Alt Text</Label>
<Input
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>
<p className="text-sm text-muted-foreground">
Image URL: {blogData.bannerImage}
</p>
</div>
) : (
<div className="space-y-2">
@@ -304,10 +324,13 @@ export function NewBlog({
onChange={handleImageUpload}
className="hidden"
id="banner-upload"
disabled={isSubmitting}
/>
<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"
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 ? (
<div className="text-center">
@@ -318,11 +341,24 @@ export function NewBlog({
<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"
disabled={isSubmitting}
/>
</div>
</CardContent>
</Card>
@@ -333,12 +369,13 @@ export function NewBlog({
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<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"
disabled={isSubmitting}
>
<option value="">Select category</option>
{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"
disabled={isSubmitting}
/>
<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"
disabled={isSubmitting}
>
<Plus className="h-4 w-4" />
</Button>
@@ -382,7 +421,7 @@ export function NewBlog({
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
onClick={() => !isSubmitting && removeTag(tag)}
/>
</Badge>
))}
@@ -399,18 +438,20 @@ export function NewBlog({
</CardHeader>
<CardContent className="space-y-3">
<Button
onClick={() => handleSave('draft')}
onClick={() => handleCreateBlog('draft')}
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"
>
Save as Draft
{isSubmitting ? 'Saving...' : 'Save as Draft'}
</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"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Publish Blog
{isSubmitting ? 'Publishing...' : 'Publish Blog'}
</Button>
</CardContent>
</Card>

View File

@@ -6,8 +6,10 @@ import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
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 { Route } from '../../types/routes';
import { useCreateFAQMutation } from '../../store/services/contentManager.service';
interface FAQ {
id: string;
@@ -15,11 +17,12 @@ interface FAQ {
answer: string;
category: string;
tags: string[];
globalTag: string[];
order: number;
}
interface NewFAQProps {
onNavigate: (route: string) => void;
onNavigate: (route: Route) => void;
onLogout: () => void;
user: any;
formData?: any;
@@ -43,6 +46,7 @@ export function NewFAQ({
answer: '',
category: '',
tags: [],
globalTag: [],
order: 0
}
]
@@ -50,6 +54,10 @@ export function NewFAQ({
const [globalTags, setGlobalTags] = useState<string[]>(formData?.globalTags || []);
const [newGlobalTag, setNewGlobalTag] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Use the RTK Query mutation hook
const [createFAQ] = useCreateFAQMutation();
// Auto-save functionality
React.useEffect(() => {
@@ -70,6 +78,7 @@ export function NewFAQ({
answer: '',
category: '',
tags: [],
globalTag: [],
order: faqs.length
};
setFaqs([...faqs, newFAQ]);
@@ -119,7 +128,7 @@ export function NewFAQ({
const applyGlobalTagsToAll = () => {
setFaqs(faqs.map(faq => ({
...faq,
tags: [...new Set([...faq.tags, ...globalTags])]
globalTag: [...new Set([...faq.globalTag, ...globalTags])]
})));
toast.success('Global tags applied to all FAQs');
};
@@ -154,27 +163,91 @@ export function NewFAQ({
return;
}
try {
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
}));
setIsSubmitting(true);
try {
// 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);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success(`${faqs.length} FAQ(s) saved successfully`);
toast.success(`${faqs.length} FAQ(s) created successfully`);
onNavigate('/content');
} catch (error) {
toast.error('Failed to save FAQs');
} catch (error: any) {
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"
onClick={() => onNavigate('/content')}
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" />
Back to Content
@@ -202,11 +276,12 @@ export function NewFAQ({
</div>
<Button
onClick={handleSave}
disabled={isSubmitting}
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" />
Save All FAQs
{isSubmitting ? 'Saving...' : 'Save All FAQs'}
</Button>
</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"
disabled={isSubmitting}
/>
<Button
type="button"
onClick={addGlobalTag}
variant="outline"
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" />
</Button>
@@ -257,7 +334,7 @@ export function NewFAQ({
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeGlobalTag(tag)}
onClick={() => !isSubmitting && removeGlobalTag(tag)}
/>
</Badge>
))}
@@ -267,6 +344,7 @@ export function NewFAQ({
variant="outline"
size="sm"
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
</Button>
@@ -286,7 +364,7 @@ export function NewFAQ({
<div className="flex flex-col gap-1">
<Button
onClick={() => moveFAQ(faq.id, 'up')}
disabled={index === 0}
disabled={index === 0 || isSubmitting}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
@@ -295,7 +373,7 @@ export function NewFAQ({
</Button>
<Button
onClick={() => moveFAQ(faq.id, 'down')}
disabled={index === faqs.length - 1}
disabled={index === faqs.length - 1 || isSubmitting}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
@@ -308,6 +386,7 @@ export function NewFAQ({
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10"
disabled={isSubmitting}
>
<X className="h-4 w-4" />
</Button>
@@ -325,6 +404,7 @@ export function NewFAQ({
placeholder="Enter the question"
rows={3}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/>
</div>
@@ -335,6 +415,7 @@ export function NewFAQ({
value={faq.category}
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"
disabled={isSubmitting}
>
<option value="">Select category</option>
{categories.map(category => (
@@ -353,6 +434,7 @@ export function NewFAQ({
placeholder="Enter the answer"
rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isSubmitting}
/>
</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"
disabled={isSubmitting}
/>
<Button
type="button"
@@ -381,6 +464,7 @@ export function NewFAQ({
}}
variant="outline"
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" />
</Button>
@@ -396,13 +480,35 @@ export function NewFAQ({
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTagFromFAQ(faq.id, tag)}
onClick={() => !isSubmitting && removeTagFromFAQ(faq.id, tag)}
/>
</Badge>
))}
</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>
</Card>
))}
@@ -412,6 +518,7 @@ export function NewFAQ({
onClick={addFAQ}
variant="outline"
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" />
Add Another FAQ
@@ -457,6 +564,7 @@ export function NewFAQ({
onClick={addFAQ}
variant="outline"
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" />
Add FAQ
@@ -467,6 +575,7 @@ export function NewFAQ({
onClick={applyGlobalTagsToAll}
variant="outline"
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
</Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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