Initial commit: ExaMetra v1.0.0 — Free & open-source competence & grading platform

Features:
- 15+ views: Dashboard, Classes, Progress, Flower, Matrix, Assessments, Grading, Reports, Attendance, Calendar, Lesson Plans, Analytics, Parent Communication, Behavior Tracking, Settings
- 34+ API endpoints
- 30+ Prisma models
- 700+ i18n keys (German + English)
- Keyboard shortcuts (Cmd+K, Cmd+1-9, Cmd+/)
- Batch deletion, drag-and-drop seating, custom color palette
- Demo accounts (admin, teacher, student, parent)
- Dark mode, responsive design, print styles
- CSV/PDF export, notification system, onboarding tour
- Zero emojis — all replaced with Lucide icons
- MIT License
This commit is contained in:
Mustafa Cetin committed 2026-07-29 05:46:10 +00:00
commit 0c1f739989
156 files changed
+51090

No files matched your search

+773
View File
@@ -0,0 +1,773 @@
'use client';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
ClipboardCheck,
CalendarCheck,
TrendingUp,
FileText,
BookOpen,
Loader2,
AlertCircle,
Clock,
} from 'lucide-react';
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
isSameDay,
isSameMonth,
isToday as isDateToday,
parseISO,
addMonths,
addDays as dateAddDays,
differenceInCalendarDays,
isWithinInterval,
} from 'date-fns';
import { de as deLocale, enUS as enLocale } from 'date-fns/locale';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from '@/components/ui/drawer';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { fetchCalendarEvents, type CalendarEvent, type CalendarEventType } from '@/lib/api';
import { toast } from 'sonner';
/* ── Event type visual config ─────────────────────────────────────── */
type TypeConfig = {
icon: React.ElementType;
labelKey: string;
chipBg: string;
chipText: string;
dotClass: string;
ringClass: string;
};
const TYPE_CONFIG: Record<CalendarEventType, TypeConfig> = {
assessment: {
icon: ClipboardCheck,
labelKey: 'calendar.event_types.assessment',
chipBg: 'bg-amber-100 dark:bg-amber-900/30',
chipText: 'text-amber-700 dark:text-amber-300',
dotClass: 'bg-amber-500',
ringClass: 'ring-amber-300 dark:ring-amber-700',
},
attendance: {
icon: CalendarCheck,
labelKey: 'calendar.event_types.attendance',
chipBg: 'bg-emerald-100 dark:bg-emerald-900/30',
chipText: 'text-emerald-700 dark:text-emerald-300',
dotClass: 'bg-emerald-500',
ringClass: 'ring-emerald-300 dark:ring-emerald-700',
},
progress: {
icon: TrendingUp,
labelKey: 'calendar.event_types.progress',
chipBg: 'bg-teal-100 dark:bg-teal-900/30',
chipText: 'text-teal-700 dark:text-teal-300',
dotClass: 'bg-teal-500',
ringClass: 'ring-teal-300 dark:ring-teal-700',
},
report: {
icon: FileText,
labelKey: 'calendar.event_types.report',
chipBg: 'bg-violet-100 dark:bg-violet-900/30',
chipText: 'text-violet-700 dark:text-violet-300',
dotClass: 'bg-violet-500',
ringClass: 'ring-violet-300 dark:ring-violet-700',
},
lesson: {
icon: BookOpen,
labelKey: 'calendar.event_types.lesson',
chipBg: 'bg-rose-100 dark:bg-rose-900/30',
chipText: 'text-rose-700 dark:text-rose-300',
dotClass: 'bg-rose-500',
ringClass: 'ring-rose-300 dark:ring-rose-700',
},
};
const ALL_TYPES: CalendarEventType[] = ['assessment', 'attendance', 'progress', 'report', 'lesson'];
/* ── helpers ──────────────────────────────────────────────────────── */
function getMonthLabel(date: Date, locale: 'de' | 'en'): string {
const localeObj = locale === 'de' ? deLocale : enLocale;
return format(date, 'MMMM yyyy', { locale: localeObj });
}
function getWeekdayLabels(locale: 'de' | 'en'): string[] {
const localeObj = locale === 'de' ? deLocale : enLocale;
// Monday-first weekday header
const base = startOfWeek(new Date(), { weekStartsOn: 1 });
return Array.from({ length: 7 }, (_, i) =>
format(dateAddDays(base, i), 'EEEEEE', { locale: localeObj })
);
}
function buildMonthGrid(monthDate: Date): Date[] {
const start = startOfWeek(startOfMonth(monthDate), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(monthDate), { weekStartsOn: 1 });
const days: Date[] = [];
let cursor = start;
while (cursor <= end) {
days.push(cursor);
cursor = dateAddDays(cursor, 1);
}
return days;
}
function formatDayLong(date: Date, locale: 'de' | 'en'): string {
const localeObj = locale === 'de' ? deLocale : enLocale;
return format(date, 'EEEE, d. MMMM yyyy', { locale: localeObj });
}
/* ── Sub-components ───────────────────────────────────────────────── */
function EventDetailContent({ event }: { event: CalendarEvent }) {
const cfg = TYPE_CONFIG[event.type];
const Icon = cfg.icon;
const meta = event.meta as Record<string, unknown>;
const rows: Array<{ key: string; value: string | number | null | undefined }> = [];
if (typeof meta.subject === 'string' && meta.subject) rows.push({ key: 'calendar.meta.subject', value: meta.subject });
if (typeof meta.classGroup === 'string' && meta.classGroup) rows.push({ key: 'calendar.meta.class', value: meta.classGroup });
if (typeof meta.assessmentType === 'string' && meta.assessmentType) rows.push({ key: 'calendar.meta.type', value: meta.assessmentType });
if (typeof meta.period === 'string' && meta.period) rows.push({ key: 'calendar.meta.period', value: meta.period });
if (typeof meta.status === 'string' && meta.status) rows.push({ key: 'calendar.meta.status', value: meta.status });
if (typeof meta.studentName === 'string' && meta.studentName) rows.push({ key: 'calendar.meta.student', value: meta.studentName });
if (typeof meta.weight === 'number') rows.push({ key: 'calendar.meta.weight', value: meta.weight });
if (typeof meta.maxScore === 'number') rows.push({ key: 'calendar.meta.max_score', value: meta.maxScore });
if (typeof meta.includesGrades === 'boolean') rows.push({ key: 'calendar.meta.includes_grades', value: meta.includesGrades ? 'Ja' : 'Nein' });
if (typeof meta.count === 'number') rows.push({ key: 'calendar.meta.count', value: meta.count });
// Attendance rates
if (typeof meta.rate === 'number') rows.push({ key: 'calendar.meta.rate', value: `${meta.rate}%` });
if (typeof meta.present === 'number') rows.push({ key: 'calendar.meta.present', value: meta.present });
if (typeof meta.absent === 'number') rows.push({ key: 'calendar.meta.absent', value: meta.absent });
if (typeof meta.excused === 'number') rows.push({ key: 'calendar.meta.excused', value: meta.excused });
if (typeof meta.late === 'number') rows.push({ key: 'calendar.meta.late', value: meta.late });
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${cfg.chipBg} ${cfg.chipText} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight truncate">
{event.title}
</p>
<p className={`text-xs ${cfg.chipText} font-medium`}>
{t(cfg.labelKey)}
</p>
</div>
</div>
{rows.length > 0 && (
<dl className="grid grid-cols-1 gap-1 pt-1 border-t border-gray-100 dark:border-gray-800">
{rows.map((row, idx) => (
<div key={idx} className="flex justify-between gap-3 text-xs">
<dt className="text-gray-500 dark:text-gray-400">{t(row.key)}</dt>
<dd className="font-medium text-gray-900 dark:text-gray-100 text-right">
{String(row.value)}
</dd>
</div>
))}
</dl>
)}
{event.type === 'progress' && Array.isArray(meta.classGroups) && meta.classGroups.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{(meta.classGroups as string[]).map((g) => (
<Badge key={g} variant="outline" className="bg-teal-50/60 dark:bg-teal-900/20 text-teal-700 dark:text-teal-300 border-teal-200/60 dark:border-teal-900/30 text-[10px]">
{g}
</Badge>
))}
</div>
)}
</div>
);
}
/* ── Main component ───────────────────────────────────────────────── */
export default function CalendarView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const localeCode = (locale === 'en' ? 'en' : 'de') as 'de' | 'en';
// Month state — defaults to today
const [monthDate, setMonthDate] = useState<Date>(() => startOfMonth(new Date()));
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Selected day (opens sheet)
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
// Selected event (opens popover) — keyed by event id
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
// Detect mobile (for bottom drawer vs side sheet)
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mql = window.matchMedia('(max-width: 768px)');
const update = () => setIsMobile(mql.matches);
update();
mql.addEventListener('change', update);
return () => mql.removeEventListener('change', update);
}, []);
// Fetch events when month or school changes
const monthKey = useMemo(() => format(monthDate, 'yyyy-MM'), [monthDate]);
const loadEvents = useCallback(async () => {
if (!currentUser?.schoolId) return;
setLoading(true);
setError(null);
try {
const res = await fetchCalendarEvents(currentUser.schoolId, monthKey);
setEvents(res.events ?? []);
} catch (err) {
console.error('Calendar fetch failed:', err);
setError(t('calendar.error'));
toast.error(t('calendar.error'));
} finally {
setLoading(false);
}
}, [currentUser?.schoolId, monthKey]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
// Group events by YYYY-MM-DD for quick lookup
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const e of events) {
const arr = map.get(e.date) ?? [];
arr.push(e);
map.set(e.date, arr);
}
return map;
}, [events]);
// Upcoming events (next 5 from today, regardless of current month view)
const upcomingEvents = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekEnd = dateAddDays(today, 7);
return events
.filter((e) => {
const d = parseISO(e.date);
return (d >= today || isSameDay(d, today)) && d <= weekEnd;
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 5);
}, [events]);
// Build month grid
const days = useMemo(() => buildMonthGrid(monthDate), [monthDate]);
const weekdayLabels = useMemo(() => getWeekdayLabels(localeCode), [localeCode]);
const handlePrev = useCallback(() => setMonthDate((d) => addMonths(d, -1)), []);
const handleNext = useCallback(() => setMonthDate((d) => addMonths(d, 1)), []);
const handleToday = useCallback(() => setMonthDate(startOfMonth(new Date())), []);
const selectedDayEvents = useMemo(() => {
if (!selectedDay) return [];
const key = format(selectedDay, 'yyyy-MM-dd');
return eventsByDay.get(key) ?? [];
}, [selectedDay, eventsByDay]);
return (
<div className="space-y-6 pb-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/40 dark:shadow-emerald-900/40 shrink-0">
<CalendarIcon className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 leading-tight">
{t('calendar.title')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('calendar.subtitle')}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={handleToday}
className="border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
>
<CalendarIcon className="h-4 w-4 mr-1.5" />
{t('calendar.today')}
</Button>
<div className="flex items-center gap-1 rounded-lg border border-emerald-200/60 dark:border-emerald-900/30 bg-white dark:bg-gray-800/50 p-0.5">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-600 dark:text-gray-300"
onClick={handlePrev}
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-2 min-w-[140px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 capitalize">
{getMonthLabel(monthDate, localeCode)}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-600 dark:text-gray-300"
onClick={handleNext}
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</motion.div>
{/* Legend */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.05 }}
className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-lg bg-gradient-to-r from-emerald-50/60 to-teal-50/40 dark:from-emerald-950/20 dark:to-teal-950/10 border border-emerald-100/60 dark:border-emerald-900/30"
>
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-700/70 dark:text-emerald-400/60 mr-1">
{t('calendar.legend')}:
</span>
{ALL_TYPES.map((type) => {
const cfg = TYPE_CONFIG[type];
const Icon = cfg.icon;
return (
<Badge
key={type}
variant="outline"
className={`gap-1 ${cfg.chipBg} ${cfg.chipText} border-transparent opacity-80 cursor-default select-none`}
>
<Icon className="h-3 w-3" />
<span className="text-[11px] font-medium">{t(cfg.labelKey)}</span>
</Badge>
);
})}
</motion.div>
{/* Calendar grid */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Card className="overflow-hidden border-emerald-100/60 dark:border-emerald-900/30 shadow-sm">
<CardContent className="p-2 sm:p-3">
{/* Weekday header */}
<div className="grid grid-cols-7 mb-1">
{weekdayLabels.map((label) => (
<div
key={label}
className="text-center text-[10px] sm:text-xs font-semibold uppercase tracking-wider text-emerald-700/70 dark:text-emerald-400/60 py-1.5"
>
{label}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-1">
{days.map((day, idx) => {
const dayKey = format(day, 'yyyy-MM-dd');
const dayEvents = eventsByDay.get(dayKey) ?? [];
const inMonth = isSameMonth(day, monthDate);
const isToday = isDateToday(day);
const maxVisible = 3;
const visibleEvents = dayEvents.slice(0, maxVisible);
const overflow = dayEvents.length - maxVisible;
return (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.18, delay: Math.min(idx * 0.005, 0.15) }}
onClick={() => dayEvents.length > 0 && setSelectedDay(day)}
className={`relative min-h-[80px] p-1 rounded-md border transition-all ${
inMonth
? 'bg-white dark:bg-gray-900/40 border-gray-100 dark:border-gray-800'
: 'bg-gray-50/60 dark:bg-gray-900/20 border-transparent'
} ${
dayEvents.length > 0 && inMonth
? 'cursor-pointer hover:shadow-md hover:border-emerald-200 dark:hover:border-emerald-800/60 hover:-translate-y-0.5'
: ''
} ${isToday ? 'ring-2 ring-emerald-500 ring-offset-1 ring-offset-white dark:ring-offset-gray-950' : ''}`}
>
{/* Day number */}
<div className="flex items-center justify-between mb-0.5">
<span
className={`text-[11px] sm:text-xs font-semibold ${
isToday
? 'flex items-center justify-center w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-emerald-500 text-white'
: inMonth
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-300 dark:text-gray-600'
}`}
>
{format(day, 'd')}
</span>
{dayEvents.length > 0 && inMonth && (
<span className="text-[9px] sm:text-[10px] text-gray-400 dark:text-gray-500 font-medium">
{dayEvents.length}
</span>
)}
</div>
{/* Empty cell placeholder */}
{inMonth && dayEvents.length === 0 && (
<span className="text-[9px] text-gray-300 dark:text-gray-600 select-none">—</span>
)}
{/* Event chips */}
<div className="space-y-0.5">
{visibleEvents.map((ev) => {
const cfg = TYPE_CONFIG[ev.type];
const Icon = cfg.icon;
return (
<button
key={`${ev.type}-${ev.id}`}
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedEvent(ev);
}}
className={`w-full max-w-full overflow-hidden text-left ${cfg.chipBg} ${cfg.chipText} rounded px-1 py-0.5 flex items-center gap-1 hover:opacity-80 transition-opacity focus:outline-none focus-visible:ring-1 ${cfg.ringClass}`}
title={ev.title}
>
<Icon className="h-2.5 w-2.5 shrink-0 hidden sm:block" />
<span className="min-w-0 text-[9px] sm:text-[10px] font-medium leading-tight line-clamp-2 break-words text-ellipsis overflow-hidden">
{ev.title}
</span>
</button>
);
})}
{overflow > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedDay(day);
}}
className="w-full text-left text-[9px] sm:text-[10px] text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 font-medium px-1 py-0.5 transition-colors"
>
{t('calendar.more_events', { count: overflow })}
</button>
)}
</div>
</motion.div>
);
})}
</div>
{/* Empty + Loading + Error states */}
{loading && (
<div className="mt-3 flex items-center justify-center gap-2 py-3 text-xs text-emerald-700/70 dark:text-emerald-400/60">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('calendar.loading')}
</div>
)}
{error && !loading && (
<div className="mt-3 flex items-center justify-center gap-2 py-3 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3.5 w-3.5" />
{error}
</div>
)}
{!loading && !error && events.length === 0 && (
<div className="mt-3 flex flex-col items-center justify-center gap-1 py-6 text-center">
<CalendarIcon className="h-8 w-8 text-emerald-300/60 dark:text-emerald-700/50 mb-1" />
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('calendar.no_events')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('calendar.no_events_desc')}
</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Upcoming this week */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15 }}
>
<Card className="border-emerald-100/60 dark:border-emerald-900/30 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-gray-100">
<Clock className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
{t('calendar.upcoming_week')}
</CardTitle>
</CardHeader>
<CardContent>
{upcomingEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Clock className="h-7 w-7 text-emerald-300/60 dark:text-emerald-700/50 mb-1" />
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('calendar.no_upcoming')}
</p>
</div>
) : (
<ul className="space-y-1.5">
<AnimatePresence>
{upcomingEvents.map((ev, idx) => {
const cfg = TYPE_CONFIG[ev.type];
const Icon = cfg.icon;
const evDate = parseISO(ev.date);
const relativeDays = differenceInCalendarDays(evDate, new Date());
const relativeLabel =
relativeDays === 0
? localeCode === 'de'
? 'Heute'
: 'Today'
: relativeDays === 1
? localeCode === 'de'
? 'Morgen'
: 'Tomorrow'
: format(evDate, 'EEE, d. MMM', {
locale: localeCode === 'de' ? deLocale : enLocale,
});
return (
<motion.li
key={`${ev.type}-${ev.id}`}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: idx * 0.04 }}
>
<button
type="button"
onClick={() => setSelectedEvent(ev)}
className="w-full text-left flex items-center gap-3 p-2 rounded-lg hover:bg-emerald-50/60 dark:hover:bg-emerald-900/10 transition-colors group"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${cfg.chipBg} ${cfg.chipText} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{ev.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
<span className={`inline-flex items-center gap-1 ${cfg.chipText} font-medium`}>
{t(cfg.labelKey)}
</span>
<span aria-hidden>·</span>
<span>{relativeLabel}</span>
</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
)}
</CardContent>
</Card>
</motion.div>
{/* Day Events Sheet (desktop) / Drawer (mobile) */}
{isMobile ? (
<Drawer
open={!!selectedDay}
onOpenChange={(open) => !open && setSelectedDay(null)}
>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="pb-2">
<DrawerTitle className="text-base font-semibold text-gray-900 dark:text-gray-100 capitalize">
{selectedDay ? formatDayLong(selectedDay, localeCode) : ''}
</DrawerTitle>
<DrawerDescription className="sr-only">
{t('calendar.day_events', { date: selectedDay ? formatDayLong(selectedDay, localeCode) : '' })}
</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-6 overflow-y-auto max-h-[60vh] scrollbar-education">
{selectedDayEvents.length === 0 ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400 py-6">
{t('calendar.no_day_events')}
</p>
) : (
<ul className="space-y-2">
{selectedDayEvents.map((ev) => {
const cfg = TYPE_CONFIG[ev.type];
const Icon = cfg.icon;
return (
<li key={`${ev.type}-${ev.id}`}>
<button
type="button"
onClick={() => {
setSelectedEvent(ev);
}}
className="w-full text-left p-3 rounded-lg border border-gray-100 dark:border-gray-800 hover:border-emerald-200 dark:hover:border-emerald-800 hover:bg-emerald-50/40 dark:hover:bg-emerald-900/10 transition-colors"
>
<div className="flex items-start gap-2.5">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${cfg.chipBg} ${cfg.chipText} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{ev.title}
</p>
<p className={`text-xs ${cfg.chipText} font-medium`}>
{t(cfg.labelKey)}
</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0" />
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
</DrawerContent>
</Drawer>
) : (
<Sheet
open={!!selectedDay}
onOpenChange={(open) => !open && setSelectedDay(null)}
>
<SheetContent
side="right"
className="w-full sm:max-w-md overflow-y-auto"
>
<SheetHeader className="pb-2">
<SheetTitle className="text-base font-semibold text-gray-900 dark:text-gray-100 capitalize">
{selectedDay ? formatDayLong(selectedDay, localeCode) : ''}
</SheetTitle>
<SheetDescription>
{t('calendar.day_events', {
date: selectedDay ? format(selectedDay, 'yyyy-MM-dd') : '',
})}
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-6">
{selectedDayEvents.length === 0 ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400 py-6">
{t('calendar.no_day_events')}
</p>
) : (
<ul className="space-y-2">
{selectedDayEvents.map((ev) => {
const cfg = TYPE_CONFIG[ev.type];
const Icon = cfg.icon;
return (
<li key={`${ev.type}-${ev.id}`}>
<button
type="button"
onClick={() => {
setSelectedEvent(ev);
}}
className="w-full text-left p-3 rounded-lg border border-gray-100 dark:border-gray-800 hover:border-emerald-200 dark:hover:border-emerald-800 hover:bg-emerald-50/40 dark:hover:bg-emerald-900/10 transition-colors"
>
<div className="flex items-start gap-2.5">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${cfg.chipBg} ${cfg.chipText} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{ev.title}
</p>
<p className={`text-xs ${cfg.chipText} font-medium`}>
{t(cfg.labelKey)}
</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0" />
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
</SheetContent>
</Sheet>
)}
{/* Event detail dialog */}
<Dialog
open={!!selectedEvent}
onOpenChange={(open) => !open && setSelectedEvent(null)}
>
<DialogContent
className="max-w-sm p-4 rounded-2xl border-emerald-100/60 dark:border-emerald-900/30"
aria-describedby={undefined}
>
<DialogHeader className="pb-1">
<DialogTitle className="flex items-center justify-between gap-2 text-base font-semibold text-gray-900 dark:text-gray-100">
<span className="capitalize">
{selectedEvent ? formatDayLong(parseISO(selectedEvent.date), localeCode) : ''}
</span>
</DialogTitle>
<DialogDescription className="sr-only">
{selectedEvent ? t('calendar.day_events', { date: selectedEvent.date }) : ''}
</DialogDescription>
</DialogHeader>
{selectedEvent ? (
<div className="pt-1">
<EventDetailContent event={selectedEvent} />
<div className="mt-3 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedEvent(null)}
className="border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
>
{t('calendar.close')}
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}