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

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

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

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

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

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

Drawing Canvas:
- Added ruler/straight edge tool

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

No files matched your search

+3 -3
View File
@@ -218,9 +218,9 @@ function drawBackground(
ctx.lineTo(w, y);
ctx.stroke();
}
// Margin line
ctx.strokeStyle = '#D1D5DB';
ctx.lineWidth = 1.5;
// German-style red margin line (like Schulhefte)
ctx.strokeStyle = 'rgba(239, 68, 68, 0.35)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(64, 0);
ctx.lineTo(64, h);
+434 -45
View File
@@ -14,6 +14,8 @@ import {
Clock,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
X,
Check,
AlertTriangle,
@@ -27,6 +29,16 @@ import {
Star,
ArrowRight,
MoreHorizontal,
CalendarDays,
List,
LayoutGrid,
RefreshCw,
AlertCircle,
CheckCircle2,
Circle,
XCircle,
Trophy,
User,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -142,6 +154,36 @@ function getDueDateColor(status: 'overdue' | 'today' | 'upcoming'): string {
}
}
function getStatusBadge(status: string): string {
switch (status) {
case 'pending': return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
case 'submitted': return 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300';
case 'graded': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300';
case 'late': return 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
}
}
function getStatusLabel(status: string): string {
switch (status) {
case 'pending': return t('homework.status_pending');
case 'submitted': return t('homework.status_submitted');
case 'graded': return t('homework.status_graded');
case 'late': return t('homework.status_late');
default: return status;
}
}
function getStatusIcon(status: string) {
switch (status) {
case 'pending': return Circle;
case 'submitted': return CheckCircle2;
case 'graded': return Trophy;
case 'late': return XCircle;
default: return Circle;
}
}
function getHomeworkTypeIcon(type: string) {
switch (type) {
case 'assignment': return FileText;
@@ -154,43 +196,226 @@ function getHomeworkTypeIcon(type: string) {
}
function getHomeworkTypeLabel(type: string): string {
const key = `homework.type_${type}`;
const label = t(key);
return label === key ? type : label;
switch (type) {
case 'assignment': return t('homework.type_assignment');
case 'reading': return t('homework.type_reading');
case 'project': return t('homework.type_project');
case 'practice': return t('homework.type_practice');
case 'research': return t('homework.type_research');
default: return type;
}
}
function getStatusBadge(status: string) {
const colors: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
submitted: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300',
graded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300',
late: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300',
function getHomeworkTypeColor(type: string): string {
switch (type) {
case 'assignment': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300';
case 'reading': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300';
case 'project': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300';
case 'practice': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
case 'research': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/40 dark:text-gray-300';
}
}
// ── Calendar Helpers ────────────────────────────────────────────────
function getCalendarDays(year: number, month: number): (number | null)[] {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const startOffset = firstDay === 0 ? 6 : firstDay - 1; // Mon-based
const days: (number | null)[] = [];
for (let i = 0; i < startOffset; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
return days;
}
const MONTH_NAMES_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const MONTH_NAMES_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function getMonthName(month: number, locale: string): string {
return locale === 'de' ? MONTH_NAMES_DE[month] : MONTH_NAMES_EN[month];
}
// ── Loading Skeleton ────────────────────────────────────────────────
function HomeworkSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<CardContent className="p-4 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</CardContent>
</Card>
))}
</div>
);
}
function CalendarSkeleton() {
return (
<Card>
<CardContent className="p-4">
<div className="grid grid-cols-7 gap-1">
{[...Array(35)].map((_, i) => (
<Skeleton key={i} className="h-16 rounded-md" />
))}
</div>
</CardContent>
</Card>
);
}
// ── Calendar View Component ─────────────────────────────────────────
function HomeworkCalendarView({
homeworks,
onHomeworkClick,
locale,
}: {
homeworks: HomeworkItem[];
onHomeworkClick: (hw: HomeworkItem) => void;
locale: string;
}) {
const now = new Date();
const [currentYear, setCurrentYear] = useState(now.getFullYear());
const [currentMonth, setCurrentMonth] = useState(now.getMonth());
const calendarDays = useMemo(() => getCalendarDays(currentYear, currentMonth), [currentYear, currentMonth]);
// Map homework to dates
const homeworkByDate = useMemo(() => {
const map: Record<string, HomeworkItem[]> = {};
for (const hw of homeworks) {
const due = new Date(hw.dueDate);
const key = `${due.getFullYear()}-${due.getMonth()}-${due.getDate()}`;
if (!map[key]) map[key] = [];
map[key].push(hw);
}
return map;
}, [homeworks]);
const prevMonth = () => {
if (currentMonth === 0) { setCurrentMonth(11); setCurrentYear(currentYear - 1); }
else setCurrentMonth(currentMonth - 1);
};
return colors[status] || colors.pending;
const nextMonth = () => {
if (currentMonth === 11) { setCurrentMonth(0); setCurrentYear(currentYear + 1); }
else setCurrentMonth(currentMonth + 1);
};
const goToToday = () => {
setCurrentYear(now.getFullYear());
setCurrentMonth(now.getMonth());
};
const isToday = (day: number) => {
return day === now.getDate() && currentMonth === now.getMonth() && currentYear === now.getFullYear();
};
return (
<Card>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-primary" />
{getMonthName(currentMonth, locale)} {currentYear}
</CardTitle>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px]" onClick={goToToday}>
<CalendarDays className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px]" onClick={prevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px]" onClick={nextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{[t('timetable.mon_short'), t('timetable.tue_short'), t('timetable.wed_short'), t('timetable.thu_short'), t('timetable.fri_short'), t('homework.sat_short'), t('homework.sun_short')].map((day) => (
<div key={day} className="text-center text-xs font-medium text-muted-foreground py-1">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((day, i) => {
if (day === null) {
return <div key={`empty-${i}`} className="h-16 rounded-md" />;
}
const key = `${currentYear}-${currentMonth}-${day}`;
const dayHomeworks = homeworkByDate[key] ?? [];
const today = isToday(day);
return (
<div
key={`${currentYear}-${currentMonth}-${day}`}
className={`h-16 rounded-md p-1 text-xs border transition-colors ${
today
? 'bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-800'
: 'border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/30'
}`}
>
<div className={`font-medium ${today ? 'text-emerald-600 dark:text-emerald-400' : ''}`}>
{day}
</div>
<div className="space-y-0.5 mt-0.5 overflow-hidden max-h-[32px]">
{dayHomeworks.slice(0, 2).map((hw) => {
const dueStatus = getDueDateStatus(hw.dueDate);
return (
<button
key={hw.id}
className={`w-full text-left px-1 py-0.5 rounded text-[10px] font-medium truncate cursor-pointer ${
dueStatus === 'overdue'
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
: dueStatus === 'today'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
}`}
onClick={() => onHomeworkClick(hw)}
title={hw.title}
>
{hw.title}
</button>
);
})}
{dayHomeworks.length > 2 && (
<div className="text-[10px] text-muted-foreground pl-1">+{dayHomeworks.length - 2}</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
function getStatusLabel(status: string): string {
const key = `homework.status_${status}`;
const label = t(key);
return label === key ? status : label;
}
/* ── Main Component ────────────────────────────────────────────── */
/* ── Main Component ─────────────────────────────────────────────── */
export default function HomeworkView() {
const currentUser = useAppStore((s) => s.currentUser);
const currentClassId = useAppStore((s) => s.currentClassId);
const locale = useAppStore((s) => s.locale);
const currentClassId = useAppStore((s) => s.currentClassId);
const [homeworks, setHomeworks] = useState<HomeworkItem[]>([]);
const [classes, setClasses] = useState<ClassGroupSimple[]>([]);
const [subjects, setSubjects] = useState<SubjectSimple[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterClass, setFilterClass] = useState<string>('all');
const [filterSubject, setFilterSubject] = useState<string>('all');
const [filterType, setFilterType] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [filterClass, setFilterClass] = useState('all');
const [filterSubject, setFilterSubject] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [viewMode, setViewMode] = useState<'grid' | 'list' | 'calendar'>('grid');
// Create dialog
const [createOpen, setCreateOpen] = useState(false);
@@ -247,6 +472,7 @@ export default function HomeworkView() {
const loadHomework = useCallback(() => {
if (!currentUser?.schoolId) return;
setLoading(true);
setError(null);
const params = new URLSearchParams({ schoolId: currentUser.schoolId });
if (filterClass !== 'all') params.set('classGroupId', filterClass);
if (filterSubject !== 'all') params.set('subjectId', filterSubject);
@@ -257,7 +483,10 @@ export default function HomeworkView() {
setHomeworks(data);
setLoading(false);
})
.catch(() => setLoading(false));
.catch((err) => {
setError(err instanceof Error ? err.message : t('homework.load_error'));
setLoading(false);
});
}, [currentUser?.schoolId, filterClass, filterSubject, filterType, isStudent]);
useEffect(() => {
@@ -454,10 +683,42 @@ 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>
<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>
@@ -563,6 +824,7 @@ export default function HomeworkView() {
</Dialog>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
@@ -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" />
{/* 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>
))}
</div>
)}
{/* 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,11 +1026,23 @@ 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">
<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>
</Card>
@@ -750,8 +1050,82 @@ export default function HomeworkView() {
);
})}
</AnimatePresence>
</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,10 +1185,17 @@ 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) => (
{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"
@@ -826,13 +1207,20 @@ export default function HomeworkView() {
<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 text-muted-foreground">
<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">
@@ -840,7 +1228,8 @@ export default function HomeworkView() {
{sub.status === 'graded' ? t('homework.regrade') : t('homework.grade')}
</Button>
</div>
))}
);
})}
</div>
)}
</div>
+380 -33
View File
@@ -129,6 +129,30 @@ interface SectionData {
pageIds: string[];
}
// ─── Sticker Data Type ────────────────────────────────────────────────
interface StickerData {
id: string;
type: string;
x: number;
y: number;
size: number;
color: string;
pageId: string;
}
// ─── Washi Tape Data Type ────────────────────────────────────────────
interface WashiTapeData {
id: string;
x: number;
y: number;
width: number;
color: string;
pattern: string;
pageId: string;
}
// ─── Page Template Definitions ───────────────────────────────────────
const PAGE_TEMPLATES: Array<{
@@ -198,6 +222,41 @@ const STICKY_NOTE_COLORS = [
{ key: 'purple', color: '#ddd6fe', border: '#a78bfa' },
];
// ─── Sticker Definitions ─────────────────────────────────────────────
const STICKER_TYPES: Array<{
key: string;
labelKey: string;
icon: React.ElementType;
color: string;
}> = [
{ key: 'star', labelKey: 'notebooks.sticker_star', icon: Star, color: '#f59e0b' },
{ key: 'thumbs_up', labelKey: 'notebooks.sticker_thumbs_up', icon: Zap, color: '#10b981' },
{ key: 'checkmark', labelKey: 'notebooks.sticker_checkmark', icon: CheckCircle2, color: '#16a34a' },
{ key: 'heart', labelKey: 'notebooks.sticker_heart', icon: Heart, color: '#ef4444' },
{ key: 'trophy', labelKey: 'notebooks.sticker_trophy', icon: Trophy, color: '#f59e0b' },
{ key: 'lightning', labelKey: 'notebooks.sticker_lightning', icon: Zap, color: '#8b5cf6' },
{ key: 'flame', labelKey: 'notebooks.sticker_flame', icon: Flame, color: '#ef4444' },
{ key: 'medal', labelKey: 'notebooks.sticker_medal', icon: Trophy, color: '#ea580c' },
{ key: 'crown', labelKey: 'notebooks.sticker_crown', icon: Star, color: '#f59e0b' },
{ key: 'sun', labelKey: 'notebooks.sticker_sun', icon: Sparkles, color: '#f59e0b' },
{ key: 'rainbow', labelKey: 'notebooks.sticker_rainbow', icon: SparklesIcon, color: '#8b5cf6' },
{ key: 'flower', labelKey: 'notebooks.sticker_flower', icon: Leaf, color: '#10b981' },
];
// ─── Washi Tape Definitions ──────────────────────────────────────────
const WASHI_TAPE_COLORS = [
{ key: 'pink', color: '#fbcfe8', pattern: 'stripes' },
{ key: 'green', color: '#bbf7d0', pattern: 'dots' },
{ key: 'blue', color: '#bfdbfe', pattern: 'stripes' },
{ key: 'yellow', color: '#fef08a', pattern: 'cross' },
{ key: 'purple', color: '#ddd6fe', pattern: 'dots' },
{ key: 'orange', color: '#fed7aa', pattern: 'stripes' },
{ key: 'teal', color: '#99f6e4', pattern: 'cross' },
{ key: 'red', color: '#fecaca', pattern: 'dots' },
];
const SECTION_COLORS = [
'#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
];
@@ -553,19 +612,32 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
function getPageBackgroundCSS(type: string): React.CSSProperties {
switch (type) {
case 'lined':
case 'deutschheft': // German/Language notebook - lined paper with margin
case 'englischheft': // English notebook - lined paper
case 'religionsheft': // Religion/Ethics notebook - lined paper
return {
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)`,
backgroundSize: '100% 32px',
backgroundPosition: '0 16px',
};
case 'deutschheft_margin': // Deutschheft with margin line
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, transparent 60px, #e5e7eb 60px, #e5e7eb 62px, transparent 62px)
linear-gradient(90deg, transparent 60px, #ef4444 60px, #ef4444 62px, transparent 62px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
};
case 'deutschheft': // German/Language notebook - lined paper with red margin line (Schulheft style)
case 'englischheft': // English notebook - lined paper with margin
case 'religionsheft': // Religion/Ethics notebook - lined paper with margin
case 'geschichtsheft': // History notebook - lined with margin
case 'sachkundeheft': // General studies - lined with margin
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, transparent 60px, #ef4444 60px, #ef4444 62px, transparent 62px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
};
case 'deutschheft_margin': // Deutschheft with margin line (legacy)
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, transparent 60px, #ef4444 60px, #ef4444 62px, transparent 62px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
@@ -609,23 +681,6 @@ function getPageBackgroundCSS(type: string): React.CSSProperties {
case 'blank':
case 'kunstheft': // Art notebook - blank paper
return {};
case 'geschichtsheft': // History notebook - lined with timeline accent
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, #f5f3ff 0px, #f5f3ff 40px, transparent 40px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
};
case 'sachkundeheft': // General studies - lined with drawing space
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)
`,
backgroundSize: '100% 32px',
backgroundPosition: '0 16px',
};
default:
return {};
}
@@ -1352,10 +1407,24 @@ function PageThumbnail({ page, notebookType }: { page: NotebookPage; notebookTyp
<span className="text-gray-300 dark:text-gray-600 italic">Leere Seite</span>
)}
</div>
{/* Bookmark indicator */}
{/* Page number badge */}
<div className="absolute bottom-0.5 left-1 text-[9px] font-mono text-gray-400 dark:text-gray-500">
{page.pageNumber}
</div>
{/* Bookmark indicator with corner fold */}
{page.isBookmark && (
<div className="absolute top-0 right-0">
<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;
+308 -54
View File
@@ -25,6 +25,13 @@ import {
ChevronDown,
Tag,
BookOpen,
RefreshCw,
AlertCircle,
FolderOpen,
Grid3X3,
List,
Heart,
BookMarked,
} from 'lucide-react';
import {
Card,
@@ -56,6 +63,7 @@ import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
@@ -95,8 +103,18 @@ interface ClassOption {
type ResourceType = 'document' | 'worksheet' | 'presentation' | 'video_link' | 'image' | 'link' | 'audio';
// Resource categories for tabs
type ResourceCategory = 'all' | 'worksheets' | 'presentations' | 'videos' | 'links' | 'favorites';
const RESOURCE_TYPES: ResourceType[] = ['document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio'];
const CATEGORY_CONFIG: Record<string, { types: ResourceType[]; icon: typeof FileText; color: string }> = {
worksheets: { types: ['worksheet', 'document'], icon: ClipboardList, color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' },
presentations: { types: ['presentation'], icon: Presentation, color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' },
videos: { types: ['video_link'], icon: Video, color: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300' },
links: { types: ['link'], icon: Link2, color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300' },
};
function getTypeIcon(type: string) {
switch (type) {
case 'document': return FileText;
@@ -136,6 +154,38 @@ function getTypeLabel(type: string): string {
return t(keyMap[type] ?? 'resources.type');
}
// ── Thumbnail placeholder ─────────────────────────────────────────────
function ResourceThumbnail({ type, title }: { type: string; title: string }) {
const color = getTypeColor(type);
return (
<div className={`w-full h-24 rounded-t-lg flex items-center justify-center ${color} relative overflow-hidden`}>
{React.createElement(getTypeIcon(type), { className: 'h-8 w-8 opacity-60' })}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/20 to-transparent h-8" />
</div>
);
}
// ── Loading Skeleton ──────────────────────────────────────────────────
function ResourceGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Card key={i} className="h-full">
<Skeleton className="h-24 rounded-t-lg" />
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</CardHeader>
<CardContent className="p-4 pt-0">
<Skeleton className="h-3 w-full mb-2" />
<Skeleton className="h-3 w-2/3" />
</CardContent>
</Card>
))}
</div>
);
}
// ── Resource Create/Edit Dialog ────────────────────────────────────────
function ResourceDialog({
open,
@@ -162,7 +212,7 @@ function ResourceDialog({
const [subjectId, setSubjectId] = useState<string>('none');
const [classGroupId, setClassGroupId] = useState<string>('none');
const [gradeLevel, setGradeLevel] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [tags, setTags] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [saving, setSaving] = useState(false);
@@ -177,11 +227,9 @@ function ResourceDialog({
setClassGroupId(resource.classGroupId ?? 'none');
setGradeLevel(resource.gradeLevel?.toString() ?? '');
try {
const parsedTags = resource.tags ? JSON.parse(resource.tags) : [];
setTagsInput(Array.isArray(parsedTags) ? parsedTags.join(', ') : '');
} catch {
setTagsInput('');
}
const parsed = JSON.parse(resource.tags ?? '[]');
setTags(Array.isArray(parsed) ? parsed.join(', ') : '');
} catch { setTags(''); }
setIsPublic(resource.isPublic);
} else {
setTitle('');
@@ -192,31 +240,29 @@ function ResourceDialog({
setSubjectId('none');
setClassGroupId('none');
setGradeLevel('');
setTagsInput('');
setTags('');
setIsPublic(true);
}
}, [resource, open]);
const handleSave = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const tags = tagsInput
const tagList = tags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const data: Record<string, unknown> = {
schoolId,
title: title.trim(),
description: description.trim() || null,
title,
description: description || null,
resourceType,
url: url.trim() || null,
content: content.trim() || null,
url: url || null,
content: content || null,
subjectId: subjectId === 'none' ? null : subjectId,
classGroupId: classGroupId === 'none' ? null : classGroupId,
gradeLevel: gradeLevel ? parseInt(gradeLevel, 10) : null,
tags: tags.length > 0 ? tags : null,
gradeLevel: gradeLevel ? parseInt(gradeLevel) : null,
tags: JSON.stringify(tagList),
isPublic,
};
if (resource) {
@@ -236,14 +282,14 @@ function ResourceDialog({
{resource ? t('resources.edit') : t('resources.create')}
</DialogTitle>
<DialogDescription>
{resource ? t('resources.edit') : t('resources.create')}
{t('resources.create_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="res-title">{t('resources.title_field')}</Label>
<Input id="res-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title..." className="min-h-[44px]" />
<Input id="res-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t('resources.title_field')} className="min-h-[44px]" />
</div>
<div className="space-y-2">
@@ -262,7 +308,7 @@ function ResourceDialog({
<div className="space-y-2">
<Label htmlFor="res-desc">{t('resources.description_field')}</Label>
<Textarea id="res-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description..." rows={3} />
<Textarea id="res-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('resources.description_field')} rows={3} />
</div>
<div className="space-y-2">
@@ -270,12 +316,10 @@ function ResourceDialog({
<Input id="res-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="min-h-[44px]" />
</div>
{(resourceType === 'document' || resourceType === 'worksheet') && (
<div className="space-y-2">
<Label htmlFor="res-content">{t('resources.content_field')}</Label>
<Textarea id="res-content" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Text content..." rows={4} />
<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]" />
<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>
<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>
<div className="flex items-start justify-between gap-2">
<DialogTitle className="flex items-center gap-2">
{React.createElement(typeIcon, { className: 'h-5 w-5' })}
{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,6 +675,27 @@ export default function ResourceLibraryView() {
<h1 className="text-2xl font-bold">{t('resources.library')}</h1>
</div>
<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={() => {
@@ -595,6 +707,33 @@ export default function ResourceLibraryView() {
{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 */}
<div className="flex items-center gap-3 flex-wrap">
@@ -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>
)}
{/* 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>
</>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<>
<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>
</motion.div>
)}
{!error && !loading && sortedResources.length > 0 && viewMode === 'list' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="space-y-2"
>
<AnimatePresence>
{sortedResources.map((resource) => {
const TypeIcon = getTypeIcon(resource.resourceType);
const isFav = favorites.has(resource.id);
return (
<motion.div
key={resource.id}
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${getTypeColor(resource.resourceType)} shrink-0`}>
<TypeIcon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{resource.title}</span>
{resource.subject && (
<Badge variant="outline" className="text-xs shrink-0">{resource.subject.name}</Badge>
)}
{resource.isPublic ? (
<Globe className="h-3 w-3 text-muted-foreground shrink-0" />
) : (
<Lock className="h-3 w-3 text-muted-foreground shrink-0" />
)}
</div>
{resource.description && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{resource.description}</p>
)}
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-0.5"><Download className="h-3 w-3" /> {resource.downloadCount}</span>
<span>{resource.author.firstName} {resource.author.lastName}</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px] flex items-center justify-center"
onClick={() => toggleFavorite(resource.id)}
aria-label={t('resources.favorite')}
>
<Star className={`h-4 w-4 ${isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'}`} />
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => { setPreviewResource(resource); setPreviewOpen(true); }}
>
<Eye className="h-4 w-4" />
</Button>
{currentUser?.id === resource.authorId && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px] text-destructive"
onClick={() => handleDeleteResource(resource.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
)}
{/* Resource Create/Edit Dialog */}
@@ -805,6 +1057,8 @@ export default function ResourceLibraryView() {
open={previewOpen}
onClose={() => { setPreviewOpen(false); setPreviewResource(null); }}
resource={previewResource}
isFav={previewResource ? favorites.has(previewResource.id) : false}
onToggleFav={() => previewResource && toggleFavorite(previewResource.id)}
/>
</div>
);
+2 -2
View File
@@ -4199,8 +4199,6 @@ export default function SettingsView() {
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
{/* ── Backup Restore Confirmation ──────────────────────────── */}
<AlertDialog open={!!restoreBackupId} onOpenChange={(open) => { if (!open) setRestoreBackupId(null); }}>
@@ -5185,6 +5183,8 @@ export default function SettingsView() {
<TabsContent value="privacy">
<PrivacyTab currentUser={currentUser} />
</TabsContent>
</Tabs>
</motion.div>
</motion.div>
);
}
+371 -36
View File
@@ -14,6 +14,13 @@ import {
ChevronUp,
Coffee,
BookOpen,
MapPin,
User,
CalendarDays,
LayoutGrid,
List,
RefreshCw,
AlertCircle,
} from 'lucide-react';
import {
Card,
@@ -42,6 +49,7 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
@@ -66,7 +74,28 @@ const SUBJECT_COLORS: Record<string, string> = {
'Art': 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-300 dark:bg-fuchsia-900/40 dark:text-fuchsia-200 dark:border-fuchsia-800',
};
// Subject accent colors for borders and highlights
const SUBJECT_ACCENT_COLORS: Record<string, string> = {
'Deutsch': 'border-l-rose-500 ring-rose-200 dark:ring-rose-800',
'Mathematik': 'border-l-blue-500 ring-blue-200 dark:ring-blue-800',
'Englisch': 'border-l-violet-500 ring-violet-200 dark:ring-violet-800',
'Sachunterricht': 'border-l-emerald-500 ring-emerald-200 dark:ring-emerald-800',
'Musik': 'border-l-orange-500 ring-orange-200 dark:ring-orange-800',
'Sport': 'border-l-teal-500 ring-teal-200 dark:ring-teal-800',
'Religion': 'border-l-amber-500 ring-amber-200 dark:ring-amber-800',
'Kunst': 'border-l-fuchsia-500 ring-fuchsia-200 dark:ring-fuchsia-800',
'German': 'border-l-rose-500 ring-rose-200 dark:ring-rose-800',
'Mathematics': 'border-l-blue-500 ring-blue-200 dark:ring-blue-800',
'English': 'border-l-violet-500 ring-violet-200 dark:ring-violet-800',
'Science': 'border-l-emerald-500 ring-emerald-200 dark:ring-emerald-800',
'Music': 'border-l-orange-500 ring-orange-200 dark:ring-orange-800',
'Physical Education': 'border-l-teal-500 ring-teal-200 dark:ring-teal-800',
'Religion/Ethics': 'border-l-amber-500 ring-amber-200 dark:ring-amber-800',
'Art': 'border-l-fuchsia-500 ring-fuchsia-200 dark:ring-fuchsia-800',
};
const FALLBACK_COLOR = 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-900/40 dark:text-gray-200 dark:border-gray-800';
const FALLBACK_ACCENT = 'border-l-gray-400 ring-gray-200 dark:ring-gray-800';
const BREAK_COLOR = 'bg-gray-50 text-gray-500 border-gray-200 dark:bg-gray-800/30 dark:text-gray-400 dark:border-gray-700';
function getSubjectColor(subjectName: string | null): string {
@@ -74,6 +103,11 @@ function getSubjectColor(subjectName: string | null): string {
return SUBJECT_COLORS[subjectName] ?? FALLBACK_COLOR;
}
function getSubjectAccentColor(subjectName: string | null): string {
if (!subjectName) return FALLBACK_ACCENT;
return SUBJECT_ACCENT_COLORS[subjectName] ?? FALLBACK_ACCENT;
}
// ── Types ──────────────────────────────────────────────────────────────
interface TimetableSlot {
id: string;
@@ -110,7 +144,7 @@ interface ClassOption {
}
const DAYS = [0, 1, 2, 3, 4]; // Mon-Fri
const PERIODS = [1, 2, 3, 4, 5, 6, 7, 8];
const PERIODS = [1, 2, 3, 4, 5, 6];
const DEFAULT_TIMES: Record<number, { start: string; end: string }> = {
1: { start: '08:00', end: '08:45' },
2: { start: '08:45', end: '09:30' },
@@ -127,6 +161,68 @@ function getDayLabel(dayOfWeek: number): string {
return t(keys[dayOfWeek] ?? 'timetable.day');
}
function getDayShortLabel(dayOfWeek: number): string {
const keys = ['timetable.mon_short', 'timetable.tue_short', 'timetable.wed_short', 'timetable.thu_short', 'timetable.fri_short'];
return t(keys[dayOfWeek] ?? 'timetable.day');
}
// ── Current Period Detection ─────────────────────────────────────────
function getCurrentPeriodInfo(): { day: number; period: number } | null {
const now = new Date();
const jsDay = now.getDay(); // 0=Sun, 1=Mon...
const dayOfWeek = jsDay - 1; // 0=Mon, 1=Tue...
if (dayOfWeek < 0 || dayOfWeek > 4) return null; // weekend
const currentMinutes = now.getHours() * 60 + now.getMinutes();
for (const [periodStr, times] of Object.entries(DEFAULT_TIMES)) {
const period = parseInt(periodStr);
const [startH, startM] = times.start.split(':').map(Number);
const [endH, endM] = times.end.split(':').map(Number);
const startMin = startH * 60 + startM;
const endMin = endH * 60 + endM;
if (currentMinutes >= startMin && currentMinutes < endMin) {
return { day: dayOfWeek, period };
}
}
return null;
}
// ── Loading Skeleton ────────────────────────────────────────────────
function TimetableSkeleton() {
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[640px]">
<thead>
<tr className="bg-emerald-50 dark:bg-emerald-950/30">
<th className="p-2 w-[80px]"><Skeleton className="h-4 w-12" /></th>
<th className="p-2"><Skeleton className="h-4 w-20 mx-auto" /></th>
{DAYS.map((d) => (
<th key={d} className="p-2 min-w-[120px]"><Skeleton className="h-4 w-16 mx-auto" /></th>
))}
</tr>
</thead>
<tbody>
{PERIODS.map((p) => (
<tr key={p}>
<td className="p-2"><Skeleton className="h-4 w-6 mx-auto" /></td>
<td className="p-2"><Skeleton className="h-3 w-16 mx-auto" /></td>
{DAYS.map((d) => (
<td key={`${d}-${p}`} className="p-1">
<Skeleton className="h-12 w-full rounded-md" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
// ── Slot Dialog ────────────────────────────────────────────────────────
function SlotDialog({
open,
@@ -261,7 +357,7 @@ function SlotDialog({
<div className="space-y-2">
<Label htmlFor="slot-room">{t('timetable.room')}</Label>
<Input id="slot-room" value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder="Room..." className="min-h-[44px]" />
<Input id="slot-room" value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder={t('timetable.room_placeholder')} className="min-h-[44px]" />
</div>
</>
)}
@@ -313,15 +409,27 @@ export default function TimetableView() {
const [teachers, setTeachers] = useState<TeacherOption[]>([]);
const [selectedClassId, setSelectedClassId] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editSlot, setEditSlot] = useState<TimetableSlot | null>(null);
const [dialogDay, setDialogDay] = useState(0);
const [dialogPeriod, setDialogPeriod] = useState(1);
const [dragSlot, setDragSlot] = useState<string | null>(null);
const [dragOverCell, setDragOverCell] = useState<{ day: number; period: number } | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const schoolId = currentUser?.schoolId ?? '';
// Current period detection
const currentPeriod = useMemo(() => getCurrentPeriodInfo(), []);
// Refresh current period every minute
const [now, setNow] = useState(Date.now());
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 60000);
return () => clearInterval(interval);
}, []);
const liveCurrentPeriod = useMemo(() => getCurrentPeriodInfo(), [now]);
// Load classes
useEffect(() => {
if (!schoolId) return;
@@ -362,11 +470,13 @@ export default function TimetableView() {
return;
}
setLoading(true);
setError(null);
try {
const data = await apiGet<TimetableSlot[]>(`/api/timetable?schoolId=${schoolId}&classGroupId=${selectedClassId}`);
setSlots(data);
} catch {
} catch (err) {
setSlots([]);
setError(err instanceof Error ? err.message : t('timetable.load_error'));
}
setLoading(false);
}, [schoolId, selectedClassId]);
@@ -384,11 +494,21 @@ export default function TimetableView() {
return map;
}, [slots]);
// Get unique subjects for legend
const subjectLegend = useMemo(() => {
const seen = new Map<string, string>();
for (const slot of slots) {
if (slot.subject && !seen.has(slot.subject.id)) {
seen.set(slot.subject.id, slot.subject.name);
}
}
return [...seen.entries()].map(([id, name]) => ({ id, name }));
}, [slots]);
// Handlers
const handleSaveSlot = async (data: Record<string, unknown>) => {
try {
if (data.id) {
// Update existing
await apiPut<TimetableSlot>(`/api/timetable/${data.id}`, {
subjectId: data.subjectId,
teacherId: data.teacherId,
@@ -398,7 +518,6 @@ export default function TimetableView() {
isBreak: data.isBreak,
});
} else {
// Create new
await apiPost<TimetableSlot>('/api/timetable', data);
}
await loadSlots();
@@ -457,10 +576,38 @@ export default function TimetableView() {
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Clock className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
<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,18 +631,54 @@ 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" />
{/* 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>
) : !selectedClassId ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{t('timetable.select_class')}
))}
</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">
@@ -509,11 +692,25 @@ export default function TimetableView() {
<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)}
{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>
@@ -523,15 +720,24 @@ export default function TimetableView() {
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
const isCurrentPeriod = liveCurrentPeriod?.period === period;
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">
<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}
@@ -540,13 +746,16 @@ export default function TimetableView() {
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' : ''
} ${cellSlot?.isBreak ? BREAK_COLOR.split(' ')[0] : ''}`}
} ${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)}
@@ -557,7 +766,11 @@ export default function TimetableView() {
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)}`}
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);
@@ -574,19 +787,23 @@ export default function TimetableView() {
<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 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">
R{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"
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);
@@ -603,8 +820,8 @@ export default function TimetableView() {
})}
</tr>
{/* Break row between period 3 and 4 */}
{isBreakRow && (
{/* 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" />
@@ -632,17 +849,135 @@ export default function TimetableView() {
</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 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')}
+116
View File
@@ -2218,6 +2218,32 @@ const de: TranslationDict = {
'notebooks.table_of_contents': 'Inhaltsverzeichnis',
'notebooks.no_sections': 'Keine Abschnitte vorhanden',
// ─── Professional Notebook Features ────────────────────────────────
'notebooks.page_number': 'Seite',
'notebooks.page_of': 'von',
'notebooks.date_stamp': 'Datum',
'notebooks.margin_line': 'Randlinie',
'notebooks.stickers': 'Sticker',
'notebooks.sticker_add': 'Sticker hinzufuegen',
'notebooks.sticker_star': 'Stern',
'notebooks.sticker_thumbs_up': 'Daumen hoch',
'notebooks.sticker_checkmark': 'Haekchen',
'notebooks.sticker_heart': 'Herz',
'notebooks.sticker_trophy': 'Pokal',
'notebooks.sticker_lightning': 'Blitz',
'notebooks.sticker_flame': 'Flamme',
'notebooks.sticker_medal': 'Medaille',
'notebooks.sticker_crown': 'Krone',
'notebooks.sticker_sun': 'Sonne',
'notebooks.sticker_rainbow': 'Regenbogen',
'notebooks.sticker_flower': 'Blume',
'notebooks.sticker_remove': 'Sticker entfernen',
'notebooks.washi_tape': 'Washi-Tape',
'notebooks.washi_tape_add': 'Washi-Tape hinzufuegen',
'notebooks.washi_tape_remove': 'Washi-Tape entfernen',
'notebooks.corner_fold': 'Eselsore',
'notebooks.date_format': 'dd.MM.yyyy',
// ─── Highlighter Tool ────────────────────────────────────────────
'drawing.tool_highlighter': 'Textmarker',
'drawing.tool_highlighter_desc': 'Halbtransparenter Textmarker zum Hervorheben',
@@ -2836,6 +2862,14 @@ const de: TranslationDict = {
'homework.due_soon': 'Bald fällig',
'homework.no_due_soon': 'Keine Hausaufgaben in nächster Zeit fällig',
'homework.view_all': 'Alle anzeigen',
'homework.my_homework': 'Meine Hausaufgaben',
'homework.load_error': 'Fehler beim Laden der Hausaufgaben',
'homework.no_homework_hint': 'Es sind noch keine Hausaufgaben vorhanden.',
'homework.view_grid': 'Rasteransicht',
'homework.view_list': 'Listenansicht',
'homework.view_calendar': 'Kalenderansicht',
'homework.sat_short': 'Sa',
'homework.sun_short': 'So',
// Announcements
'announcement.title': 'Ankündigungen',
@@ -2960,6 +2994,18 @@ const de: TranslationDict = {
'timetable.delete_slot': 'Stunde löschen',
'timetable.break_period': 'Pause',
'timetable.drag_hint': 'Ziehen zum Verschieben',
'timetable.current_period': 'Aktuelle Stunde {period}',
'timetable.today': 'Heute',
'timetable.legend': 'Legende',
'timetable.load_error': 'Fehler beim Laden des Stundenplans',
'timetable.select_class_hint': 'Wählen Sie eine Klasse, um den Stundenplan anzuzeigen.',
'timetable.no_slots_hint': 'Klicken Sie auf ein Feld, um eine Stunde hinzuzufügen.',
'timetable.room_placeholder': 'z.B. Raum 101',
'timetable.mon_short': 'Mo',
'timetable.tue_short': 'Di',
'timetable.wed_short': 'Mi',
'timetable.thu_short': 'Do',
'timetable.fri_short': 'Fr',
// Resources
'resources.title': 'Ressourcen',
@@ -2998,6 +3044,18 @@ const de: TranslationDict = {
'resources.tags_field': 'Schlagwörter',
'resources.add_tag': 'Schlagwort hinzufügen',
'resources.delete_confirm': 'Ressource wirklich löschen?',
'resources.load_error': 'Fehler beim Laden der Ressourcen',
'resources.no_favorites': 'Keine Favoriten',
'resources.no_favorites_hint': 'Markieren Sie Ressourcen als Favorit, um sie hier zu sehen.',
'resources.no_resources_hint': 'Erstellen Sie eine neue Ressource, um zu beginnen.',
'resources.create_description': 'Erstellen Sie eine neue Ressource für den Unterricht.',
'resources.tags_placeholder': 'Schlagwort1, Schlagwort2, ...',
'resources.category_all': 'Alle',
'resources.category_worksheets': 'Arbeitsblätter',
'resources.category_presentations': 'Präsentationen',
'resources.category_videos': 'Videos',
'resources.category_links': 'Links',
'resources.category_favorites': 'Favoriten',
// ─── School Branding ──────────────────────────────────────────────
'branding.title': 'Schulbranding',
@@ -5989,6 +6047,32 @@ const en: TranslationDict = {
'notebooks.table_of_contents': 'Table of Contents',
'notebooks.no_sections': 'No sections yet',
// ─── Professional Notebook Features ────────────────────────────────
'notebooks.page_number': 'Page',
'notebooks.page_of': 'of',
'notebooks.date_stamp': 'Date',
'notebooks.margin_line': 'Margin line',
'notebooks.stickers': 'Stickers',
'notebooks.sticker_add': 'Add sticker',
'notebooks.sticker_star': 'Star',
'notebooks.sticker_thumbs_up': 'Thumbs up',
'notebooks.sticker_checkmark': 'Checkmark',
'notebooks.sticker_heart': 'Heart',
'notebooks.sticker_trophy': 'Trophy',
'notebooks.sticker_lightning': 'Lightning',
'notebooks.sticker_flame': 'Flame',
'notebooks.sticker_medal': 'Medal',
'notebooks.sticker_crown': 'Crown',
'notebooks.sticker_sun': 'Sun',
'notebooks.sticker_rainbow': 'Rainbow',
'notebooks.sticker_flower': 'Flower',
'notebooks.sticker_remove': 'Remove sticker',
'notebooks.washi_tape': 'Washi tape',
'notebooks.washi_tape_add': 'Add washi tape',
'notebooks.washi_tape_remove': 'Remove washi tape',
'notebooks.corner_fold': 'Dog-ear',
'notebooks.date_format': 'MM/dd/yyyy',
// ─── Highlighter Tool ────────────────────────────────────────────
'drawing.tool_highlighter': 'Highlighter',
'drawing.tool_highlighter_desc': 'Semi-transparent highlighter for emphasizing',
@@ -6607,6 +6691,14 @@ const en: TranslationDict = {
'homework.due_soon': 'Due Soon',
'homework.no_due_soon': 'No homework due soon',
'homework.view_all': 'View All',
'homework.my_homework': 'My Homework',
'homework.load_error': 'Error loading homework',
'homework.no_homework_hint': 'No homework has been created yet.',
'homework.view_grid': 'Grid View',
'homework.view_list': 'List View',
'homework.view_calendar': 'Calendar View',
'homework.sat_short': 'Sat',
'homework.sun_short': 'Sun',
// Announcements
'announcement.title': 'Announcements',
@@ -6731,6 +6823,18 @@ const en: TranslationDict = {
'timetable.delete_slot': 'Delete Slot',
'timetable.break_period': 'Break',
'timetable.drag_hint': 'Drag to move',
'timetable.current_period': 'Current Period {period}',
'timetable.today': 'Today',
'timetable.legend': 'Legend',
'timetable.load_error': 'Error loading timetable',
'timetable.select_class_hint': 'Select a class to view the timetable.',
'timetable.no_slots_hint': 'Click on a cell to add a slot.',
'timetable.room_placeholder': 'e.g. Room 101',
'timetable.mon_short': 'Mon',
'timetable.tue_short': 'Tue',
'timetable.wed_short': 'Wed',
'timetable.thu_short': 'Thu',
'timetable.fri_short': 'Fri',
// Resources
'resources.title': 'Resources',
@@ -6769,6 +6873,18 @@ const en: TranslationDict = {
'resources.tags_field': 'Tags',
'resources.add_tag': 'Add Tag',
'resources.delete_confirm': 'Are you sure you want to delete this resource?',
'resources.load_error': 'Error loading resources',
'resources.no_favorites': 'No Favorites',
'resources.no_favorites_hint': 'Mark resources as favorite to see them here.',
'resources.no_resources_hint': 'Create a new resource to get started.',
'resources.create_description': 'Create a new resource for the classroom.',
'resources.tags_placeholder': 'Tag1, Tag2, ...',
'resources.category_all': 'All',
'resources.category_worksheets': 'Worksheets',
'resources.category_presentations': 'Presentations',
'resources.category_videos': 'Videos',
'resources.category_links': 'Links',
'resources.category_favorites': 'Favorites',
// ─── School Branding ──────────────────────────────────────────────
'branding.title': 'School Branding',
+99
View File
@@ -2345,3 +2345,102 @@ Stage Summary:
- Database connection confirmed working (the "db bulunamiyor" report was not a database issue)
- Component-level TypeScript errors reduced from ~30 to 0
- Total TypeScript errors reduced from 173 to 53 (remaining are all in API routes, not user-facing)
---
Task ID: 5
Agent: professional-notebooks-enhancement
Task: Make Notebooks Even More Professional - Match Real Notebook Capabilities
Date: 2025-01-28
Work Log:
- **Page numbering** (`src/components/notebooks-view.tsx`): Added auto page number footer at the bottom of each page in both text-only and split view modes. Displayed as "— {pageNumber} —" in a subtle font-mono style.
- **German-style margin lines** (`src/components/notebooks-view.tsx` + `src/components/drawing-canvas.tsx`): Added red margin line on the left side of lined pages (like German Schulhefte). The CSS background pattern now includes a red line at 60px from the left edge. Updated the DrawingCanvas to also render the red margin line. Applied to all lined notebook types: lined, deutschheft, englischheft, religionsheft, geschichtsheft, sachkundeheft, calligraphy.
- **Date stamp** (`src/components/notebooks-view.tsx`): Modified `handleAddPage` to auto-add a date stamp (e.g., "Datum: 28.01.2025") at the top of each new page. The date is prepended to the page content in a subtle gray style.
- **Sticker/stamp collection** (`src/components/notebooks-view.tsx`): Added a sticker panel with 12 educational stickers (Star, Thumbs up, Checkmark, Heart, Trophy, Lightning, Flame, Medal, Crown, Sun, Rainbow, Flower) using Lucide icons. Stickers are placed on pages as overlays with hover-to-remove functionality. The panel is accessible via a Star icon button in the toolbar.
- **Page corner fold** (`src/components/notebooks-view.tsx`): Added a visual dog-ear (Eselsohr) effect on bookmarked pages in both the page thumbnail sidebar and the main content area. The corner fold uses a gradient effect matching the bookmark color.
- **Washi tape** (`src/components/notebooks-view.tsx`): Added a washi tape panel with 8 decorative tape colors (pink, green, blue, yellow, purple, orange, teal, red) and 3 pattern types (stripes, dots, crosshatch). Tapes are placed on pages as horizontal strips with hover-to-remove functionality. The panel is accessible via a Minus icon button in the toolbar.
- **i18n keys** (`src/lib/i18n.ts`): Added 25+ new translation keys in both German and English sections for all new features (page numbering, date stamp, margin line, stickers, washi tape, corner fold).
Files Modified:
1. `src/components/notebooks-view.tsx` — Added StickerData/WashiTapeData types, STICKER_TYPES/WASHI_TAPE_COLORS constants, sticker/washi handlers, page number footer, margin line overlay, corner fold, sticker/washi overlay rendering, sticker/washi panel popovers, date stamp in handleAddPage
2. `src/components/drawing-canvas.tsx` — Updated margin line color to red (German Schulheft style) in drawBackground function
3. `src/lib/i18n.ts` — Added 25+ new i18n keys in both German and English sections
Summary:
All 6 professional notebook features have been implemented:
1. Page numbering — Auto page numbers at bottom of each page
2. German-style margin lines — Red margin line on left side of lined pages
3. Date stamp — Auto-added date when creating new pages
4. Sticker/stamp collection — 12 educational stickers with placement panel
5. Page corner fold — Dog-ear visual on bookmarked pages
6. Washi tape — 8 decorative tape colors with 3 pattern types
---
Task ID: 5b
Agent: feature-enhancement
Task: Add More Features and Polish - Continue PRD Development
Date: 2025-01-28
Work Log:
### 1. Timetable View Enhancement (`src/components/timetable-view.tsx`)
- **Current period highlight**: Added live detection of the current period based on time of day. Current period row and column are highlighted with emerald styling. A pulsing green dot indicator shows the current period. Auto-refreshes every minute.
- **Today column highlight**: The current day column in the grid header shows a green background with "Today" label and pulsing indicator.
- **Room/location display**: Added `MapPin` icon alongside room IDs in slot cells. Added room placeholder text in the dialog.
- **View mode toggle**: Added Grid/List view toggle buttons. Grid view shows the traditional table layout. List view shows slots grouped by day in card format.
- **Subject legend**: Added a color-coded legend bar showing all subjects used in the timetable.
- **Subject accent colors**: Added `SUBJECT_ACCENT_COLORS` map for left-border color coding on slot cards.
- **Loading skeleton**: Added `TimetableSkeleton` component with proper table structure skeleton.
- **Error state**: Added error state with retry button when timetable loading fails.
- **Better empty states**: Added descriptive text hints for empty states (e.g., "Click on a cell to add a slot").
- **Short day labels**: Added `mon_short`, `tue_short`, etc. for compact display.
- **Transition animations**: Added `motion.div` wrapper with fade-in/slide-up animations for grid and list views.
### 2. Resource Library Enhancement (`src/components/resource-library-view.tsx`)
- **Category tabs**: Added horizontal tab bar with categories: All, Worksheets, Presentations, Videos, Links, Favorites. Each tab shows a count badge. Tabs filter resources by type.
- **Favorites tab**: Dedicated tab showing only favorited resources. Favoriting is done via the star button on each card.
- **Resource thumbnails**: Added `ResourceThumbnail` component with type-colored background, icon, and gradient overlay.
- **View mode toggle**: Added Grid/List view toggle. Grid view shows thumbnail cards. List view shows compact rows.
- **Better empty states**: Different empty state messages for "no favorites" vs "no resources" with descriptive hints.
- **Loading skeleton**: Added `ResourceGridSkeleton` component with thumbnail card skeletons.
- **Error state**: Added error state with retry button.
- **Tags placeholder**: Added placeholder text for tags input field.
- **Transition animations**: Added `motion.div` wrapper with fade-in animations for grid and list views.
### 3. Homework View Enhancement (`src/components/homework-view.tsx`)
- **Calendar view**: Added `HomeworkCalendarView` component with full month calendar grid. Shows homework due dates as colored badges on calendar days. Supports month navigation (prev/next/today). Color-coded by due date status (overdue=today=upcoming). Shows up to 2 homework items per day with "+N" overflow indicator.
- **View mode toggle**: Added Grid/List/Calendar view toggle buttons.
- **Submission status indicators**: Added status icons (Circle, CheckCircle2, Trophy, XCircle) for pending/submitted/graded/late submissions. Color-coded status badges in submission list.
- **Grade display**: Added Trophy icon and grade display (score/maxPoints) in the submission list. Shows feedback preview in line-clamp.
- **"My Homework" view**: Changed header title for students to "My Homework" (t('homework.my_homework')).
- **Better empty states**: Added descriptive text hints for empty states.
- **Loading skeletons**: Added `HomeworkSkeleton` and `CalendarSkeleton` components.
- **Error state**: Added error state with retry button.
- **List view**: Added compact list view with type-colored icons, status indicators, and due date badges.
- **Transition animations**: Added `motion.div` wrapper with fade-in animations.
### 4. Global Styling Improvements
- **Empty state illustrations**: All three views now use Lucide icons (CalendarDays, Heart, Library, BookCheck, Send, AlertCircle, BookOpen) at 12x12 size with opacity-30 for empty states. Added descriptive title and hint text.
- **Loading skeleton states**: Added `TimetableSkeleton`, `ResourceGridSkeleton`, `HomeworkSkeleton`, `CalendarSkeleton` components with proper structure matching the respective views.
- **Transition animations**: All view transitions use `motion.div` with `initial={{ opacity: 0, y: 10 }}` and `animate={{ opacity: 1, y: 0 }}` for smooth fade-in/slide-up.
- **Error states with retry**: All three views now have error states with `AlertCircle` icon, error message, and `RefreshCw` retry button.
### 5. i18n Keys Added (`src/lib/i18n.ts`)
Added new keys in both German and English sections:
- **Timetable**: `current_period`, `today`, `legend`, `load_error`, `select_class_hint`, `no_slots_hint`, `room_placeholder`, `mon_short`, `tue_short`, `wed_short`, `thu_short`, `fri_short`
- **Resources**: `load_error`, `no_favorites`, `no_favorites_hint`, `no_resources_hint`, `create_description`, `tags_placeholder`, `category_all`, `category_worksheets`, `category_presentations`, `category_videos`, `category_links`, `category_favorites`
- **Homework**: `my_homework`, `load_error`, `no_homework_hint`, `view_grid`, `view_list`, `view_calendar`, `sat_short`, `sun_short`
Files Modified:
1. `src/components/timetable-view.tsx` — Complete rewrite with enhanced features
2. `src/components/resource-library-view.tsx` — Complete rewrite with enhanced features
3. `src/components/homework-view.tsx` — Complete rewrite with enhanced features
4. `src/lib/i18n.ts` — Added 30+ new i18n keys in both German and English sections
Summary:
All 4 enhancement areas have been implemented:
1. Timetable View — Current period highlight, room display, view mode toggle, subject legend, accent colors, loading/error states
2. Resource Library — Category tabs, favorites, thumbnails, view mode toggle, loading/error states
3. Homework View — Calendar view, submission status indicators, grade display, "My Homework" view, loading/error states
4. Global Styling — Empty state illustrations, loading skeletons, transition animations, error states with retry buttons