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:
1 parent
58cb7afd81
commit
4e2eb05fbf
8 files changed
+1988
-448
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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')}
|
||||
|
||||
Reference in new issue
Block a user