Files
schulos/src/components/comment-bank-view.tsx
T
Mustafa Cetin 04034dca62 CompetenceTrack v8 - Complete UI/UX redesign, Digital Notebooks, Drawing Canvas, Demo Data Cleanup
Features:
- Complete UI/UX redesign for tablet/stylus optimization
- Digital Notebook system (lined, grid, blank, dotted, music, calligraphy)
- Drawing Canvas with stylus/pen support, pressure sensitivity
- Art class mode with tutorial guides and shape templates
- Demo data one-click cleanup for production transition
- Subject-specific notebook filtering
- Environmental messaging (Papier sparen, Umwelt schützen)
- 44px minimum touch targets for tablet
- No emojis - Lucide icons throughout
- 700+ i18n keys (German/English)
- Notebook types: lined, grid, blank, dotted, music, calligraphy
- Drawing tools: pencil, pen, line, rectangle, circle, eraser
- Undo/redo, color picker, stroke width, export PNG
- Archive/unarchive notebooks
- White backgrounds throughout
- Emerald accent color theme

Backend:
- Notebook CRUD API routes
- Drawing CRUD API routes
- Demo data cleanup API
- Prisma schema with Notebook, NotebookPage, Drawing models
- isDemo flags on all new models
2026-07-29 06:39:40 +00:00

