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:
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({
|
||||
textContent: z.string().optional().nullable(),
|
||||
drawingData: z.string().optional().nullable(),
|
||||
contentType: z.enum(['text', 'drawing', 'mixed']).optional(),
|
||||
background: z
|
||||
.enum(['lined', 'grid', 'blank', 'dotted', 'music'])
|
||||
.optional(),
|
||||
|
||||
@@ -17,8 +17,100 @@ export async function GET(request: Request) {
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
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> = {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -29,7 +121,6 @@ export async function GET(request: Request) {
|
||||
where.isArchived = isArchived === 'true';
|
||||
}
|
||||
|
||||
// Default schoolId from user session
|
||||
if (!schoolId && session.user.schoolId) {
|
||||
where.schoolId = session.user.schoolId;
|
||||
}
|
||||
@@ -65,6 +156,7 @@ const createNotebookSchema = z.object({
|
||||
icon: z.string().optional().nullable(),
|
||||
subjectId: z.string().optional().nullable(),
|
||||
classGroupId: z.string().optional().nullable(),
|
||||
isPublic: z.boolean().default(false).optional(),
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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({
|
||||
data: {
|
||||
schoolId,
|
||||
ownerId: session.user.id,
|
||||
ownerType: 'TEACHER',
|
||||
ownerType,
|
||||
title,
|
||||
description,
|
||||
notebookType,
|
||||
@@ -106,6 +200,7 @@ export async function POST(request: Request) {
|
||||
icon,
|
||||
subjectId,
|
||||
classGroupId,
|
||||
isPublic: isPublic ?? false,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
|
||||
@@ -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
@@ -594,6 +594,87 @@
|
||||
.animate-tooltip-enter {
|
||||
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 */
|
||||
@@ -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 */
|
||||
@media (pointer: fine) and (hover: none) {
|
||||
/* Stylus devices often lack hover — replace hover effects with active */
|
||||
@@ -751,7 +848,8 @@
|
||||
/* Remove animations for print */
|
||||
.animate-float, .animate-float-reverse, .animate-float-slow,
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -765,4 +863,23 @@
|
||||
[class*="shadow-"] {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -279,7 +279,7 @@ export default function AppLayout() {
|
||||
const isMod = e.metaKey || e.ctrlKey;
|
||||
|
||||
// Ctrl/Cmd + K: Open command palette
|
||||
if (isMod && e.key.toLowerCase() === 'k') {
|
||||
if (isMod && !e.shiftKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setSearchOpen((v) => !v);
|
||||
return;
|
||||
@@ -292,13 +292,67 @@ export default function AppLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + N: Create new entry (navigate to progress)
|
||||
if (isMod && e.key.toLowerCase() === 'n') {
|
||||
// Ctrl/Cmd + Shift + N: Add new page to current notebook (when on notebooks view)
|
||||
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();
|
||||
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');
|
||||
trackAction('new-entry', t('shortcuts.new_entry'), 'progress');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -326,7 +380,7 @@ export default function AppLayout() {
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [searchOpen, shortcutsOpen, helpOpen, setCurrentView, trackAction]);
|
||||
}, [searchOpen, shortcutsOpen, helpOpen, currentView, setCurrentView, trackAction]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
|
||||
@@ -580,6 +580,21 @@ export default function ClassesView() {
|
||||
</div>
|
||||
<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>
|
||||
{/* 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="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>
|
||||
@@ -614,6 +629,17 @@ export default function ClassesView() {
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}
|
||||
</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 className="flex items-center gap-2 shrink-0">
|
||||
{/* Small progress indicator */}
|
||||
|
||||
@@ -599,6 +599,14 @@ export default function CompetenceFlowerView() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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}>
|
||||
<RadarChart data={chartData} cx="50%" cy="50%" outerRadius="70%">
|
||||
<defs>
|
||||
@@ -614,6 +622,10 @@ export default function CompetenceFlowerView() {
|
||||
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
|
||||
</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>
|
||||
<PolarGrid stroke="#e5e7eb" strokeDasharray="3 3" />
|
||||
<PolarAngleAxis
|
||||
@@ -635,6 +647,7 @@ export default function CompetenceFlowerView() {
|
||||
fillOpacity={1}
|
||||
strokeWidth={2.5}
|
||||
dot={{ r: 4, fill: primaryColor, stroke: '#fff', strokeWidth: 2 }}
|
||||
filter="url(#chartShadow)"
|
||||
/>
|
||||
{/* Class average comparison line */}
|
||||
{showClassAverage && (
|
||||
@@ -667,7 +680,8 @@ export default function CompetenceFlowerView() {
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
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 } }) => {
|
||||
const p = props?.payload ?? {};
|
||||
@@ -740,6 +754,32 @@ export default function CompetenceFlowerView() {
|
||||
))}
|
||||
</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>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
Star,
|
||||
MessageSquare,
|
||||
Timer,
|
||||
Globe,
|
||||
Palette,
|
||||
Notebook,
|
||||
CircleDot,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -654,6 +658,51 @@ export default function DashboardView() {
|
||||
</Card>
|
||||
</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 ===== */}
|
||||
<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 }}>
|
||||
@@ -1145,13 +1194,61 @@ export default function DashboardView() {
|
||||
</Card>
|
||||
</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}>
|
||||
<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]">
|
||||
<CardContent className="p-5">
|
||||
<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-6">
|
||||
<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">
|
||||
<Leaf className="h-6 w-6" />
|
||||
<motion.div
|
||||
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 className="flex-1 min-w-0">
|
||||
<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">
|
||||
{t('dashboard.env_tip_text')}
|
||||
</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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
MessageSquareText,
|
||||
CalendarDays,
|
||||
Settings,
|
||||
Palette,
|
||||
FileDown,
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -120,6 +124,18 @@ export default function KeyboardShortcutsDialog({ open, onOpenChange }: Keyboard
|
||||
{ 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 (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,7 +2,12 @@
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
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 {
|
||||
Dialog,
|
||||
@@ -35,6 +40,12 @@ const steps: OnboardingStep[] = [
|
||||
descKey: 'onboarding.dashboard_desc',
|
||||
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,
|
||||
titleKey: 'onboarding.progress_title',
|
||||
@@ -47,12 +58,54 @@ const steps: OnboardingStep[] = [
|
||||
descKey: 'onboarding.flower_desc',
|
||||
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,
|
||||
titleKey: 'onboarding.reports_title',
|
||||
descKey: 'onboarding.reports_desc',
|
||||
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,
|
||||
titleKey: 'onboarding.done_title',
|
||||
@@ -130,12 +183,12 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="flex flex-col items-center text-center px-8 pt-10 pb-6"
|
||||
>
|
||||
{/* Emoji illustration */}
|
||||
{/* Icon illustration */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
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" />
|
||||
</motion.div>
|
||||
@@ -164,7 +217,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={i}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
PenLine, User, Clock, Plus, ChevronDown, BadgeCheck,
|
||||
Download, CalendarDays, Keyboard, Sparkles, BookOpen,
|
||||
GraduationCap, Trash2, CheckSquare, X, ListChecks,
|
||||
Sprout, Leaf, TreePine, Trees,
|
||||
Sprout, Leaf, TreePine, Trees, BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -675,7 +675,61 @@ export default function ProgressEntriesView() {
|
||||
</Card>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
|
||||
+190
@@ -1633,6 +1633,22 @@ const de: TranslationDict = {
|
||||
'onboarding.back': 'Zurück',
|
||||
'onboarding.start': 'Loslegen',
|
||||
'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)
|
||||
'progress.batch_select': 'Auswählen',
|
||||
@@ -1701,6 +1717,13 @@ const de: TranslationDict = {
|
||||
'shortcuts.recent_actions': 'Letzte Aktionen',
|
||||
'shortcuts.views': 'Ansichten',
|
||||
'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.title': 'Zeichnungen',
|
||||
@@ -1862,6 +1885,78 @@ const de: TranslationDict = {
|
||||
'notebooks.total_pages': 'Seiten gesamt',
|
||||
'notebooks.paper_saved': 'Papier gespart',
|
||||
'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 = {
|
||||
@@ -3494,6 +3589,22 @@ const en: TranslationDict = {
|
||||
'onboarding.back': 'Back',
|
||||
'onboarding.start': 'Get started',
|
||||
'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)
|
||||
'progress.batch_select': 'Select',
|
||||
@@ -3562,6 +3673,13 @@ const en: TranslationDict = {
|
||||
'shortcuts.recent_actions': 'Recent Actions',
|
||||
'shortcuts.views': 'Views',
|
||||
'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.title': 'Drawings',
|
||||
@@ -3723,6 +3841,78 @@ const en: TranslationDict = {
|
||||
'notebooks.total_pages': 'Total Pages',
|
||||
'notebooks.paper_saved': 'Paper Saved',
|
||||
'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 };
|
||||
|
||||
Reference in new issue
Block a user