CompetenceTrack v9 - Rich text editor, Drawing integration, PDF export, Notebook sharing, Templates, Keyboard shortcuts

Features:
- Rich text toolbar for notebook pages (Bold, Italic, Underline, Headings, Lists)
- Drawing canvas integration in notebook pages (toggle drawing mode)
- Notebook PDF export (client-side print with print CSS)
- Page background rendering (lined, grid, blank, dotted, music, calligraphy)
- Page thumbnails in sidebar with bookmark indicators
- Auto-save with 3-second debounce
- Notebook sharing with classes (isPublic toggle)
- Notebook duplication (copy with all pages)
- 6 pre-built notebook templates (Math, Deutsch, Englisch, Kunst, Musik, Sachkunde)
- Shared notebooks tab (view other teachers' shared notebooks)
- Templates tab with one-click creation
- Keyboard shortcuts (Cmd+N, Cmd+D, Cmd+Shift+N, Cmd+E, Cmd+Shift+A, Cmd+.)
- Student notebook access (role-based UI)
- Role indicator in notebook header (Teacher/Student)
- Onboarding tour expanded to 14 steps
- Dashboard improvements (Today's Schedule, Paper Saved counter, Recent Activity)
- Classes view improvements (student/subject counts, type badges)
- Competence flower improvements (shadow, shimmer, mastery summary)
- Progress entries improvements (mastery distribution chart)
- Global CSS improvements (dark scrollbar, focus-visible, card-hover-glow)
- 60+ new i18n keys (German/English)

Bug Fixes:
- Notebook creation not refreshing list (fixed by calling loadData after creation)
- Missing BarChart3 import in progress-entries-view
- Escaped quotes in JSX className attributes
This commit is contained in:
Mustafa Cetin committed 2026-07-29 08:02:04 +00:00
1 parent 04034dca62
commit 62f4a150e0
14 files changed
+1876 -59

No files matched your search

@@ -0,0 +1,118 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── POST: Duplicate a notebook (create a new copy with all pages) ──
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
// Find the source notebook
const source = await db.notebook.findUnique({
where: { id, deletedAt: null },
include: {
pages: { orderBy: { pageNumber: 'asc' } },
},
});
if (!source) {
return NextResponse.json(
{ error: 'Notebook not found' },
{ status: 404 }
);
}
// Access check: owner or public notebook in the same school
if (
source.ownerId !== session.user.id &&
!(source.isPublic && source.schoolId === session.user.schoolId)
) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}
// Get locale to determine copy suffix
const locale = session.user.locale ?? 'de';
const copySuffix = locale === 'en' ? ' (Copy)' : ' (Kopie)';
// Create the duplicated notebook
const duplicated = await db.notebook.create({
data: {
schoolId: source.schoolId,
ownerId: session.user.id,
ownerType: 'TEACHER',
subjectId: source.subjectId,
classGroupId: source.classGroupId,
title: source.title + copySuffix,
description: source.description,
notebookType: source.notebookType,
color: source.color,
icon: source.icon,
isArchived: false,
isPublic: false, // duplicated notebooks start as private
sortOrder: 0,
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
_count: { select: { pages: true } },
},
});
// Create all pages from the source notebook
for (const page of source.pages) {
await db.notebookPage.create({
data: {
notebookId: duplicated.id,
pageNumber: page.pageNumber,
title: page.title,
contentType: page.contentType,
textContent: page.textContent,
drawingData: page.drawingData,
background: page.background,
isBookmark: page.isBookmark,
},
});
}
// Fetch the duplicated notebook again with updated _count
const result = await db.notebook.findUnique({
where: { id: duplicated.id },
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
_count: { select: { pages: true } },
},
});
// Audit log
await db.auditLog.create({
data: {
userId: session.user.id,
schoolId: source.schoolId,
action: 'CREATE',
entityType: 'Notebook',
entityId: duplicated.id,
metadata: JSON.stringify({
duplicatedFrom: id,
title: duplicated.title,
}),
},
});
return NextResponse.json(result);
} catch (error) {
console.error('Notebook duplicate error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
@@ -7,6 +7,7 @@ import { getSession } from '@/lib/auth';
const updatePageSchema = z.object({ const updatePageSchema = z.object({
textContent: z.string().optional().nullable(), textContent: z.string().optional().nullable(),
drawingData: z.string().optional().nullable(), drawingData: z.string().optional().nullable(),
contentType: z.enum(['text', 'drawing', 'mixed']).optional(),
background: z background: z
.enum(['lined', 'grid', 'blank', 'dotted', 'music']) .enum(['lined', 'grid', 'blank', 'dotted', 'music'])
.optional(), .optional(),
+99 -4
View File
@@ -17,8 +17,100 @@ export async function GET(request: Request) {
const classGroupId = searchParams.get('classGroupId'); const classGroupId = searchParams.get('classGroupId');
const isArchived = searchParams.get('isArchived'); const isArchived = searchParams.get('isArchived');
const userRole = session.user.role;
const userId = session.user.id;
// Default schoolId from user session
const effectiveSchoolId = schoolId || session.user.schoolId;
if (!effectiveSchoolId) {
return NextResponse.json([]);
}
if (userRole === 'STUDENT') {
// Students see their own notebooks + public notebooks shared with their classes
const enrollments = await db.enrollment.findMany({
where: {
student: {
schoolId: effectiveSchoolId,
},
// Use the user's enrolled classes — we need to find the student
// associated with this user. For now, we'll look up via the
// student's externalId matching the user's id, or we can use
// a simpler approach: find all class groups where the student
// is enrolled.
},
select: { classGroupId: true },
});
// For students, we need to find their enrolled class group IDs.
// Since the Student model doesn't have a direct userId field,
// we'll look for students with matching firstName/lastName in the school
// and then find their enrollments. A more robust approach would be to
// add a userId field to the Student model, but for now we'll use
// a pragmatic approach: find all public notebooks for the school
// that have a classGroupId matching any of the user's class groups.
// Find student IDs that match the user (by firstName + lastName in the same school)
const matchingStudents = await db.student.findMany({
where: {
schoolId: effectiveSchoolId,
firstName: session.user.firstName,
lastName: session.user.lastName,
deletedAt: null,
},
select: { id: true },
});
const studentIds = matchingStudents.map(s => s.id);
// Get class group IDs where the student is enrolled
const studentEnrollments = studentIds.length > 0
? await db.enrollment.findMany({
where: {
studentId: { in: studentIds },
},
select: { classGroupId: true },
})
: [];
const enrolledClassGroupIds = studentEnrollments.map(e => e.classGroupId);
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
ownerId: session.user.id, deletedAt: null,
schoolId: effectiveSchoolId,
OR: [
// Own notebooks
{ ownerId: userId },
// Public notebooks shared with the student's classes
...(enrolledClassGroupIds.length > 0
? [{ isPublic: true, classGroupId: { in: enrolledClassGroupIds } }]
: []),
],
};
if (subjectId) where.subjectId = subjectId;
if (classGroupId) where.classGroupId = classGroupId;
if (isArchived !== null && isArchived !== undefined) {
where.isArchived = isArchived === 'true';
}
const notebooks = await db.notebook.findMany({
where,
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
owner: { select: { id: true, firstName: true, lastName: true, role: true } },
_count: { select: { pages: true } },
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return NextResponse.json(notebooks);
}
// Teacher / Admin: show own notebooks
const where: Record<string, unknown> = {
ownerId: userId,
deletedAt: null, deletedAt: null,
}; };
@@ -29,7 +121,6 @@ export async function GET(request: Request) {
where.isArchived = isArchived === 'true'; where.isArchived = isArchived === 'true';
} }
// Default schoolId from user session
if (!schoolId && session.user.schoolId) { if (!schoolId && session.user.schoolId) {
where.schoolId = session.user.schoolId; where.schoolId = session.user.schoolId;
} }
@@ -65,6 +156,7 @@ const createNotebookSchema = z.object({
icon: z.string().optional().nullable(), icon: z.string().optional().nullable(),
subjectId: z.string().optional().nullable(), subjectId: z.string().optional().nullable(),
classGroupId: z.string().optional().nullable(), classGroupId: z.string().optional().nullable(),
isPublic: z.boolean().default(false).optional(),
}); });
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -83,7 +175,7 @@ export async function POST(request: Request) {
); );
} }
const { title, description, notebookType, color, icon, subjectId, classGroupId } = const { title, description, notebookType, color, icon, subjectId, classGroupId, isPublic } =
parsed.data; parsed.data;
const schoolId = session.user.schoolId; const schoolId = session.user.schoolId;
@@ -94,11 +186,13 @@ export async function POST(request: Request) {
); );
} }
const ownerType = session.user.role === 'STUDENT' ? 'STUDENT' : 'TEACHER';
const notebook = await db.notebook.create({ const notebook = await db.notebook.create({
data: { data: {
schoolId, schoolId,
ownerId: session.user.id, ownerId: session.user.id,
ownerType: 'TEACHER', ownerType,
title, title,
description, description,
notebookType, notebookType,
@@ -106,6 +200,7 @@ export async function POST(request: Request) {
icon, icon,
subjectId, subjectId,
classGroupId, classGroupId,
isPublic: isPublic ?? false,
}, },
include: { include: {
subject: { select: { id: true, name: true } }, subject: { select: { id: true, name: true } },
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: Get public notebooks shared by other teachers in the same school ──
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId');
if (!schoolId) {
return NextResponse.json(
{ error: 'schoolId is required' },
{ status: 400 }
);
}
// Only show public notebooks from other teachers in the same school
const sharedNotebooks = await db.notebook.findMany({
where: {
schoolId,
isPublic: true,
isArchived: false,
deletedAt: null,
ownerId: { not: session.user.id }, // Exclude own notebooks
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
owner: { select: { id: true, firstName: true, lastName: true } },
_count: { select: { pages: true } },
},
orderBy: { updatedAt: 'desc' },
});
return NextResponse.json(sharedNotebooks);
} catch (error) {
console.error('Shared notebooks get error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+118 -1
View File
@@ -594,6 +594,87 @@
.animate-tooltip-enter { .animate-tooltip-enter {
animation: tooltip-enter 0.15s ease-out; animation: tooltip-enter 0.15s ease-out;
} }
/* ─── Smooth Dark Mode Scrollbar ───────────────────────────────────── */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: oklch(0.15 0.01 260);
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(0.35 0.015 260 / 0.6);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(0.50 0.10 155 / 0.7);
border: 2px solid transparent;
background-clip: padding-box;
}
/* Firefox scrollbar for dark mode */
.dark * {
scrollbar-width: thin;
scrollbar-color: oklch(0.35 0.015 260 / 0.6) oklch(0.15 0.01 260);
}
/* ─── Better Focus-Visible Styles ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*:focus-visible {
transition: none;
}
}
/* Enhanced focus ring for card-based interactive elements */
[role="button"]:focus-visible,
button:focus-visible {
outline: 2px solid oklch(0.55 0.17 155);
outline-offset: 2px;
box-shadow: 0 0 0 4px oklch(0.55 0.17 155 / 0.15);
border-radius: 8px;
transition: box-shadow 0.15s ease-out;
}
.dark [role="button"]:focus-visible,
.dark button:focus-visible {
outline: 2px solid oklch(0.65 0.17 155);
box-shadow: 0 0 0 4px oklch(0.65 0.17 155 / 0.2);
}
/* ─── Subtle Card Hover Effects ────────────────────────────────────── */
.card-hover-glow {
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
}
.card-hover-glow:hover {
transform: translateY(-2px);
box-shadow:
0 8px 24px -6px oklch(0.55 0.17 155 / 0.12),
0 0 0 1px oklch(0.55 0.17 155 / 0.08);
}
.dark .card-hover-glow:hover {
box-shadow:
0 8px 24px -6px oklch(0.65 0.17 155 / 0.2),
0 0 0 1px oklch(0.65 0.17 155 / 0.12);
}
/* ─── Staggered Entry Animation ────────────────────────────────────── */
@keyframes stagger-fade-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.animate-stagger-in {
animation: stagger-fade-in 0.35s ease-out backwards;
}
/* ─── Chart Shimmer on Load ────────────────────────────────────────── */
@keyframes chart-shimmer {
0% { opacity: 0.5; transform: translateX(-100%); }
50% { opacity: 0.3; }
100% { opacity: 0; transform: translateX(100%); }
}
.animate-chart-shimmer {
animation: chart-shimmer 1.5s ease-out forwards;
}
} }
/* Print-specific styling — expands to all reports when printing */ /* Print-specific styling — expands to all reports when printing */
@@ -681,6 +762,22 @@
} }
} }
/* Tablet-specific viewport styles (768px-1024px) */
@media (min-width: 768px) and (max-width: 1024px) {
/* Adjust grid layouts for tablet */
.lg\:grid-cols-5 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
/* More compact spacing for tablet */
.space-y-7 > * + * {
margin-top: 1.25rem;
}
/* Ensure sidebar doesn't collapse too much */
[data-sidebar] {
min-width: 220px;
}
}
/* Stylus-specific improvements */ /* Stylus-specific improvements */
@media (pointer: fine) and (hover: none) { @media (pointer: fine) and (hover: none) {
/* Stylus devices often lack hover — replace hover effects with active */ /* Stylus devices often lack hover — replace hover effects with active */
@@ -751,7 +848,8 @@
/* Remove animations for print */ /* Remove animations for print */
.animate-float, .animate-float-reverse, .animate-float-slow, .animate-float, .animate-float-reverse, .animate-float-slow,
.animate-pulse-soft, .animate-shimmer, .animate-progress-fill, .animate-pulse-soft, .animate-shimmer, .animate-progress-fill,
.animate-view-fade-in, .animate-enter-fade, .animate-count-up { .animate-view-fade-in, .animate-enter-fade, .animate-count-up,
.animate-stagger-in, .animate-chart-shimmer {
animation: none !important; animation: none !important;
} }
@@ -765,4 +863,23 @@
[class*="shadow-"] { [class*="shadow-"] {
box-shadow: none !important; box-shadow: none !important;
} }
/* Notebook export specific styles */
.no-print { display: none !important; }
.notebook-export {
padding: 2cm;
font-size: 12pt;
}
.notebook-export .page-break {
page-break-after: always;
}
.notebook-export .page-content {
min-height: 24cm;
}
/* Lined background for print */
.notebook-export .page-lined {
background-image: repeating-linear-gradient(
transparent, transparent 31px, #e5e7eb 31px, #e5e7eb 32px
);
}
} }
+58 -4
View File
@@ -279,7 +279,7 @@ export default function AppLayout() {
const isMod = e.metaKey || e.ctrlKey; const isMod = e.metaKey || e.ctrlKey;
// Ctrl/Cmd + K: Open command palette // Ctrl/Cmd + K: Open command palette
if (isMod && e.key.toLowerCase() === 'k') { if (isMod && !e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault(); e.preventDefault();
setSearchOpen((v) => !v); setSearchOpen((v) => !v);
return; return;
@@ -292,13 +292,67 @@ export default function AppLayout() {
return; return;
} }
// Ctrl/Cmd + N: Create new entry (navigate to progress) // Ctrl/Cmd + Shift + N: Add new page to current notebook (when on notebooks view)
if (isMod && e.key.toLowerCase() === 'n') { if (isMod && e.shiftKey && e.key.toLowerCase() === 'n') {
e.preventDefault();
if (currentView === 'notebooks') {
window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'new-page' }));
return;
}
}
// Ctrl/Cmd + Shift + A: Toggle archive mode (when on notebooks view)
if (isMod && e.shiftKey && e.key.toLowerCase() === 'a') {
e.preventDefault();
if (currentView === 'notebooks') {
window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'archive-toggle' }));
return;
}
}
// Ctrl/Cmd + N: Context-dependent new action
if (isMod && !e.shiftKey && e.key.toLowerCase() === 'n') {
e.preventDefault(); e.preventDefault();
setSearchOpen(false); setSearchOpen(false);
if (currentView === 'notebooks') {
// Open create notebook dialog
window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'new-notebook' }));
} else {
// Default: navigate to progress
setCurrentView('progress'); setCurrentView('progress');
trackAction('new-entry', t('shortcuts.new_entry'), 'progress'); trackAction('new-entry', t('shortcuts.new_entry'), 'progress');
toast.info(t('shortcuts.new_entry')); toast.info(t('shortcuts.new_entry'));
}
return;
}
// Ctrl/Cmd + D: Open drawing canvas
if (isMod && e.key.toLowerCase() === 'd') {
e.preventDefault();
if (currentView === 'notebooks') {
window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'drawing' }));
} else {
setCurrentView('drawing');
trackAction('drawing', t('shortcuts.drawing'), 'drawing');
}
return;
}
// Ctrl/Cmd + E: Export current notebook as PDF
if (isMod && e.key.toLowerCase() === 'e') {
e.preventDefault();
if (currentView === 'notebooks') {
window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'export-pdf' }));
}
return;
}
// Ctrl/Cmd + .: Close current notebook (go back to library)
if (isMod && e.key === '.') {
e.preventDefault();
if (currentView === 'notebooks') {
window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'close-notebook' }));
}
return; return;
} }
@@ -326,7 +380,7 @@ export default function AppLayout() {
} }
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [searchOpen, shortcutsOpen, helpOpen, setCurrentView, trackAction]); }, [searchOpen, shortcutsOpen, helpOpen, currentView, setCurrentView, trackAction]);
// Debounced search // Debounced search
useEffect(() => { useEffect(() => {
+26
View File
@@ -580,6 +580,21 @@ export default function ClassesView() {
</div> </div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_no_data')}</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('classes.no_classes')}</p> <p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('classes.no_classes')}</p>
{/* Illustration-like decorative elements */}
<div className="mt-6 flex items-center justify-center gap-3">
<div className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-emerald-50/60 dark:bg-emerald-900/20 border border-emerald-200/30 dark:border-emerald-800/20">
<Users className="h-4 w-4 text-emerald-500 dark:text-emerald-400" />
<span className="text-[11px] text-emerald-600/70 dark:text-emerald-400/60">{locale === 'de' ? 'Klasse erstellen' : 'Create class'}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-teal-50/60 dark:bg-teal-900/20 border border-teal-200/30 dark:border-teal-800/20">
<UserPlus className="h-4 w-4 text-teal-500 dark:text-teal-400" />
<span className="text-[11px] text-teal-600/70 dark:text-teal-400/60">{locale === 'de' ? 'Schüler hinzufuegen' : 'Add students'}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-amber-50/60 dark:bg-amber-900/20 border border-amber-200/30 dark:border-amber-800/20">
<PenLine className="h-4 w-4 text-amber-500 dark:text-amber-400" />
<span className="text-[11px] text-amber-600/70 dark:text-amber-400/60">{locale === 'de' ? 'Fortschritte loggen' : 'Log progress'}</span>
</div>
</div>
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<div className="h-1 w-16 rounded-full bg-gradient-to-r from-emerald-300 to-teal-300 dark:from-emerald-600 dark:to-teal-600" /> <div className="h-1 w-16 rounded-full bg-gradient-to-r from-emerald-300 to-teal-300 dark:from-emerald-600 dark:to-teal-600" />
</div> </div>
@@ -614,6 +629,17 @@ export default function ClassesView() {
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label} {t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}
</p> </p>
{/* Student count + subject count with icons */}
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-600/70 dark:text-emerald-400/60">
<Users className="h-3 w-3" />
{cls.studentCount ?? 0} {t('label.student_count').toLowerCase()}
</span>
<span className="inline-flex items-center gap-1 text-[11px] text-teal-600/70 dark:text-teal-400/60">
<BookOpen className="h-3 w-3" />
{cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'}
</span>
</div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{/* Small progress indicator */} {/* Small progress indicator */}
+41 -1
View File
@@ -599,6 +599,14 @@ export default function CompetenceFlowerView() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div ref={chartRef} className="w-full relative"> <div ref={chartRef} className="w-full relative">
{/* Shimmer overlay on load */}
<motion.div
initial={{ opacity: 0.6 }}
animate={{ opacity: 0 }}
transition={{ duration: 1.5, delay: 0.3 }}
className="absolute inset-0 z-10 pointer-events-none rounded-xl bg-gradient-to-r from-transparent via-white/30 to-transparent"
style={{ backgroundSize: '200% 100%', animation: 'shimmer 2s linear' }}
/>
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
<RadarChart data={chartData} cx="50%" cy="50%" outerRadius="70%"> <RadarChart data={chartData} cx="50%" cy="50%" outerRadius="70%">
<defs> <defs>
@@ -614,6 +622,10 @@ export default function CompetenceFlowerView() {
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.25} /> <stop offset="0%" stopColor="#f59e0b" stopOpacity={0.25} />
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} /> <stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
</linearGradient> </linearGradient>
{/* Shadow filter for chart */}
<filter id="chartShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="6" floodColor={primaryColor} floodOpacity="0.12" />
</filter>
</defs> </defs>
<PolarGrid stroke="#e5e7eb" strokeDasharray="3 3" /> <PolarGrid stroke="#e5e7eb" strokeDasharray="3 3" />
<PolarAngleAxis <PolarAngleAxis
@@ -635,6 +647,7 @@ export default function CompetenceFlowerView() {
fillOpacity={1} fillOpacity={1}
strokeWidth={2.5} strokeWidth={2.5}
dot={{ r: 4, fill: primaryColor, stroke: '#fff', strokeWidth: 2 }} dot={{ r: 4, fill: primaryColor, stroke: '#fff', strokeWidth: 2 }}
filter="url(#chartShadow)"
/> />
{/* Class average comparison line */} {/* Class average comparison line */}
{showClassAverage && ( {showClassAverage && (
@@ -667,7 +680,8 @@ export default function CompetenceFlowerView() {
borderRadius: '12px', borderRadius: '12px',
border: '1px solid #e5e7eb', border: '1px solid #e5e7eb',
fontSize: '12px', fontSize: '12px',
boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1)', boxShadow: '0 4px 12px -2px rgba(0,0,0,0.12), 0 0 0 1px rgba(16,185,129,0.1)',
padding: '10px 14px',
}} }}
formatter={(value: number, _name: string, props: { payload?: { trueValue?: number; assessed?: number; total?: number } }) => { formatter={(value: number, _name: string, props: { payload?: { trueValue?: number; assessed?: number; total?: number } }) => {
const p = props?.payload ?? {}; const p = props?.payload ?? {};
@@ -740,6 +754,32 @@ export default function CompetenceFlowerView() {
))} ))}
</div> </div>
</div> </div>
{/* Mastery Summary Section */}
<div className="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: locale === 'de' ? 'Gesamtdurchschnitt' : 'Overall Average', value: overallAverage.toFixed(2), icon: Target, color: primaryColor, bg: `rgba(${parseInt(primaryColor.slice(1,3),16)},${parseInt(primaryColor.slice(3,5),16)},${parseInt(primaryColor.slice(5,7),16)},0.1)` },
{ label: locale === 'de' ? 'Kategorien' : 'Categories', value: flowerData.categories.length, icon: BarChart3, color: '#10b981', bg: 'rgba(16,185,129,0.1)' },
{ label: locale === 'de' ? 'Erfasst' : 'Assessed', value: flowerData.categories.reduce((s, c) => s + c.assessedCompetencyCount, 0), icon: Check, color: '#14b8a6', bg: 'rgba(20,184,166,0.1)' },
{ label: locale === 'de' ? 'Gesamt' : 'Total', value: flowerData.categories.reduce((s, c) => s + c.competencyCount, 0), icon: Star, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
].map((item, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.05, duration: 0.3 }}
className="p-3 rounded-xl border border-gray-200/40 dark:border-gray-700/30 bg-white/60 dark:bg-gray-800/30"
>
<div className="flex items-center gap-2 mb-1.5">
<div className="flex items-center justify-center w-7 h-7 rounded-lg" style={{ backgroundColor: item.bg }}>
<item.icon className="h-3.5 w-3.5" style={{ color: item.color }} />
</div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-gray-500/70 dark:text-gray-400/60 truncate">{item.label}</p>
</div>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">{item.value}</p>
</motion.div>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
+114 -11
View File
@@ -37,6 +37,10 @@ import {
Star, Star,
MessageSquare, MessageSquare,
Timer, Timer,
Globe,
Palette,
Notebook,
CircleDot,
} 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';
@@ -654,6 +658,51 @@ export default function DashboardView() {
</Card> </Card>
</motion.div> </motion.div>
{/* ===== TODAY'S SCHEDULE ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] card-hover-lift">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-amber-50/50 via-emerald-50/20 to-transparent dark:from-amber-900/10 dark:via-emerald-900/5 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-emerald-500 text-white shadow-sm">
<CalendarDays className="h-4 w-4" />
</div>
{locale === 'de' ? 'Heutige Aufgaben' : 'Today\'s Schedule'}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{[
{ icon: PenLine, label: locale === 'de' ? 'Fortschritte erfassen' : 'Log progress entries', desc: locale === 'de' ? 'Neue Beobachtungen eintragen' : 'Record new observations', view: 'progress', color: 'from-emerald-400 to-teal-500' },
{ icon: ClipboardCheck, label: locale === 'de' ? 'Uberpruefung erstellen' : 'Create assessment', desc: locale === 'de' ? 'Leistungsueberpruefung planen' : 'Plan performance assessment', view: 'assessments', color: 'from-amber-400 to-rose-500' },
{ icon: BookOpen, label: locale === 'de' ? 'Kompetenzblume ansehen' : 'View competence flower', desc: locale === 'de' ? 'Radar-Chart pruefen' : 'Check radar chart', view: 'flower', color: 'from-violet-400 to-emerald-500' },
{ icon: Notebook, label: locale === 'de' ? 'Notizbuch aktualisieren' : 'Update notebook', desc: locale === 'de' ? 'Hefte bearbeiten' : 'Edit notebooks', view: 'notebooks', color: 'from-teal-400 to-emerald-500' },
].map((item, i) => (
<motion.button
key={i}
whileHover={{ scale: 1.01, x: 2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.15 }}
onClick={() => setCurrentView(item.view)}
className="w-full group flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-all relative overflow-hidden"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${item.color} text-white shadow-sm shrink-0 transition-transform duration-200 group-hover:scale-110`}>
<item.icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{item.label}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{item.desc}</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 group-hover:text-emerald-500 transition-colors shrink-0" />
</motion.button>
))}
</div>
</CardContent>
</Card>
</motion.div>
{/* ===== QUICK ACTION BUTTONS ===== */} {/* ===== QUICK ACTION BUTTONS ===== */}
<motion.div variants={itemVariants} className="flex flex-col sm:flex-row flex-wrap gap-3"> <motion.div variants={itemVariants} className="flex flex-col sm:flex-row flex-wrap gap-3">
<motion.div whileHover={{ scale: 1.02, y: -1 }} whileTap={{ scale: 0.98 }} transition={{ duration: 0.15 }}> <motion.div whileHover={{ scale: 1.02, y: -1 }} whileTap={{ scale: 0.98 }} transition={{ duration: 0.15 }}>
@@ -1145,13 +1194,61 @@ export default function DashboardView() {
</Card> </Card>
</motion.div> </motion.div>
{/* ===== ENVIRONMENTAL TIPS SECTION ===== */} {/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Paper Saved Counter */}
<motion.div variants={itemVariants}> <motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05]"> <Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05] card-hover-lift">
<CardContent className="p-5"> <CardContent className="p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shrink-0"> <motion.div
<Leaf className="h-6 w-6" /> initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4, type: 'spring', stiffness: 150 }}
className="flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 shrink-0"
>
<Leaf className="h-8 w-8" />
</motion.div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1">
{locale === 'de' ? 'Papier gespart' : 'Paper Saved'}
</h3>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{data.stats.totalProgressEntries * 50}
</span>
<span className="text-sm text-emerald-600/70 dark:text-emerald-400/60 font-medium">
{locale === 'de' ? 'Seiten' : 'pages'}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mt-2">
{locale === 'de'
? 'Jeder digitale Eintrag spart ca. 50 Seiten Papier. Danke fuer Ihren Beitrag zum Umweltschutz!'
: 'Each digital entry saves ~50 pages of paper. Thank you for contributing to environmental protection!'}
</p>
<div className="flex items-center gap-2 mt-3">
<div className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-emerald-100/60 dark:bg-emerald-900/30 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400">
<TreePine className="h-3 w-3" />
{locale === 'de' ? '~0.5 Baume' : '~0.5 trees'}
</div>
<div className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-teal-100/60 dark:bg-teal-900/30 text-[11px] font-semibold text-teal-600 dark:text-teal-400">
<Sprout className="h-3 w-3" />
{locale === 'de' ? 'CO2 reduziert' : 'CO2 reduced'}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Environmental Tips — visually enhanced */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05] card-hover-lift">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-md shrink-0">
<TreePine className="h-6 w-6" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1"> <h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1">
@@ -1160,19 +1257,25 @@ export default function DashboardView() {
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed"> <p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
{t('dashboard.env_tip_text')} {t('dashboard.env_tip_text')}
</p> </p>
<div className="grid grid-cols-2 gap-2 mt-3">
{[
{ icon: Leaf, text: locale === 'de' ? 'Digital statt Papier' : 'Digital instead of paper' },
{ icon: Sprout, text: locale === 'de' ? 'Nachhaltig dokumentieren' : 'Sustainable documentation' },
{ icon: TreePine, text: locale === 'de' ? 'Baeume schuetzen' : 'Protect trees' },
{ icon: Trees, text: locale === 'de' ? 'CO2 einsparen' : 'Reduce CO2' },
].map((tip, i) => (
<div key={i} className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-emerald-50/60 dark:bg-emerald-900/20 border border-emerald-200/30 dark:border-emerald-800/20">
<tip.icon className="h-3.5 w-3.5 text-emerald-500 dark:text-emerald-400 shrink-0" />
<span className="text-[11px] text-emerald-700/80 dark:text-emerald-300/80 font-medium">{tip.text}</span>
</div> </div>
<div className="hidden md:flex items-center gap-2 shrink-0"> ))}
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<TreePine className="h-4 w-4" />
</div>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<Sprout className="h-4 w-4" />
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
</div>
</motion.div> </motion.div>
); );
} }
@@ -30,6 +30,10 @@ import {
MessageSquareText, MessageSquareText,
CalendarDays, CalendarDays,
Settings, Settings,
Palette,
FileDown,
Archive,
ArrowLeft,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -120,6 +124,18 @@ export default function KeyboardShortcutsDialog({ open, onOpenChange }: Keyboard
{ keys: ['Esc'], labelKey: 'shortcuts.close_dialog', icon: X }, { keys: ['Esc'], labelKey: 'shortcuts.close_dialog', icon: X },
], ],
}, },
{
titleKey: 'shortcuts.notebooks_drawing',
icon: BookOpen,
entries: [
{ keys: ['Cmd/Ctrl', 'N'], labelKey: 'shortcuts.new_notebook', icon: BookOpen },
{ keys: ['Cmd/Ctrl', 'D'], labelKey: 'shortcuts.drawing', icon: Palette },
{ keys: ['Cmd/Ctrl', 'Shift', 'N'], labelKey: 'shortcuts.new_page', icon: Plus },
{ keys: ['Cmd/Ctrl', 'E'], labelKey: 'shortcuts.export_pdf', icon: FileDown },
{ keys: ['Cmd/Ctrl', 'Shift', 'A'], labelKey: 'shortcuts.archive_toggle', icon: Archive },
{ keys: ['Cmd/Ctrl', '.'], labelKey: 'shortcuts.close_notebook', icon: ArrowLeft },
],
},
]; ];
return ( return (
File diff suppressed because it is too large. Load diff
+57 -4
View File
@@ -2,7 +2,12 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, BookOpen, BarChart3, Pencil, Flower2, FileText, PartyPopper } from 'lucide-react'; import {
X, BookOpen, BarChart3, Pencil, Flower2, FileText, PartyPopper,
Users, ClipboardCheck, GraduationCap, Palette, Leaf,
TreePine, CalendarDays, MessageSquare, BarChart, Target,
Zap, Award, Shield, Notebook,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -35,6 +40,12 @@ const steps: OnboardingStep[] = [
descKey: 'onboarding.dashboard_desc', descKey: 'onboarding.dashboard_desc',
accent: 'from-teal-400 to-emerald-500', accent: 'from-teal-400 to-emerald-500',
}, },
{
iconComponent: Users,
titleKey: 'onboarding.classes_title',
descKey: 'onboarding.classes_desc',
accent: 'from-emerald-400 to-cyan-500',
},
{ {
iconComponent: Pencil, iconComponent: Pencil,
titleKey: 'onboarding.progress_title', titleKey: 'onboarding.progress_title',
@@ -47,12 +58,54 @@ const steps: OnboardingStep[] = [
descKey: 'onboarding.flower_desc', descKey: 'onboarding.flower_desc',
accent: 'from-rose-400 to-emerald-500', accent: 'from-rose-400 to-emerald-500',
}, },
{
iconComponent: ClipboardCheck,
titleKey: 'onboarding.assessments_title',
descKey: 'onboarding.assessments_desc',
accent: 'from-amber-400 to-rose-500',
},
{
iconComponent: GraduationCap,
titleKey: 'onboarding.grading_title',
descKey: 'onboarding.grading_desc',
accent: 'from-violet-400 to-emerald-500',
},
{ {
iconComponent: FileText, iconComponent: FileText,
titleKey: 'onboarding.reports_title', titleKey: 'onboarding.reports_title',
descKey: 'onboarding.reports_desc', descKey: 'onboarding.reports_desc',
accent: 'from-violet-400 to-emerald-500', accent: 'from-violet-400 to-emerald-500',
}, },
{
iconComponent: Notebook,
titleKey: 'onboarding.notebooks_title',
descKey: 'onboarding.notebooks_desc',
accent: 'from-teal-400 to-emerald-500',
},
{
iconComponent: Palette,
titleKey: 'onboarding.drawing_title',
descKey: 'onboarding.drawing_desc',
accent: 'from-rose-400 to-violet-500',
},
{
iconComponent: CalendarDays,
titleKey: 'onboarding.calendar_title',
descKey: 'onboarding.calendar_desc',
accent: 'from-amber-400 to-teal-500',
},
{
iconComponent: MessageSquare,
titleKey: 'onboarding.parents_title',
descKey: 'onboarding.parents_desc',
accent: 'from-cyan-400 to-emerald-500',
},
{
iconComponent: Leaf,
titleKey: 'onboarding.environmental_title',
descKey: 'onboarding.environmental_desc',
accent: 'from-emerald-400 to-green-500',
},
{ {
iconComponent: PartyPopper, iconComponent: PartyPopper,
titleKey: 'onboarding.done_title', titleKey: 'onboarding.done_title',
@@ -130,12 +183,12 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
transition={{ duration: 0.3, ease: 'easeInOut' }} transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex flex-col items-center text-center px-8 pt-10 pb-6" className="flex flex-col items-center text-center px-8 pt-10 pb-6"
> >
{/* Emoji illustration */} {/* Icon illustration */}
<motion.div <motion.div
initial={{ scale: 0.5, opacity: 0 }} initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.15, duration: 0.4, type: 'spring', stiffness: 200 }} transition={{ delay: 0.15, duration: 0.4, type: 'spring', stiffness: 200 }}
className={`w-24 h-24 rounded-3xl bg-gradient-to-br ${step.accent} flex items-center justify-center text-5xl shadow-lg mb-6`} className={`w-24 h-24 rounded-3xl bg-gradient-to-br ${step.accent} flex items-center justify-center shadow-lg mb-6`}
> >
<step.iconComponent className="w-10 h-10 text-white" /> <step.iconComponent className="w-10 h-10 text-white" />
</motion.div> </motion.div>
@@ -164,7 +217,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
</div> </div>
{/* Step indicator dots */} {/* Step indicator dots */}
<div className="flex items-center justify-center gap-2 pb-4"> <div className="flex items-center justify-center gap-1.5 pb-4 flex-wrap px-4">
{steps.map((_, i) => ( {steps.map((_, i) => (
<button <button
key={i} key={i}
+56 -2
View File
@@ -6,7 +6,7 @@ import {
PenLine, User, Clock, Plus, ChevronDown, BadgeCheck, PenLine, User, Clock, Plus, ChevronDown, BadgeCheck,
Download, CalendarDays, Keyboard, Sparkles, BookOpen, Download, CalendarDays, Keyboard, Sparkles, BookOpen,
GraduationCap, Trash2, CheckSquare, X, ListChecks, GraduationCap, Trash2, CheckSquare, X, ListChecks,
Sprout, Leaf, TreePine, Trees, Sprout, Leaf, TreePine, Trees, BarChart3,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -675,7 +675,61 @@ export default function ProgressEntriesView() {
</Card> </Card>
{/* Student timeline */} {/* Student timeline */}
<div className="lg:col-span-2"> <div className="lg:col-span-2 space-y-6">
{/* Mastery Distribution mini chart */}
{selectedStudent && studentEntries.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden card-hover-lift">
<CardContent className="p-4">
<p className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60 mb-3 flex items-center gap-1.5">
<BarChart3 className="h-3 w-3" />
{locale === 'de' ? 'Mastery-Verteilung' : 'Mastery Distribution'}
</p>
<div className="flex items-center gap-3">
{[1, 2, 3, 4].map((level) => {
const count = studentEntries.filter((e) => e.masteryLevelValue === level).length;
const pct = studentEntries.length > 0 ? (count / studentEntries.length) * 100 : 0;
const colors = [
'bg-red-400 dark:bg-red-500',
'bg-amber-400 dark:bg-amber-500',
'bg-emerald-400 dark:bg-emerald-500',
'bg-teal-400 dark:bg-teal-500',
];
const labelColors = [
'text-red-600 dark:text-red-400',
'text-amber-600 dark:text-amber-400',
'text-emerald-600 dark:text-emerald-400',
'text-teal-600 dark:text-teal-400',
];
return (
<div key={level} className="flex-1 text-center">
<div className="h-16 bg-gray-100 dark:bg-gray-800/50 rounded-lg overflow-hidden relative mb-1">
<motion.div
initial={{ height: 0 }}
animate={{ height: `${Math.max(pct, 2)}%` }}
transition={{ duration: 0.5, delay: 0.1 + level * 0.08 }}
className={`absolute bottom-0 left-0 right-0 ${colors[level - 1]} rounded-t-md`}
/>
<span className="absolute inset-0 flex items-center justify-center text-xs font-bold text-gray-700 dark:text-gray-300">
{count}
</span>
</div>
<span className={`text-[10px] font-semibold ${labelColors[level - 1]}`}>
{getMasteryIcon(level)} {level}
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
)}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
+190
View File
@@ -1633,6 +1633,22 @@ const de: TranslationDict = {
'onboarding.back': 'Zurück', 'onboarding.back': 'Zurück',
'onboarding.start': 'Loslegen', 'onboarding.start': 'Loslegen',
'onboarding.step': 'Schritt {current} von {total}', 'onboarding.step': 'Schritt {current} von {total}',
'onboarding.classes_title': 'Klassen & Schüler',
'onboarding.classes_desc': 'Verwalten Sie Ihre Klassen und Schüler. Erstellen Sie neue Klassen, fügen Sie Schüler hinzu und weisen Sie Kompetenzraster zu.',
'onboarding.assessments_title': 'Leistungsüberprüfungen',
'onboarding.assessments_desc': 'Erstellen und verwalten Sie Leistungsüberprüfungen. Bewerten Sie Schüler und verfolgen Sie deren Fortschritt.',
'onboarding.grading_title': 'Benotung',
'onboarding.grading_desc': 'Konfigurieren Sie Notenschemata und berechnen Sie Noten basierend auf Kompetenzniveaus und Leistungsüberprüfungen.',
'onboarding.notebooks_title': 'Digitale Hefte',
'onboarding.notebooks_desc': 'Erstellen Sie digitale Notizbücher für Ihre Fächer. Spart Papier und schützt die Umwelt — jedes Heft spart ca. 50 Seiten.',
'onboarding.drawing_title': 'Zeichnen',
'onboarding.drawing_desc': 'Nutzen Sie die digitale Zeichenfläche für Skizzen und Illustrationen. Stylus-optimiert mit Druckempfindlichkeit.',
'onboarding.calendar_title': 'Kalender & Anwesenheit',
'onboarding.calendar_desc': 'Verwalten Sie den Stundenplan und die Anwesenheit Ihrer Schüler. Behalten Sie den Überblick über Fehlzeiten.',
'onboarding.parents_title': 'Elternkommunikation',
'onboarding.parents_desc': 'Kommunizieren Sie mit Eltern über den Lernfortschritt. Senden Sie Berichte und halten Sie den Dialog aufrecht.',
'onboarding.environmental_title': 'Umweltschutz',
'onboarding.environmental_desc': 'CompetenceTrack spart Papier: Jeder digitale Eintrag ersetzt ca. 50 Seiten Papier. Danke für Ihren Beitrag zum Umweltschutz!',
// Batch deletion (progress entries) // Batch deletion (progress entries)
'progress.batch_select': 'Auswählen', 'progress.batch_select': 'Auswählen',
@@ -1701,6 +1717,13 @@ const de: TranslationDict = {
'shortcuts.recent_actions': 'Letzte Aktionen', 'shortcuts.recent_actions': 'Letzte Aktionen',
'shortcuts.views': 'Ansichten', 'shortcuts.views': 'Ansichten',
'shortcuts.quick_actions': 'Schnellaktionen', 'shortcuts.quick_actions': 'Schnellaktionen',
'shortcuts.new_notebook': 'Neues Heft',
'shortcuts.drawing': 'Zeichnen',
'shortcuts.new_page': 'Neue Seite',
'shortcuts.export_pdf': 'PDF exportieren',
'shortcuts.archive_toggle': 'Archiv umschalten',
'shortcuts.close_notebook': 'Heft schließen',
'shortcuts.notebooks_drawing': 'Hefte & Zeichnen',
// Drawing // Drawing
'drawing.title': 'Zeichnungen', 'drawing.title': 'Zeichnungen',
@@ -1862,6 +1885,78 @@ const de: TranslationDict = {
'notebooks.total_pages': 'Seiten gesamt', 'notebooks.total_pages': 'Seiten gesamt',
'notebooks.paper_saved': 'Papier gespart', 'notebooks.paper_saved': 'Papier gespart',
'notebooks.pages_unit': 'Seiten', 'notebooks.pages_unit': 'Seiten',
// Notebook toolbar
'notebooks.toolbar_bold': 'Fett',
'notebooks.toolbar_italic': 'Kursiv',
'notebooks.toolbar_underline': 'Unterstrichen',
'notebooks.toolbar_heading1': 'Ueberschrift 1',
'notebooks.toolbar_heading2': 'Ueberschrift 2',
'notebooks.toolbar_heading3': 'Ueberschrift 3',
'notebooks.toolbar_bullet_list': 'Aufzaehlungsliste',
'notebooks.toolbar_numbered_list': 'Nummerierte Liste',
'notebooks.toolbar_align_left': 'Linksbuendig',
'notebooks.toolbar_align_center': 'Zentriert',
'notebooks.toolbar_align_right': 'Rechtsbuendig',
'notebooks.auto_saving': 'Automatisch speichern...',
'notebooks.auto_saved': 'Automatisch gespeichert',
'notebooks.page_thumbnail': 'Seitenvorschau',
// Drawing integration
'notebooks.drawing_saved': 'Zeichnung gespeichert',
'notebooks.drawing_page_label': 'Diese Seite enthält eine Zeichnung',
'notebooks.edit_drawing': 'Zeichnung bearbeiten',
'notebooks.switch_to_text': 'Zur Textseite wechseln',
'notebooks.drawing_section_label': 'Zeichnungsbereich',
'notebooks.open_drawing': 'Zeichnung öffnen',
'notebooks.switch_to_mixed': 'Text + Zeichnung',
// PDF Export
'notebooks.export_pdf': 'PDF exportieren',
'notebooks.export_error': 'Fehler beim Exportieren. Bitte Popups erlauben.',
'notebooks.export_drawing_note': 'Diese Seite enthält eine Zeichnung (nicht im PDF enthalten)',
'notebooks.export_empty_page': 'Leere Seite',
'notebooks.export_eco_message': 'Dieses Heft wurde digital erstellt — Papier gespart!',
// Student notebook access
'notebooks.shared_with_me': 'Mit mir geteilt',
'notebooks.my_notebooks': 'Meine Hefte',
'notebooks.shared_notebook': 'Geteiltes Heft',
// Sharing & Collaboration
'notebooks.share': 'Teilen',
'notebooks.duplicate': 'Duplizieren',
'notebooks.shared': 'Geteilt',
'notebooks.copy_suffix': ' (Kopie)',
'notebooks.templates': 'Vorlagen',
'notebooks.template_desc': 'Vorgefertigte Hefte fuer haeufige Faecher',
'notebooks.tab_shared': 'Geteilt',
'notebooks.tab_templates': 'Vorlagen',
'notebooks.share_confirm': 'Dieses Heft mit der Klasse teilen?',
'notebooks.share_confirm_desc': 'Wenn Sie dieses Heft teilen, koennen alle Schueler der Klasse es einsehen (schreibgeschuetzt).',
'notebooks.unshare_confirm': 'Teilen aufheben?',
'notebooks.unshare_confirm_desc': 'Wenn Sie das Teilen aufheben, koennen die Schueler der Klasse dieses Heft nicht mehr einsehen.',
'notebooks.duplicated': 'Heft dupliziert',
'notebooks.duplicate_error': 'Fehler beim Duplizieren des Hefts',
'notebooks.shared_notebooks': 'Geteilte Hefte',
'notebooks.shared_empty': 'Keine geteilten Hefte vorhanden',
'notebooks.shared_empty_desc': 'Hefte von anderen Lehrern werden hier angezeigt, wenn sie geteilt werden.',
'notebooks.read_only': 'Schreibgeschuetzt',
'notebooks.create_from_template': 'Aus Vorlage erstellen',
'notebooks.template_math': 'Mathematik',
'notebooks.template_german': 'Deutsch',
'notebooks.template_english': 'Englisch',
'notebooks.template_art': 'Kunst',
'notebooks.template_music': 'Musik',
'notebooks.template_science': 'Sachkunde',
'notebooks.template_math_desc': 'Kariertes Heft mit Mathe-Abschnitten',
'notebooks.template_german_desc': 'Liniertes Heft mit Schreib-Abschnitten',
'notebooks.template_english_desc': 'Liniertes Heft mit Vokabel-Abschnitten',
'notebooks.template_art_desc': 'Blanko-Heft zum Zeichnen',
'notebooks.template_music_desc': 'Musikheft zum Komponieren',
'notebooks.template_science_desc': 'Kariertes Heft fuer Experimente',
'notebooks.tab_all': 'Meine Hefte',
'notebooks.owner': 'Von',
}; };
const en: TranslationDict = { const en: TranslationDict = {
@@ -3494,6 +3589,22 @@ const en: TranslationDict = {
'onboarding.back': 'Back', 'onboarding.back': 'Back',
'onboarding.start': 'Get started', 'onboarding.start': 'Get started',
'onboarding.step': 'Step {current} of {total}', 'onboarding.step': 'Step {current} of {total}',
'onboarding.classes_title': 'Classes & Students',
'onboarding.classes_desc': 'Manage your classes and students. Create new classes, add students, and assign competency grids.',
'onboarding.assessments_title': 'Assessments',
'onboarding.assessments_desc': 'Create and manage assessments. Grade students and track their progress over time.',
'onboarding.grading_title': 'Grading',
'onboarding.grading_desc': 'Configure grading schemes and calculate grades based on competency levels and assessments.',
'onboarding.notebooks_title': 'Digital Notebooks',
'onboarding.notebooks_desc': 'Create digital notebooks for your subjects. Saves paper and protects the environment — each notebook saves ~50 pages.',
'onboarding.drawing_title': 'Drawing',
'onboarding.drawing_desc': 'Use the digital drawing canvas for sketches and illustrations. Stylus-optimized with pressure sensitivity.',
'onboarding.calendar_title': 'Calendar & Attendance',
'onboarding.calendar_desc': 'Manage your schedule and student attendance. Keep track of absences and upcoming events.',
'onboarding.parents_title': 'Parent Communication',
'onboarding.parents_desc': 'Communicate with parents about learning progress. Send reports and maintain an ongoing dialogue.',
'onboarding.environmental_title': 'Environmental Protection',
'onboarding.environmental_desc': 'CompetenceTrack saves paper: Each digital entry replaces ~50 pages of paper. Thank you for contributing to environmental protection!',
// Batch deletion (progress entries) // Batch deletion (progress entries)
'progress.batch_select': 'Select', 'progress.batch_select': 'Select',
@@ -3562,6 +3673,13 @@ const en: TranslationDict = {
'shortcuts.recent_actions': 'Recent Actions', 'shortcuts.recent_actions': 'Recent Actions',
'shortcuts.views': 'Views', 'shortcuts.views': 'Views',
'shortcuts.quick_actions': 'Quick Actions', 'shortcuts.quick_actions': 'Quick Actions',
'shortcuts.new_notebook': 'New Notebook',
'shortcuts.drawing': 'Drawing',
'shortcuts.new_page': 'New Page',
'shortcuts.export_pdf': 'Export PDF',
'shortcuts.archive_toggle': 'Toggle Archive',
'shortcuts.close_notebook': 'Close Notebook',
'shortcuts.notebooks_drawing': 'Notebooks & Drawing',
// Drawing // Drawing
'drawing.title': 'Drawings', 'drawing.title': 'Drawings',
@@ -3723,6 +3841,78 @@ const en: TranslationDict = {
'notebooks.total_pages': 'Total Pages', 'notebooks.total_pages': 'Total Pages',
'notebooks.paper_saved': 'Paper Saved', 'notebooks.paper_saved': 'Paper Saved',
'notebooks.pages_unit': 'Pages', 'notebooks.pages_unit': 'Pages',
// Notebook toolbar
'notebooks.toolbar_bold': 'Bold',
'notebooks.toolbar_italic': 'Italic',
'notebooks.toolbar_underline': 'Underline',
'notebooks.toolbar_heading1': 'Heading 1',
'notebooks.toolbar_heading2': 'Heading 2',
'notebooks.toolbar_heading3': 'Heading 3',
'notebooks.toolbar_bullet_list': 'Bullet List',
'notebooks.toolbar_numbered_list': 'Numbered List',
'notebooks.toolbar_align_left': 'Align Left',
'notebooks.toolbar_align_center': 'Align Center',
'notebooks.toolbar_align_right': 'Align Right',
'notebooks.auto_saving': 'Auto-saving...',
'notebooks.auto_saved': 'Auto-saved',
'notebooks.page_thumbnail': 'Page Thumbnail',
// Drawing integration
'notebooks.drawing_saved': 'Drawing saved',
'notebooks.drawing_page_label': 'This page contains a drawing',
'notebooks.edit_drawing': 'Edit drawing',
'notebooks.switch_to_text': 'Switch to text page',
'notebooks.drawing_section_label': 'Drawing section',
'notebooks.open_drawing': 'Open drawing',
'notebooks.switch_to_mixed': 'Text + Drawing',
// PDF Export
'notebooks.export_pdf': 'Export PDF',
'notebooks.export_error': 'Export error. Please allow popups.',
'notebooks.export_drawing_note': 'This page contains a drawing (not included in PDF)',
'notebooks.export_empty_page': 'Empty page',
'notebooks.export_eco_message': 'This notebook was created digitally — paper saved!',
// Student notebook access
'notebooks.shared_with_me': 'Shared with me',
'notebooks.my_notebooks': 'My Notebooks',
'notebooks.shared_notebook': 'Shared Notebook',
// Sharing & Collaboration
'notebooks.share': 'Share',
'notebooks.duplicate': 'Duplicate',
'notebooks.shared': 'Shared',
'notebooks.copy_suffix': ' (Copy)',
'notebooks.templates': 'Templates',
'notebooks.template_desc': 'Pre-built notebooks for common subjects',
'notebooks.tab_shared': 'Shared',
'notebooks.tab_templates': 'Templates',
'notebooks.share_confirm': 'Share this notebook with the class?',
'notebooks.share_confirm_desc': 'If you share this notebook, all students in the class can view it (read-only).',
'notebooks.unshare_confirm': 'Unshare this notebook?',
'notebooks.unshare_confirm_desc': 'If you unshare this notebook, students in the class will no longer be able to view it.',
'notebooks.duplicated': 'Notebook duplicated',
'notebooks.duplicate_error': 'Error duplicating notebook',
'notebooks.shared_notebooks': 'Shared Notebooks',
'notebooks.shared_empty': 'No shared notebooks available',
'notebooks.shared_empty_desc': 'Notebooks shared by other teachers will appear here.',
'notebooks.read_only': 'Read-only',
'notebooks.create_from_template': 'Create from template',
'notebooks.template_math': 'Mathematics',
'notebooks.template_german': 'German',
'notebooks.template_english': 'English',
'notebooks.template_art': 'Art',
'notebooks.template_music': 'Music',
'notebooks.template_science': 'Science',
'notebooks.template_math_desc': 'Grid notebook with math sections',
'notebooks.template_german_desc': 'Lined notebook with writing sections',
'notebooks.template_english_desc': 'Lined notebook with vocabulary sections',
'notebooks.template_art_desc': 'Blank notebook for drawing',
'notebooks.template_music_desc': 'Music notebook for composition',
'notebooks.template_science_desc': 'Grid notebook for experiments',
'notebooks.tab_all': 'My Notebooks',
'notebooks.owner': 'By',
}; };
const translations: Record<string, TranslationDict> = { de, en }; const translations: Record<string, TranslationDict> = { de, en };