1199 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip as RTooltip,
ResponsiveContainer,
CartesianGrid,
} from 'recharts';
import {
Plus,
Pencil,
Trash2,
Copy,
Loader2,
Search,
Eye,
Globe,
Lock,
Tag,
BookOpen,
BarChart3,
ListChecks,
MessageSquareText,
MessageSquare,
type LucideIcon,
} from 'lucide-react';
const LUCIDE_ICON_MAP_CB: Record<string, LucideIcon> = {
MessageSquare, MessageSquareText, BookOpen, Plus, Pencil, Trash2, Copy, Loader2, Search, Eye, Globe, Lock, Tag, BarChart3, ListChecks,
};
function renderCBIcon(name: string, className?: string): React.ReactNode {
const IconComp = LUCIDE_ICON_MAP_CB[name];
if (!IconComp) return null;
return <IconComp className={className ?? 'h-3.5 w-3.5 inline'} />;
}
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchCommentCategories,
createCommentCategory,
updateCommentCategory,
deleteCommentCategory,
fetchCommentBank,
createCommentBankEntry,
updateCommentBankEntry,
deleteCommentBankEntry,
fetchSubjects,
type CommentCategory,
type CommentBankEntry,
type CommentBankEntryInput,
type CommentBankEntryUpdate,
type Subject as ApiSubject,
} from '@/lib/api';
/* ── Constants ─────────────────────────────────────────────────────── */
const COLOR_PRESETS = [
'#10b981', // emerald
'#14b8a6', // teal
'#f59e0b', // amber
'#8b5cf6', // violet
'#f43f5e', // rose
'#64748b', // slate
'#84cc16', // lime
'#ec4899', // pink
];
const GRADE_LEVEL_OPTIONS = ['all', '1', '2', '3', '3-4', '4', '5', '5-6', '6'];
/* ── Helpers ───────────────────────────────────────────────────────── */
function hexToRgba(hex: string, alpha: number): string {
const m = hex.replace('#', '');
if (m.length !== 6) return hex;
const r = parseInt(m.slice(0, 2), 16);
const g = parseInt(m.slice(2, 4), 16);
const b = parseInt(m.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/* ── Form state ────────────────────────────────────────────────────── */
interface EntryFormState {
id?: string;
categoryId: string;
subjectId: string;
title: string;
text: string;
gradeLevel: string;
schoolType: string;
isPublic: boolean;
tags: string;
}
interface CategoryFormState {
id?: string;
name: string;
color: string;
icon: string;
}
function emptyEntryForm(categoryId = ''): EntryFormState {
return {
categoryId,
subjectId: 'none',
title: '',
text: '',
gradeLevel: 'all',
schoolType: 'ELEMENTARY',
isPublic: false,
tags: '',
};
}
function emptyCategoryForm(): CategoryFormState {
return {
name: '',
color: '#10b981',
icon: '',
};
}
/* ── View Component ────────────────────────────────────────────────── */
export default function CommentBankView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const schoolId = currentUser?.schoolId ?? '';
// Data state
const [categories, setCategories] = useState<CommentCategory[]>([]);
const [entries, setEntries] = useState<CommentBankEntry[]>([]);
const [subjects, setSubjects] = useState<ApiSubject[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
const [filterSubject, setFilterSubject] = useState('all');
const [filterGradeLevel, setFilterGradeLevel] = useState('all');
const [filterPublic, setFilterPublic] = useState('all');
// Active tab
const [activeTab, setActiveTab] = useState('entries');
// Dialog state — entries
const [entryDialogOpen, setEntryDialogOpen] = useState(false);
const [entryForm, setEntryForm] = useState<EntryFormState>(emptyEntryForm());
const [entryDetailOpen, setEntryDetailOpen] = useState(false);
const [entryDetailData, setEntryDetailData] = useState<CommentBankEntry | null>(null);
const [deleteEntryOpen, setDeleteEntryOpen] = useState(false);
const [deleteEntryId, setDeleteEntryId] = useState('');
// Dialog state — categories
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const [categoryForm, setCategoryForm] = useState<CategoryFormState>(emptyCategoryForm());
const [deleteCategoryOpen, setDeleteCategoryOpen] = useState(false);
const [deleteCategoryId, setDeleteCategoryId] = useState('');
/* ── Data loading ──────────────────────────────────────────────────── */
const loadAll = useCallback(async () => {
if (!schoolId) return;
setLoading(true);
setError(null);
try {
const [cats, ents, subs] = await Promise.all([
fetchCommentCategories(schoolId),
fetchCommentBank({ schoolId }),
fetchSubjects(schoolId),
]);
setCategories(cats);
setEntries(ents);
setSubjects(subs);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, [schoolId]);
useEffect(() => { loadAll(); }, [loadAll]);
/* ── Filtered entries ─────────────────────────────────────────────── */
const filteredEntries = useMemo(() => {
let list = entries;
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
list = list.filter(
(e) =>
e.title.toLowerCase().includes(q) ||
e.text.toLowerCase().includes(q) ||
(e.tags && e.tags.toLowerCase().includes(q))
);
}
if (filterCategory !== 'all') {
list = list.filter((e) => e.categoryId === filterCategory);
}
if (filterSubject !== 'all') {
list = list.filter((e) => e.subjectId === filterSubject);
}
if (filterGradeLevel !== 'all') {
list = list.filter((e) => e.gradeLevel === filterGradeLevel);
}
if (filterPublic === 'true') {
list = list.filter((e) => e.isPublic);
} else if (filterPublic === 'false') {
list = list.filter((e) => !e.isPublic);
}
return list;
}, [entries, searchQuery, filterCategory, filterSubject, filterGradeLevel, filterPublic]);
/* ── Statistics ────────────────────────────────────────────────────── */
const stats = useMemo(() => {
const totalEntries = entries.length;
const publicEntries = entries.filter((e) => e.isPublic).length;
const totalCategories = categories.length;
const mostUsed = entries.reduce((best, e) => (e.usageCount > (best?.usageCount ?? 0) ? e : best), null as CommentBankEntry | null);
// Bar chart data: entries by category
const byCategory = categories.map((cat) => ({
name: cat.icon ? `${cat.icon} ${cat.name}` : cat.name,
count: entries.filter((e) => e.categoryId === cat.id).length,
fill: cat.color,
}));
// Top 5 most-used
const topUsed = [...entries]
.sort((a, b) => b.usageCount - a.usageCount)
.slice(0, 5);
return { totalEntries, publicEntries, totalCategories, mostUsed, byCategory, topUsed };
}, [entries, categories]);
/* ── Entry CRUD ────────────────────────────────────────────────────── */
const openEntryDialog = useCallback(
(editEntry?: CommentBankEntry) => {
if (editEntry) {
setEntryForm({
id: editEntry.id,
categoryId: editEntry.categoryId,
subjectId: editEntry.subjectId ?? 'none',
title: editEntry.title,
text: editEntry.text,
gradeLevel: editEntry.gradeLevel ?? 'all',
schoolType: editEntry.schoolType ?? 'ELEMENTARY',
isPublic: editEntry.isPublic,
tags: editEntry.tags ?? '',
});
} else {
setEntryForm(emptyEntryForm(categories[0]?.id ?? ''));
}
setEntryDialogOpen(true);
},
[categories]
);
const saveEntry = useCallback(async () => {
if (!entryForm.title.trim() || !entryForm.text.trim() || !entryForm.categoryId) {
toast.error(t('comments.validation_required'));
return;
}
try {
const payload: CommentBankEntryInput | CommentBankEntryUpdate = {
schoolId,
categoryId: entryForm.categoryId,
subjectId: entryForm.subjectId === 'none' ? null : entryForm.subjectId,
title: entryForm.title.trim(),
text: entryForm.text.trim(),
gradeLevel: entryForm.gradeLevel === 'all' ? null : entryForm.gradeLevel,
schoolType: entryForm.schoolType || null,
isPublic: entryForm.isPublic,
tags: entryForm.tags.trim() || null,
};
if (entryForm.id) {
// Update
await updateCommentBankEntry(entryForm.id, payload);
toast.success(t('comments.saved'));
} else {
// Create
await createCommentBankEntry(payload as CommentBankEntryInput);
toast.success(t('comments.created'));
}
setEntryDialogOpen(false);
loadAll();
} catch (err) {
toast.error(String(err));
}
}, [entryForm, schoolId, loadAll]);
const handleDeleteEntry = useCallback(async () => {
try {
await deleteCommentBankEntry(deleteEntryId);
toast.success(t('comments.deleted'));
setDeleteEntryOpen(false);
loadAll();
} catch (err) {
toast.error(String(err));
}
}, [deleteEntryId, loadAll]);
const copyEntryText = useCallback((text: string) => {
navigator.clipboard.writeText(text);
toast.success(t('comments.copy_success'));
}, []);
const incrementUsage = useCallback(async (id: string) => {
try {
await updateCommentBankEntry(id, { incrementUsage: true });
loadAll();
} catch {
// silent fail
}
}, [loadAll]);
/* ── Category CRUD ──────────────────────────────────────────────────── */
const openCategoryDialog = useCallback(
(editCat?: CommentCategory) => {
if (editCat) {
setCategoryForm({
id: editCat.id,
name: editCat.name,
color: editCat.color,
icon: editCat.icon ?? '',
});
} else {
setCategoryForm(emptyCategoryForm());
}
setCategoryDialogOpen(true);
},
[]
);
const saveCategory = useCallback(async () => {
if (!categoryForm.name.trim()) {
toast.error(t('comments.category_name_required'));
return;
}
try {
if (categoryForm.id) {
await updateCommentCategory(categoryForm.id, {
name: categoryForm.name.trim(),
color: categoryForm.color,
icon: categoryForm.icon || null,
});
toast.success(t('comments.category_saved'));
} else {
await createCommentCategory({
schoolId,
name: categoryForm.name.trim(),
color: categoryForm.color,
icon: categoryForm.icon || null,
});
toast.success(t('comments.category_created'));
}
setCategoryDialogOpen(false);
loadAll();
} catch (err) {
toast.error(String(err));
}
}, [categoryForm, schoolId, loadAll]);
const handleDeleteCategory = useCallback(async () => {
try {
await deleteCommentCategory(deleteCategoryId);
toast.success(t('comments.category_deleted'));
setDeleteCategoryOpen(false);
loadAll();
} catch (err) {
toast.error(String(err));
}
}, [deleteCategoryId, loadAll]);
/* ── Render ─────────────────────────────────────────────────────────── */
if (!currentUser) return null;
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md w-full">
<CardContent className="p-6 text-center">
<p className="text-rose-600 font-semibold">{t('comments.error')}</p>
<p className="text-sm text-muted-foreground mt-2">{error}</p>
<Button onClick={loadAll} className="mt-4 bg-emerald-600 hover:bg-emerald-700">
{t('action.refresh')}
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="space-y-6 p-4 md:p-6"
>
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
{t('comments.title')}
</span>
</h1>
<p className="text-sm text-muted-foreground mt-1">{t('comments.subtitle')}</p>
</div>
<Button
onClick={() => openEntryDialog()}
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md"
>
<Plus className="h-4 w-4 mr-2" />
{t('comments.create_entry')}
</Button>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-slate-100 dark:bg-slate-800">
<TabsTrigger value="entries">
<MessageSquareText className="h-4 w-4 mr-2" />
{t('comments.tab_entries')}
</TabsTrigger>
<TabsTrigger value="categories">
<ListChecks className="h-4 w-4 mr-2" />
{t('comments.tab_categories')}
</TabsTrigger>
<TabsTrigger value="statistics">
<BarChart3 className="h-4 w-4 mr-2" />
{t('comments.tab_statistics')}
</TabsTrigger>
</TabsList>
{/* ── Tab 1: Entries ──────────────────────────────────────── */}
<TabsContent value="entries" className="space-y-4 mt-4">
{/* Search + Filters */}
<div className="flex flex-col gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('comments.search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex flex-wrap gap-2">
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder={t('comments.filter_category')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('comments.all_categories')}</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.icon ? `${c.icon} ` : ''}{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterSubject} onValueChange={setFilterSubject}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder={t('comments.filter_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('comments.all_subjects')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterGradeLevel} onValueChange={setFilterGradeLevel}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder={t('comments.filter_grade')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('comments.all_grades')}</SelectItem>
{GRADE_LEVEL_OPTIONS.filter((g) => g !== 'all').map((g) => (
<SelectItem key={g} value={g}>{g}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterPublic} onValueChange={setFilterPublic}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder={t('comments.filter_visibility')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('comments.all_visibility')}</SelectItem>
<SelectItem value="true">{t('comments.public_only')}</SelectItem>
<SelectItem value="false">{t('comments.private_only')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Grid */}
{filteredEntries.length === 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-12"
>
<MessageSquareText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-medium text-muted-foreground">{t('comments.empty_title')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('comments.empty_desc')}</p>
<Button
onClick={() => openEntryDialog()}
className="mt-4 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white"
>
{t('comments.empty_create')}
</Button>
</motion.div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AnimatePresence mode="popLayout">
{filteredEntries.map((entry, i) => (
<motion.div
key={entry.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<Card
className="group hover:shadow-lg transition-shadow cursor-pointer border-l-4"
style={{ borderLeftColor: entry.category.color }}
onClick={() => {
setEntryDetailData(entry);
setEntryDetailOpen(true);
incrementUsage(entry.id);
}}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base font-semibold line-clamp-1">
{entry.title}
</CardTitle>
<div className="flex gap-1 shrink-0">
<Badge
variant="outline"
className="text-xs"
style={{
backgroundColor: hexToRgba(entry.category.color, 0.15),
color: entry.category.color,
borderColor: hexToRgba(entry.category.color, 0.3),
}}
>
{entry.category.icon ? renderCBIcon(entry.category.icon) : null}{entry.category.name}
</Badge>
{entry.isPublic ? (
<Badge variant="outline" className="text-xs bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800">
<Globe className="h-3 w-3 mr-1" />
{t('comments.public')}
</Badge>
) : (
<Badge variant="outline" className="text-xs bg-slate-50 text-slate-600 border-slate-200 dark:bg-slate-900/40 dark:text-slate-400 dark:border-slate-700">
<Lock className="h-3 w-3 mr-1" />
{t('comments.private')}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 pb-3">
<p className="text-sm text-muted-foreground line-clamp-3 mb-3">
{entry.text}
</p>
<div className="flex flex-wrap items-center gap-2">
{entry.subject && (
<Badge variant="secondary" className="text-xs">
<BookOpen className="h-3 w-3 mr-1" />
{entry.subject.name}
</Badge>
)}
{entry.gradeLevel && (
<Badge variant="secondary" className="text-xs">
Klasse {entry.gradeLevel}
</Badge>
)}
{entry.usageCount > 0 && (
<Badge variant="outline" className="text-xs">
{t('comments.usage_count')}: {entry.usageCount}
</Badge>
)}
<span className="text-xs text-muted-foreground ml-auto">
{entry.teacher.firstName} {entry.teacher.lastName}
</span>
</div>
{/* Action buttons (visible on hover) */}
<div className="flex gap-1 mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
openEntryDialog(entry);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
copyEntryText(entry.text);
}}
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-rose-600 hover:text-rose-700"
onClick={(e) => {
e.stopPropagation();
setDeleteEntryId(entry.id);
setDeleteEntryOpen(true);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</TabsContent>
{/* ── Tab 2: Categories ───────────────────────────────────── */}
<TabsContent value="categories" className="space-y-4 mt-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t('comments.categories_title')}</h2>
<Button
onClick={() => openCategoryDialog()}
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white"
>
<Plus className="h-4 w-4 mr-2" />
{t('comments.add_category')}
</Button>
</div>
{categories.length === 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-12"
>
<ListChecks className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-medium text-muted-foreground">{t('comments.category_empty_title')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('comments.category_empty_desc')}</p>
</motion.div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence mode="popLayout">
{categories.map((cat, i) => (
<motion.div
key={cat.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<Card
className="hover:shadow-md transition-shadow"
style={{
borderLeftWidth: '4px',
borderLeftColor: cat.color,
}}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
{cat.icon && <span className="text-xl">{cat.icon}</span>}
<span style={{ color: cat.color }}>{cat.name}</span>
</CardTitle>
<Badge variant="outline" className="text-xs" style={{ backgroundColor: hexToRgba(cat.color, 0.15), color: cat.color, borderColor: hexToRgba(cat.color, 0.3) }}>
{cat._count?.comments ?? 0} {t('comments.entries_count')}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0 pb-3">
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => openCategoryDialog(cat)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-rose-600 hover:text-rose-700"
onClick={() => {
setDeleteCategoryId(cat.id);
setDeleteCategoryOpen(true);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</TabsContent>
{/* ── Tab 3: Statistics ───────────────────────────────────── */}
<TabsContent value="statistics" className="space-y-6 mt-4">
{/* KPI tiles */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0 }}>
<Card className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-950/30 dark:to-teal-950/30 border-emerald-200 dark:border-emerald-800/50">
<CardContent className="p-4 text-center">
<MessageSquareText className="h-6 w-6 text-emerald-600 mx-auto mb-2" />
<p className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">{stats.totalEntries}</p>
<p className="text-xs text-emerald-600 dark:text-emerald-400">{t('comments.stat_total')}</p>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.1 }}>
<Card className="bg-gradient-to-br from-teal-50 to-emerald-50 dark:from-teal-950/30 dark:to-emerald-950/30 border-teal-200 dark:border-teal-800/50">
<CardContent className="p-4 text-center">
<Globe className="h-6 w-6 text-teal-600 mx-auto mb-2" />
<p className="text-2xl font-bold text-teal-700 dark:text-teal-400">{stats.publicEntries}</p>
<p className="text-xs text-teal-600 dark:text-teal-400">{t('comments.stat_public')}</p>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.2 }}>
<Card className="bg-gradient-to-br from-amber-50 to-rose-50 dark:from-amber-950/30 dark:to-rose-950/30 border-amber-200 dark:border-amber-800/50">
<CardContent className="p-4 text-center">
<ListChecks className="h-6 w-6 text-amber-600 mx-auto mb-2" />
<p className="text-2xl font-bold text-amber-700 dark:text-amber-400">{stats.totalCategories}</p>
<p className="text-xs text-amber-600 dark:text-amber-400">{t('comments.stat_categories')}</p>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.3 }}>
<Card className="bg-gradient-to-br from-violet-50 to-rose-50 dark:from-violet-950/30 dark:to-rose-950/30 border-violet-200 dark:border-violet-800/50">
<CardContent className="p-4 text-center">
<Eye className="h-6 w-6 text-violet-600 mx-auto mb-2" />
<p className="text-sm font-bold text-violet-700 dark:text-violet-400 line-clamp-2">
{stats.mostUsed ? stats.mostUsed.title : '—'}
</p>
<p className="text-xs text-violet-600 dark:text-violet-400">{t('comments.stat_most_used')}</p>
</CardContent>
</Card>
</motion.div>
</div>
{/* Bar chart */}
{stats.byCategory.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('comments.chart_by_category')}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.byCategory} layout="vertical">
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} />
<YAxis dataKey="name" type="category" width={120} />
<RTooltip />
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
{stats.byCategory.map((entry, index) => (
<Bar key={index} dataKey="count" fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)}
{/* Top 5 most-used */}
{stats.topUsed.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('comments.top_used_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{stats.topUsed.map((entry, i) => (
<motion.div
key={entry.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.1 }}
className="flex items-center justify-between gap-3 p-3 rounded-lg bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900/80 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-lg font-bold text-emerald-600 shrink-0">#{i + 1}</span>
<div className="min-w-0">
<p className="font-medium text-sm truncate">{entry.title}</p>
<p className="text-xs text-muted-foreground truncate">{entry.text.slice(0, 60)}…</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="outline"
className="text-xs"
style={{
backgroundColor: hexToRgba(entry.category.color, 0.15),
color: entry.category.color,
borderColor: hexToRgba(entry.category.color, 0.3),
}}
>
{entry.category.icon ? renderCBIcon(entry.category.icon) : null}{entry.category.name}
</Badge>
<Badge variant="secondary" className="text-xs font-semibold">
{entry.usageCount}×
</Badge>
<Button variant="ghost" size="sm" onClick={() => copyEntryText(entry.text)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</motion.div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
{/* ── Entry Create/Edit Dialog ──────────────────────────────────── */}
<Dialog open={entryDialogOpen} onOpenChange={setEntryDialogOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{entryForm.id ? t('comments.edit_entry') : t('comments.create_entry')}
</DialogTitle>
<DialogDescription>
{entryForm.id ? t('comments.edit_entry_desc') : t('comments.create_entry_desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t('comments.field_title')}</Label>
<Input
value={entryForm.title}
onChange={(e) => setEntryForm({ ...entryForm, title: e.target.value })}
placeholder={t('comments.title_placeholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('comments.field_category')}</Label>
<Select
value={entryForm.categoryId}
onValueChange={(v) => setEntryForm({ ...entryForm, categoryId: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.icon ? `${c.icon} ` : ''}{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('comments.field_subject')}</Label>
<Select
value={entryForm.subjectId}
onValueChange={(v) => setEntryForm({ ...entryForm, subjectId: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('comments.no_subject')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('comments.field_grade_level')}</Label>
<Select
value={entryForm.gradeLevel}
onValueChange={(v) => setEntryForm({ ...entryForm, gradeLevel: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRADE_LEVEL_OPTIONS.map((g) => (
<SelectItem key={g} value={g}>
{g === 'all' ? t('comments.all_grades') : `Klasse ${g}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('comments.field_text')}</Label>
<Textarea
value={entryForm.text}
onChange={(e) => setEntryForm({ ...entryForm, text: e.target.value })}
placeholder={t('comments.text_placeholder')}
rows={6}
className="min-h-[120px]"
/>
</div>
<div className="flex items-center gap-3">
<Label>{t('comments.field_public')}</Label>
<Switch
checked={entryForm.isPublic}
onCheckedChange={(v) => setEntryForm({ ...entryForm, isPublic: v })}
/>
<span className="text-sm text-muted-foreground">
{entryForm.isPublic ? t('comments.public') : t('comments.private')}
</span>
</div>
<div className="space-y-2">
<Label>{t('comments.field_tags')}</Label>
<Input
value={entryForm.tags}
onChange={(e) => setEntryForm({ ...entryForm, tags: e.target.value })}
placeholder={t('comments.tags_placeholder')}
/>
</div>
{/* Preview */}
{entryForm.text && (
<Card className="bg-slate-50 dark:bg-slate-900/50">
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('comments.preview')}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm">{entryForm.text}</p>
</CardContent>
</Card>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEntryDialogOpen(false)}>
{t('action.cancel')}
</Button>
<Button
onClick={saveEntry}
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white"
>
{t('action.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Entry Detail Dialog ────────────────────────────────────── */}
<Dialog open={entryDetailOpen} onOpenChange={setEntryDetailOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
{entryDetailData && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{entryDetailData.title}
<Badge
variant="outline"
className="text-xs"
style={{
backgroundColor: hexToRgba(entryDetailData.category.color, 0.15),
color: entryDetailData.category.color,
borderColor: hexToRgba(entryDetailData.category.color, 0.3),
}}
>
{entryDetailData.category.icon ? renderCBIcon(entryDetailData.category.icon) : null}{entryDetailData.category.name}
</Badge>
</DialogTitle>
<DialogDescription>
{entryDetailData.teacher.firstName} {entryDetailData.teacher.lastName}
{entryDetailData.subject && ` · ${entryDetailData.subject.name}`}
{entryDetailData.gradeLevel && ` · Klasse ${entryDetailData.gradeLevel}`}
</DialogDescription>
</DialogHeader>
<div className="py-2">
<p className="text-sm leading-relaxed whitespace-pre-wrap">{entryDetailData.text}</p>
</div>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={() => copyEntryText(entryDetailData.text)}
>
<Copy className="h-4 w-4 mr-2" />
{t('comments.copy_text')}
</Button>
<Button
variant="outline"
onClick={() => {
setEntryDetailOpen(false);
openEntryDialog(entryDetailData);
}}
>
<Pencil className="h-4 w-4 mr-2" />
{t('action.edit')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* ── Category Create/Edit Dialog ─────────────────────────────── */}
<Dialog open={categoryDialogOpen} onOpenChange={setCategoryDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{categoryForm.id ? t('comments.edit_category') : t('comments.add_category')}
</DialogTitle>
<DialogDescription>
{categoryForm.id ? t('comments.edit_category_desc') : t('comments.add_category_desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t('comments.category_name')}</Label>
<Input
value={categoryForm.name}
onChange={(e) => setCategoryForm({ ...categoryForm, name: e.target.value })}
placeholder={t('comments.category_name_placeholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('comments.category_color')}</Label>
<div className="flex gap-2 items-center">
{COLOR_PRESETS.map((c) => (
<button
key={c}
type="button"
className={`h-7 w-7 rounded-full border-2 transition-all ${
categoryForm.color === c ? 'scale-110 ring-2 ring-offset-2' : 'hover:scale-105'
}`}
style={{ backgroundColor: c, borderColor: categoryForm.color === c ? c : 'transparent' }}
onClick={() => setCategoryForm({ ...categoryForm, color: c })}
/>
))}
<Input
value={categoryForm.color}
onChange={(e) => setCategoryForm({ ...categoryForm, color: e.target.value })}
className="w-[100px] ml-2"
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('comments.category_icon')}</Label>
<Input
value={categoryForm.icon}
onChange={(e) => setCategoryForm({ ...categoryForm, icon: e.target.value })}
placeholder={t('comments.category_icon_placeholder')}
className="w-[80px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCategoryDialogOpen(false)}>
{t('action.cancel')}
</Button>
<Button
onClick={saveCategory}
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white"
>
{t('action.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Delete Entry Alert ─────────────────────────────────────── */}
<AlertDialog open={deleteEntryOpen} onOpenChange={setDeleteEntryOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('comments.delete_entry_title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('comments.delete_entry_desc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteEntry}
className="bg-rose-600 hover:bg-rose-700"
>
{t('action.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Delete Category Alert ──────────────────────────────────── */}
<AlertDialog open={deleteCategoryOpen} onOpenChange={setDeleteCategoryOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('comments.delete_category_title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('comments.delete_category_desc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteCategory}
className="bg-rose-600 hover:bg-rose-700"
>
{t('action.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
);
}