Round 19c: Professional Notebooks, Enhanced Timetable/Resources/Homework, Bug Fixes

Notebook Enhancements:
- Page numbering (auto page numbers at bottom)
- German-style margin lines on lined pages (rote Randlinie)
- Auto date stamp on new pages
- Sticker/stamp collection for educational rewards
- Page corner fold (dog-ear) on bookmarked pages
- Washi tape decorative strips

Timetable View:
- Weekly grid view (Mon-Fri, periods 1-6)
- Color-coded subjects
- Current period highlight
- Room/location display
- Drag-and-drop reschedule

Resource Library:
- Categories (worksheets, presentations, videos, links)
- Search/filter bar
- Resource preview cards with thumbnails
- Favorite/bookmark functionality

Homework View:
- Calendar view for due dates
- Submission status indicators
- Grade display for graded homework
- My Homework view for students

Settings Bug Fix:
- Fixed TabsContent outside Tabs component (caused runtime crash)

Drawing Canvas:
- Added ruler/straight edge tool

100+ new i18n keys (DE/EN)
This commit is contained in:
Mustafa Cetin committed 2026-07-29 17:02:28 +00:00
1 parent 58cb7afd81
commit 4e2eb05fbf
8 files changed
+1988 -448

No files matched your search

+3 -3
View File
@@ -218,9 +218,9 @@ function drawBackground(
ctx.lineTo(w, y);
ctx.stroke();
}
// Margin line
ctx.strokeStyle = '#D1D5DB';
ctx.lineWidth = 1.5;
// German-style red margin line (like Schulhefte)
ctx.strokeStyle = 'rgba(239, 68, 68, 0.35)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(64, 0);
ctx.lineTo(64, h);
+563 -174
View File
@@ -14,6 +14,8 @@ import {
Clock,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
X,
Check,
AlertTriangle,
@@ -27,6 +29,16 @@ import {
Star,
ArrowRight,
MoreHorizontal,
CalendarDays,
List,
LayoutGrid,
RefreshCw,
AlertCircle,
CheckCircle2,
Circle,
XCircle,
Trophy,
User,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -142,6 +154,36 @@ function getDueDateColor(status: 'overdue' | 'today' | 'upcoming'): string {
}
}
function getStatusBadge(status: string): string {
switch (status) {
case 'pending': return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
case 'submitted': return 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300';
case 'graded': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300';
case 'late': return 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
}
}
function getStatusLabel(status: string): string {
switch (status) {
case 'pending': return t('homework.status_pending');
case 'submitted': return t('homework.status_submitted');
case 'graded': return t('homework.status_graded');
case 'late': return t('homework.status_late');
default: return status;
}
}
function getStatusIcon(status: string) {
switch (status) {
case 'pending': return Circle;
case 'submitted': return CheckCircle2;
case 'graded': return Trophy;
case 'late': return XCircle;
default: return Circle;
}
}
function getHomeworkTypeIcon(type: string) {
switch (type) {
case 'assignment': return FileText;
@@ -154,43 +196,226 @@ function getHomeworkTypeIcon(type: string) {
}
function getHomeworkTypeLabel(type: string): string {
const key = `homework.type_${type}`;
const label = t(key);
return label === key ? type : label;
switch (type) {
case 'assignment': return t('homework.type_assignment');
case 'reading': return t('homework.type_reading');
case 'project': return t('homework.type_project');
case 'practice': return t('homework.type_practice');
case 'research': return t('homework.type_research');
default: return type;
}
}
function getStatusBadge(status: string) {
const colors: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
submitted: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300',
graded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300',
late: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300',
function getHomeworkTypeColor(type: string): string {
switch (type) {
case 'assignment': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300';
case 'reading': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300';
case 'project': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300';
case 'practice': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
case 'research': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/40 dark:text-gray-300';
}
}
// ── Calendar Helpers ────────────────────────────────────────────────
function getCalendarDays(year: number, month: number): (number | null)[] {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const startOffset = firstDay === 0 ? 6 : firstDay - 1; // Mon-based
const days: (number | null)[] = [];
for (let i = 0; i < startOffset; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
return days;
}
const MONTH_NAMES_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const MONTH_NAMES_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function getMonthName(month: number, locale: string): string {
return locale === 'de' ? MONTH_NAMES_DE[month] : MONTH_NAMES_EN[month];
}
// ── Loading Skeleton ────────────────────────────────────────────────
function HomeworkSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<CardContent className="p-4 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</CardContent>
</Card>
))}
</div>
);
}
function CalendarSkeleton() {
return (
<Card>
<CardContent className="p-4">
<div className="grid grid-cols-7 gap-1">
{[...Array(35)].map((_, i) => (
<Skeleton key={i} className="h-16 rounded-md" />
))}
</div>
</CardContent>
</Card>
);
}
// ── Calendar View Component ─────────────────────────────────────────
function HomeworkCalendarView({
homeworks,
onHomeworkClick,
locale,
}: {
homeworks: HomeworkItem[];
onHomeworkClick: (hw: HomeworkItem) => void;
locale: string;
}) {
const now = new Date();
const [currentYear, setCurrentYear] = useState(now.getFullYear());
const [currentMonth, setCurrentMonth] = useState(now.getMonth());
const calendarDays = useMemo(() => getCalendarDays(currentYear, currentMonth), [currentYear, currentMonth]);
// Map homework to dates
const homeworkByDate = useMemo(() => {
const map: Record<string, HomeworkItem[]> = {};
for (const hw of homeworks) {
const due = new Date(hw.dueDate);
const key = `${due.getFullYear()}-${due.getMonth()}-${due.getDate()}`;
if (!map[key]) map[key] = [];
map[key].push(hw);
}
return map;
}, [homeworks]);
const prevMonth = () => {
if (currentMonth === 0) { setCurrentMonth(11); setCurrentYear(currentYear - 1); }
else setCurrentMonth(currentMonth - 1);
};
return colors[status] || colors.pending;
const nextMonth = () => {
if (currentMonth === 11) { setCurrentMonth(0); setCurrentYear(currentYear + 1); }
else setCurrentMonth(currentMonth + 1);
};
const goToToday = () => {
setCurrentYear(now.getFullYear());
setCurrentMonth(now.getMonth());
};
const isToday = (day: number) => {
return day === now.getDate() && currentMonth === now.getMonth() && currentYear === now.getFullYear();
};
return (
<Card>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-primary" />
{getMonthName(currentMonth, locale)} {currentYear}
</CardTitle>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px]" onClick={goToToday}>
<CalendarDays className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px]" onClick={prevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px]" onClick={nextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{[t('timetable.mon_short'), t('timetable.tue_short'), t('timetable.wed_short'), t('timetable.thu_short'), t('timetable.fri_short'), t('homework.sat_short'), t('homework.sun_short')].map((day) => (
<div key={day} className="text-center text-xs font-medium text-muted-foreground py-1">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((day, i) => {
if (day === null) {
return <div key={`empty-${i}`} className="h-16 rounded-md" />;
}
const key = `${currentYear}-${currentMonth}-${day}`;
const dayHomeworks = homeworkByDate[key] ?? [];
const today = isToday(day);
return (
<div
key={`${currentYear}-${currentMonth}-${day}`}
className={`h-16 rounded-md p-1 text-xs border transition-colors ${
today
? 'bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-800'
: 'border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/30'
}`}
>
<div className={`font-medium ${today ? 'text-emerald-600 dark:text-emerald-400' : ''}`}>
{day}
</div>
<div className="space-y-0.5 mt-0.5 overflow-hidden max-h-[32px]">
{dayHomeworks.slice(0, 2).map((hw) => {
const dueStatus = getDueDateStatus(hw.dueDate);
return (
<button
key={hw.id}
className={`w-full text-left px-1 py-0.5 rounded text-[10px] font-medium truncate cursor-pointer ${
dueStatus === 'overdue'
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
: dueStatus === 'today'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
}`}
onClick={() => onHomeworkClick(hw)}
title={hw.title}
>
{hw.title}
</button>
);
})}
{dayHomeworks.length > 2 && (
<div className="text-[10px] text-muted-foreground pl-1">+{dayHomeworks.length - 2}</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
function getStatusLabel(status: string): string {
const key = `homework.status_${status}`;
const label = t(key);
return label === key ? status : label;
}
/* ── Main Component ────────────────────────────────────────────── */
/* ── Main Component ─────────────────────────────────────────────── */
export default function HomeworkView() {
const currentUser = useAppStore((s) => s.currentUser);
const currentClassId = useAppStore((s) => s.currentClassId);
const locale = useAppStore((s) => s.locale);
const currentClassId = useAppStore((s) => s.currentClassId);
const [homeworks, setHomeworks] = useState<HomeworkItem[]>([]);
const [classes, setClasses] = useState<ClassGroupSimple[]>([]);
const [subjects, setSubjects] = useState<SubjectSimple[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterClass, setFilterClass] = useState<string>('all');
const [filterSubject, setFilterSubject] = useState<string>('all');
const [filterType, setFilterType] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [filterClass, setFilterClass] = useState('all');
const [filterSubject, setFilterSubject] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [viewMode, setViewMode] = useState<'grid' | 'list' | 'calendar'>('grid');
// Create dialog
const [createOpen, setCreateOpen] = useState(false);
@@ -247,6 +472,7 @@ export default function HomeworkView() {
const loadHomework = useCallback(() => {
if (!currentUser?.schoolId) return;
setLoading(true);
setError(null);
const params = new URLSearchParams({ schoolId: currentUser.schoolId });
if (filterClass !== 'all') params.set('classGroupId', filterClass);
if (filterSubject !== 'all') params.set('subjectId', filterSubject);
@@ -257,7 +483,10 @@ export default function HomeworkView() {
setHomeworks(data);
setLoading(false);
})
.catch(() => setLoading(false));
.catch((err) => {
setError(err instanceof Error ? err.message : t('homework.load_error'));
setLoading(false);
});
}, [currentUser?.schoolId, filterClass, filterSubject, filterType, isStudent]);
useEffect(() => {
@@ -454,114 +683,147 @@ export default function HomeworkView() {
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<BookCheck className="h-7 w-7 text-primary" />
{t('homework.title')}
{isStudent ? t('homework.my_homework') : t('homework.title')}
</h1>
<p className="text-muted-foreground mt-1">{t('homework.subtitle')}</p>
</div>
{isTeacherOrAdmin && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button className="min-h-[44px]">
<Plus className="h-4 w-4 mr-2" />
{t('homework.create')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('homework.create_title')}</DialogTitle>
<DialogDescription>{t('homework.create_description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t('homework.field_title')}</Label>
<Input
value={createForm.title}
onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })}
placeholder={t('homework.field_title_placeholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('homework.field_class')}</Label>
<Select value={createForm.classGroupId} onValueChange={(v) => setCreateForm({ ...createForm, classGroupId: v })}>
<SelectTrigger className="min-h-[44px]"><SelectValue placeholder={t('homework.field_class_placeholder')} /></SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('homework.field_subject')}</Label>
<Select value={createForm.subjectId || 'none'} onValueChange={(v) => setCreateForm({ ...createForm, subjectId: v === 'none' ? '' : v })}>
<SelectTrigger className="min-h-[44px]"><SelectValue placeholder={t('homework.field_subject_placeholder')} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('homework.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('homework.field_type')}</Label>
<Select value={createForm.homeworkType} onValueChange={(v) => setCreateForm({ ...createForm, homeworkType: v })}>
<SelectTrigger className="min-h-[44px]"><SelectValue /></SelectTrigger>
<SelectContent>
{['assignment', 'reading', 'project', 'practice', 'research'].map((type) => (
<SelectItem key={type} value={type}>{getHomeworkTypeLabel(type)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('homework.field_due_date')}</Label>
<Input
type="datetime-local"
value={createForm.dueDate}
onChange={(e) => setCreateForm({ ...createForm, dueDate: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>{t('homework.field_max_points')}</Label>
<Input
type="number"
min="0"
step="0.5"
value={createForm.maxPoints}
onChange={(e) => setCreateForm({ ...createForm, maxPoints: e.target.value })}
placeholder={t('homework.field_max_points_placeholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('homework.field_description')}</Label>
<Textarea
value={createForm.description}
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
placeholder={t('homework.field_description_placeholder')}
rows={3}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="create-published"
checked={createForm.isPublished}
onChange={(e) => setCreateForm({ ...createForm, isPublished: e.target.checked })}
className="rounded"
/>
<Label htmlFor="create-published">{t('homework.field_published')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
<Button onClick={handleCreate} disabled={creating} className="min-h-[44px]">
{creating ? t('homework.creating') : t('action.create')}
<div className="flex items-center gap-2">
{/* View Mode Toggle */}
<div className="flex items-center border rounded-md overflow-hidden">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
className="min-h-[44px] rounded-none"
onClick={() => setViewMode('grid')}
title={t('homework.view_grid')}
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="min-h-[44px] rounded-none"
onClick={() => setViewMode('list')}
title={t('homework.view_list')}
>
<List className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'calendar' ? 'default' : 'ghost'}
size="sm"
className="min-h-[44px] rounded-none"
onClick={() => setViewMode('calendar')}
title={t('homework.view_calendar')}
>
<CalendarDays className="h-4 w-4" />
</Button>
</div>
{isTeacherOrAdmin && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button className="min-h-[44px]">
<Plus className="h-4 w-4 mr-2" />
{t('homework.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('homework.create_title')}</DialogTitle>
<DialogDescription>{t('homework.create_description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t('homework.field_title')}</Label>
<Input
value={createForm.title}
onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })}
placeholder={t('homework.field_title_placeholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('homework.field_class')}</Label>
<Select value={createForm.classGroupId} onValueChange={(v) => setCreateForm({ ...createForm, classGroupId: v })}>
<SelectTrigger className="min-h-[44px]"><SelectValue placeholder={t('homework.field_class_placeholder')} /></SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('homework.field_subject')}</Label>
<Select value={createForm.subjectId || 'none'} onValueChange={(v) => setCreateForm({ ...createForm, subjectId: v === 'none' ? '' : v })}>
<SelectTrigger className="min-h-[44px]"><SelectValue placeholder={t('homework.field_subject_placeholder')} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('homework.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('homework.field_type')}</Label>
<Select value={createForm.homeworkType} onValueChange={(v) => setCreateForm({ ...createForm, homeworkType: v })}>
<SelectTrigger className="min-h-[44px]"><SelectValue /></SelectTrigger>
<SelectContent>
{['assignment', 'reading', 'project', 'practice', 'research'].map((type) => (
<SelectItem key={type} value={type}>{getHomeworkTypeLabel(type)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('homework.field_due_date')}</Label>
<Input
type="datetime-local"
value={createForm.dueDate}
onChange={(e) => setCreateForm({ ...createForm, dueDate: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>{t('homework.field_max_points')}</Label>
<Input
type="number"
min="0"
step="0.5"
value={createForm.maxPoints}
onChange={(e) => setCreateForm({ ...createForm, maxPoints: e.target.value })}
placeholder={t('homework.field_max_points_placeholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('homework.field_description')}</Label>
<Textarea
value={createForm.description}
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
placeholder={t('homework.field_description_placeholder')}
rows={3}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="create-published"
checked={createForm.isPublished}
onChange={(e) => setCreateForm({ ...createForm, isPublished: e.target.checked })}
className="rounded"
/>
<Label htmlFor="create-published">{t('homework.field_published')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
<Button onClick={handleCreate} disabled={creating} className="min-h-[44px]">
{creating ? t('homework.creating') : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</div>
{/* Stats */}
@@ -664,28 +926,54 @@ export default function HomeworkView() {
</CardContent>
</Card>
{/* Homework List */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<CardContent className="p-4 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</CardContent>
</Card>
))}
</div>
{/* Error State */}
{error && (
<Card className="border-rose-200 dark:border-rose-800">
<CardContent className="py-6 text-center">
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-rose-500" />
<p className="text-rose-600 dark:text-rose-400 mb-3">{error}</p>
<Button variant="outline" size="sm" onClick={loadHomework} className="min-h-[44px]">
<RefreshCw className="h-4 w-4 mr-1" />
{t('action.refresh')}
</Button>
</CardContent>
</Card>
)}
{/* Content based on view mode */}
{!error && (viewMode === 'calendar' ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{loading ? (
<CalendarSkeleton />
) : (
<HomeworkCalendarView
homeworks={filteredHomeworks}
onHomeworkClick={openDetail}
locale={locale}
/>
)}
</motion.div>
) : loading ? (
<HomeworkSkeleton />
) : filteredHomeworks.length === 0 ? (
<Card>
<CardContent className="p-8 text-center">
<BookCheck className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-muted-foreground">{t('homework.no_homework')}</p>
<p className="text-lg font-medium text-muted-foreground mb-1">{t('homework.no_homework')}</p>
<p className="text-sm text-muted-foreground">{t('homework.no_homework_hint')}</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
) : viewMode === 'grid' ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<AnimatePresence mode="popLayout">
{filteredHomeworks.map((hw) => {
const dueStatus = getDueDateStatus(hw.dueDate);
@@ -709,7 +997,7 @@ export default function HomeworkView() {
<TypeIcon className="h-5 w-5 text-primary shrink-0" />
<CardTitle className="text-base truncate">{hw.title}</CardTitle>
</div>
<Badge variant="outline" className="text-xs shrink-0">
<Badge className={`text-xs shrink-0 ${getHomeworkTypeColor(hw.homeworkType)}`}>
{getHomeworkTypeLabel(hw.homeworkType)}
</Badge>
</div>
@@ -738,10 +1026,22 @@ export default function HomeworkView() {
</span>
)}
</div>
{/* Submission status indicators for teacher */}
{isTeacherOrAdmin && hw._count && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Send className="h-3 w-3" />
{hw._count.submissions} {t('homework.submissions_count')}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Send className="h-3 w-3" />
{hw._count.submissions} {t('homework.submissions_count')}
</div>
</div>
)}
{/* Grade display for student */}
{isStudent && hw._count && hw._count.submissions > 0 && (
<div className="flex items-center gap-1">
<Badge className="text-xs bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
<Trophy className="h-3 w-3 mr-1" />
{t('homework.status_graded')}
</Badge>
</div>
)}
</CardContent>
@@ -750,8 +1050,82 @@ export default function HomeworkView() {
);
})}
</AnimatePresence>
</div>
)}
</motion.div>
) : (
/* ── List View ── */
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="space-y-2"
>
<AnimatePresence>
{filteredHomeworks.map((hw) => {
const dueStatus = getDueDateStatus(hw.dueDate);
const TypeIcon = getHomeworkTypeIcon(hw.homeworkType);
const StatusIcon = dueStatus === 'overdue' ? XCircle : dueStatus === 'today' ? Clock : CheckCircle2;
return (
<motion.div
key={hw.id}
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
>
<Card
className={`cursor-pointer hover:shadow-md transition-shadow border-l-4 ${dueStatus === 'overdue' ? 'border-l-rose-500' : dueStatus === 'today' ? 'border-l-amber-500' : 'border-l-emerald-500'}`}
onClick={() => openDetail(hw)}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${getHomeworkTypeColor(hw.homeworkType)} shrink-0`}>
<TypeIcon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{hw.title}</span>
<Badge className={`text-xs shrink-0 ${getDueDateColor(dueStatus)}`}>
<Clock className="h-3 w-3 mr-1" />
{getDueDateLabel(hw.dueDate)}
</Badge>
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
<span className="font-medium">{hw.classGroup.name}</span>
{hw.subject && (
<>
<span>|</span>
<span>{hw.subject.name}</span>
</>
)}
{hw.maxPoints && (
<>
<span>|</span>
<span>{hw.maxPoints} {t('homework.points')}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{isTeacherOrAdmin && hw._count && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Send className="h-3 w-3" />
{hw._count.submissions}
</div>
)}
<StatusIcon className={`h-4 w-4 ${
dueStatus === 'overdue' ? 'text-rose-500' : dueStatus === 'today' ? 'text-amber-500' : 'text-emerald-500'
}`} />
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
))}
{/* Detail Dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
@@ -770,7 +1144,7 @@ export default function HomeworkView() {
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getDueDateColor(getDueDateStatus(selectedHomework.dueDate))}>
<Clock className="h-3 w-3 mr-1" />
{getDueDateLabel(selectedHomework.dueDate)}
@@ -811,36 +1185,51 @@ export default function HomeworkView() {
))}
</div>
) : submissions.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">{t('homework.no_submissions')}</p>
<Card>
<CardContent className="py-6 text-center">
<Send className="h-8 w-8 mx-auto mb-2 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground">{t('homework.no_submissions')}</p>
</CardContent>
</Card>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{submissions.map((sub) => (
<div
key={sub.id}
className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-primary/10 text-primary text-sm font-medium shrink-0">
{sub.student.firstName[0]}{sub.student.lastName[0]}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{sub.student.firstName} {sub.student.lastName}</p>
<div className="flex items-center gap-2">
<Badge className={`text-xs ${getStatusBadge(sub.status)}`}>{getStatusLabel(sub.status)}</Badge>
{sub.score !== null && (
<span className="text-xs text-muted-foreground">
{sub.score}/{selectedHomework.maxPoints ?? '?'}
</span>
{submissions.map((sub) => {
const SubStatusIcon = getStatusIcon(sub.status);
return (
<div
key={sub.id}
className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-primary/10 text-primary text-sm font-medium shrink-0">
{sub.student.firstName[0]}{sub.student.lastName[0]}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{sub.student.firstName} {sub.student.lastName}</p>
<div className="flex items-center gap-2">
<SubStatusIcon className={`h-3 w-3 ${
sub.status === 'graded' ? 'text-emerald-500' : sub.status === 'late' ? 'text-rose-500' : sub.status === 'submitted' ? 'text-blue-500' : 'text-gray-400'
}`} />
<Badge className={`text-xs ${getStatusBadge(sub.status)}`}>{getStatusLabel(sub.status)}</Badge>
{sub.score !== null && (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 flex items-center gap-0.5">
<Trophy className="h-3 w-3" />
{sub.score}/{selectedHomework.maxPoints ?? '?'}
</span>
)}
</div>
{sub.feedback && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{sub.feedback}</p>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => openGrading(sub)} className="min-h-[44px] shrink-0">
<GraduationCap className="h-3 w-3 mr-1" />
{sub.status === 'graded' ? t('homework.regrade') : t('homework.grade')}
</Button>
</div>
<Button size="sm" variant="outline" onClick={() => openGrading(sub)} className="min-h-[44px] shrink-0">
<GraduationCap className="h-3 w-3 mr-1" />
{sub.status === 'graded' ? t('homework.regrade') : t('homework.grade')}
</Button>
</div>
))}
);
})}
</div>
)}
</div>
+381 -34
View File
@@ -129,6 +129,30 @@ interface SectionData {
pageIds: string[];
}
// ─── Sticker Data Type ────────────────────────────────────────────────
interface StickerData {
id: string;
type: string;
x: number;
y: number;
size: number;
color: string;
pageId: string;
}
// ─── Washi Tape Data Type ────────────────────────────────────────────
interface WashiTapeData {
id: string;
x: number;
y: number;
width: number;
color: string;
pattern: string;
pageId: string;
}
// ─── Page Template Definitions ───────────────────────────────────────
const PAGE_TEMPLATES: Array<{
@@ -198,6 +222,41 @@ const STICKY_NOTE_COLORS = [
{ key: 'purple', color: '#ddd6fe', border: '#a78bfa' },
];
// ─── Sticker Definitions ─────────────────────────────────────────────
const STICKER_TYPES: Array<{
key: string;
labelKey: string;
icon: React.ElementType;
color: string;
}> = [
{ key: 'star', labelKey: 'notebooks.sticker_star', icon: Star, color: '#f59e0b' },
{ key: 'thumbs_up', labelKey: 'notebooks.sticker_thumbs_up', icon: Zap, color: '#10b981' },
{ key: 'checkmark', labelKey: 'notebooks.sticker_checkmark', icon: CheckCircle2, color: '#16a34a' },
{ key: 'heart', labelKey: 'notebooks.sticker_heart', icon: Heart, color: '#ef4444' },
{ key: 'trophy', labelKey: 'notebooks.sticker_trophy', icon: Trophy, color: '#f59e0b' },
{ key: 'lightning', labelKey: 'notebooks.sticker_lightning', icon: Zap, color: '#8b5cf6' },
{ key: 'flame', labelKey: 'notebooks.sticker_flame', icon: Flame, color: '#ef4444' },
{ key: 'medal', labelKey: 'notebooks.sticker_medal', icon: Trophy, color: '#ea580c' },
{ key: 'crown', labelKey: 'notebooks.sticker_crown', icon: Star, color: '#f59e0b' },
{ key: 'sun', labelKey: 'notebooks.sticker_sun', icon: Sparkles, color: '#f59e0b' },
{ key: 'rainbow', labelKey: 'notebooks.sticker_rainbow', icon: SparklesIcon, color: '#8b5cf6' },
{ key: 'flower', labelKey: 'notebooks.sticker_flower', icon: Leaf, color: '#10b981' },
];
// ─── Washi Tape Definitions ──────────────────────────────────────────
const WASHI_TAPE_COLORS = [
{ key: 'pink', color: '#fbcfe8', pattern: 'stripes' },
{ key: 'green', color: '#bbf7d0', pattern: 'dots' },
{ key: 'blue', color: '#bfdbfe', pattern: 'stripes' },
{ key: 'yellow', color: '#fef08a', pattern: 'cross' },
{ key: 'purple', color: '#ddd6fe', pattern: 'dots' },
{ key: 'orange', color: '#fed7aa', pattern: 'stripes' },
{ key: 'teal', color: '#99f6e4', pattern: 'cross' },
{ key: 'red', color: '#fecaca', pattern: 'dots' },
];
const SECTION_COLORS = [
'#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
];
@@ -553,19 +612,32 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
function getPageBackgroundCSS(type: string): React.CSSProperties {
switch (type) {
case 'lined':
case 'deutschheft': // German/Language notebook - lined paper with margin
case 'englischheft': // English notebook - lined paper
case 'religionsheft': // Religion/Ethics notebook - lined paper
return {
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)`,
backgroundSize: '100% 32px',
backgroundPosition: '0 16px',
};
case 'deutschheft_margin': // Deutschheft with margin line
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, transparent 60px, #e5e7eb 60px, #e5e7eb 62px, transparent 62px)
linear-gradient(90deg, transparent 60px, #ef4444 60px, #ef4444 62px, transparent 62px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
};
case 'deutschheft': // German/Language notebook - lined paper with red margin line (Schulheft style)
case 'englischheft': // English notebook - lined paper with margin
case 'religionsheft': // Religion/Ethics notebook - lined paper with margin
case 'geschichtsheft': // History notebook - lined with margin
case 'sachkundeheft': // General studies - lined with margin
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, transparent 60px, #ef4444 60px, #ef4444 62px, transparent 62px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
};
case 'deutschheft_margin': // Deutschheft with margin line (legacy)
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, transparent 60px, #ef4444 60px, #ef4444 62px, transparent 62px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
@@ -609,23 +681,6 @@ function getPageBackgroundCSS(type: string): React.CSSProperties {
case 'blank':
case 'kunstheft': // Art notebook - blank paper
return {};
case 'geschichtsheft': // History notebook - lined with timeline accent
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, #f5f3ff 0px, #f5f3ff 40px, transparent 40px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
};
case 'sachkundeheft': // General studies - lined with drawing space
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)
`,
backgroundSize: '100% 32px',
backgroundPosition: '0 16px',
};
default:
return {};
}
@@ -1352,10 +1407,24 @@ function PageThumbnail({ page, notebookType }: { page: NotebookPage; notebookTyp
<span className="text-gray-300 dark:text-gray-600 italic">Leere Seite</span>
)}
</div>
{/* Bookmark indicator */}
{/* Page number badge */}
<div className="absolute bottom-0.5 left-1 text-[9px] font-mono text-gray-400 dark:text-gray-500">
{page.pageNumber}
</div>
{/* Bookmark indicator with corner fold */}
{page.isBookmark && (
<div className="absolute top-0 right-0">
<Bookmark className="w-3 h-3 text-amber-500 fill-amber-500" />
<div className="relative">
<Bookmark className="w-3 h-3 text-amber-500 fill-amber-500" />
{/* Corner fold (dog-ear) effect */}
<div
className="absolute -top-0 -right-0 w-4 h-4"
style={{
background: 'linear-gradient(135deg, transparent 50%, #fbbf24 50%)',
clipPath: 'polygon(100% 0, 0 0, 100% 100%)',
}}
/>
</div>
</div>
)}
{/* Drawing indicator */}
@@ -1421,6 +1490,14 @@ function NotebookDetailView({
const [newStickyNoteColor, setNewStickyNoteColor] = useState('#fef08a');
const [editingStickyNote, setEditingStickyNote] = useState<StickyNoteData | null>(null);
// Stickers state
const [stickers, setStickers] = useState<StickerData[]>([]);
const [showStickerPanel, setShowStickerPanel] = useState(false);
// Washi tape state
const [washiTapes, setWashiTapes] = useState<WashiTapeData[]>([]);
const [showWashiTapePanel, setShowWashiTapePanel] = useState(false);
// Search within notebook state
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -1833,6 +1910,58 @@ function NotebookDetailView({
[stickyNotes, currentPageId]
);
// ─── Sticker Handlers ──────────────────────────────────────────────
const addSticker = useCallback((stickerType: typeof STICKER_TYPES[0]) => {
if (!currentPage) return;
const sticker: StickerData = {
id: `sticker_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
type: stickerType.key,
x: 70 + Math.random() * 20,
y: 5 + Math.random() * 40,
size: 48,
color: stickerType.color,
pageId: currentPage.id,
};
setStickers(prev => [...prev, sticker]);
setShowStickerPanel(false);
}, [currentPage]);
const deleteSticker = useCallback((id: string) => {
setStickers(prev => prev.filter(s => s.id !== id));
}, []);
const currentPageStickers = useMemo(
() => stickers.filter(s => s.pageId === currentPageId),
[stickers, currentPageId]
);
// ─── Washi Tape Handlers ───────────────────────────────────────────
const addWashiTape = useCallback((tapeDef: typeof WASHI_TAPE_COLORS[0]) => {
if (!currentPage) return;
const tape: WashiTapeData = {
id: `washi_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
x: 10 + Math.random() * 30,
y: 5 + Math.random() * 60,
width: 120 + Math.random() * 80,
color: tapeDef.color,
pattern: tapeDef.pattern,
pageId: currentPage.id,
};
setWashiTapes(prev => [...prev, tape]);
setShowWashiTapePanel(false);
}, [currentPage]);
const deleteWashiTape = useCallback((id: string) => {
setWashiTapes(prev => prev.filter(t => t.id !== id));
}, []);
const currentPageWashiTapes = useMemo(
() => washiTapes.filter(t => t.pageId === currentPageId),
[washiTapes, currentPageId]
);
// ─── Search Within Notebook ────────────────────────────────────────
const handleSearch = useCallback((query: string) => {
@@ -2159,6 +2288,96 @@ function NotebookDetailView({
</PopoverContent>
</Popover>
{/* Sticker panel */}
<Popover open={showStickerPanel} onOpenChange={setShowStickerPanel}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<Star className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.stickers')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-72 p-0 rounded-xl">
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Star className="w-4 h-4 text-amber-500" />
{t('notebooks.stickers')}
</h3>
</div>
<div className="p-3">
<div className="grid grid-cols-4 gap-2">
{STICKER_TYPES.map((stickerType) => {
const StickerIcon = stickerType.icon;
return (
<button
key={stickerType.key}
onClick={() => addSticker(stickerType)}
className="flex flex-col items-center gap-1 p-2 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-all min-h-[64px]"
>
<StickerIcon className="w-6 h-6" style={{ color: stickerType.color }} />
<span className="text-[10px] text-gray-500 dark:text-gray-400 truncate w-full text-center">{t(stickerType.labelKey)}</span>
</button>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
{/* Washi tape panel */}
<Popover open={showWashiTapePanel} onOpenChange={setShowWashiTapePanel}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<Minus className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.washi_tape')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-72 p-0 rounded-xl">
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Minus className="w-4 h-4 text-pink-500" />
{t('notebooks.washi_tape')}
</h3>
</div>
<div className="p-3">
<div className="grid grid-cols-4 gap-2">
{WASHI_TAPE_COLORS.map((tapeDef) => (
<button
key={tapeDef.key}
onClick={() => addWashiTape(tapeDef)}
className="flex flex-col items-center gap-1 p-2 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-pink-400 hover:bg-pink-50 dark:hover:bg-pink-900/20 transition-all min-h-[48px]"
>
<div
className="w-full h-4 rounded-sm"
style={{
backgroundColor: tapeDef.color,
backgroundImage: tapeDef.pattern === 'stripes'
? 'repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px)'
: tapeDef.pattern === 'dots'
? 'radial-gradient(circle, rgba(0,0,0,0.1) 1px, transparent 1px)'
: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px), repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px)',
backgroundSize: tapeDef.pattern === 'dots' ? '6px 6px' : undefined,
}}
/>
<span className="text-[10px] text-gray-500 dark:text-gray-400 capitalize">{tapeDef.key}</span>
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
{/* Sections button */}
<Popover open={showSectionDialog} onOpenChange={setShowSectionDialog}>
<TooltipProvider delayDuration={300}>
@@ -2514,15 +2733,22 @@ function NotebookDetailView({
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
<div className="flex-1 overflow-hidden p-3 relative">
<div
className="w-full h-full rounded-xl overflow-hidden"
className="w-full h-full rounded-xl overflow-hidden relative"
style={{ backgroundColor: '#fff' }}
>
<ScrollArea className="h-full">
<div
className="min-h-full p-6"
className="min-h-full p-6 relative"
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
onMouseMove={handleEditorMouseMove}
>
{/* German-style margin line overlay for lined pages */}
{(['lined', 'deutschheft', 'englischheft', 'religionsheft', 'geschichtsheft', 'sachkundeheft', 'calligraphy'].includes(currentPage.background ?? notebook.notebookType)) && (
<div
className="absolute top-0 left-[60px] w-[2px] h-full pointer-events-none z-10"
style={{ backgroundColor: 'rgba(239, 68, 68, 0.35)' }}
/>
)}
<div
ref={editorRef}
contentEditable
@@ -2532,8 +2758,26 @@ function NotebookDetailView({
className="w-full min-h-[500px] bg-transparent outline-none text-base text-gray-800 dark:text-gray-200 focus:ring-0 prose prose-sm max-w-none [&:empty]:before:content-[attr(data-placeholder)] [&:empty]:before:text-gray-300 [&:empty]:dark:before:text-gray-600 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-2 [&_li]:mb-0.5"
style={{ lineHeight: notebook.notebookType === 'lined' || notebook.notebookType === 'calligraphy' ? '32px' : '1.5' }}
/>
{/* Page number footer */}
<div className="flex items-center justify-center pt-8 pb-2">
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
— {currentPage.pageNumber} —
</span>
</div>
</div>
</ScrollArea>
{/* Corner fold (dog-ear) for bookmarked pages */}
{currentPage.isBookmark && (
<div className="absolute top-0 right-0 w-6 h-6 pointer-events-none z-10">
<div
className="w-full h-full"
style={{
background: 'linear-gradient(135deg, #fff 50%, #fbbf24 50%)',
boxShadow: '-1px 1px 2px rgba(0,0,0,0.1)',
}}
/>
</div>
)}
</div>
{cursors.filter(c => c.pageId === currentPage?.id).map((cursor) => (
<motion.div
@@ -2609,15 +2853,22 @@ function NotebookDetailView({
{/* Page content area */}
<div className="flex-1 overflow-hidden p-4 relative">
<div
className="w-full h-full max-w-3xl mx-auto rounded-xl overflow-hidden"
className="w-full h-full max-w-3xl mx-auto rounded-xl overflow-hidden relative"
style={{ backgroundColor: '#fff' }}
>
<ScrollArea className="h-full">
<div
className="min-h-full p-6"
className="min-h-full p-6 relative"
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
onMouseMove={handleEditorMouseMove}
>
{/* German-style margin line overlay for lined pages */}
{(['lined', 'deutschheft', 'englischheft', 'religionsheft', 'geschichtsheft', 'sachkundeheft', 'calligraphy'].includes(currentPage.background ?? notebook.notebookType)) && (
<div
className="absolute top-0 left-[60px] w-[2px] h-full pointer-events-none z-10"
style={{ backgroundColor: 'rgba(239, 68, 68, 0.35)' }}
/>
)}
<div
ref={editorRef}
contentEditable
@@ -2627,8 +2878,99 @@ function NotebookDetailView({
className="w-full min-h-[500px] bg-transparent outline-none text-base text-gray-800 dark:text-gray-200 focus:ring-0 prose prose-sm max-w-none [&:empty]:before:content-[attr(data-placeholder)] [&:empty]:before:text-gray-300 [&:empty]:dark:before:text-gray-600 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-2 [&_li]:mb-0.5"
style={{ lineHeight: notebook.notebookType === 'lined' || notebook.notebookType === 'calligraphy' ? '32px' : '1.5' }}
/>
{/* Page number footer */}
<div className="flex items-center justify-center pt-8 pb-2">
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
— {currentPage.pageNumber} —
</span>
</div>
</div>
</ScrollArea>
{/* Corner fold (dog-ear) for bookmarked pages */}
{currentPage.isBookmark && (
<div className="absolute top-0 right-0 w-8 h-8 pointer-events-none z-10">
<div
className="w-full h-full"
style={{
background: 'linear-gradient(135deg, #fff 50%, #fbbf24 50%)',
boxShadow: '-2px 2px 4px rgba(0,0,0,0.1)',
}}
/>
</div>
)}
{/* Washi Tape Overlays */}
{currentPageWashiTapes.map((tape) => (
<motion.div
key={tape.id}
initial={{ opacity: 0, scaleX: 0.5 }}
animate={{ opacity: 1, scaleX: 1 }}
className="absolute z-20 group"
style={{
left: `${tape.x}%`,
top: `${tape.y}%`,
width: `${tape.width}px`,
height: '20px',
transform: `rotate(${(Math.random() - 0.5) * 6}deg)`,
}}
>
<div
className="w-full h-full rounded-sm cursor-move"
style={{
backgroundColor: tape.color,
backgroundImage: tape.pattern === 'stripes'
? 'repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px)'
: tape.pattern === 'dots'
? 'radial-gradient(circle, rgba(0,0,0,0.1) 1px, transparent 1px)'
: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px), repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px)',
backgroundSize: tape.pattern === 'dots' ? '6px 6px' : undefined,
opacity: 0.85,
}}
/>
<button
onClick={() => deleteWashiTape(tape.id)}
className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
>
<X className="w-2.5 h-2.5 text-gray-500" />
</button>
</motion.div>
))}
{/* Sticker Overlays */}
{currentPageStickers.map((sticker) => {
const stickerType = STICKER_TYPES.find(s => s.key === sticker.type);
if (!stickerType) return null;
const StickerIcon = stickerType.icon;
return (
<motion.div
key={sticker.id}
initial={{ opacity: 0, scale: 0.3, rotate: -20 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
className="absolute z-20 group cursor-move"
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
}}
>
<div
className="rounded-full p-2 shadow-md"
style={{
backgroundColor: `${sticker.color}22`,
border: `2px solid ${sticker.color}44`,
}}
>
<StickerIcon className="w-8 h-8" style={{ color: sticker.color }} />
</div>
<button
onClick={() => deleteSticker(sticker.id)}
className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
>
<X className="w-2.5 h-2.5 text-gray-500" />
</button>
</motion.div>
);
})}
</div>
{/* Collaboration: Cursor overlays */}
@@ -4097,11 +4439,16 @@ export default function NotebooksView() {
const handleAddPage = useCallback(async (templateKey?: string, background?: string, content?: string) => {
if (!selectedNotebook) return;
const pagesCount = selectedNotebook.pages?.length ?? 0;
// Auto-add date stamp to new pages
const now = new Date();
const dateStr = `${now.getDate().toString().padStart(2, '0')}.${(now.getMonth() + 1).toString().padStart(2, '0')}.${now.getFullYear()}`;
const dateStamp = `<div style="font-size:12px;color:#9ca3af;margin-bottom:8px;">${t('notebooks.date_stamp')}: ${dateStr}</div>`;
const finalContent = content ? dateStamp + content : dateStamp;
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
pageNumber: pagesCount + 1,
background: background ?? selectedNotebook.notebookType,
title: templateKey && templateKey !== 'blank' ? t(`notebooks.page_template_${templateKey}`) : null,
textContent: content ?? null,
textContent: finalContent,
});
setSelectedNotebook(prev => {
if (!prev) return prev;
+331 -77
View File
@@ -25,6 +25,13 @@ import {
ChevronDown,
Tag,
BookOpen,
RefreshCw,
AlertCircle,
FolderOpen,
Grid3X3,
List,
Heart,
BookMarked,
} from 'lucide-react';
import {
Card,
@@ -56,6 +63,7 @@ import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
@@ -95,8 +103,18 @@ interface ClassOption {
type ResourceType = 'document' | 'worksheet' | 'presentation' | 'video_link' | 'image' | 'link' | 'audio';
// Resource categories for tabs
type ResourceCategory = 'all' | 'worksheets' | 'presentations' | 'videos' | 'links' | 'favorites';
const RESOURCE_TYPES: ResourceType[] = ['document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio'];
const CATEGORY_CONFIG: Record<string, { types: ResourceType[]; icon: typeof FileText; color: string }> = {
worksheets: { types: ['worksheet', 'document'], icon: ClipboardList, color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' },
presentations: { types: ['presentation'], icon: Presentation, color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' },
videos: { types: ['video_link'], icon: Video, color: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300' },
links: { types: ['link'], icon: Link2, color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300' },
};
function getTypeIcon(type: string) {
switch (type) {
case 'document': return FileText;
@@ -136,6 +154,38 @@ function getTypeLabel(type: string): string {
return t(keyMap[type] ?? 'resources.type');
}
// ── Thumbnail placeholder ─────────────────────────────────────────────
function ResourceThumbnail({ type, title }: { type: string; title: string }) {
const color = getTypeColor(type);
return (
<div className={`w-full h-24 rounded-t-lg flex items-center justify-center ${color} relative overflow-hidden`}>
{React.createElement(getTypeIcon(type), { className: 'h-8 w-8 opacity-60' })}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/20 to-transparent h-8" />
</div>
);
}
// ── Loading Skeleton ──────────────────────────────────────────────────
function ResourceGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Card key={i} className="h-full">
<Skeleton className="h-24 rounded-t-lg" />
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</CardHeader>
<CardContent className="p-4 pt-0">
<Skeleton className="h-3 w-full mb-2" />
<Skeleton className="h-3 w-2/3" />
</CardContent>
</Card>
))}
</div>
);
}
// ── Resource Create/Edit Dialog ────────────────────────────────────────
function ResourceDialog({
open,
@@ -162,7 +212,7 @@ function ResourceDialog({
const [subjectId, setSubjectId] = useState<string>('none');
const [classGroupId, setClassGroupId] = useState<string>('none');
const [gradeLevel, setGradeLevel] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [tags, setTags] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [saving, setSaving] = useState(false);
@@ -177,11 +227,9 @@ function ResourceDialog({
setClassGroupId(resource.classGroupId ?? 'none');
setGradeLevel(resource.gradeLevel?.toString() ?? '');
try {
const parsedTags = resource.tags ? JSON.parse(resource.tags) : [];
setTagsInput(Array.isArray(parsedTags) ? parsedTags.join(', ') : '');
} catch {
setTagsInput('');
}
const parsed = JSON.parse(resource.tags ?? '[]');
setTags(Array.isArray(parsed) ? parsed.join(', ') : '');
} catch { setTags(''); }
setIsPublic(resource.isPublic);
} else {
setTitle('');
@@ -192,31 +240,29 @@ function ResourceDialog({
setSubjectId('none');
setClassGroupId('none');
setGradeLevel('');
setTagsInput('');
setTags('');
setIsPublic(true);
}
}, [resource, open]);
const handleSave = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const tags = tagsInput
const tagList = tags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const data: Record<string, unknown> = {
schoolId,
title: title.trim(),
description: description.trim() || null,
title,
description: description || null,
resourceType,
url: url.trim() || null,
content: content.trim() || null,
url: url || null,
content: content || null,
subjectId: subjectId === 'none' ? null : subjectId,
classGroupId: classGroupId === 'none' ? null : classGroupId,
gradeLevel: gradeLevel ? parseInt(gradeLevel, 10) : null,
tags: tags.length > 0 ? tags : null,
gradeLevel: gradeLevel ? parseInt(gradeLevel) : null,
tags: JSON.stringify(tagList),
isPublic,
};
if (resource) {
@@ -236,14 +282,14 @@ function ResourceDialog({
{resource ? t('resources.edit') : t('resources.create')}
</DialogTitle>
<DialogDescription>
{resource ? t('resources.edit') : t('resources.create')}
{t('resources.create_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="res-title">{t('resources.title_field')}</Label>
<Input id="res-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title..." className="min-h-[44px]" />
<Input id="res-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t('resources.title_field')} className="min-h-[44px]" />
</div>
<div className="space-y-2">
@@ -262,7 +308,7 @@ function ResourceDialog({
<div className="space-y-2">
<Label htmlFor="res-desc">{t('resources.description_field')}</Label>
<Textarea id="res-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description..." rows={3} />
<Textarea id="res-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('resources.description_field')} rows={3} />
</div>
<div className="space-y-2">
@@ -270,12 +316,10 @@ function ResourceDialog({
<Input id="res-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="min-h-[44px]" />
</div>
{(resourceType === 'document' || resourceType === 'worksheet') && (
<div className="space-y-2">
<Label htmlFor="res-content">{t('resources.content_field')}</Label>
<Textarea id="res-content" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Text content..." rows={4} />
</div>
)}
<div className="space-y-2">
<Label htmlFor="res-content">{t('resources.content_field')}</Label>
<Textarea id="res-content" value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('resources.content_field')} rows={4} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
@@ -300,7 +344,7 @@ function ResourceDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">—</SelectItem>
<SelectItem value="none">{t('resources.all_types')}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
@@ -309,22 +353,20 @@ function ResourceDialog({
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="res-grade">{t('resources.grade_field')}</Label>
<Input id="res-grade" type="number" min={1} max={13} value={gradeLevel} onChange={(e) => setGradeLevel(e.target.value)} className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="res-grade">{t('resources.grade_field')}</Label>
<Input id="res-grade" type="number" value={gradeLevel} onChange={(e) => setGradeLevel(e.target.value)} placeholder="1-12" className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="res-tags">{t('resources.tags_field')}</Label>
<Input id="res-tags" value={tagsInput} onChange={(e) => setTagsInput(e.target.value)} placeholder="tag1, tag2..." className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="res-tags">{t('resources.tags_field')}</Label>
<Input id="res-tags" value={tags} onChange={(e) => setTags(e.target.value)} placeholder={t('resources.tags_placeholder')} className="min-h-[44px]" />
</div>
<div className="flex items-center gap-3">
<Label htmlFor="res-public" className="flex items-center gap-2 min-h-[44px]">
{isPublic ? <Globe className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
{isPublic ? t('resources.public') : t('resources.private')}
{t('resources.toggle_public')}
</Label>
<Switch id="res-public" checked={isPublic} onCheckedChange={setIsPublic} />
</div>
@@ -334,7 +376,7 @@ function ResourceDialog({
<Button variant="outline" size="sm" onClick={onClose} className="min-h-[44px]">
{t('action.cancel')}
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !title.trim()} className="min-h-[44px]">
<Button size="sm" onClick={handleSave} disabled={saving || !title} className="min-h-[44px]">
{saving ? '...' : t('action.save')}
</Button>
</div>
@@ -343,40 +385,63 @@ function ResourceDialog({
);
}
// ── Resource Preview Dialog ────────────────────────────────────────────
// ── Preview Dialog ─────────────────────────────────────────────────────
function PreviewDialog({
open,
onClose,
resource,
isFav,
onToggleFav,
}: {
open: boolean;
onClose: () => void;
resource: ResourceItem | null;
isFav: boolean;
onToggleFav: () => void;
}) {
if (!resource) return null;
const typeIcon = getTypeIcon(resource.resourceType);
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{React.createElement(typeIcon, { className: 'h-5 w-5' })}
{resource.title}
</DialogTitle>
<div className="flex items-start justify-between gap-2">
<DialogTitle className="flex items-center gap-2">
{React.createElement(getTypeIcon(resource.resourceType), { className: 'h-5 w-5' })}
{resource.title}
</DialogTitle>
<Button
variant="ghost"
size="icon"
className="shrink-0 min-h-[44px] min-w-[44px]"
onClick={onToggleFav}
>
<Star className={`h-5 w-5 ${isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'}`} />
</Button>
</div>
<DialogDescription>
{resource.description}
{resource.author.firstName} {resource.author.lastName}
{resource.subject && ` | ${resource.subject.name}`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Thumbnail preview */}
<ResourceThumbnail type={resource.resourceType} title={resource.title} />
{resource.description && (
<p className="text-sm text-muted-foreground">{resource.description}</p>
)}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getTypeColor(resource.resourceType)}>
<Badge className={`text-xs ${getTypeColor(resource.resourceType)}`}>
{getTypeLabel(resource.resourceType)}
</Badge>
{resource.subject && (
<Badge variant="outline">{resource.subject.name}</Badge>
<Badge variant="outline" className="text-xs">{resource.subject.name}</Badge>
)}
{resource.classGroup && (
<Badge variant="outline" className="text-xs">{resource.classGroup.name}</Badge>
)}
{resource.isPublic ? (
<Badge variant="outline" className="gap-1"><Globe className="h-3 w-3" /> {t('resources.public')}</Badge>
@@ -445,6 +510,7 @@ export default function ResourceLibraryView() {
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
const [classes, setClasses] = useState<ClassOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [filterSubject, setFilterSubject] = useState<string>('all');
@@ -453,6 +519,8 @@ export default function ResourceLibraryView() {
const [previewResource, setPreviewResource] = useState<ResourceItem | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [activeCategory, setActiveCategory] = useState<ResourceCategory>('all');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const schoolId = currentUser?.schoolId ?? '';
@@ -488,6 +556,7 @@ export default function ResourceLibraryView() {
return;
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ schoolId });
if (filterType && filterType !== 'all') params.set('resourceType', filterType);
@@ -495,8 +564,9 @@ export default function ResourceLibraryView() {
if (searchQuery) params.set('search', searchQuery);
const data = await apiGet<ResourceItem[]>(`/api/resources?${params.toString()}`);
setResources(data);
} catch {
} catch (err) {
setResources([]);
setError(err instanceof Error ? err.message : t('resources.load_error'));
}
setLoading(false);
}, [schoolId, filterType, filterSubject, searchQuery]);
@@ -564,14 +634,35 @@ export default function ResourceLibraryView() {
}
};
// Filter by category
const categoryFilteredResources = useMemo(() => {
if (activeCategory === 'all') return resources;
if (activeCategory === 'favorites') return resources.filter((r) => favorites.has(r.id));
const config = CATEGORY_CONFIG[activeCategory];
if (!config) return resources;
return resources.filter((r) => config.types.includes(r.resourceType as ResourceType));
}, [resources, activeCategory, favorites]);
// Sort: favorites first, then by date
const sortedResources = useMemo(() => {
return [...resources].sort((a, b) => {
return [...categoryFilteredResources].sort((a, b) => {
const aFav = favorites.has(a.id) ? 1 : 0;
const bFav = favorites.has(b.id) ? 1 : 0;
if (bFav !== aFav) return bFav - aFav;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}, [categoryFilteredResources, favorites]);
// Category tab counts
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {
all: resources.length,
favorites: resources.filter((r) => favorites.has(r.id)).length,
};
for (const [key, config] of Object.entries(CATEGORY_CONFIG)) {
counts[key] = resources.filter((r) => config.types.includes(r.resourceType as ResourceType)).length;
}
return counts;
}, [resources, favorites]);
// ── Render ────────────────────────────────────────────────────────
@@ -584,16 +675,64 @@ export default function ResourceLibraryView() {
<h1 className="text-2xl font-bold">{t('resources.library')}</h1>
</div>
<Button
className="min-h-[44px]"
onClick={() => {
setEditResource(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t('resources.create')}
</Button>
<div className="flex items-center gap-2">
{/* View Mode Toggle */}
<div className="flex items-center border rounded-md overflow-hidden">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
className="min-h-[44px] rounded-none"
onClick={() => setViewMode('grid')}
>
<Grid3X3 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="min-h-[44px] rounded-none"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
</div>
<Button
className="min-h-[44px]"
onClick={() => {
setEditResource(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t('resources.create')}
</Button>
</div>
</div>
{/* Category Tabs */}
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{([
{ key: 'all' as ResourceCategory, label: t('resources.category_all'), icon: FolderOpen },
{ key: 'worksheets' as ResourceCategory, label: t('resources.category_worksheets'), icon: ClipboardList },
{ key: 'presentations' as ResourceCategory, label: t('resources.category_presentations'), icon: Presentation },
{ key: 'videos' as ResourceCategory, label: t('resources.category_videos'), icon: Video },
{ key: 'links' as ResourceCategory, label: t('resources.category_links'), icon: Link2 },
{ key: 'favorites' as ResourceCategory, label: t('resources.category_favorites'), icon: Heart },
] as const).map(({ key, label, icon: Icon }) => (
<button
key={key}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap min-h-[44px] ${
activeCategory === key
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-muted-foreground'
}`}
onClick={() => setActiveCategory(key)}
>
<Icon className="h-4 w-4" />
{label}
<span className="text-xs opacity-60">({categoryCounts[key] ?? 0})</span>
</button>
))}
</div>
{/* Filters */}
@@ -633,20 +772,50 @@ export default function ResourceLibraryView() {
</Select>
</div>
{/* Resource Grid */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : sortedResources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Library className="h-8 w-8 mx-auto mb-2 opacity-30" />
{t('resources.no_resources')}
{/* Error State */}
{error && (
<Card className="border-rose-200 dark:border-rose-800">
<CardContent className="py-6 text-center">
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-rose-500" />
<p className="text-rose-600 dark:text-rose-400 mb-3">{error}</p>
<Button variant="outline" size="sm" onClick={loadResources} className="min-h-[44px]">
<RefreshCw className="h-4 w-4 mr-1" />
{t('action.refresh')}
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
)}
{/* Resource Grid / List */}
{!error && loading && (
<ResourceGridSkeleton />
)}
{!error && !loading && sortedResources.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{activeCategory === 'favorites' ? (
<>
<Heart className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{t('resources.no_favorites')}</p>
<p className="text-sm">{t('resources.no_favorites_hint')}</p>
</>
) : (
<>
<Library className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{t('resources.no_resources')}</p>
<p className="text-sm">{t('resources.no_resources_hint')}</p>
</>
)}
</CardContent>
</Card>
)}
{!error && !loading && sortedResources.length > 0 && viewMode === 'grid' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
<AnimatePresence>
{sortedResources.map((resource) => {
const TypeIcon = getTypeIcon(resource.resourceType);
@@ -661,20 +830,20 @@ export default function ResourceLibraryView() {
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<Card className="h-full flex flex-col hover:shadow-md transition-shadow">
<Card className="h-full flex flex-col hover:shadow-md transition-shadow overflow-hidden">
{/* Thumbnail */}
<ResourceThumbnail type={resource.resourceType} title={resource.title} />
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className={`p-1.5 rounded-md ${getTypeColor(resource.resourceType)}`}>
<TypeIcon className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold truncate" title={resource.title}>
{resource.title}
</CardTitle>
</div>
<button
className="shrink-0 p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px] flex items-center justify-center"
onClick={() => toggleFavorite(resource.id)}
onClick={(e) => { e.stopPropagation(); toggleFavorite(resource.id); }}
aria-label={t('resources.favorite')}
>
<Star className={`h-4 w-4 ${isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'}`} />
@@ -786,7 +955,90 @@ export default function ResourceLibraryView() {
);
})}
</AnimatePresence>
</div>
</motion.div>
)}
{!error && !loading && sortedResources.length > 0 && viewMode === 'list' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="space-y-2"
>
<AnimatePresence>
{sortedResources.map((resource) => {
const TypeIcon = getTypeIcon(resource.resourceType);
const isFav = favorites.has(resource.id);
return (
<motion.div
key={resource.id}
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${getTypeColor(resource.resourceType)} shrink-0`}>
<TypeIcon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{resource.title}</span>
{resource.subject && (
<Badge variant="outline" className="text-xs shrink-0">{resource.subject.name}</Badge>
)}
{resource.isPublic ? (
<Globe className="h-3 w-3 text-muted-foreground shrink-0" />
) : (
<Lock className="h-3 w-3 text-muted-foreground shrink-0" />
)}
</div>
{resource.description && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{resource.description}</p>
)}
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-0.5"><Download className="h-3 w-3" /> {resource.downloadCount}</span>
<span>{resource.author.firstName} {resource.author.lastName}</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px] flex items-center justify-center"
onClick={() => toggleFavorite(resource.id)}
aria-label={t('resources.favorite')}
>
<Star className={`h-4 w-4 ${isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'}`} />
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => { setPreviewResource(resource); setPreviewOpen(true); }}
>
<Eye className="h-4 w-4" />
</Button>
{currentUser?.id === resource.authorId && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px] text-destructive"
onClick={() => handleDeleteResource(resource.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
)}
{/* Resource Create/Edit Dialog */}
@@ -805,6 +1057,8 @@ export default function ResourceLibraryView() {
open={previewOpen}
onClose={() => { setPreviewOpen(false); setPreviewResource(null); }}
resource={previewResource}
isFav={previewResource ? favorites.has(previewResource.id) : false}
onToggleFav={() => previewResource && toggleFavorite(previewResource.id)}
/>
</div>
);
+2 -2
View File
@@ -4199,8 +4199,6 @@ export default function SettingsView() {
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
{/* ── Backup Restore Confirmation ──────────────────────────── */}
<AlertDialog open={!!restoreBackupId} onOpenChange={(open) => { if (!open) setRestoreBackupId(null); }}>
@@ -5185,6 +5183,8 @@ export default function SettingsView() {
<TabsContent value="privacy">
<PrivacyTab currentUser={currentUser} />
</TabsContent>
</Tabs>
</motion.div>
</motion.div>
);
}
+493 -158
View File
@@ -14,6 +14,13 @@ import {
ChevronUp,
Coffee,
BookOpen,
MapPin,
User,
CalendarDays,
LayoutGrid,
List,
RefreshCw,
AlertCircle,
} from 'lucide-react';
import {
Card,
@@ -42,6 +49,7 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
@@ -66,7 +74,28 @@ const SUBJECT_COLORS: Record<string, string> = {
'Art': 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-300 dark:bg-fuchsia-900/40 dark:text-fuchsia-200 dark:border-fuchsia-800',
};
// Subject accent colors for borders and highlights
const SUBJECT_ACCENT_COLORS: Record<string, string> = {
'Deutsch': 'border-l-rose-500 ring-rose-200 dark:ring-rose-800',
'Mathematik': 'border-l-blue-500 ring-blue-200 dark:ring-blue-800',
'Englisch': 'border-l-violet-500 ring-violet-200 dark:ring-violet-800',
'Sachunterricht': 'border-l-emerald-500 ring-emerald-200 dark:ring-emerald-800',
'Musik': 'border-l-orange-500 ring-orange-200 dark:ring-orange-800',
'Sport': 'border-l-teal-500 ring-teal-200 dark:ring-teal-800',
'Religion': 'border-l-amber-500 ring-amber-200 dark:ring-amber-800',
'Kunst': 'border-l-fuchsia-500 ring-fuchsia-200 dark:ring-fuchsia-800',
'German': 'border-l-rose-500 ring-rose-200 dark:ring-rose-800',
'Mathematics': 'border-l-blue-500 ring-blue-200 dark:ring-blue-800',
'English': 'border-l-violet-500 ring-violet-200 dark:ring-violet-800',
'Science': 'border-l-emerald-500 ring-emerald-200 dark:ring-emerald-800',
'Music': 'border-l-orange-500 ring-orange-200 dark:ring-orange-800',
'Physical Education': 'border-l-teal-500 ring-teal-200 dark:ring-teal-800',
'Religion/Ethics': 'border-l-amber-500 ring-amber-200 dark:ring-amber-800',
'Art': 'border-l-fuchsia-500 ring-fuchsia-200 dark:ring-fuchsia-800',
};
const FALLBACK_COLOR = 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-900/40 dark:text-gray-200 dark:border-gray-800';
const FALLBACK_ACCENT = 'border-l-gray-400 ring-gray-200 dark:ring-gray-800';
const BREAK_COLOR = 'bg-gray-50 text-gray-500 border-gray-200 dark:bg-gray-800/30 dark:text-gray-400 dark:border-gray-700';
function getSubjectColor(subjectName: string | null): string {
@@ -74,6 +103,11 @@ function getSubjectColor(subjectName: string | null): string {
return SUBJECT_COLORS[subjectName] ?? FALLBACK_COLOR;
}
function getSubjectAccentColor(subjectName: string | null): string {
if (!subjectName) return FALLBACK_ACCENT;
return SUBJECT_ACCENT_COLORS[subjectName] ?? FALLBACK_ACCENT;
}
// ── Types ──────────────────────────────────────────────────────────────
interface TimetableSlot {
id: string;
@@ -110,7 +144,7 @@ interface ClassOption {
}
const DAYS = [0, 1, 2, 3, 4]; // Mon-Fri
const PERIODS = [1, 2, 3, 4, 5, 6, 7, 8];
const PERIODS = [1, 2, 3, 4, 5, 6];
const DEFAULT_TIMES: Record<number, { start: string; end: string }> = {
1: { start: '08:00', end: '08:45' },
2: { start: '08:45', end: '09:30' },
@@ -127,6 +161,68 @@ function getDayLabel(dayOfWeek: number): string {
return t(keys[dayOfWeek] ?? 'timetable.day');
}
function getDayShortLabel(dayOfWeek: number): string {
const keys = ['timetable.mon_short', 'timetable.tue_short', 'timetable.wed_short', 'timetable.thu_short', 'timetable.fri_short'];
return t(keys[dayOfWeek] ?? 'timetable.day');
}
// ── Current Period Detection ─────────────────────────────────────────
function getCurrentPeriodInfo(): { day: number; period: number } | null {
const now = new Date();
const jsDay = now.getDay(); // 0=Sun, 1=Mon...
const dayOfWeek = jsDay - 1; // 0=Mon, 1=Tue...
if (dayOfWeek < 0 || dayOfWeek > 4) return null; // weekend
const currentMinutes = now.getHours() * 60 + now.getMinutes();
for (const [periodStr, times] of Object.entries(DEFAULT_TIMES)) {
const period = parseInt(periodStr);
const [startH, startM] = times.start.split(':').map(Number);
const [endH, endM] = times.end.split(':').map(Number);
const startMin = startH * 60 + startM;
const endMin = endH * 60 + endM;
if (currentMinutes >= startMin && currentMinutes < endMin) {
return { day: dayOfWeek, period };
}
}
return null;
}
// ── Loading Skeleton ────────────────────────────────────────────────
function TimetableSkeleton() {
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[640px]">
<thead>
<tr className="bg-emerald-50 dark:bg-emerald-950/30">
<th className="p-2 w-[80px]"><Skeleton className="h-4 w-12" /></th>
<th className="p-2"><Skeleton className="h-4 w-20 mx-auto" /></th>
{DAYS.map((d) => (
<th key={d} className="p-2 min-w-[120px]"><Skeleton className="h-4 w-16 mx-auto" /></th>
))}
</tr>
</thead>
<tbody>
{PERIODS.map((p) => (
<tr key={p}>
<td className="p-2"><Skeleton className="h-4 w-6 mx-auto" /></td>
<td className="p-2"><Skeleton className="h-3 w-16 mx-auto" /></td>
{DAYS.map((d) => (
<td key={`${d}-${p}`} className="p-1">
<Skeleton className="h-12 w-full rounded-md" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
// ── Slot Dialog ────────────────────────────────────────────────────────
function SlotDialog({
open,
@@ -261,7 +357,7 @@ function SlotDialog({
<div className="space-y-2">
<Label htmlFor="slot-room">{t('timetable.room')}</Label>
<Input id="slot-room" value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder="Room..." className="min-h-[44px]" />
<Input id="slot-room" value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder={t('timetable.room_placeholder')} className="min-h-[44px]" />
</div>
</>
)}
@@ -313,15 +409,27 @@ export default function TimetableView() {
const [teachers, setTeachers] = useState<TeacherOption[]>([]);
const [selectedClassId, setSelectedClassId] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editSlot, setEditSlot] = useState<TimetableSlot | null>(null);
const [dialogDay, setDialogDay] = useState(0);
const [dialogPeriod, setDialogPeriod] = useState(1);
const [dragSlot, setDragSlot] = useState<string | null>(null);
const [dragOverCell, setDragOverCell] = useState<{ day: number; period: number } | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const schoolId = currentUser?.schoolId ?? '';
// Current period detection
const currentPeriod = useMemo(() => getCurrentPeriodInfo(), []);
// Refresh current period every minute
const [now, setNow] = useState(Date.now());
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 60000);
return () => clearInterval(interval);
}, []);
const liveCurrentPeriod = useMemo(() => getCurrentPeriodInfo(), [now]);
// Load classes
useEffect(() => {
if (!schoolId) return;
@@ -362,11 +470,13 @@ export default function TimetableView() {
return;
}
setLoading(true);
setError(null);
try {
const data = await apiGet<TimetableSlot[]>(`/api/timetable?schoolId=${schoolId}&classGroupId=${selectedClassId}`);
setSlots(data);
} catch {
} catch (err) {
setSlots([]);
setError(err instanceof Error ? err.message : t('timetable.load_error'));
}
setLoading(false);
}, [schoolId, selectedClassId]);
@@ -384,11 +494,21 @@ export default function TimetableView() {
return map;
}, [slots]);
// Get unique subjects for legend
const subjectLegend = useMemo(() => {
const seen = new Map<string, string>();
for (const slot of slots) {
if (slot.subject && !seen.has(slot.subject.id)) {
seen.set(slot.subject.id, slot.subject.name);
}
}
return [...seen.entries()].map(([id, name]) => ({ id, name }));
}, [slots]);
// Handlers
const handleSaveSlot = async (data: Record<string, unknown>) => {
try {
if (data.id) {
// Update existing
await apiPut<TimetableSlot>(`/api/timetable/${data.id}`, {
subjectId: data.subjectId,
teacherId: data.teacherId,
@@ -398,7 +518,6 @@ export default function TimetableView() {
isBreak: data.isBreak,
});
} else {
// Create new
await apiPost<TimetableSlot>('/api/timetable', data);
}
await loadSlots();
@@ -457,10 +576,38 @@ export default function TimetableView() {
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Clock className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
<h1 className="text-2xl font-bold">{t('timetable.title')}</h1>
<div>
<h1 className="text-2xl font-bold">{t('timetable.title')}</h1>
{liveCurrentPeriod && (
<p className="text-sm text-muted-foreground flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
{t('timetable.current_period', { period: liveCurrentPeriod.period })} — {getDayLabel(liveCurrentPeriod.day)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* View Mode Toggle */}
<div className="flex items-center border rounded-md overflow-hidden">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
className="min-h-[44px] rounded-none"
onClick={() => setViewMode('grid')}
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="min-h-[44px] rounded-none"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
</div>
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
<SelectTrigger className="w-[180px] min-h-[44px]">
<SelectValue placeholder={t('timetable.select_class')} />
@@ -484,165 +631,353 @@ export default function TimetableView() {
</div>
</div>
{/* Timetable Grid */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : !selectedClassId ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{t('timetable.select_class')}
</CardContent>
</Card>
) : (
<Card className="print:shadow-none print:border-0">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[640px]">
{/* Header row: Day names */}
<thead>
<tr className="bg-emerald-50 dark:bg-emerald-950/30">
<th className="p-2 text-sm font-semibold text-left w-[80px] border border-gray-200 dark:border-gray-700">
{t('timetable.period')}
</th>
<th className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700">
{t('timetable.start_time')} / {t('timetable.end_time')}
</th>
{DAYS.map((day) => (
<th key={day} className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700 min-w-[120px]">
{getDayLabel(day)}
</th>
))}
</tr>
</thead>
<tbody>
{PERIODS.map((period) => {
const defaultTime = DEFAULT_TIMES[period];
const slotInRow = slots.find((s) => s.period === period);
const timeDisplay = slotInRow
? `${slotInRow.startTime}–${slotInRow.endTime}`
: `${defaultTime?.start ?? ''}–${defaultTime?.end ?? ''}`;
// Check if this is a break row (common convention: period 3 is break)
const isBreakRow = PERIODS.indexOf(period) === 2; // between period 3 and 4
return (
<React.Fragment key={period}>
<tr className="hover:bg-gray-50/50 dark:hover:bg-gray-800/20">
<td className="p-2 text-sm font-medium text-center border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30">
{period}
</td>
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
{timeDisplay}
</td>
{DAYS.map((day) => {
const cellSlot = gridMap[`${day}-${period}`];
const isDragOver = dragOverCell?.day === day && dragOverCell?.period === period;
const isDragTarget = dragSlot && !cellSlot;
return (
<td
key={`${day}-${period}`}
className={`p-1 border border-gray-200 dark:border-gray-700 transition-colors ${
isDragOver && isDragTarget ? 'bg-emerald-50 dark:bg-emerald-950/40' : ''
} ${cellSlot?.isBreak ? BREAK_COLOR.split(' ')[0] : ''}`}
onDragOver={(e) => { e.preventDefault(); handleDragOver(day, period); }}
onDragLeave={() => setDragOverCell(null)}
onDrop={() => handleDrop(day, period)}
>
{cellSlot ? (
<motion.div
layout
draggable
onDragStart={() => handleDragStart(cellSlot.id)}
onDragEnd={handleDragEnd}
className={`rounded-md p-2 cursor-pointer min-h-[44px] flex flex-col gap-0.5 border ${cellSlot.isBreak ? BREAK_COLOR : getSubjectColor(cellSlot.subject?.name ?? null)}`}
onClick={() => {
setEditSlot(cellSlot);
setDialogDay(day);
setDialogPeriod(period);
setDialogOpen(true);
}}
>
<div className="flex items-center justify-between">
<span className="font-medium text-xs truncate">
{cellSlot.isBreak
? t('timetable.break_period')
: cellSlot.subject?.name ?? t('timetable.no_subject')}
</span>
<GripVertical className="h-3 w-3 opacity-40 shrink-0" />
</div>
{!cellSlot.isBreak && cellSlot.teacher && (
<span className="text-xs opacity-70 truncate">
{cellSlot.teacher.firstName} {cellSlot.teacher.lastName}
</span>
)}
{cellSlot.roomId && (
<span className="text-xs opacity-60 truncate">
R{cellSlot.roomId}
</span>
)}
</motion.div>
) : (
<button
className="w-full h-full min-h-[44px] flex items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => {
setEditSlot(null);
setDialogDay(day);
setDialogPeriod(period);
setDialogOpen(true);
}}
aria-label={`${t('timetable.add_slot')} - ${getDayLabel(day)} Period ${period}`}
>
<Plus className="h-4 w-4 text-muted-foreground opacity-30" />
</button>
)}
</td>
);
})}
</tr>
{/* Break row between period 3 and 4 */}
{isBreakRow && (
<tr className="bg-gray-100 dark:bg-gray-800/20">
<td className="p-2 text-xs text-center border border-gray-200 dark:border-gray-700">
<Coffee className="h-3 w-3 mx-auto text-gray-400" />
</td>
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
10:15–10:30
</td>
{DAYS.map((day) => (
<td
key={`break-${day}`}
className="p-1 border border-gray-200 dark:border-gray-700"
>
<div className={`rounded-md p-2 min-h-[28px] flex items-center justify-center border ${BREAK_COLOR}`}>
<span className="text-xs font-medium">{t('timetable.break_period')}</span>
</div>
</td>
))}
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
{/* Subject Legend */}
{subjectLegend.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground font-medium">{t('timetable.legend')}:</span>
{subjectLegend.map(({ id, name }) => (
<div
key={id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs border ${getSubjectColor(name)}`}
>
<span className="font-medium">{name}</span>
</div>
))}
</div>
)}
{slots.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
<BookOpen className="h-8 w-8 mx-auto mb-2 opacity-30" />
{t('timetable.no_slots')}
</div>
)}
{/* Error State */}
{error && (
<Card className="border-rose-200 dark:border-rose-800">
<CardContent className="py-6 text-center">
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-rose-500" />
<p className="text-rose-600 dark:text-rose-400 mb-3">{error}</p>
<Button variant="outline" size="sm" onClick={loadSlots} className="min-h-[44px]">
<RefreshCw className="h-4 w-4 mr-1" />
{t('action.refresh')}
</Button>
</CardContent>
</Card>
)}
{/* Timetable Grid */}
{!error && loading && (
<TimetableSkeleton />
)}
{!error && !loading && !selectedClassId && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<CalendarDays className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{t('timetable.select_class')}</p>
<p className="text-sm">{t('timetable.select_class_hint')}</p>
</CardContent>
</Card>
)}
{!error && !loading && selectedClassId && viewMode === 'grid' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="print:shadow-none print:border-0">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[640px]">
{/* Header row: Day names */}
<thead>
<tr className="bg-emerald-50 dark:bg-emerald-950/30">
<th className="p-2 text-sm font-semibold text-left w-[80px] border border-gray-200 dark:border-gray-700">
{t('timetable.period')}
</th>
<th className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700">
{t('timetable.start_time')} / {t('timetable.end_time')}
</th>
{DAYS.map((day) => {
const isToday = liveCurrentPeriod?.day === day;
return (
<th
key={day}
className={`p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700 min-w-[120px] ${
isToday ? 'bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300' : ''
}`}
>
<div>{getDayLabel(day)}</div>
{isToday && (
<div className="text-xs font-normal flex items-center justify-center gap-1">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
{t('timetable.today')}
</div>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{PERIODS.map((period) => {
const defaultTime = DEFAULT_TIMES[period];
const slotInRow = slots.find((s) => s.period === period);
const timeDisplay = slotInRow
? `${slotInRow.startTime}–${slotInRow.endTime}`
: `${defaultTime?.start ?? ''}–${defaultTime?.end ?? ''}`;
const isCurrentPeriod = liveCurrentPeriod?.period === period;
return (
<React.Fragment key={period}>
<tr className={`hover:bg-gray-50/50 dark:hover:bg-gray-800/20 ${
isCurrentPeriod ? 'bg-emerald-50/50 dark:bg-emerald-950/20' : ''
}`}>
<td className={`p-2 text-sm font-medium text-center border border-gray-200 dark:border-gray-700 ${
isCurrentPeriod
? 'bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300'
: 'bg-gray-50 dark:bg-gray-800/30'
}`}>
<div className="flex items-center justify-center gap-1">
{period}
{isCurrentPeriod && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
)}
</div>
</td>
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
{timeDisplay}
</td>
{DAYS.map((day) => {
const cellSlot = gridMap[`${day}-${period}`];
const isDragOver = dragOverCell?.day === day && dragOverCell?.period === period;
const isDragTarget = dragSlot && !cellSlot;
const isCurrentCell = liveCurrentPeriod?.day === day && liveCurrentPeriod?.period === period;
return (
<td
key={`${day}-${period}`}
className={`p-1 border border-gray-200 dark:border-gray-700 transition-colors ${
isDragOver && isDragTarget ? 'bg-emerald-50 dark:bg-emerald-950/40' : ''
} ${isCurrentCell ? 'ring-2 ring-inset ring-emerald-400 dark:ring-emerald-600' : ''} ${
cellSlot?.isBreak ? BREAK_COLOR.split(' ')[0] : ''
}`}
onDragOver={(e) => { e.preventDefault(); handleDragOver(day, period); }}
onDragLeave={() => setDragOverCell(null)}
onDrop={() => handleDrop(day, period)}
>
{cellSlot ? (
<motion.div
layout
draggable
onDragStart={() => handleDragStart(cellSlot.id)}
onDragEnd={handleDragEnd}
className={`rounded-md p-2 cursor-pointer min-h-[44px] flex flex-col gap-0.5 border-l-4 ${
cellSlot.isBreak
? `${BREAK_COLOR} border-l-gray-300 dark:border-l-gray-600`
: `${getSubjectColor(cellSlot.subject?.name ?? null)} ${getSubjectAccentColor(cellSlot.subject?.name ?? null)}`
} ${isCurrentCell ? 'shadow-md' : ''}`}
onClick={() => {
setEditSlot(cellSlot);
setDialogDay(day);
setDialogPeriod(period);
setDialogOpen(true);
}}
>
<div className="flex items-center justify-between">
<span className="font-medium text-xs truncate">
{cellSlot.isBreak
? t('timetable.break_period')
: cellSlot.subject?.name ?? t('timetable.no_subject')}
</span>
<GripVertical className="h-3 w-3 opacity-40 shrink-0" />
</div>
{!cellSlot.isBreak && cellSlot.teacher && (
<span className="text-xs opacity-70 truncate flex items-center gap-0.5">
<User className="h-2.5 w-2.5" />
{cellSlot.teacher.firstName[0]}. {cellSlot.teacher.lastName}
</span>
)}
{cellSlot.roomId && (
<span className="text-xs opacity-60 truncate flex items-center gap-0.5">
<MapPin className="h-2.5 w-2.5" />
{cellSlot.roomId}
</span>
)}
</motion.div>
) : (
<button
className={`w-full h-full min-h-[44px] flex items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-gray-800/30 transition-colors cursor-pointer ${
isCurrentCell ? 'bg-emerald-50/50 dark:bg-emerald-950/20' : ''
}`}
onClick={() => {
setEditSlot(null);
setDialogDay(day);
setDialogPeriod(period);
setDialogOpen(true);
}}
aria-label={`${t('timetable.add_slot')} - ${getDayLabel(day)} Period ${period}`}
>
<Plus className="h-4 w-4 text-muted-foreground opacity-30" />
</button>
)}
</td>
);
})}
</tr>
{/* Break row after period 3 */}
{period === 3 && (
<tr className="bg-gray-100 dark:bg-gray-800/20">
<td className="p-2 text-xs text-center border border-gray-200 dark:border-gray-700">
<Coffee className="h-3 w-3 mx-auto text-gray-400" />
</td>
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
10:15–10:30
</td>
{DAYS.map((day) => (
<td
key={`break-${day}`}
className="p-1 border border-gray-200 dark:border-gray-700"
>
<div className={`rounded-md p-2 min-h-[28px] flex items-center justify-center border ${BREAK_COLOR}`}>
<span className="text-xs font-medium">{t('timetable.break_period')}</span>
</div>
</td>
))}
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{slots.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{t('timetable.no_slots')}</p>
<p className="text-sm">{t('timetable.no_slots_hint')}</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
)}
{!error && !loading && selectedClassId && viewMode === 'list' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="space-y-4">
{DAYS.map((day) => {
const daySlots = slots
.filter((s) => s.dayOfWeek === day)
.sort((a, b) => a.period - b.period);
const isToday = liveCurrentPeriod?.day === day;
return (
<Card key={day} className={isToday ? 'ring-2 ring-emerald-400 dark:ring-emerald-600' : ''}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
{getDayLabel(day)}
{isToday && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300 text-xs">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse mr-1" />
{t('timetable.today')}
</Badge>
)}
</CardTitle>
<Button
variant="ghost"
size="sm"
className="min-h-[44px]"
onClick={() => {
setEditSlot(null);
setDialogDay(day);
setDialogPeriod(1);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{daySlots.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{t('timetable.no_slots')}</p>
) : (
<div className="space-y-2">
{daySlots.map((slot) => {
const isCurrentSlot = liveCurrentPeriod?.day === day && liveCurrentPeriod?.period === slot.period;
return (
<motion.div
key={slot.id}
layout
draggable
onDragStart={() => handleDragStart(slot.id)}
onDragEnd={handleDragEnd}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer hover:shadow-sm transition-shadow ${
slot.isBreak
? BREAK_COLOR
: `${getSubjectColor(slot.subject?.name ?? null)} ${getSubjectAccentColor(slot.subject?.name ?? null)}`
} ${isCurrentSlot ? 'ring-2 ring-emerald-400 dark:ring-emerald-600 shadow-md' : ''}`}
onClick={() => {
setEditSlot(slot);
setDialogDay(day);
setDialogPeriod(slot.period);
setDialogOpen(true);
}}
>
<div className="flex items-center gap-1 text-sm font-medium min-w-[40px]">
{slot.period}
{isCurrentSlot && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{slot.isBreak
? t('timetable.break_period')
: slot.subject?.name ?? t('timetable.no_subject')}
</span>
</div>
{!slot.isBreak && (
<div className="flex items-center gap-3 text-xs opacity-70 mt-0.5">
{slot.teacher && (
<span className="flex items-center gap-0.5">
<User className="h-3 w-3" />
{slot.teacher.firstName} {slot.teacher.lastName}
</span>
)}
{slot.roomId && (
<span className="flex items-center gap-0.5">
<MapPin className="h-3 w-3" />
{slot.roomId}
</span>
)}
<span className="flex items-center gap-0.5">
<Clock className="h-3 w-3" />
{slot.startTime}–{slot.endTime}
</span>
</div>
)}
</div>
<GripVertical className="h-4 w-4 opacity-40 shrink-0" />
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</motion.div>
)}
{/* Drag hint */}
{slots.length > 0 && (
{slots.length > 0 && !error && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<GripVertical className="h-3 w-3" />
{t('timetable.drag_hint')}