Initial commit: ExaMetra v1.0.0 — Free & open-source competence & grading platform
Features: - 15+ views: Dashboard, Classes, Progress, Flower, Matrix, Assessments, Grading, Reports, Attendance, Calendar, Lesson Plans, Analytics, Parent Communication, Behavior Tracking, Settings - 34+ API endpoints - 30+ Prisma models - 700+ i18n keys (German + English) - Keyboard shortcuts (Cmd+K, Cmd+1-9, Cmd+/) - Batch deletion, drag-and-drop seating, custom color palette - Demo accounts (admin, teacher, student, parent) - Dark mode, responsive design, print styles - CSV/PDF export, notification system, onboarding tour - Zero emojis — all replaced with Lucide icons - MIT License
This commit is contained in:
commit
0c1f739989
156 files changed
+51090
No files matched your search
+2002
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,69 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'ct_session';
|
||||
const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export async function createSession(userId: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, userId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUserSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
schoolId: true,
|
||||
locale: true,
|
||||
isDemo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export type SessionUser = Prisma.UserGetPayload<{ select: typeof sessionUserSelect }>;
|
||||
|
||||
export async function getSession(): Promise<{
|
||||
userId: string;
|
||||
user: SessionUser | null;
|
||||
} | null> {
|
||||
const cookieStore = await cookies();
|
||||
const userId = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!userId) return null;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId, deletedAt: null },
|
||||
select: sessionUserSelect,
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return { userId, user };
|
||||
}
|
||||
|
||||
export async function clearSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const db =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: ['query'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
|
||||
+3404
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,157 @@
|
||||
// CompetenceTrack — Zustand Store
|
||||
import { create } from 'zustand';
|
||||
import { setLocale, getLocale } from '@/lib/i18n';
|
||||
|
||||
export type ViewName =
|
||||
| 'dashboard'
|
||||
| 'classes'
|
||||
| 'competencies'
|
||||
| 'progress'
|
||||
| 'flower'
|
||||
| 'assessments'
|
||||
| 'grading'
|
||||
| 'reports'
|
||||
| 'settings'
|
||||
| 'student-detail'
|
||||
| 'analytics'
|
||||
| 'matrix'
|
||||
| 'attendance'
|
||||
| 'calendar'
|
||||
| 'lesson-plans'
|
||||
| 'parents'
|
||||
| 'behavior'
|
||||
| 'coverage'
|
||||
| 'rubrics'
|
||||
| 'comments';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
schoolId: string | null;
|
||||
locale: string;
|
||||
isDemo?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationPrefs {
|
||||
showProgress: boolean;
|
||||
showAssessments: boolean;
|
||||
showGrades: boolean;
|
||||
showReports: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Navigation
|
||||
currentView: ViewName;
|
||||
previousView: ViewName | null;
|
||||
setCurrentView: (view: ViewName) => void;
|
||||
navigateToStudentDetail: (studentId: string, fromView?: ViewName) => void;
|
||||
navigateBack: () => void;
|
||||
|
||||
// Auth
|
||||
currentUser: CurrentUser | null;
|
||||
isLoadingAuth: boolean;
|
||||
setCurrentUser: (user: CurrentUser | null) => void;
|
||||
logout: () => Promise<void>;
|
||||
|
||||
// Selection
|
||||
currentClassId: string | null;
|
||||
currentStudentId: string | null;
|
||||
currentSubjectId: string | null;
|
||||
schoolYearId: string | null;
|
||||
setCurrentClass: (id: string | null) => void;
|
||||
setCurrentStudent: (id: string | null) => void;
|
||||
setCurrentSubject: (id: string | null) => void;
|
||||
setSchoolYearId: (id: string | null) => void;
|
||||
|
||||
// UI
|
||||
sidebarCollapsed: boolean;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
// Locale
|
||||
locale: string;
|
||||
setLocale: (locale: string) => void;
|
||||
|
||||
// Notifications
|
||||
notificationPrefs: NotificationPrefs;
|
||||
setNotificationPrefs: (prefs: NotificationPrefs) => void;
|
||||
}
|
||||
|
||||
function loadNotificationPrefs(): NotificationPrefs {
|
||||
try {
|
||||
const stored = localStorage.getItem('ct_notification_prefs');
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { showProgress: true, showAssessments: true, showGrades: true, showReports: true };
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
// Navigation
|
||||
currentView: 'dashboard',
|
||||
previousView: null,
|
||||
setCurrentView: (view) => set({ currentView: view }),
|
||||
navigateToStudentDetail: (studentId, fromView) => set((s) => ({
|
||||
currentStudentId: studentId,
|
||||
previousView: fromView ?? s.currentView,
|
||||
currentView: 'student-detail' as ViewName,
|
||||
})),
|
||||
navigateBack: () => set((s) => ({
|
||||
currentView: s.previousView ?? 'classes',
|
||||
previousView: null,
|
||||
})),
|
||||
|
||||
// Auth
|
||||
currentUser: null,
|
||||
isLoadingAuth: true,
|
||||
setCurrentUser: (user) => set({ currentUser: user, isLoadingAuth: false }),
|
||||
logout: async () => {
|
||||
try {
|
||||
await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'logout' }),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
set({ currentUser: null, currentView: 'dashboard' });
|
||||
},
|
||||
|
||||
// Selection
|
||||
currentClassId: null,
|
||||
currentStudentId: null,
|
||||
currentSubjectId: null,
|
||||
schoolYearId: null,
|
||||
setCurrentClass: (id) => set({ currentClassId: id, currentStudentId: null }),
|
||||
setCurrentStudent: (id) => set({ currentStudentId: id }),
|
||||
setCurrentSubject: (id) => set({ currentSubjectId: id }),
|
||||
setSchoolYearId: (id) => set({ schoolYearId: id }),
|
||||
|
||||
// UI
|
||||
sidebarCollapsed: false,
|
||||
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
|
||||
// Locale
|
||||
locale: getLocale(),
|
||||
setLocale: (locale) => {
|
||||
setLocale(locale);
|
||||
set({ locale });
|
||||
},
|
||||
|
||||
// Notifications
|
||||
notificationPrefs: loadNotificationPrefs(),
|
||||
setNotificationPrefs: (prefs) => {
|
||||
try {
|
||||
localStorage.setItem('ct_notification_prefs', JSON.stringify(prefs));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
set({ notificationPrefs: prefs });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in new issue
Block a user