Round 19c: Professional Notebooks, Enhanced Timetable/Resources/Homework, Bug Fixes
Notebook Enhancements: - Page numbering (auto page numbers at bottom) - German-style margin lines on lined pages (rote Randlinie) - Auto date stamp on new pages - Sticker/stamp collection for educational rewards - Page corner fold (dog-ear) on bookmarked pages - Washi tape decorative strips Timetable View: - Weekly grid view (Mon-Fri, periods 1-6) - Color-coded subjects - Current period highlight - Room/location display - Drag-and-drop reschedule Resource Library: - Categories (worksheets, presentations, videos, links) - Search/filter bar - Resource preview cards with thumbnails - Favorite/bookmark functionality Homework View: - Calendar view for due dates - Submission status indicators - Grade display for graded homework - My Homework view for students Settings Bug Fix: - Fixed TabsContent outside Tabs component (caused runtime crash) Drawing Canvas: - Added ruler/straight edge tool 100+ new i18n keys (DE/EN)
This commit is contained in:
1 parent
58cb7afd81
commit
4e2eb05fbf
8 files changed
+1988
-448
No files matched your search
@@ -218,9 +218,9 @@ function drawBackground(
|
|||||||
ctx.lineTo(w, y);
|
ctx.lineTo(w, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
// Margin line
|
// German-style red margin line (like Schulhefte)
|
||||||
ctx.strokeStyle = '#D1D5DB';
|
ctx.strokeStyle = 'rgba(239, 68, 68, 0.35)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(64, 0);
|
ctx.moveTo(64, 0);
|
||||||
ctx.lineTo(64, h);
|
ctx.lineTo(64, h);
|
||||||
|
|||||||
+563
-174
@@ -14,6 +14,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -27,6 +29,16 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
CalendarDays,
|
||||||
|
List,
|
||||||
|
LayoutGrid,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
XCircle,
|
||||||
|
Trophy,
|
||||||
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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) {
|
function getHomeworkTypeIcon(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'assignment': return FileText;
|
case 'assignment': return FileText;
|
||||||
@@ -154,43 +196,226 @@ function getHomeworkTypeIcon(type: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getHomeworkTypeLabel(type: string): string {
|
function getHomeworkTypeLabel(type: string): string {
|
||||||
const key = `homework.type_${type}`;
|
switch (type) {
|
||||||
const label = t(key);
|
case 'assignment': return t('homework.type_assignment');
|
||||||
return label === key ? type : label;
|
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) {
|
function getHomeworkTypeColor(type: string): string {
|
||||||
const colors: Record<string, string> = {
|
switch (type) {
|
||||||
pending: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
case 'assignment': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300';
|
||||||
submitted: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300',
|
case 'reading': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300';
|
||||||
graded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300',
|
case 'project': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300';
|
||||||
late: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-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 {
|
/* ── Main Component ─────────────────────────────────────────────── */
|
||||||
const key = `homework.status_${status}`;
|
|
||||||
const label = t(key);
|
|
||||||
return label === key ? status : label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main Component ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export default function HomeworkView() {
|
export default function HomeworkView() {
|
||||||
const currentUser = useAppStore((s) => s.currentUser);
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
const currentClassId = useAppStore((s) => s.currentClassId);
|
|
||||||
const locale = useAppStore((s) => s.locale);
|
const locale = useAppStore((s) => s.locale);
|
||||||
|
const currentClassId = useAppStore((s) => s.currentClassId);
|
||||||
|
|
||||||
const [homeworks, setHomeworks] = useState<HomeworkItem[]>([]);
|
const [homeworks, setHomeworks] = useState<HomeworkItem[]>([]);
|
||||||
const [classes, setClasses] = useState<ClassGroupSimple[]>([]);
|
const [classes, setClasses] = useState<ClassGroupSimple[]>([]);
|
||||||
const [subjects, setSubjects] = useState<SubjectSimple[]>([]);
|
const [subjects, setSubjects] = useState<SubjectSimple[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [filterClass, setFilterClass] = useState<string>('all');
|
const [filterClass, setFilterClass] = useState('all');
|
||||||
const [filterSubject, setFilterSubject] = useState<string>('all');
|
const [filterSubject, setFilterSubject] = useState('all');
|
||||||
const [filterType, setFilterType] = useState<string>('all');
|
const [filterType, setFilterType] = useState('all');
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list' | 'calendar'>('grid');
|
||||||
|
|
||||||
// Create dialog
|
// Create dialog
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@@ -247,6 +472,7 @@ export default function HomeworkView() {
|
|||||||
const loadHomework = useCallback(() => {
|
const loadHomework = useCallback(() => {
|
||||||
if (!currentUser?.schoolId) return;
|
if (!currentUser?.schoolId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
const params = new URLSearchParams({ schoolId: currentUser.schoolId });
|
const params = new URLSearchParams({ schoolId: currentUser.schoolId });
|
||||||
if (filterClass !== 'all') params.set('classGroupId', filterClass);
|
if (filterClass !== 'all') params.set('classGroupId', filterClass);
|
||||||
if (filterSubject !== 'all') params.set('subjectId', filterSubject);
|
if (filterSubject !== 'all') params.set('subjectId', filterSubject);
|
||||||
@@ -257,7 +483,10 @@ export default function HomeworkView() {
|
|||||||
setHomeworks(data);
|
setHomeworks(data);
|
||||||
setLoading(false);
|
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]);
|
}, [currentUser?.schoolId, filterClass, filterSubject, filterType, isStudent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -454,114 +683,147 @@ export default function HomeworkView() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<BookCheck className="h-7 w-7 text-primary" />
|
<BookCheck className="h-7 w-7 text-primary" />
|
||||||
{t('homework.title')}
|
{isStudent ? t('homework.my_homework') : t('homework.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">{t('homework.subtitle')}</p>
|
<p className="text-muted-foreground mt-1">{t('homework.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
{isTeacherOrAdmin && (
|
<div className="flex items-center gap-2">
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
{/* View Mode Toggle */}
|
||||||
<DialogTrigger asChild>
|
<div className="flex items-center border rounded-md overflow-hidden">
|
||||||
<Button className="min-h-[44px]">
|
<Button
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||||
{t('homework.create')}
|
size="sm"
|
||||||
</Button>
|
className="min-h-[44px] rounded-none"
|
||||||
</DialogTrigger>
|
onClick={() => setViewMode('grid')}
|
||||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
title={t('homework.view_grid')}
|
||||||
<DialogHeader>
|
>
|
||||||
<DialogTitle>{t('homework.create_title')}</DialogTitle>
|
<LayoutGrid className="h-4 w-4" />
|
||||||
<DialogDescription>{t('homework.create_description')}</DialogDescription>
|
</Button>
|
||||||
</DialogHeader>
|
<Button
|
||||||
<div className="space-y-4 py-4">
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
<div className="space-y-2">
|
size="sm"
|
||||||
<Label>{t('homework.field_title')}</Label>
|
className="min-h-[44px] rounded-none"
|
||||||
<Input
|
onClick={() => setViewMode('list')}
|
||||||
value={createForm.title}
|
title={t('homework.view_list')}
|
||||||
onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })}
|
>
|
||||||
placeholder={t('homework.field_title_placeholder')}
|
<List className="h-4 w-4" />
|
||||||
/>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
<div className="space-y-2">
|
variant={viewMode === 'calendar' ? 'default' : 'ghost'}
|
||||||
<Label>{t('homework.field_class')}</Label>
|
size="sm"
|
||||||
<Select value={createForm.classGroupId} onValueChange={(v) => setCreateForm({ ...createForm, classGroupId: v })}>
|
className="min-h-[44px] rounded-none"
|
||||||
<SelectTrigger className="min-h-[44px]"><SelectValue placeholder={t('homework.field_class_placeholder')} /></SelectTrigger>
|
onClick={() => setViewMode('calendar')}
|
||||||
<SelectContent>
|
title={t('homework.view_calendar')}
|
||||||
{classes.map((c) => (
|
>
|
||||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
<CalendarDays className="h-4 w-4" />
|
||||||
))}
|
</Button>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
|
||||||
</div>
|
{isTeacherOrAdmin && (
|
||||||
<div className="space-y-2">
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<Label>{t('homework.field_subject')}</Label>
|
<DialogTrigger asChild>
|
||||||
<Select value={createForm.subjectId || 'none'} onValueChange={(v) => setCreateForm({ ...createForm, subjectId: v === 'none' ? '' : v })}>
|
<Button className="min-h-[44px]">
|
||||||
<SelectTrigger className="min-h-[44px]"><SelectValue placeholder={t('homework.field_subject_placeholder')} /></SelectTrigger>
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
<SelectContent>
|
{t('homework.create')}
|
||||||
<SelectItem value="none">{t('homework.no_subject')}</SelectItem>
|
|
||||||
{subjects.map((s) => (
|
|
||||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('homework.field_type')}</Label>
|
|
||||||
<Select value={createForm.homeworkType} onValueChange={(v) => setCreateForm({ ...createForm, homeworkType: v })}>
|
|
||||||
<SelectTrigger className="min-h-[44px]"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{['assignment', 'reading', 'project', 'practice', 'research'].map((type) => (
|
|
||||||
<SelectItem key={type} value={type}>{getHomeworkTypeLabel(type)}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('homework.field_due_date')}</Label>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
value={createForm.dueDate}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, dueDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('homework.field_max_points')}</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.5"
|
|
||||||
value={createForm.maxPoints}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, maxPoints: e.target.value })}
|
|
||||||
placeholder={t('homework.field_max_points_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('homework.field_description')}</Label>
|
|
||||||
<Textarea
|
|
||||||
value={createForm.description}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
|
|
||||||
placeholder={t('homework.field_description_placeholder')}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="create-published"
|
|
||||||
checked={createForm.isPublished}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, isPublished: e.target.checked })}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="create-published">{t('homework.field_published')}</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={creating} className="min-h-[44px]">
|
|
||||||
{creating ? t('homework.creating') : t('action.create')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogTrigger>
|
||||||
</DialogContent>
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
</Dialog>
|
<DialogHeader>
|
||||||
)}
|
<DialogTitle>{t('homework.create_title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('homework.create_description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('homework.field_title')}</Label>
|
||||||
|
<Input
|
||||||
|
value={createForm.title}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })}
|
||||||
|
placeholder={t('homework.field_title_placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('homework.field_class')}</Label>
|
||||||
|
<Select value={createForm.classGroupId} onValueChange={(v) => setCreateForm({ ...createForm, classGroupId: v })}>
|
||||||
|
<SelectTrigger className="min-h-[44px]"><SelectValue placeholder={t('homework.field_class_placeholder')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('homework.field_subject')}</Label>
|
||||||
|
<Select value={createForm.subjectId || 'none'} onValueChange={(v) => setCreateForm({ ...createForm, subjectId: v === 'none' ? '' : v })}>
|
||||||
|
<SelectTrigger className="min-h-[44px]"><SelectValue placeholder={t('homework.field_subject_placeholder')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">{t('homework.no_subject')}</SelectItem>
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('homework.field_type')}</Label>
|
||||||
|
<Select value={createForm.homeworkType} onValueChange={(v) => setCreateForm({ ...createForm, homeworkType: v })}>
|
||||||
|
<SelectTrigger className="min-h-[44px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{['assignment', 'reading', 'project', 'practice', 'research'].map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>{getHomeworkTypeLabel(type)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('homework.field_due_date')}</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={createForm.dueDate}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, dueDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('homework.field_max_points')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.5"
|
||||||
|
value={createForm.maxPoints}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, maxPoints: e.target.value })}
|
||||||
|
placeholder={t('homework.field_max_points_placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('homework.field_description')}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={createForm.description}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
|
||||||
|
placeholder={t('homework.field_description_placeholder')}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="create-published"
|
||||||
|
checked={createForm.isPublished}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, isPublished: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="create-published">{t('homework.field_published')}</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={creating} className="min-h-[44px]">
|
||||||
|
{creating ? t('homework.creating') : t('action.create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -664,28 +926,54 @@ export default function HomeworkView() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Homework List */}
|
{/* Error State */}
|
||||||
{loading ? (
|
{error && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<Card className="border-rose-200 dark:border-rose-800">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
<CardContent className="py-6 text-center">
|
||||||
<Card key={i}>
|
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-rose-500" />
|
||||||
<CardContent className="p-4 space-y-3">
|
<p className="text-rose-600 dark:text-rose-400 mb-3">{error}</p>
|
||||||
<Skeleton className="h-5 w-3/4" />
|
<Button variant="outline" size="sm" onClick={loadHomework} className="min-h-[44px]">
|
||||||
<Skeleton className="h-4 w-1/2" />
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
<Skeleton className="h-4 w-1/3" />
|
{t('action.refresh')}
|
||||||
</CardContent>
|
</Button>
|
||||||
</Card>
|
</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 ? (
|
) : filteredHomeworks.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-8 text-center">
|
<CardContent className="p-8 text-center">
|
||||||
<BookCheck className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : viewMode === 'grid' ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<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">
|
<AnimatePresence mode="popLayout">
|
||||||
{filteredHomeworks.map((hw) => {
|
{filteredHomeworks.map((hw) => {
|
||||||
const dueStatus = getDueDateStatus(hw.dueDate);
|
const dueStatus = getDueDateStatus(hw.dueDate);
|
||||||
@@ -709,7 +997,7 @@ export default function HomeworkView() {
|
|||||||
<TypeIcon className="h-5 w-5 text-primary shrink-0" />
|
<TypeIcon className="h-5 w-5 text-primary shrink-0" />
|
||||||
<CardTitle className="text-base truncate">{hw.title}</CardTitle>
|
<CardTitle className="text-base truncate">{hw.title}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-xs shrink-0">
|
<Badge className={`text-xs shrink-0 ${getHomeworkTypeColor(hw.homeworkType)}`}>
|
||||||
{getHomeworkTypeLabel(hw.homeworkType)}
|
{getHomeworkTypeLabel(hw.homeworkType)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -738,10 +1026,22 @@ export default function HomeworkView() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Submission status indicators for teacher */}
|
||||||
{isTeacherOrAdmin && hw._count && (
|
{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">
|
||||||
<Send className="h-3 w-3" />
|
<div className="flex items-center gap-1">
|
||||||
{hw._count.submissions} {t('homework.submissions_count')}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -750,8 +1050,82 @@ export default function HomeworkView() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
) : (
|
||||||
|
/* ── List View ── */
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{filteredHomeworks.map((hw) => {
|
||||||
|
const dueStatus = getDueDateStatus(hw.dueDate);
|
||||||
|
const TypeIcon = getHomeworkTypeIcon(hw.homeworkType);
|
||||||
|
const StatusIcon = dueStatus === 'overdue' ? XCircle : dueStatus === 'today' ? Clock : CheckCircle2;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={hw.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={`cursor-pointer hover:shadow-md transition-shadow border-l-4 ${dueStatus === 'overdue' ? 'border-l-rose-500' : dueStatus === 'today' ? 'border-l-amber-500' : 'border-l-emerald-500'}`}
|
||||||
|
onClick={() => openDetail(hw)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${getHomeworkTypeColor(hw.homeworkType)} shrink-0`}>
|
||||||
|
<TypeIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">{hw.title}</span>
|
||||||
|
<Badge className={`text-xs shrink-0 ${getDueDateColor(dueStatus)}`}>
|
||||||
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
{getDueDateLabel(hw.dueDate)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium">{hw.classGroup.name}</span>
|
||||||
|
{hw.subject && (
|
||||||
|
<>
|
||||||
|
<span>|</span>
|
||||||
|
<span>{hw.subject.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hw.maxPoints && (
|
||||||
|
<>
|
||||||
|
<span>|</span>
|
||||||
|
<span>{hw.maxPoints} {t('homework.points')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isTeacherOrAdmin && hw._count && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Send className="h-3 w-3" />
|
||||||
|
{hw._count.submissions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<StatusIcon className={`h-4 w-4 ${
|
||||||
|
dueStatus === 'overdue' ? 'text-rose-500' : dueStatus === 'today' ? 'text-amber-500' : 'text-emerald-500'
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Detail Dialog */}
|
{/* Detail Dialog */}
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
@@ -770,7 +1144,7 @@ export default function HomeworkView() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<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))}>
|
<Badge className={getDueDateColor(getDueDateStatus(selectedHomework.dueDate))}>
|
||||||
<Clock className="h-3 w-3 mr-1" />
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
{getDueDateLabel(selectedHomework.dueDate)}
|
{getDueDateLabel(selectedHomework.dueDate)}
|
||||||
@@ -811,36 +1185,51 @@ export default function HomeworkView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : submissions.length === 0 ? (
|
) : 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">
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
{submissions.map((sub) => (
|
{submissions.map((sub) => {
|
||||||
<div
|
const SubStatusIcon = getStatusIcon(sub.status);
|
||||||
key={sub.id}
|
return (
|
||||||
className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
<div
|
||||||
>
|
key={sub.id}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||||
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-primary/10 text-primary text-sm font-medium shrink-0">
|
>
|
||||||
{sub.student.firstName[0]}{sub.student.lastName[0]}
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
</div>
|
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-primary/10 text-primary text-sm font-medium shrink-0">
|
||||||
<div className="min-w-0">
|
{sub.student.firstName[0]}{sub.student.lastName[0]}
|
||||||
<p className="text-sm font-medium truncate">{sub.student.firstName} {sub.student.lastName}</p>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0">
|
||||||
<Badge className={`text-xs ${getStatusBadge(sub.status)}`}>{getStatusLabel(sub.status)}</Badge>
|
<p className="text-sm font-medium truncate">{sub.student.firstName} {sub.student.lastName}</p>
|
||||||
{sub.score !== null && (
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<SubStatusIcon className={`h-3 w-3 ${
|
||||||
{sub.score}/{selectedHomework.maxPoints ?? '?'}
|
sub.status === 'graded' ? 'text-emerald-500' : sub.status === 'late' ? 'text-rose-500' : sub.status === 'submitted' ? 'text-blue-500' : 'text-gray-400'
|
||||||
</span>
|
}`} />
|
||||||
|
<Badge className={`text-xs ${getStatusBadge(sub.status)}`}>{getStatusLabel(sub.status)}</Badge>
|
||||||
|
{sub.score !== null && (
|
||||||
|
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 flex items-center gap-0.5">
|
||||||
|
<Trophy className="h-3 w-3" />
|
||||||
|
{sub.score}/{selectedHomework.maxPoints ?? '?'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sub.feedback && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{sub.feedback}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openGrading(sub)} className="min-h-[44px] shrink-0">
|
||||||
|
<GraduationCap className="h-3 w-3 mr-1" />
|
||||||
|
{sub.status === 'graded' ? t('homework.regrade') : t('homework.grade')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => openGrading(sub)} className="min-h-[44px] shrink-0">
|
);
|
||||||
<GraduationCap className="h-3 w-3 mr-1" />
|
})}
|
||||||
{sub.status === 'graded' ? t('homework.regrade') : t('homework.grade')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -129,6 +129,30 @@ interface SectionData {
|
|||||||
pageIds: string[];
|
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 ───────────────────────────────────────
|
// ─── Page Template Definitions ───────────────────────────────────────
|
||||||
|
|
||||||
const PAGE_TEMPLATES: Array<{
|
const PAGE_TEMPLATES: Array<{
|
||||||
@@ -198,6 +222,41 @@ const STICKY_NOTE_COLORS = [
|
|||||||
{ key: 'purple', color: '#ddd6fe', border: '#a78bfa' },
|
{ 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 = [
|
const SECTION_COLORS = [
|
||||||
'#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
|
'#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
|
||||||
];
|
];
|
||||||
@@ -553,19 +612,32 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
|
|||||||
function getPageBackgroundCSS(type: string): React.CSSProperties {
|
function getPageBackgroundCSS(type: string): React.CSSProperties {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'lined':
|
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 {
|
return {
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
|
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%',
|
backgroundSize: '100% 32px, 100% 100%',
|
||||||
backgroundPosition: '0 16px, 0 0',
|
backgroundPosition: '0 16px, 0 0',
|
||||||
@@ -609,23 +681,6 @@ function getPageBackgroundCSS(type: string): React.CSSProperties {
|
|||||||
case 'blank':
|
case 'blank':
|
||||||
case 'kunstheft': // Art notebook - blank paper
|
case 'kunstheft': // Art notebook - blank paper
|
||||||
return {};
|
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:
|
default:
|
||||||
return {};
|
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>
|
<span className="text-gray-300 dark:text-gray-600 italic">Leere Seite</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{page.isBookmark && (
|
||||||
<div className="absolute top-0 right-0">
|
<div className="absolute top-0 right-0">
|
||||||
<Bookmark className="w-3 h-3 text-amber-500 fill-amber-500" />
|
<div className="relative">
|
||||||
|
<Bookmark className="w-3 h-3 text-amber-500 fill-amber-500" />
|
||||||
|
{/* Corner fold (dog-ear) effect */}
|
||||||
|
<div
|
||||||
|
className="absolute -top-0 -right-0 w-4 h-4"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, transparent 50%, #fbbf24 50%)',
|
||||||
|
clipPath: 'polygon(100% 0, 0 0, 100% 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Drawing indicator */}
|
{/* Drawing indicator */}
|
||||||
@@ -1421,6 +1490,14 @@ function NotebookDetailView({
|
|||||||
const [newStickyNoteColor, setNewStickyNoteColor] = useState('#fef08a');
|
const [newStickyNoteColor, setNewStickyNoteColor] = useState('#fef08a');
|
||||||
const [editingStickyNote, setEditingStickyNote] = useState<StickyNoteData | null>(null);
|
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
|
// Search within notebook state
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -1833,6 +1910,58 @@ function NotebookDetailView({
|
|||||||
[stickyNotes, currentPageId]
|
[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 ────────────────────────────────────────
|
// ─── Search Within Notebook ────────────────────────────────────────
|
||||||
|
|
||||||
const handleSearch = useCallback((query: string) => {
|
const handleSearch = useCallback((query: string) => {
|
||||||
@@ -2159,6 +2288,96 @@ function NotebookDetailView({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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 */}
|
{/* Sections button */}
|
||||||
<Popover open={showSectionDialog} onOpenChange={setShowSectionDialog}>
|
<Popover open={showSectionDialog} onOpenChange={setShowSectionDialog}>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
@@ -2514,15 +2733,22 @@ function NotebookDetailView({
|
|||||||
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
|
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
|
||||||
<div className="flex-1 overflow-hidden p-3 relative">
|
<div className="flex-1 overflow-hidden p-3 relative">
|
||||||
<div
|
<div
|
||||||
className="w-full h-full rounded-xl overflow-hidden"
|
className="w-full h-full rounded-xl overflow-hidden relative"
|
||||||
style={{ backgroundColor: '#fff' }}
|
style={{ backgroundColor: '#fff' }}
|
||||||
>
|
>
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div
|
<div
|
||||||
className="min-h-full p-6"
|
className="min-h-full p-6 relative"
|
||||||
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
||||||
onMouseMove={handleEditorMouseMove}
|
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
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
contentEditable
|
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"
|
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' }}
|
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>
|
</div>
|
||||||
</ScrollArea>
|
</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>
|
</div>
|
||||||
{cursors.filter(c => c.pageId === currentPage?.id).map((cursor) => (
|
{cursors.filter(c => c.pageId === currentPage?.id).map((cursor) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -2609,15 +2853,22 @@ function NotebookDetailView({
|
|||||||
{/* Page content area */}
|
{/* Page content area */}
|
||||||
<div className="flex-1 overflow-hidden p-4 relative">
|
<div className="flex-1 overflow-hidden p-4 relative">
|
||||||
<div
|
<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' }}
|
style={{ backgroundColor: '#fff' }}
|
||||||
>
|
>
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div
|
<div
|
||||||
className="min-h-full p-6"
|
className="min-h-full p-6 relative"
|
||||||
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
||||||
onMouseMove={handleEditorMouseMove}
|
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
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
contentEditable
|
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"
|
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' }}
|
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>
|
</div>
|
||||||
</ScrollArea>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Collaboration: Cursor overlays */}
|
{/* Collaboration: Cursor overlays */}
|
||||||
@@ -4097,11 +4439,16 @@ export default function NotebooksView() {
|
|||||||
const handleAddPage = useCallback(async (templateKey?: string, background?: string, content?: string) => {
|
const handleAddPage = useCallback(async (templateKey?: string, background?: string, content?: string) => {
|
||||||
if (!selectedNotebook) return;
|
if (!selectedNotebook) return;
|
||||||
const pagesCount = selectedNotebook.pages?.length ?? 0;
|
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`, {
|
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
|
||||||
pageNumber: pagesCount + 1,
|
pageNumber: pagesCount + 1,
|
||||||
background: background ?? selectedNotebook.notebookType,
|
background: background ?? selectedNotebook.notebookType,
|
||||||
title: templateKey && templateKey !== 'blank' ? t(`notebooks.page_template_${templateKey}`) : null,
|
title: templateKey && templateKey !== 'blank' ? t(`notebooks.page_template_${templateKey}`) : null,
|
||||||
textContent: content ?? null,
|
textContent: finalContent,
|
||||||
});
|
});
|
||||||
setSelectedNotebook(prev => {
|
setSelectedNotebook(prev => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Tag,
|
Tag,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
FolderOpen,
|
||||||
|
Grid3X3,
|
||||||
|
List,
|
||||||
|
Heart,
|
||||||
|
BookMarked,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -56,6 +63,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { useAppStore } from '@/lib/store';
|
import { useAppStore } from '@/lib/store';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||||
@@ -95,8 +103,18 @@ interface ClassOption {
|
|||||||
|
|
||||||
type ResourceType = 'document' | 'worksheet' | 'presentation' | 'video_link' | 'image' | 'link' | 'audio';
|
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 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) {
|
function getTypeIcon(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'document': return FileText;
|
case 'document': return FileText;
|
||||||
@@ -136,6 +154,38 @@ function getTypeLabel(type: string): string {
|
|||||||
return t(keyMap[type] ?? 'resources.type');
|
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 ────────────────────────────────────────
|
// ── Resource Create/Edit Dialog ────────────────────────────────────────
|
||||||
function ResourceDialog({
|
function ResourceDialog({
|
||||||
open,
|
open,
|
||||||
@@ -162,7 +212,7 @@ function ResourceDialog({
|
|||||||
const [subjectId, setSubjectId] = useState<string>('none');
|
const [subjectId, setSubjectId] = useState<string>('none');
|
||||||
const [classGroupId, setClassGroupId] = useState<string>('none');
|
const [classGroupId, setClassGroupId] = useState<string>('none');
|
||||||
const [gradeLevel, setGradeLevel] = useState('');
|
const [gradeLevel, setGradeLevel] = useState('');
|
||||||
const [tagsInput, setTagsInput] = useState('');
|
const [tags, setTags] = useState('');
|
||||||
const [isPublic, setIsPublic] = useState(true);
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -177,11 +227,9 @@ function ResourceDialog({
|
|||||||
setClassGroupId(resource.classGroupId ?? 'none');
|
setClassGroupId(resource.classGroupId ?? 'none');
|
||||||
setGradeLevel(resource.gradeLevel?.toString() ?? '');
|
setGradeLevel(resource.gradeLevel?.toString() ?? '');
|
||||||
try {
|
try {
|
||||||
const parsedTags = resource.tags ? JSON.parse(resource.tags) : [];
|
const parsed = JSON.parse(resource.tags ?? '[]');
|
||||||
setTagsInput(Array.isArray(parsedTags) ? parsedTags.join(', ') : '');
|
setTags(Array.isArray(parsed) ? parsed.join(', ') : '');
|
||||||
} catch {
|
} catch { setTags(''); }
|
||||||
setTagsInput('');
|
|
||||||
}
|
|
||||||
setIsPublic(resource.isPublic);
|
setIsPublic(resource.isPublic);
|
||||||
} else {
|
} else {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
@@ -192,31 +240,29 @@ function ResourceDialog({
|
|||||||
setSubjectId('none');
|
setSubjectId('none');
|
||||||
setClassGroupId('none');
|
setClassGroupId('none');
|
||||||
setGradeLevel('');
|
setGradeLevel('');
|
||||||
setTagsInput('');
|
setTags('');
|
||||||
setIsPublic(true);
|
setIsPublic(true);
|
||||||
}
|
}
|
||||||
}, [resource, open]);
|
}, [resource, open]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!title.trim()) return;
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const tags = tagsInput
|
const tagList = tags
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const data: Record<string, unknown> = {
|
const data: Record<string, unknown> = {
|
||||||
schoolId,
|
schoolId,
|
||||||
title: title.trim(),
|
title,
|
||||||
description: description.trim() || null,
|
description: description || null,
|
||||||
resourceType,
|
resourceType,
|
||||||
url: url.trim() || null,
|
url: url || null,
|
||||||
content: content.trim() || null,
|
content: content || null,
|
||||||
subjectId: subjectId === 'none' ? null : subjectId,
|
subjectId: subjectId === 'none' ? null : subjectId,
|
||||||
classGroupId: classGroupId === 'none' ? null : classGroupId,
|
classGroupId: classGroupId === 'none' ? null : classGroupId,
|
||||||
gradeLevel: gradeLevel ? parseInt(gradeLevel, 10) : null,
|
gradeLevel: gradeLevel ? parseInt(gradeLevel) : null,
|
||||||
tags: tags.length > 0 ? tags : null,
|
tags: JSON.stringify(tagList),
|
||||||
isPublic,
|
isPublic,
|
||||||
};
|
};
|
||||||
if (resource) {
|
if (resource) {
|
||||||
@@ -236,14 +282,14 @@ function ResourceDialog({
|
|||||||
{resource ? t('resources.edit') : t('resources.create')}
|
{resource ? t('resources.edit') : t('resources.create')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{resource ? t('resources.edit') : t('resources.create')}
|
{t('resources.create_description')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="res-title">{t('resources.title_field')}</Label>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -262,7 +308,7 @@ function ResourceDialog({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="res-desc">{t('resources.description_field')}</Label>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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]" />
|
<Input id="res-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="min-h-[44px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(resourceType === 'document' || resourceType === 'worksheet') && (
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="res-content">{t('resources.content_field')}</Label>
|
||||||
<Label htmlFor="res-content">{t('resources.content_field')}</Label>
|
<Textarea id="res-content" value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('resources.content_field')} rows={4} />
|
||||||
<Textarea id="res-content" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Text content..." rows={4} />
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -300,7 +344,7 @@ function ResourceDialog({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">—</SelectItem>
|
<SelectItem value="none">{t('resources.all_types')}</SelectItem>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -309,22 +353,20 @@ function ResourceDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="res-grade">{t('resources.grade_field')}</Label>
|
||||||
<Label htmlFor="res-grade">{t('resources.grade_field')}</Label>
|
<Input id="res-grade" type="number" value={gradeLevel} onChange={(e) => setGradeLevel(e.target.value)} placeholder="1-12" className="min-h-[44px]" />
|
||||||
<Input id="res-grade" type="number" min={1} max={13} value={gradeLevel} onChange={(e) => setGradeLevel(e.target.value)} className="min-h-[44px]" />
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="res-tags">{t('resources.tags_field')}</Label>
|
<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]" />
|
<Input id="res-tags" value={tags} onChange={(e) => setTags(e.target.value)} placeholder={t('resources.tags_placeholder')} className="min-h-[44px]" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Label htmlFor="res-public" className="flex items-center gap-2 min-h-[44px]">
|
<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 ? <Globe className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
|
||||||
{isPublic ? t('resources.public') : t('resources.private')}
|
{t('resources.toggle_public')}
|
||||||
</Label>
|
</Label>
|
||||||
<Switch id="res-public" checked={isPublic} onCheckedChange={setIsPublic} />
|
<Switch id="res-public" checked={isPublic} onCheckedChange={setIsPublic} />
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +376,7 @@ function ResourceDialog({
|
|||||||
<Button variant="outline" size="sm" onClick={onClose} className="min-h-[44px]">
|
<Button variant="outline" size="sm" onClick={onClose} className="min-h-[44px]">
|
||||||
{t('action.cancel')}
|
{t('action.cancel')}
|
||||||
</Button>
|
</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')}
|
{saving ? '...' : t('action.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,40 +385,63 @@ function ResourceDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resource Preview Dialog ────────────────────────────────────────────
|
// ── Preview Dialog ─────────────────────────────────────────────────────
|
||||||
function PreviewDialog({
|
function PreviewDialog({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
resource,
|
resource,
|
||||||
|
isFav,
|
||||||
|
onToggleFav,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
resource: ResourceItem | null;
|
resource: ResourceItem | null;
|
||||||
|
isFav: boolean;
|
||||||
|
onToggleFav: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (!resource) return null;
|
if (!resource) return null;
|
||||||
|
|
||||||
const typeIcon = getTypeIcon(resource.resourceType);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{React.createElement(typeIcon, { className: 'h-5 w-5' })}
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{resource.title}
|
{React.createElement(getTypeIcon(resource.resourceType), { className: 'h-5 w-5' })}
|
||||||
</DialogTitle>
|
{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>
|
<DialogDescription>
|
||||||
{resource.description}
|
{resource.author.firstName} {resource.author.lastName}
|
||||||
|
{resource.subject && ` | ${resource.subject.name}`}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge className={getTypeColor(resource.resourceType)}>
|
<Badge className={`text-xs ${getTypeColor(resource.resourceType)}`}>
|
||||||
{getTypeLabel(resource.resourceType)}
|
{getTypeLabel(resource.resourceType)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{resource.subject && (
|
{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 ? (
|
{resource.isPublic ? (
|
||||||
<Badge variant="outline" className="gap-1"><Globe className="h-3 w-3" /> {t('resources.public')}</Badge>
|
<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 [subjects, setSubjects] = useState<SubjectOption[]>([]);
|
||||||
const [classes, setClasses] = useState<ClassOption[]>([]);
|
const [classes, setClasses] = useState<ClassOption[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string>('all');
|
const [filterType, setFilterType] = useState<string>('all');
|
||||||
const [filterSubject, setFilterSubject] = 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 [previewResource, setPreviewResource] = useState<ResourceItem | null>(null);
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
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 ?? '';
|
const schoolId = currentUser?.schoolId ?? '';
|
||||||
|
|
||||||
@@ -488,6 +556,7 @@ export default function ResourceLibraryView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ schoolId });
|
const params = new URLSearchParams({ schoolId });
|
||||||
if (filterType && filterType !== 'all') params.set('resourceType', filterType);
|
if (filterType && filterType !== 'all') params.set('resourceType', filterType);
|
||||||
@@ -495,8 +564,9 @@ export default function ResourceLibraryView() {
|
|||||||
if (searchQuery) params.set('search', searchQuery);
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
const data = await apiGet<ResourceItem[]>(`/api/resources?${params.toString()}`);
|
const data = await apiGet<ResourceItem[]>(`/api/resources?${params.toString()}`);
|
||||||
setResources(data);
|
setResources(data);
|
||||||
} catch {
|
} catch (err) {
|
||||||
setResources([]);
|
setResources([]);
|
||||||
|
setError(err instanceof Error ? err.message : t('resources.load_error'));
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [schoolId, filterType, filterSubject, searchQuery]);
|
}, [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
|
// Sort: favorites first, then by date
|
||||||
const sortedResources = useMemo(() => {
|
const sortedResources = useMemo(() => {
|
||||||
return [...resources].sort((a, b) => {
|
return [...categoryFilteredResources].sort((a, b) => {
|
||||||
const aFav = favorites.has(a.id) ? 1 : 0;
|
const aFav = favorites.has(a.id) ? 1 : 0;
|
||||||
const bFav = favorites.has(b.id) ? 1 : 0;
|
const bFav = favorites.has(b.id) ? 1 : 0;
|
||||||
if (bFav !== aFav) return bFav - aFav;
|
if (bFav !== aFav) return bFav - aFav;
|
||||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
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]);
|
}, [resources, favorites]);
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────
|
||||||
@@ -584,16 +675,64 @@ export default function ResourceLibraryView() {
|
|||||||
<h1 className="text-2xl font-bold">{t('resources.library')}</h1>
|
<h1 className="text-2xl font-bold">{t('resources.library')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
className="min-h-[44px]"
|
{/* View Mode Toggle */}
|
||||||
onClick={() => {
|
<div className="flex items-center border rounded-md overflow-hidden">
|
||||||
setEditResource(null);
|
<Button
|
||||||
setDialogOpen(true);
|
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||||
}}
|
size="sm"
|
||||||
>
|
className="min-h-[44px] rounded-none"
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
onClick={() => setViewMode('grid')}
|
||||||
{t('resources.create')}
|
>
|
||||||
</Button>
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="min-h-[44px] rounded-none"
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="min-h-[44px]"
|
||||||
|
onClick={() => {
|
||||||
|
setEditResource(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('resources.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Tabs */}
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto pb-1">
|
||||||
|
{([
|
||||||
|
{ key: 'all' as ResourceCategory, label: t('resources.category_all'), icon: FolderOpen },
|
||||||
|
{ key: 'worksheets' as ResourceCategory, label: t('resources.category_worksheets'), icon: ClipboardList },
|
||||||
|
{ key: 'presentations' as ResourceCategory, label: t('resources.category_presentations'), icon: Presentation },
|
||||||
|
{ key: 'videos' as ResourceCategory, label: t('resources.category_videos'), icon: Video },
|
||||||
|
{ key: 'links' as ResourceCategory, label: t('resources.category_links'), icon: Link2 },
|
||||||
|
{ key: 'favorites' as ResourceCategory, label: t('resources.category_favorites'), icon: Heart },
|
||||||
|
] as const).map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap min-h-[44px] ${
|
||||||
|
activeCategory === key
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||||
|
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveCategory(key)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
<span className="text-xs opacity-60">({categoryCounts[key] ?? 0})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -633,20 +772,50 @@ export default function ResourceLibraryView() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resource Grid */}
|
{/* Error State */}
|
||||||
{loading ? (
|
{error && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<Card className="border-rose-200 dark:border-rose-800">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
<CardContent className="py-6 text-center">
|
||||||
</div>
|
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-rose-500" />
|
||||||
) : sortedResources.length === 0 ? (
|
<p className="text-rose-600 dark:text-rose-400 mb-3">{error}</p>
|
||||||
<Card>
|
<Button variant="outline" size="sm" onClick={loadResources} className="min-h-[44px]">
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
<Library className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
{t('action.refresh')}
|
||||||
{t('resources.no_resources')}
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
)}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
|
{/* Resource Grid / List */}
|
||||||
|
{!error && loading && (
|
||||||
|
<ResourceGridSkeleton />
|
||||||
|
)}
|
||||||
|
{!error && !loading && sortedResources.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
{activeCategory === 'favorites' ? (
|
||||||
|
<>
|
||||||
|
<Heart className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-lg font-medium mb-1">{t('resources.no_favorites')}</p>
|
||||||
|
<p className="text-sm">{t('resources.no_favorites_hint')}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Library className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-lg font-medium mb-1">{t('resources.no_resources')}</p>
|
||||||
|
<p className="text-sm">{t('resources.no_resources_hint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{!error && !loading && sortedResources.length > 0 && viewMode === 'grid' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||||
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{sortedResources.map((resource) => {
|
{sortedResources.map((resource) => {
|
||||||
const TypeIcon = getTypeIcon(resource.resourceType);
|
const TypeIcon = getTypeIcon(resource.resourceType);
|
||||||
@@ -661,20 +830,20 @@ export default function ResourceLibraryView() {
|
|||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
transition={{ duration: 0.2 }}
|
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">
|
<CardHeader className="p-4 pb-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<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}>
|
<CardTitle className="text-sm font-semibold truncate" title={resource.title}>
|
||||||
{resource.title}
|
{resource.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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')}
|
aria-label={t('resources.favorite')}
|
||||||
>
|
>
|
||||||
<Star className={`h-4 w-4 ${isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'}`} />
|
<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>
|
</AnimatePresence>
|
||||||
</div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{!error && !loading && sortedResources.length > 0 && viewMode === 'list' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{sortedResources.map((resource) => {
|
||||||
|
const TypeIcon = getTypeIcon(resource.resourceType);
|
||||||
|
const isFav = favorites.has(resource.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={resource.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${getTypeColor(resource.resourceType)} shrink-0`}>
|
||||||
|
<TypeIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">{resource.title}</span>
|
||||||
|
{resource.subject && (
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">{resource.subject.name}</Badge>
|
||||||
|
)}
|
||||||
|
{resource.isPublic ? (
|
||||||
|
<Globe className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Lock className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{resource.description && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{resource.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-0.5"><Download className="h-3 w-3" /> {resource.downloadCount}</span>
|
||||||
|
<span>{resource.author.firstName} {resource.author.lastName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||||
|
onClick={() => toggleFavorite(resource.id)}
|
||||||
|
aria-label={t('resources.favorite')}
|
||||||
|
>
|
||||||
|
<Star className={`h-4 w-4 ${isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'}`} />
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 min-h-[44px] min-w-[44px]"
|
||||||
|
onClick={() => { setPreviewResource(resource); setPreviewOpen(true); }}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{currentUser?.id === resource.authorId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 min-h-[44px] min-w-[44px] text-destructive"
|
||||||
|
onClick={() => handleDeleteResource(resource.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource Create/Edit Dialog */}
|
{/* Resource Create/Edit Dialog */}
|
||||||
@@ -805,6 +1057,8 @@ export default function ResourceLibraryView() {
|
|||||||
open={previewOpen}
|
open={previewOpen}
|
||||||
onClose={() => { setPreviewOpen(false); setPreviewResource(null); }}
|
onClose={() => { setPreviewOpen(false); setPreviewResource(null); }}
|
||||||
resource={previewResource}
|
resource={previewResource}
|
||||||
|
isFav={previewResource ? favorites.has(previewResource.id) : false}
|
||||||
|
onToggleFav={() => previewResource && toggleFavorite(previewResource.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4199,8 +4199,6 @@ export default function SettingsView() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* ── Backup Restore Confirmation ──────────────────────────── */}
|
{/* ── Backup Restore Confirmation ──────────────────────────── */}
|
||||||
<AlertDialog open={!!restoreBackupId} onOpenChange={(open) => { if (!open) setRestoreBackupId(null); }}>
|
<AlertDialog open={!!restoreBackupId} onOpenChange={(open) => { if (!open) setRestoreBackupId(null); }}>
|
||||||
@@ -5185,6 +5183,8 @@ export default function SettingsView() {
|
|||||||
<TabsContent value="privacy">
|
<TabsContent value="privacy">
|
||||||
<PrivacyTab currentUser={currentUser} />
|
<PrivacyTab currentUser={currentUser} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+493
-158
@@ -14,6 +14,13 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
Coffee,
|
Coffee,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
CalendarDays,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -42,6 +49,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { useAppStore } from '@/lib/store';
|
import { useAppStore } from '@/lib/store';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
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',
|
'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_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';
|
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 {
|
function getSubjectColor(subjectName: string | null): string {
|
||||||
@@ -74,6 +103,11 @@ function getSubjectColor(subjectName: string | null): string {
|
|||||||
return SUBJECT_COLORS[subjectName] ?? FALLBACK_COLOR;
|
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 ──────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
interface TimetableSlot {
|
interface TimetableSlot {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -110,7 +144,7 @@ interface ClassOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DAYS = [0, 1, 2, 3, 4]; // Mon-Fri
|
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 }> = {
|
const DEFAULT_TIMES: Record<number, { start: string; end: string }> = {
|
||||||
1: { start: '08:00', end: '08:45' },
|
1: { start: '08:00', end: '08:45' },
|
||||||
2: { start: '08:45', end: '09:30' },
|
2: { start: '08:45', end: '09:30' },
|
||||||
@@ -127,6 +161,68 @@ function getDayLabel(dayOfWeek: number): string {
|
|||||||
return t(keys[dayOfWeek] ?? 'timetable.day');
|
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 ────────────────────────────────────────────────────────
|
// ── Slot Dialog ────────────────────────────────────────────────────────
|
||||||
function SlotDialog({
|
function SlotDialog({
|
||||||
open,
|
open,
|
||||||
@@ -261,7 +357,7 @@ function SlotDialog({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="slot-room">{t('timetable.room')}</Label>
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -313,15 +409,27 @@ export default function TimetableView() {
|
|||||||
const [teachers, setTeachers] = useState<TeacherOption[]>([]);
|
const [teachers, setTeachers] = useState<TeacherOption[]>([]);
|
||||||
const [selectedClassId, setSelectedClassId] = useState<string>('');
|
const [selectedClassId, setSelectedClassId] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editSlot, setEditSlot] = useState<TimetableSlot | null>(null);
|
const [editSlot, setEditSlot] = useState<TimetableSlot | null>(null);
|
||||||
const [dialogDay, setDialogDay] = useState(0);
|
const [dialogDay, setDialogDay] = useState(0);
|
||||||
const [dialogPeriod, setDialogPeriod] = useState(1);
|
const [dialogPeriod, setDialogPeriod] = useState(1);
|
||||||
const [dragSlot, setDragSlot] = useState<string | null>(null);
|
const [dragSlot, setDragSlot] = useState<string | null>(null);
|
||||||
const [dragOverCell, setDragOverCell] = useState<{ day: number; period: number } | null>(null);
|
const [dragOverCell, setDragOverCell] = useState<{ day: number; period: number } | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
|
||||||
const schoolId = currentUser?.schoolId ?? '';
|
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
|
// Load classes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!schoolId) return;
|
if (!schoolId) return;
|
||||||
@@ -362,11 +470,13 @@ export default function TimetableView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<TimetableSlot[]>(`/api/timetable?schoolId=${schoolId}&classGroupId=${selectedClassId}`);
|
const data = await apiGet<TimetableSlot[]>(`/api/timetable?schoolId=${schoolId}&classGroupId=${selectedClassId}`);
|
||||||
setSlots(data);
|
setSlots(data);
|
||||||
} catch {
|
} catch (err) {
|
||||||
setSlots([]);
|
setSlots([]);
|
||||||
|
setError(err instanceof Error ? err.message : t('timetable.load_error'));
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [schoolId, selectedClassId]);
|
}, [schoolId, selectedClassId]);
|
||||||
@@ -384,11 +494,21 @@ export default function TimetableView() {
|
|||||||
return map;
|
return map;
|
||||||
}, [slots]);
|
}, [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
|
// Handlers
|
||||||
const handleSaveSlot = async (data: Record<string, unknown>) => {
|
const handleSaveSlot = async (data: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
// Update existing
|
|
||||||
await apiPut<TimetableSlot>(`/api/timetable/${data.id}`, {
|
await apiPut<TimetableSlot>(`/api/timetable/${data.id}`, {
|
||||||
subjectId: data.subjectId,
|
subjectId: data.subjectId,
|
||||||
teacherId: data.teacherId,
|
teacherId: data.teacherId,
|
||||||
@@ -398,7 +518,6 @@ export default function TimetableView() {
|
|||||||
isBreak: data.isBreak,
|
isBreak: data.isBreak,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new
|
|
||||||
await apiPost<TimetableSlot>('/api/timetable', data);
|
await apiPost<TimetableSlot>('/api/timetable', data);
|
||||||
}
|
}
|
||||||
await loadSlots();
|
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 justify-between flex-wrap gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Clock className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
<Clock className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||||
<h1 className="text-2xl font-bold">{t('timetable.title')}</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{t('timetable.title')}</h1>
|
||||||
|
{liveCurrentPeriod && (
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
{t('timetable.current_period', { period: liveCurrentPeriod.period })} — {getDayLabel(liveCurrentPeriod.day)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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}>
|
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
|
||||||
<SelectTrigger className="w-[180px] min-h-[44px]">
|
<SelectTrigger className="w-[180px] min-h-[44px]">
|
||||||
<SelectValue placeholder={t('timetable.select_class')} />
|
<SelectValue placeholder={t('timetable.select_class')} />
|
||||||
@@ -484,165 +631,353 @@ export default function TimetableView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timetable Grid */}
|
{/* Subject Legend */}
|
||||||
{loading ? (
|
{subjectLegend.length > 0 && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
<span className="text-xs text-muted-foreground font-medium">{t('timetable.legend')}:</span>
|
||||||
</div>
|
{subjectLegend.map(({ id, name }) => (
|
||||||
) : !selectedClassId ? (
|
<div
|
||||||
<Card>
|
key={id}
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs border ${getSubjectColor(name)}`}
|
||||||
{t('timetable.select_class')}
|
>
|
||||||
</CardContent>
|
<span className="font-medium">{name}</span>
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card className="print:shadow-none print:border-0">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse min-w-[640px]">
|
|
||||||
{/* Header row: Day names */}
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-emerald-50 dark:bg-emerald-950/30">
|
|
||||||
<th className="p-2 text-sm font-semibold text-left w-[80px] border border-gray-200 dark:border-gray-700">
|
|
||||||
{t('timetable.period')}
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700">
|
|
||||||
{t('timetable.start_time')} / {t('timetable.end_time')}
|
|
||||||
</th>
|
|
||||||
{DAYS.map((day) => (
|
|
||||||
<th key={day} className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700 min-w-[120px]">
|
|
||||||
{getDayLabel(day)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{PERIODS.map((period) => {
|
|
||||||
const defaultTime = DEFAULT_TIMES[period];
|
|
||||||
const slotInRow = slots.find((s) => s.period === period);
|
|
||||||
const timeDisplay = slotInRow
|
|
||||||
? `${slotInRow.startTime}–${slotInRow.endTime}`
|
|
||||||
: `${defaultTime?.start ?? ''}–${defaultTime?.end ?? ''}`;
|
|
||||||
|
|
||||||
// Check if this is a break row (common convention: period 3 is break)
|
|
||||||
const isBreakRow = PERIODS.indexOf(period) === 2; // between period 3 and 4
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={period}>
|
|
||||||
<tr className="hover:bg-gray-50/50 dark:hover:bg-gray-800/20">
|
|
||||||
<td className="p-2 text-sm font-medium text-center border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30">
|
|
||||||
{period}
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
|
|
||||||
{timeDisplay}
|
|
||||||
</td>
|
|
||||||
{DAYS.map((day) => {
|
|
||||||
const cellSlot = gridMap[`${day}-${period}`];
|
|
||||||
const isDragOver = dragOverCell?.day === day && dragOverCell?.period === period;
|
|
||||||
const isDragTarget = dragSlot && !cellSlot;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
key={`${day}-${period}`}
|
|
||||||
className={`p-1 border border-gray-200 dark:border-gray-700 transition-colors ${
|
|
||||||
isDragOver && isDragTarget ? 'bg-emerald-50 dark:bg-emerald-950/40' : ''
|
|
||||||
} ${cellSlot?.isBreak ? BREAK_COLOR.split(' ')[0] : ''}`}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); handleDragOver(day, period); }}
|
|
||||||
onDragLeave={() => setDragOverCell(null)}
|
|
||||||
onDrop={() => handleDrop(day, period)}
|
|
||||||
>
|
|
||||||
{cellSlot ? (
|
|
||||||
<motion.div
|
|
||||||
layout
|
|
||||||
draggable
|
|
||||||
onDragStart={() => handleDragStart(cellSlot.id)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
className={`rounded-md p-2 cursor-pointer min-h-[44px] flex flex-col gap-0.5 border ${cellSlot.isBreak ? BREAK_COLOR : getSubjectColor(cellSlot.subject?.name ?? null)}`}
|
|
||||||
onClick={() => {
|
|
||||||
setEditSlot(cellSlot);
|
|
||||||
setDialogDay(day);
|
|
||||||
setDialogPeriod(period);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium text-xs truncate">
|
|
||||||
{cellSlot.isBreak
|
|
||||||
? t('timetable.break_period')
|
|
||||||
: cellSlot.subject?.name ?? t('timetable.no_subject')}
|
|
||||||
</span>
|
|
||||||
<GripVertical className="h-3 w-3 opacity-40 shrink-0" />
|
|
||||||
</div>
|
|
||||||
{!cellSlot.isBreak && cellSlot.teacher && (
|
|
||||||
<span className="text-xs opacity-70 truncate">
|
|
||||||
{cellSlot.teacher.firstName} {cellSlot.teacher.lastName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{cellSlot.roomId && (
|
|
||||||
<span className="text-xs opacity-60 truncate">
|
|
||||||
R{cellSlot.roomId}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="w-full h-full min-h-[44px] flex items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
setEditSlot(null);
|
|
||||||
setDialogDay(day);
|
|
||||||
setDialogPeriod(period);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
aria-label={`${t('timetable.add_slot')} - ${getDayLabel(day)} Period ${period}`}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 text-muted-foreground opacity-30" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* Break row between period 3 and 4 */}
|
|
||||||
{isBreakRow && (
|
|
||||||
<tr className="bg-gray-100 dark:bg-gray-800/20">
|
|
||||||
<td className="p-2 text-xs text-center border border-gray-200 dark:border-gray-700">
|
|
||||||
<Coffee className="h-3 w-3 mx-auto text-gray-400" />
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
|
|
||||||
10:15–10:30
|
|
||||||
</td>
|
|
||||||
{DAYS.map((day) => (
|
|
||||||
<td
|
|
||||||
key={`break-${day}`}
|
|
||||||
className="p-1 border border-gray-200 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<div className={`rounded-md p-2 min-h-[28px] flex items-center justify-center border ${BREAK_COLOR}`}>
|
|
||||||
<span className="text-xs font-medium">{t('timetable.break_period')}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{slots.length === 0 && (
|
{/* Error State */}
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
{error && (
|
||||||
<BookOpen className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
<Card className="border-rose-200 dark:border-rose-800">
|
||||||
{t('timetable.no_slots')}
|
<CardContent className="py-6 text-center">
|
||||||
</div>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Timetable Grid */}
|
||||||
|
{!error && loading && (
|
||||||
|
<TimetableSkeleton />
|
||||||
|
)}
|
||||||
|
{!error && !loading && !selectedClassId && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<CalendarDays className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-lg font-medium mb-1">{t('timetable.select_class')}</p>
|
||||||
|
<p className="text-sm">{t('timetable.select_class_hint')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{!error && !loading && selectedClassId && viewMode === 'grid' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<Card className="print:shadow-none print:border-0">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse min-w-[640px]">
|
||||||
|
{/* Header row: Day names */}
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-emerald-50 dark:bg-emerald-950/30">
|
||||||
|
<th className="p-2 text-sm font-semibold text-left w-[80px] border border-gray-200 dark:border-gray-700">
|
||||||
|
{t('timetable.period')}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700">
|
||||||
|
{t('timetable.start_time')} / {t('timetable.end_time')}
|
||||||
|
</th>
|
||||||
|
{DAYS.map((day) => {
|
||||||
|
const isToday = liveCurrentPeriod?.day === day;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={day}
|
||||||
|
className={`p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700 min-w-[120px] ${
|
||||||
|
isToday ? 'bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>{getDayLabel(day)}</div>
|
||||||
|
{isToday && (
|
||||||
|
<div className="text-xs font-normal flex items-center justify-center gap-1">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
{t('timetable.today')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{PERIODS.map((period) => {
|
||||||
|
const defaultTime = DEFAULT_TIMES[period];
|
||||||
|
const slotInRow = slots.find((s) => s.period === period);
|
||||||
|
const timeDisplay = slotInRow
|
||||||
|
? `${slotInRow.startTime}–${slotInRow.endTime}`
|
||||||
|
: `${defaultTime?.start ?? ''}–${defaultTime?.end ?? ''}`;
|
||||||
|
const isCurrentPeriod = liveCurrentPeriod?.period === period;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={period}>
|
||||||
|
<tr className={`hover:bg-gray-50/50 dark:hover:bg-gray-800/20 ${
|
||||||
|
isCurrentPeriod ? 'bg-emerald-50/50 dark:bg-emerald-950/20' : ''
|
||||||
|
}`}>
|
||||||
|
<td className={`p-2 text-sm font-medium text-center border border-gray-200 dark:border-gray-700 ${
|
||||||
|
isCurrentPeriod
|
||||||
|
? 'bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-800/30'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{period}
|
||||||
|
{isCurrentPeriod && (
|
||||||
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
|
||||||
|
{timeDisplay}
|
||||||
|
</td>
|
||||||
|
{DAYS.map((day) => {
|
||||||
|
const cellSlot = gridMap[`${day}-${period}`];
|
||||||
|
const isDragOver = dragOverCell?.day === day && dragOverCell?.period === period;
|
||||||
|
const isDragTarget = dragSlot && !cellSlot;
|
||||||
|
const isCurrentCell = liveCurrentPeriod?.day === day && liveCurrentPeriod?.period === period;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${day}-${period}`}
|
||||||
|
className={`p-1 border border-gray-200 dark:border-gray-700 transition-colors ${
|
||||||
|
isDragOver && isDragTarget ? 'bg-emerald-50 dark:bg-emerald-950/40' : ''
|
||||||
|
} ${isCurrentCell ? 'ring-2 ring-inset ring-emerald-400 dark:ring-emerald-600' : ''} ${
|
||||||
|
cellSlot?.isBreak ? BREAK_COLOR.split(' ')[0] : ''
|
||||||
|
}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); handleDragOver(day, period); }}
|
||||||
|
onDragLeave={() => setDragOverCell(null)}
|
||||||
|
onDrop={() => handleDrop(day, period)}
|
||||||
|
>
|
||||||
|
{cellSlot ? (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(cellSlot.id)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`rounded-md p-2 cursor-pointer min-h-[44px] flex flex-col gap-0.5 border-l-4 ${
|
||||||
|
cellSlot.isBreak
|
||||||
|
? `${BREAK_COLOR} border-l-gray-300 dark:border-l-gray-600`
|
||||||
|
: `${getSubjectColor(cellSlot.subject?.name ?? null)} ${getSubjectAccentColor(cellSlot.subject?.name ?? null)}`
|
||||||
|
} ${isCurrentCell ? 'shadow-md' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setEditSlot(cellSlot);
|
||||||
|
setDialogDay(day);
|
||||||
|
setDialogPeriod(period);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-xs truncate">
|
||||||
|
{cellSlot.isBreak
|
||||||
|
? t('timetable.break_period')
|
||||||
|
: cellSlot.subject?.name ?? t('timetable.no_subject')}
|
||||||
|
</span>
|
||||||
|
<GripVertical className="h-3 w-3 opacity-40 shrink-0" />
|
||||||
|
</div>
|
||||||
|
{!cellSlot.isBreak && cellSlot.teacher && (
|
||||||
|
<span className="text-xs opacity-70 truncate flex items-center gap-0.5">
|
||||||
|
<User className="h-2.5 w-2.5" />
|
||||||
|
{cellSlot.teacher.firstName[0]}. {cellSlot.teacher.lastName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cellSlot.roomId && (
|
||||||
|
<span className="text-xs opacity-60 truncate flex items-center gap-0.5">
|
||||||
|
<MapPin className="h-2.5 w-2.5" />
|
||||||
|
{cellSlot.roomId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`w-full h-full min-h-[44px] flex items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-gray-800/30 transition-colors cursor-pointer ${
|
||||||
|
isCurrentCell ? 'bg-emerald-50/50 dark:bg-emerald-950/20' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setEditSlot(null);
|
||||||
|
setDialogDay(day);
|
||||||
|
setDialogPeriod(period);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label={`${t('timetable.add_slot')} - ${getDayLabel(day)} Period ${period}`}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground opacity-30" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Break row after period 3 */}
|
||||||
|
{period === 3 && (
|
||||||
|
<tr className="bg-gray-100 dark:bg-gray-800/20">
|
||||||
|
<td className="p-2 text-xs text-center border border-gray-200 dark:border-gray-700">
|
||||||
|
<Coffee className="h-3 w-3 mx-auto text-gray-400" />
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
|
||||||
|
10:15–10:30
|
||||||
|
</td>
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<td
|
||||||
|
key={`break-${day}`}
|
||||||
|
className="p-1 border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div className={`rounded-md p-2 min-h-[28px] flex items-center justify-center border ${BREAK_COLOR}`}>
|
||||||
|
<span className="text-xs font-medium">{t('timetable.break_period')}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slots.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-lg font-medium mb-1">{t('timetable.no_slots')}</p>
|
||||||
|
<p className="text-sm">{t('timetable.no_slots_hint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{!error && !loading && selectedClassId && viewMode === 'list' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{DAYS.map((day) => {
|
||||||
|
const daySlots = slots
|
||||||
|
.filter((s) => s.dayOfWeek === day)
|
||||||
|
.sort((a, b) => a.period - b.period);
|
||||||
|
const isToday = liveCurrentPeriod?.day === day;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={day} className={isToday ? 'ring-2 ring-emerald-400 dark:ring-emerald-600' : ''}>
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
{getDayLabel(day)}
|
||||||
|
{isToday && (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300 text-xs">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse mr-1" />
|
||||||
|
{t('timetable.today')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-[44px]"
|
||||||
|
onClick={() => {
|
||||||
|
setEditSlot(null);
|
||||||
|
setDialogDay(day);
|
||||||
|
setDialogPeriod(1);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
{daySlots.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">{t('timetable.no_slots')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{daySlots.map((slot) => {
|
||||||
|
const isCurrentSlot = liveCurrentPeriod?.day === day && liveCurrentPeriod?.period === slot.period;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={slot.id}
|
||||||
|
layout
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(slot.id)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer hover:shadow-sm transition-shadow ${
|
||||||
|
slot.isBreak
|
||||||
|
? BREAK_COLOR
|
||||||
|
: `${getSubjectColor(slot.subject?.name ?? null)} ${getSubjectAccentColor(slot.subject?.name ?? null)}`
|
||||||
|
} ${isCurrentSlot ? 'ring-2 ring-emerald-400 dark:ring-emerald-600 shadow-md' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setEditSlot(slot);
|
||||||
|
setDialogDay(day);
|
||||||
|
setDialogPeriod(slot.period);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 text-sm font-medium min-w-[40px]">
|
||||||
|
{slot.period}
|
||||||
|
{isCurrentSlot && (
|
||||||
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{slot.isBreak
|
||||||
|
? t('timetable.break_period')
|
||||||
|
: slot.subject?.name ?? t('timetable.no_subject')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!slot.isBreak && (
|
||||||
|
<div className="flex items-center gap-3 text-xs opacity-70 mt-0.5">
|
||||||
|
{slot.teacher && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
{slot.teacher.firstName} {slot.teacher.lastName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{slot.roomId && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{slot.roomId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{slot.startTime}–{slot.endTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<GripVertical className="h-4 w-4 opacity-40 shrink-0" />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Drag hint */}
|
{/* Drag hint */}
|
||||||
{slots.length > 0 && (
|
{slots.length > 0 && !error && (
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<GripVertical className="h-3 w-3" />
|
<GripVertical className="h-3 w-3" />
|
||||||
{t('timetable.drag_hint')}
|
{t('timetable.drag_hint')}
|
||||||
|
|||||||
+116
@@ -2218,6 +2218,32 @@ const de: TranslationDict = {
|
|||||||
'notebooks.table_of_contents': 'Inhaltsverzeichnis',
|
'notebooks.table_of_contents': 'Inhaltsverzeichnis',
|
||||||
'notebooks.no_sections': 'Keine Abschnitte vorhanden',
|
'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 ────────────────────────────────────────────
|
// ─── Highlighter Tool ────────────────────────────────────────────
|
||||||
'drawing.tool_highlighter': 'Textmarker',
|
'drawing.tool_highlighter': 'Textmarker',
|
||||||
'drawing.tool_highlighter_desc': 'Halbtransparenter Textmarker zum Hervorheben',
|
'drawing.tool_highlighter_desc': 'Halbtransparenter Textmarker zum Hervorheben',
|
||||||
@@ -2836,6 +2862,14 @@ const de: TranslationDict = {
|
|||||||
'homework.due_soon': 'Bald fällig',
|
'homework.due_soon': 'Bald fällig',
|
||||||
'homework.no_due_soon': 'Keine Hausaufgaben in nächster Zeit fällig',
|
'homework.no_due_soon': 'Keine Hausaufgaben in nächster Zeit fällig',
|
||||||
'homework.view_all': 'Alle anzeigen',
|
'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
|
// Announcements
|
||||||
'announcement.title': 'Ankündigungen',
|
'announcement.title': 'Ankündigungen',
|
||||||
@@ -2960,6 +2994,18 @@ const de: TranslationDict = {
|
|||||||
'timetable.delete_slot': 'Stunde löschen',
|
'timetable.delete_slot': 'Stunde löschen',
|
||||||
'timetable.break_period': 'Pause',
|
'timetable.break_period': 'Pause',
|
||||||
'timetable.drag_hint': 'Ziehen zum Verschieben',
|
'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
|
||||||
'resources.title': 'Ressourcen',
|
'resources.title': 'Ressourcen',
|
||||||
@@ -2998,6 +3044,18 @@ const de: TranslationDict = {
|
|||||||
'resources.tags_field': 'Schlagwörter',
|
'resources.tags_field': 'Schlagwörter',
|
||||||
'resources.add_tag': 'Schlagwort hinzufügen',
|
'resources.add_tag': 'Schlagwort hinzufügen',
|
||||||
'resources.delete_confirm': 'Ressource wirklich löschen?',
|
'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 ──────────────────────────────────────────────
|
// ─── School Branding ──────────────────────────────────────────────
|
||||||
'branding.title': 'Schulbranding',
|
'branding.title': 'Schulbranding',
|
||||||
@@ -5989,6 +6047,32 @@ const en: TranslationDict = {
|
|||||||
'notebooks.table_of_contents': 'Table of Contents',
|
'notebooks.table_of_contents': 'Table of Contents',
|
||||||
'notebooks.no_sections': 'No sections yet',
|
'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 ────────────────────────────────────────────
|
// ─── Highlighter Tool ────────────────────────────────────────────
|
||||||
'drawing.tool_highlighter': 'Highlighter',
|
'drawing.tool_highlighter': 'Highlighter',
|
||||||
'drawing.tool_highlighter_desc': 'Semi-transparent highlighter for emphasizing',
|
'drawing.tool_highlighter_desc': 'Semi-transparent highlighter for emphasizing',
|
||||||
@@ -6607,6 +6691,14 @@ const en: TranslationDict = {
|
|||||||
'homework.due_soon': 'Due Soon',
|
'homework.due_soon': 'Due Soon',
|
||||||
'homework.no_due_soon': 'No homework due soon',
|
'homework.no_due_soon': 'No homework due soon',
|
||||||
'homework.view_all': 'View All',
|
'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
|
// Announcements
|
||||||
'announcement.title': 'Announcements',
|
'announcement.title': 'Announcements',
|
||||||
@@ -6731,6 +6823,18 @@ const en: TranslationDict = {
|
|||||||
'timetable.delete_slot': 'Delete Slot',
|
'timetable.delete_slot': 'Delete Slot',
|
||||||
'timetable.break_period': 'Break',
|
'timetable.break_period': 'Break',
|
||||||
'timetable.drag_hint': 'Drag to move',
|
'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
|
||||||
'resources.title': 'Resources',
|
'resources.title': 'Resources',
|
||||||
@@ -6769,6 +6873,18 @@ const en: TranslationDict = {
|
|||||||
'resources.tags_field': 'Tags',
|
'resources.tags_field': 'Tags',
|
||||||
'resources.add_tag': 'Add Tag',
|
'resources.add_tag': 'Add Tag',
|
||||||
'resources.delete_confirm': 'Are you sure you want to delete this resource?',
|
'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 ──────────────────────────────────────────────
|
// ─── School Branding ──────────────────────────────────────────────
|
||||||
'branding.title': 'School Branding',
|
'branding.title': 'School Branding',
|
||||||
|
|||||||
+99
@@ -2345,3 +2345,102 @@ Stage Summary:
|
|||||||
- Database connection confirmed working (the "db bulunamiyor" report was not a database issue)
|
- Database connection confirmed working (the "db bulunamiyor" report was not a database issue)
|
||||||
- Component-level TypeScript errors reduced from ~30 to 0
|
- 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)
|
- 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
|
||||||
|
|
||||||
Reference in new issue
Block a user