318 lines
16 KiB
TypeScript
318 lines
16 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { Button } from '../../components/ui/button';
|
|
import { Card, CardContent } from '../../components/ui/card';
|
|
import { Input } from '../../components/ui/input';
|
|
import { Textarea } from '../../components/ui/textarea';
|
|
import { Badge } from '../../components/ui/badge';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
|
import { AlertCircle, Calendar, MessageCircle, Plus, Search, User, X } from 'lucide-react';
|
|
import { useCreateThreadMutation, useGetThreadsQuery } from '../../redux/services/forumApi';
|
|
import { useToast } from '../../components/toast/useToast';
|
|
import DiscussionsView from './DiscussionsView';
|
|
|
|
const DiscussionsPage: React.FC = () => {
|
|
const { showToast } = useToast();
|
|
const [showNewThreadModal, setShowNewThreadModal] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [tagsFilter, setTagsFilter] = useState('all');
|
|
const [newThreadTitle, setNewThreadTitle] = useState('');
|
|
const [newThreadContent, setNewThreadContent] = useState('');
|
|
const [tagInput, setTagInput] = useState('');
|
|
const [newThreadTags, setNewThreadTags] = useState<string[]>([]);
|
|
const [createError, setCreateError] = useState('');
|
|
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
|
|
|
const { data: threadsResponse, isLoading: threadsLoading, isFetching: threadsFetching } =
|
|
useGetThreadsQuery();
|
|
const [createThread, { isLoading: isCreatingThread }] = useCreateThreadMutation();
|
|
|
|
const threads = threadsResponse?.data ?? [];
|
|
|
|
const allTags = useMemo(() => {
|
|
const set = new Set<string>();
|
|
threads.forEach((thread) => {
|
|
thread.tags.forEach((tag) => set.add(tag));
|
|
});
|
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
|
}, [threads]);
|
|
|
|
const filteredThreads = useMemo(() => {
|
|
return threads.filter((thread) => {
|
|
const query = searchTerm.trim().toLowerCase();
|
|
const matchesSearch =
|
|
!query ||
|
|
thread.title.toLowerCase().includes(query) ||
|
|
thread.content.toLowerCase().includes(query) ||
|
|
thread.tags.some((tag) => tag.toLowerCase().includes(query));
|
|
const matchesTag = tagsFilter === 'all' || thread.tags.includes(tagsFilter);
|
|
return matchesSearch && matchesTag;
|
|
});
|
|
}, [threads, searchTerm, tagsFilter]);
|
|
|
|
const selectedThread = useMemo(
|
|
() => threads.find((thread) => thread.id === selectedThreadId) ?? null,
|
|
[threads, selectedThreadId]
|
|
);
|
|
|
|
const getThreadReactionCount = (thread: (typeof threads)[number]) =>
|
|
thread.reactions.reduce((sum, r) => sum + r.count, 0);
|
|
|
|
const formatDate = (date: string) => {
|
|
const d = new Date(date);
|
|
return d.toLocaleDateString('en-GB');
|
|
};
|
|
|
|
const resetModal = () => {
|
|
setNewThreadTitle('');
|
|
setNewThreadContent('');
|
|
setTagInput('');
|
|
setNewThreadTags([]);
|
|
setCreateError('');
|
|
};
|
|
|
|
const addTag = (rawTag: string) => {
|
|
const value = rawTag.trim().toLowerCase();
|
|
if (!value) return;
|
|
if (newThreadTags.includes(value)) return;
|
|
setNewThreadTags((prev) => [...prev, value]);
|
|
};
|
|
|
|
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addTag(tagInput);
|
|
setTagInput('');
|
|
}
|
|
};
|
|
|
|
const handleCreateThread = async () => {
|
|
setCreateError('');
|
|
if (!newThreadTitle.trim() || !newThreadContent.trim()) {
|
|
setCreateError('Title and content are required.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await createThread({
|
|
title: newThreadTitle.trim(),
|
|
content: newThreadContent.trim(),
|
|
tags: newThreadTags,
|
|
}).unwrap();
|
|
showToast('Thread created', response.message || 'Thread created successfully.', 'success');
|
|
setShowNewThreadModal(false);
|
|
resetModal();
|
|
} catch (error: any) {
|
|
const message = error?.data?.message || 'Failed to create thread.';
|
|
setCreateError(message);
|
|
showToast('Create failed', message, 'error');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{selectedThread ? (
|
|
<DiscussionsView thread={selectedThread} onBack={() => setSelectedThreadId(null)} />
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-4xl font-bold tracking-tight">Discussion Forums</h1>
|
|
<p className="text-muted-foreground">Connect, share, and learn with your cohort members</p>
|
|
</div>
|
|
<Button onClick={() => setShowNewThreadModal(true)} className="min-tap-44">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
New Thread
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="py-4">
|
|
<div className="flex items-center gap-3 flex-nowrap">
|
|
<Select defaultValue="leadership-development-q4-2024">
|
|
<SelectTrigger className="w-[300px] shrink-0">
|
|
<SelectValue placeholder="Programme" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="leadership-development-q4-2024">Leadership Development Q4 2024</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="relative min-w-0 flex-1">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
className="pl-9"
|
|
placeholder="Search threads..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
style={{ paddingLeft: '30px' }}
|
|
/>
|
|
</div>
|
|
<Select value={tagsFilter} onValueChange={setTagsFilter}>
|
|
<SelectTrigger className="w-[170px] shrink-0">
|
|
<SelectValue placeholder="All Tags" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Tags</SelectItem>
|
|
{allTags.map((tag) => (
|
|
<SelectItem key={tag} value={tag}>
|
|
{tag}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="py-4 space-y-4">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold">Discussions ({filteredThreads.length})</h2>
|
|
<p className="text-muted-foreground">Leadership Development Q4 2024 • 30 members</p>
|
|
</div>
|
|
|
|
{(threadsLoading || threadsFetching) && (
|
|
<div className="text-sm text-muted-foreground">Loading threads...</div>
|
|
)}
|
|
|
|
{!threadsLoading && filteredThreads.length === 0 && (
|
|
<div className="rounded-md border p-6 text-sm text-muted-foreground">No threads found.</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{filteredThreads.map((thread) => (
|
|
<button
|
|
key={thread.id}
|
|
type="button"
|
|
className="w-full rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:bg-slate-50 cursor-pointer"
|
|
onClick={() => setSelectedThreadId(thread.id)}
|
|
style={{borderRadius: '12px'}}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
|
L
|
|
</div>
|
|
<div className="min-w-0 flex-1 border-l-[3px] border-[#0a2f6f] pl-3">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="truncate text-3xl font-semibold leading-tight">{thread.title}</h3>
|
|
</div>
|
|
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">{thread.content}</p>
|
|
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<User className="h-4 w-4" />
|
|
Learner
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="h-4 w-4" />
|
|
{formatDate(thread.created_at)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<MessageCircle className="h-4 w-4" />
|
|
{thread.reactions.length}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{thread.tags.map((tag) => (
|
|
<Badge key={tag} variant="secondary" className="text-xs">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
<Dialog
|
|
open={showNewThreadModal}
|
|
onOpenChange={(open: boolean) => {
|
|
setShowNewThreadModal(open);
|
|
if (!open) resetModal();
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Start New Discussion</DialogTitle>
|
|
<DialogDescription>Create a new thread for this forum.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{createError && (
|
|
<div className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
|
<AlertCircle className="h-4 w-4" />
|
|
{createError}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium">Title *</label>
|
|
<Input
|
|
value={newThreadTitle}
|
|
onChange={(e) => setNewThreadTitle(e.target.value)}
|
|
placeholder="What would you like to discuss?"
|
|
maxLength={150}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium">Content *</label>
|
|
<Textarea
|
|
value={newThreadContent}
|
|
onChange={(e) => setNewThreadContent(e.target.value)}
|
|
placeholder="Share your thoughts..."
|
|
className="min-h-[140px]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium">Tags</label>
|
|
<Input
|
|
value={tagInput}
|
|
onChange={(e) => setTagInput(e.target.value)}
|
|
onKeyDown={handleTagKeyDown}
|
|
placeholder="Type a tag and press Enter"
|
|
/>
|
|
{newThreadTags.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{newThreadTags.map((tag) => (
|
|
<Badge key={tag} variant="secondary" className="gap-1">
|
|
{tag}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setNewThreadTags((prev) => prev.filter((existingTag) => existingTag !== tag))
|
|
}
|
|
aria-label={`Remove ${tag}`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
className="flex-1"
|
|
onClick={handleCreateThread}
|
|
disabled={isCreatingThread || !newThreadTitle.trim() || !newThreadContent.trim()}
|
|
>
|
|
{isCreatingThread ? 'Creating...' : 'Create Thread'}
|
|
</Button>
|
|
<Button variant="outline" className="flex-1" onClick={() => setShowNewThreadModal(false)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DiscussionsPage; |