first commit
This commit is contained in:
27
Smartes-Klassenzimmer-Frontend/.gitignore
vendored
Normal file
27
Smartes-Klassenzimmer-Frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
.vscode
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
1
Smartes-Klassenzimmer-Frontend/.npmrc
Normal file
1
Smartes-Klassenzimmer-Frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
14
Smartes-Klassenzimmer-Frontend/Dockerfile
Normal file
14
Smartes-Klassenzimmer-Frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "build/index.js"]
|
||||
25
Smartes-Klassenzimmer-Frontend/README.md
Normal file
25
Smartes-Klassenzimmer-Frontend/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Smartes Klassenzimmer - Frontend
|
||||
|
||||
Vollständige Projekt-Dokumentation befindet sich in der [CLAUDE.md](../CLAUDE.md) im Root-Verzeichnis.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
npm install
|
||||
|
||||
# Development Server starten
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Der Server läuft auf `http://localhost:5173`.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Production Build erstellen
|
||||
npm run build
|
||||
|
||||
# Production Build testen
|
||||
npm run preview
|
||||
```
|
||||
1926
Smartes-Klassenzimmer-Frontend/package-lock.json
generated
Normal file
1926
Smartes-Klassenzimmer-Frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
Smartes-Klassenzimmer-Frontend/package.json
Normal file
27
Smartes-Klassenzimmer-Frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "smartes-klassenzimmer-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-svelte": "^0.556.0",
|
||||
"socket.io-client": "^4.8.1"
|
||||
}
|
||||
}
|
||||
891
Smartes-Klassenzimmer-Frontend/src/app.css
Normal file
891
Smartes-Klassenzimmer-Frontend/src/app.css
Normal file
@@ -0,0 +1,891 @@
|
||||
/* ============================================
|
||||
SMARTROOM DESIGN SYSTEM
|
||||
Minimalistisch · Klar · Professionell
|
||||
============================================ */
|
||||
|
||||
/* ============================================
|
||||
1. DESIGN TOKENS
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* === Farben === */
|
||||
|
||||
/* Neutral (Graustufen) - Minimalistisches Design */
|
||||
--neutral-50: #fafafa;
|
||||
--neutral-100: #f5f5f5;
|
||||
--neutral-200: #e0e0e0;
|
||||
--neutral-300: #ddd;
|
||||
--neutral-400: #999;
|
||||
--neutral-500: #666;
|
||||
--neutral-600: #333;
|
||||
--neutral-700: #333;
|
||||
--neutral-800: #222;
|
||||
--neutral-900: #111;
|
||||
--neutral-950: #000;
|
||||
|
||||
/* Primary (Blau als Hauptfarbe aus DESIGN_SYSTEM.md) */
|
||||
--primary-50: #eef0fe;
|
||||
--primary-100: #dde1fd;
|
||||
--primary-200: #bcc4fb;
|
||||
--primary-300: #9aa6f9;
|
||||
--primary-400: #7989f7;
|
||||
--primary-500: #3C45F0;
|
||||
--primary-600: #2d34d1;
|
||||
--primary-700: #2328a8;
|
||||
--primary-800: #1a1d7f;
|
||||
--primary-900: #101256;
|
||||
|
||||
/* Shortcuts für einfache Verwendung */
|
||||
--primary: var(--primary-500);
|
||||
--primary-hover: var(--primary-600);
|
||||
--primary-light: var(--primary-400);
|
||||
|
||||
/* Semantic Colors */
|
||||
--success-50: #ecfdf5;
|
||||
--success-200: #a7f3d0;
|
||||
--success-400: #34d399;
|
||||
--success-500: #10b981;
|
||||
--success-700: #047857;
|
||||
|
||||
--error-50: #fef2f2;
|
||||
--error-200: #fecaca;
|
||||
--error-400: #f87171;
|
||||
--error-500: #ef4444;
|
||||
--error-700: #b91c1c;
|
||||
|
||||
--warning-50: #fffbeb;
|
||||
--warning-200: #fde68a;
|
||||
--warning-400: #fbbf24;
|
||||
--warning-500: #f59e0b;
|
||||
--warning-700: #b45309;
|
||||
|
||||
--info-50: #eff6ff;
|
||||
--info-500: #3b82f6;
|
||||
--info-700: #1d4ed8;
|
||||
|
||||
/* === Spacing === */
|
||||
--space-xs: 0.25rem; /* 4px */
|
||||
--space-sm: 0.5rem; /* 8px */
|
||||
--space-md: 1rem; /* 16px */
|
||||
--space-lg: 1.5rem; /* 24px */
|
||||
--space-xl: 2rem; /* 32px */
|
||||
--space-2xl: 3rem; /* 48px */
|
||||
--space-3xl: 4rem; /* 64px */
|
||||
|
||||
/* === Typography === */
|
||||
--font-heading: 'Noto Serif', Georgia, serif;
|
||||
--font-body: 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
|
||||
--font-light: 300;
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* === Border Radius === */
|
||||
--radius-sm: 0.375rem; /* 6px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 0.5rem; /* 8px */
|
||||
--radius-xl: 0.5rem; /* 8px */
|
||||
--radius-2xl: 0.5rem; /* 8px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* === Shadows - Minimiert === */
|
||||
--shadow-sm: none;
|
||||
--shadow-md: none;
|
||||
--shadow-lg: none;
|
||||
--shadow-xl: none;
|
||||
|
||||
/* === Transitions === */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* === Z-Index === */
|
||||
--z-dropdown: 1000;
|
||||
--z-sticky: 1020;
|
||||
--z-fixed: 1030;
|
||||
--z-modal-backdrop: 1040;
|
||||
--z-modal: 1050;
|
||||
--z-popover: 1060;
|
||||
--z-tooltip: 1070;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
2. RESET & BASE STYLES
|
||||
============================================ */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
color: var(--neutral-900);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* === Typography === */
|
||||
h1 {
|
||||
font-family: var(--font-heading); /* Noto Serif nur für große Hauptüberschriften */
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.3;
|
||||
color: var(--neutral-900);
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
|
||||
h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-body); /* Figtree für alle anderen Überschriften */
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.3;
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
h3 { font-size: var(--text-2xl); }
|
||||
h4 { font-size: var(--text-xl); }
|
||||
h5 { font-size: var(--text-lg); }
|
||||
h6 { font-size: var(--text-base); }
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-md);
|
||||
color: var(--neutral-700);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
/* === Lists === */
|
||||
ul, ol {
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
3. LAYOUT
|
||||
============================================ */
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
.container-fluid {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
.container-wide {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.container-narrow {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
/* === Grid === */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-2,
|
||||
.grid-cols-3,
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Flexbox Utilities === */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-sm { gap: var(--space-sm); }
|
||||
.gap-md { gap: var(--space-md); }
|
||||
.gap-lg { gap: var(--space-lg); }
|
||||
|
||||
/* ============================================
|
||||
4. KOMPONENTEN
|
||||
============================================ */
|
||||
|
||||
/* === Card === */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--neutral-200);
|
||||
}
|
||||
|
||||
.card-compact {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--neutral-600);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: var(--text-base);
|
||||
color: var(--neutral-700);
|
||||
}
|
||||
|
||||
/* === Button === */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 0.625rem 1rem;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--neutral-300);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
background: white;
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: var(--neutral-900);
|
||||
background: var(--neutral-100);
|
||||
}
|
||||
|
||||
/* Button Variants */
|
||||
.btn-primary {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-600);
|
||||
border-color: var(--primary-600);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--neutral-200);
|
||||
color: var(--neutral-700);
|
||||
border-color: var(--neutral-300);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--neutral-300);
|
||||
border-color: var(--neutral-400);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--neutral-900);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--neutral-100);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-500);
|
||||
color: white;
|
||||
border-color: var(--success-500);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: var(--success-700);
|
||||
}
|
||||
/* Button Sizes */
|
||||
.btn-sm, .btn-small {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* === Form Elements === */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--neutral-700);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea,
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--neutral-900);
|
||||
background: white;
|
||||
border: 1px solid var(--neutral-300);
|
||||
border-radius: 6px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.form-input.error,
|
||||
.form-select.error,
|
||||
.form-textarea.error {
|
||||
border-color: var(--error-500);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--error-700);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
/* === Badge === */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
border: 1px solid var(--neutral-200);
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
background: #f0f0f0;
|
||||
color: var(--neutral-500);
|
||||
border-color: var(--neutral-200);
|
||||
}
|
||||
|
||||
.badge-primary, .badge.admin {
|
||||
background: var(--neutral-900);
|
||||
color: white;
|
||||
border-color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-50);
|
||||
color: var(--success-700);
|
||||
border: 1px solid var(--success-500);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: var(--error-50);
|
||||
color: var(--error-700);
|
||||
border: 1px solid var(--error-500);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--warning-50);
|
||||
color: var(--warning-700);
|
||||
border: 1px solid var(--warning-500);
|
||||
}
|
||||
|
||||
/* === Avatar === */
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 2rem;
|
||||
font-weight: var(--font-bold);
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.avatar-md {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.avatar-lg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
/* === Info Grid === */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 0.75rem;
|
||||
background: var(--neutral-50);
|
||||
border: 1px solid var(--neutral-200);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Stats === */
|
||||
.stat-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--primary-500);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--neutral-400);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
/* === Widget === */
|
||||
.widget {
|
||||
padding: var(--space-md);
|
||||
background: var(--neutral-50);
|
||||
border: 1px solid var(--neutral-200);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.widget-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.widget-number {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--neutral-900);
|
||||
line-height: 1;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.widget-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
/* === Table === */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--neutral-200);
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--neutral-50);
|
||||
}
|
||||
|
||||
.table td {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: var(--neutral-50);
|
||||
}
|
||||
|
||||
/* === Alert === */
|
||||
.alert {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--success-50);
|
||||
border-color: var(--success-500);
|
||||
color: var(--success-700);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--error-50);
|
||||
border-color: var(--error-500);
|
||||
color: var(--error-700);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: var(--warning-50);
|
||||
border-color: var(--warning-500);
|
||||
color: var(--warning-700);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--info-50);
|
||||
border-color: var(--info-500);
|
||||
color: var(--info-700);
|
||||
}
|
||||
|
||||
/* === Loading === */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 2px solid var(--neutral-200);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
/* === Modal === */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal-backdrop);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--space-lg);
|
||||
border-bottom: 1px solid var(--neutral-200);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: var(--space-lg);
|
||||
border-top: 1px solid var(--neutral-200);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* === Empty State === */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--space-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-900);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
5. UTILITY CLASSES
|
||||
============================================ */
|
||||
|
||||
/* === Spacing === */
|
||||
.m-0 { margin: 0; }
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
|
||||
.m-sm { margin: var(--space-sm); }
|
||||
.mt-sm { margin-top: var(--space-sm); }
|
||||
.mb-sm { margin-bottom: var(--space-sm); }
|
||||
|
||||
.m-md { margin: var(--space-md); }
|
||||
.mt-md { margin-top: var(--space-md); }
|
||||
.mb-md { margin-bottom: var(--space-md); }
|
||||
|
||||
.m-lg { margin: var(--space-lg); }
|
||||
.mt-lg { margin-top: var(--space-lg); }
|
||||
.mb-lg { margin-bottom: var(--space-lg); }
|
||||
|
||||
.p-0 { padding: 0; }
|
||||
.pt-0 { padding-top: 0; }
|
||||
.pb-0 { padding-bottom: 0; }
|
||||
|
||||
.p-sm { padding: var(--space-sm); }
|
||||
.pt-sm { padding-top: var(--space-sm); }
|
||||
.pb-sm { padding-bottom: var(--space-sm); }
|
||||
|
||||
.p-md { padding: var(--space-md); }
|
||||
.pt-md { padding-top: var(--space-md); }
|
||||
.pb-md { padding-bottom: var(--space-md); }
|
||||
|
||||
.p-lg { padding: var(--space-lg); }
|
||||
.pt-lg { padding-top: var(--space-lg); }
|
||||
.pb-lg { padding-bottom: var(--space-lg); }
|
||||
|
||||
/* === Text === */
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-xl { font-size: var(--text-xl); }
|
||||
|
||||
.font-light { font-weight: var(--font-light); }
|
||||
.font-normal { font-weight: var(--font-normal); }
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
|
||||
.text-left { text-align: left; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.text-primary { color: var(--neutral-900); }
|
||||
.text-secondary { color: var(--neutral-700); }
|
||||
.text-muted { color: var(--neutral-600); }
|
||||
.text-success { color: var(--success-700); }
|
||||
.text-error { color: var(--error-700); }
|
||||
.text-warning { color: var(--warning-700); }
|
||||
|
||||
.uppercase { text-transform: uppercase; }
|
||||
|
||||
/* === Display === */
|
||||
.block { display: block; }
|
||||
.inline-block { display: inline-block; }
|
||||
.hidden { display: none; }
|
||||
|
||||
/* === Width === */
|
||||
.w-full { width: 100%; }
|
||||
.w-auto { width: auto; }
|
||||
|
||||
/* === Border === */
|
||||
.border { border: 1px solid var(--neutral-200); }
|
||||
.border-t { border-top: 1px solid var(--neutral-200); }
|
||||
.border-b { border-bottom: 1px solid var(--neutral-200); }
|
||||
|
||||
/* === Border Radius === */
|
||||
.rounded-sm { border-radius: var(--radius-sm); }
|
||||
.rounded-md { border-radius: var(--radius-md); }
|
||||
.rounded-lg { border-radius: var(--radius-lg); }
|
||||
.rounded-full { border-radius: var(--radius-full); }
|
||||
|
||||
/* === Shadow === */
|
||||
.shadow-sm { box-shadow: var(--shadow-sm); }
|
||||
.shadow-md { box-shadow: var(--shadow-md); }
|
||||
.shadow-lg { box-shadow: var(--shadow-lg); }
|
||||
13
Smartes-Klassenzimmer-Frontend/src/app.d.ts
vendored
Normal file
13
Smartes-Klassenzimmer-Frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
14
Smartes-Klassenzimmer-Frontend/src/app.html
Normal file
14
Smartes-Klassenzimmer-Frontend/src/app.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Noto+Serif:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
31
Smartes-Klassenzimmer-Frontend/src/hooks.server.ts
Normal file
31
Smartes-Klassenzimmer-Frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
// Routen die NICHT authentifiziert sein müssen
|
||||
const PUBLIC_ROUTES = ['/', '/login', '/register', '/forgot-password', '/reset-password'];
|
||||
|
||||
// Routen die NUR für nicht-authentifizierte Nutzer sind (redirect zu Dashboard wenn eingeloggt)
|
||||
const AUTH_ROUTES = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { url, cookies } = event;
|
||||
const accessToken = cookies.get('access_token');
|
||||
|
||||
// 1. Wenn User eingeloggt ist und auf eine Auth-Seite will -> Redirect zum Dashboard
|
||||
if (accessToken && AUTH_ROUTES.includes(url.pathname)) {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
// 2. Wenn auf öffentlicher Route (und nicht oben abgefangen), einfach durchlassen
|
||||
if (PUBLIC_ROUTES.includes(url.pathname)) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// 3. Wenn nicht authentifiziert und auf geschützter Route -> Redirect zum Login
|
||||
if (!accessToken) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
// Request normal weitergeben
|
||||
return resolve(event);
|
||||
};
|
||||
651
Smartes-Klassenzimmer-Frontend/src/lib/api.ts
Normal file
651
Smartes-Klassenzimmer-Frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// API Client für Backend-Kommunikation
|
||||
const API_BASE_URL = browser
|
||||
? (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api')
|
||||
: (import.meta.env.VITE_INTERNAL_API_URL || 'http://backend:3000/api');
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const headers: HeadersInit = { ...options.headers };
|
||||
|
||||
// Content-Type nur setzen, wenn kein FormData gesendet wird (Browser setzt Boundary automatisch)
|
||||
if (!(options.body instanceof FormData)) {
|
||||
(headers as any)['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...options,
|
||||
credentials: 'include', // Wichtig für httpOnly Cookies
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Ein Fehler ist aufgetreten' }));
|
||||
return { error: errorData.message || `HTTP Error ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Netzwerkfehler' };
|
||||
}
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, body: any) {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
// Auth Endpoints
|
||||
async login(username: string, password: string) {
|
||||
return this.request<{ message: string; user: User }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
}
|
||||
|
||||
async register(email: string, username: string, password: string, role: 'Student' | 'Lehrer' = 'Student') {
|
||||
return this.request<{ message: string; user: User }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, username, password, role })
|
||||
});
|
||||
}
|
||||
|
||||
async logout() {
|
||||
return this.request<{ message: string }>('/auth/logout', {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
async getMe() {
|
||||
return this.request<User>('/auth/me');
|
||||
}
|
||||
|
||||
// Users Endpoints (nur für Lehrer)
|
||||
async getUsers() {
|
||||
return this.request<User[]>('/users');
|
||||
}
|
||||
|
||||
async getUser(id: number) {
|
||||
return this.request<User>(`/users/${id}`);
|
||||
}
|
||||
|
||||
async updateUser(id: number, data: Partial<UpdateUserDto>) {
|
||||
return this.request<User>(`/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id: number) {
|
||||
return this.request<{ message: string }>(`/users/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Files Endpoints
|
||||
async uploadFile(file: File, timetableEntryId?: number) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (timetableEntryId) {
|
||||
formData.append('timetableEntryId', timetableEntryId.toString());
|
||||
}
|
||||
|
||||
return this.request<FileEntity>('/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
async getGeneralFiles() {
|
||||
return this.request<FileEntity[]>('/files');
|
||||
}
|
||||
|
||||
async getLessonFiles(lessonId: number) {
|
||||
return this.request<FileEntity[]>(`/files/lesson/${lessonId}`);
|
||||
}
|
||||
|
||||
async deleteFile(id: number) {
|
||||
return this.request<{ success: boolean }>(`/files/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
getDownloadUrl(id: number) {
|
||||
return `${this.baseUrl}/files/download/${id}`;
|
||||
}
|
||||
|
||||
// Subjects Endpoints
|
||||
async getSubjects() {
|
||||
return this.request<Subject[]>('/subjects');
|
||||
}
|
||||
|
||||
async getSubject(id: number) {
|
||||
return this.request<Subject>(`/subjects/${id}`);
|
||||
}
|
||||
|
||||
async createSubject(data: CreateSubjectDto) {
|
||||
return this.request<Subject>('/subjects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async updateSubject(id: number, data: Partial<CreateSubjectDto>) {
|
||||
return this.request<Subject>(`/subjects/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSubject(id: number) {
|
||||
return this.request<{ message: string }>(`/subjects/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Rooms Endpoints
|
||||
async getRooms() {
|
||||
return this.request<Room[]>('/rooms');
|
||||
}
|
||||
|
||||
async getRoom(id: number) {
|
||||
return this.request<Room>(`/rooms/${id}`);
|
||||
}
|
||||
|
||||
async createRoom(data: CreateRoomDto) {
|
||||
return this.request<Room>('/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async updateRoom(id: number, data: Partial<CreateRoomDto>) {
|
||||
return this.request<Room>(`/rooms/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRoom(id: number) {
|
||||
return this.request<{ message: string }>(`/rooms/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Timetable Endpoints
|
||||
async getTimetable(weekNumber?: number, year?: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (weekNumber !== undefined) params.append('weekNumber', weekNumber.toString());
|
||||
if (year !== undefined) params.append('year', year.toString());
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
return this.request<TimetableEntry[]>(`/timetable${query}`);
|
||||
}
|
||||
|
||||
async createTimetableEntry(data: CreateTimetableEntryDto) {
|
||||
return this.request<TimetableEntry>('/timetable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async updateTimetableEntry(id: number, data: Partial<CreateTimetableEntryDto>) {
|
||||
return this.request<TimetableEntry>(`/timetable/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTimetableEntry(id: number) {
|
||||
return this.request<{ message: string }>(`/timetable/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Feedback Endpoints
|
||||
async getFeedback() {
|
||||
return this.request<Feedback[]>('/feedback/all');
|
||||
}
|
||||
|
||||
async getFeedbackByLesson(lessonId: string) {
|
||||
return this.request<Feedback[]>(`/feedback/lesson/${lessonId}`);
|
||||
}
|
||||
|
||||
async createFeedback(data: CreateFeedbackDto) {
|
||||
return this.request<Feedback>('/feedback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async getTeachers() {
|
||||
return this.request<User[]>('/users/teachers');
|
||||
}
|
||||
|
||||
// Grades Endpoints
|
||||
async getGradesByTimetableEntry(timetableEntryId: number, weekNumber: number, year: number) {
|
||||
return this.request<Grade[]>(`/grades/timetable/${timetableEntryId}?weekNumber=${weekNumber}&year=${year}`);
|
||||
}
|
||||
|
||||
async getGrades(filters?: { studentId?: number; timetableEntryId?: number; teacherId?: number }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.studentId) params.append('studentId', filters.studentId.toString());
|
||||
if (filters?.timetableEntryId) params.append('timetableEntryId', filters.timetableEntryId.toString());
|
||||
if (filters?.teacherId) params.append('teacherId', filters.teacherId.toString());
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
return this.request<Grade[]>(`/grades${query}`);
|
||||
}
|
||||
|
||||
async getAverageGrade(timetableEntryId: number) {
|
||||
return this.request<GradeAverage>(`/grades/average/${timetableEntryId}`);
|
||||
}
|
||||
|
||||
async getStudentOverview() {
|
||||
return this.request<SubjectOverview[]>('/grades/overview');
|
||||
}
|
||||
|
||||
async createGrade(data: CreateGradeDto) {
|
||||
return this.request<Grade>('/grades', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async updateGrade(id: number, data: Partial<CreateGradeDto>) {
|
||||
return this.request<Grade>(`/grades/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGrade(id: number) {
|
||||
return this.request<{ message: string }>(`/grades/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Lehrer: Komplette Notenübersicht für alle Fächer
|
||||
async getTeacherGradesOverview() {
|
||||
return this.request<TeacherGradesOverview[]>('/grades/teacher/overview');
|
||||
}
|
||||
|
||||
// Lehrer: Detaillierte Notenansicht für ein spezifisches Fach
|
||||
async getSubjectGradeDetails(timetableEntryId: number) {
|
||||
return this.request<SubjectGradeDetails>(`/grades/subject/${timetableEntryId}`);
|
||||
}
|
||||
|
||||
// Lehrer: Detaillierte Notenansicht für alle Stundenplan-Einträge mit gleichem Fachnamen
|
||||
async getSubjectGradeDetailsByName(subjectName: string) {
|
||||
return this.request<SubjectGradeDetailsByName>(`/grades/subject-by-name/${encodeURIComponent(subjectName)}`);
|
||||
}
|
||||
|
||||
// Attendance Endpoints
|
||||
async getPresentStudents() {
|
||||
return this.request<AttendanceStatus>('/attendance/present');
|
||||
}
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface PresentStudent {
|
||||
studentId: number;
|
||||
name: string;
|
||||
className: string;
|
||||
checkInTime: string;
|
||||
minutesPresent: number;
|
||||
}
|
||||
|
||||
export interface AbsentStudent {
|
||||
studentId: number;
|
||||
name: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export interface AttendanceStatus {
|
||||
date: string;
|
||||
time: string;
|
||||
presentStudents: PresentStudent[];
|
||||
absentStudents: AbsentStudent[];
|
||||
totalPresent: number;
|
||||
totalAbsent: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: 'Student' | 'Lehrer';
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserDto {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
role?: 'Student' | 'Lehrer';
|
||||
}
|
||||
|
||||
export interface Subject {
|
||||
id: number;
|
||||
name: string; // z.B. "Mathematik", "Deutsch"
|
||||
abbreviation?: string | null; // z.B. "Mathe", "DE"
|
||||
color?: string | null; // z.B. "#FF5733"
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateSubjectDto {
|
||||
name: string;
|
||||
abbreviation?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: number;
|
||||
number: string; // z.B. "A101", "B203"
|
||||
building?: string | null; // z.B. "Hauptgebäude"
|
||||
capacity?: number | null;
|
||||
equipment?: string | null; // z.B. "Beamer, Whiteboard"
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoomDto {
|
||||
number: string;
|
||||
building?: string;
|
||||
capacity?: number;
|
||||
equipment?: string;
|
||||
}
|
||||
|
||||
export interface TimetableEntry {
|
||||
id: number;
|
||||
dayOfWeek: number; // 1-5 (Montag-Freitag)
|
||||
startTime: string; // Format: "HH:MM"
|
||||
endTime: string; // Format: "HH:MM"
|
||||
subjectId: number;
|
||||
subject: Subject;
|
||||
teacherId?: number | null;
|
||||
teacher?: User | null;
|
||||
roomId?: number | null;
|
||||
room?: Room | null;
|
||||
weekNumber?: number; // 1-53 (Kalenderwoche)
|
||||
year?: number; // Jahr (z.B. 2025)
|
||||
isRecurring?: boolean; // true = wiederholt sich jede Woche
|
||||
allowStudentUploads?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateTimetableEntryDto {
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
subjectId?: number;
|
||||
subjectName?: string;
|
||||
teacherId?: number;
|
||||
roomId?: number;
|
||||
roomNumber?: string;
|
||||
weekNumber?: number;
|
||||
year?: number;
|
||||
isRecurring?: boolean;
|
||||
allowStudentUploads?: boolean;
|
||||
}
|
||||
|
||||
export interface FileEntity {
|
||||
id: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
uploadedById: number;
|
||||
uploadedBy: {
|
||||
id?: number;
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
timetableEntryId?: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Feedback {
|
||||
id: number;
|
||||
studentId: number;
|
||||
student?: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
teacherId: number;
|
||||
teacher?: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
lessonId: number;
|
||||
timetableEntry?: {
|
||||
id: number;
|
||||
subject: Subject;
|
||||
teacher?: User | null;
|
||||
room?: Room | null;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
lessonDate: string;
|
||||
overallRating: number;
|
||||
categories?: {
|
||||
clarity?: number;
|
||||
pace?: number;
|
||||
interaction?: number;
|
||||
materials?: number;
|
||||
atmosphere?: number;
|
||||
};
|
||||
whatWasGood?: string;
|
||||
whatCanImprove?: string;
|
||||
additionalComments?: string;
|
||||
isAnonymous: boolean;
|
||||
allowTeacherResponse: boolean;
|
||||
teacherResponse?: string | null;
|
||||
teacherRespondedAt?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateFeedbackDto {
|
||||
studentId: number;
|
||||
teacherId: number;
|
||||
lessonId: number;
|
||||
lessonDate: Date | string;
|
||||
overallRating: number;
|
||||
categories?: {
|
||||
clarity?: number;
|
||||
pace?: number;
|
||||
interaction?: number;
|
||||
materials?: number;
|
||||
atmosphere?: number;
|
||||
};
|
||||
whatWasGood?: string;
|
||||
whatCanImprove?: string;
|
||||
additionalComments?: string;
|
||||
isAnonymous?: boolean;
|
||||
allowTeacherResponse?: boolean;
|
||||
}
|
||||
|
||||
export interface Grade {
|
||||
id: number;
|
||||
studentId: number;
|
||||
student?: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
timetableEntryId: number;
|
||||
timetableEntry?: {
|
||||
id: number;
|
||||
subject: Subject;
|
||||
teacher?: User | null;
|
||||
room?: Room | null;
|
||||
dayOfWeek?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
};
|
||||
weekNumber: number;
|
||||
year: number;
|
||||
grade: number;
|
||||
gradeType: string;
|
||||
weight?: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
maxPoints?: number;
|
||||
achievedPoints?: number;
|
||||
date: string;
|
||||
teacherId: number;
|
||||
teacher?: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateGradeDto {
|
||||
studentId: number;
|
||||
timetableEntryId: number;
|
||||
weekNumber: number;
|
||||
year: number;
|
||||
grade: number;
|
||||
gradeType: string;
|
||||
weight?: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
maxPoints?: number;
|
||||
achievedPoints?: number;
|
||||
date: string;
|
||||
teacherId: number;
|
||||
}
|
||||
|
||||
export interface GradeAverage {
|
||||
average: number;
|
||||
gradeCount: number;
|
||||
grades: {
|
||||
id: number;
|
||||
grade: number;
|
||||
gradeType: string;
|
||||
weight?: number;
|
||||
title?: string;
|
||||
date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface SubjectOverview {
|
||||
timetableEntryId: number;
|
||||
subject: Subject;
|
||||
teacher?: User | null;
|
||||
average: number;
|
||||
gradeCount: number;
|
||||
latestGrade: Grade;
|
||||
}
|
||||
|
||||
// Lehrer-spezifische Notenübersicht
|
||||
export interface TeacherGradesOverview {
|
||||
subject: Subject;
|
||||
timetableEntryIds: number[]; // Alle IDs für dieses Fach
|
||||
timetableEntries: Array<{
|
||||
id: number;
|
||||
teacher?: User | null;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>; // Alle Stundenplan-Einträge für dieses Fach
|
||||
studentCount: number;
|
||||
totalGrades: number;
|
||||
students: StudentGradesSummary[];
|
||||
}
|
||||
|
||||
export interface StudentGradesSummary {
|
||||
student: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
average: number | null;
|
||||
gradeCount: number;
|
||||
grades: Grade[];
|
||||
}
|
||||
|
||||
// Detaillierte Notenansicht für ein Fach
|
||||
export interface SubjectGradeDetails {
|
||||
timetableEntry: {
|
||||
id: number;
|
||||
subject: Subject;
|
||||
teacher?: User | null;
|
||||
room?: Room | null;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
students: StudentWithGrades[];
|
||||
totalStudents: number;
|
||||
totalGrades: number;
|
||||
}
|
||||
|
||||
export interface SubjectGradeDetailsByName {
|
||||
subject: Subject;
|
||||
timetableEntries: Array<{
|
||||
id: number;
|
||||
teacher?: User | null;
|
||||
room?: Room | null;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>;
|
||||
students: StudentWithGrades[];
|
||||
totalStudents: number;
|
||||
totalGrades: number;
|
||||
}
|
||||
|
||||
export interface StudentWithGrades {
|
||||
student: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
average: number | null;
|
||||
totalWeight: number;
|
||||
gradeCount: number;
|
||||
grades: GradeDetail[];
|
||||
}
|
||||
|
||||
export interface GradeDetail {
|
||||
id: number;
|
||||
grade: number;
|
||||
gradeType: string;
|
||||
weight: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
maxPoints?: number;
|
||||
achievedPoints?: number;
|
||||
date: string;
|
||||
weekNumber: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { timetableEntryId = null, onUploadSuccess } = $props();
|
||||
|
||||
let dragging = $state(false);
|
||||
let uploading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragging = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragging = true;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragging = false;
|
||||
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||
await uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
await uploadFiles(target.files);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFiles(files: FileList) {
|
||||
uploading = true;
|
||||
error = null;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const res = await api.uploadFile(file, timetableEntryId);
|
||||
if (res.error) {
|
||||
error = res.error;
|
||||
}
|
||||
}
|
||||
|
||||
uploading = false;
|
||||
if (!error) {
|
||||
dispatch('success');
|
||||
if(onUploadSuccess) onUploadSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg py-16 text-center transition-colors cursor-pointer flex flex-col justify-center items-center mb-2"
|
||||
style="background-color: {dragging ? 'var(--primary-100)' : 'var(--primary-50)'}; border-color: {dragging ? 'var(--primary-500)' : 'var(--primary-200)'};"
|
||||
ondragenter={handleDragEnter}
|
||||
ondragleave={handleDragLeave}
|
||||
ondragover={handleDragOver}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => document.getElementById('fileInput')?.click()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === 'Enter' && document.getElementById('fileInput')?.click()}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="fileInput"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
multiple
|
||||
/>
|
||||
|
||||
{#if uploading}
|
||||
<div class="text-primary">Uploading...</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center" style="padding: 32px 0px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 mb-2" style="color: var(--primary-400);height:32px;width:32px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<p class="font-medium text-sm" style="color: var(--primary-800);">Dateien hierher ziehen oder klicken</p>
|
||||
<p class="text-xs mt-1" style="color: var(--primary-500);">Alle Dateitypen erlaubt</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mt-2 text-red-500 text-sm">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Palette,
|
||||
Users,
|
||||
LogOut
|
||||
} from 'lucide-svelte';
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return $page.url.pathname === path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<h1>Smartes Klassenzimmer</h1>
|
||||
</div>
|
||||
<div class="nav-menu">
|
||||
<a href="/dashboard" class="nav-link" class:active={isActive('/dashboard')}>
|
||||
<LayoutDashboard size={18} />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="/timetable" class="nav-link" class:active={isActive('/timetable')}>
|
||||
<Calendar size={18} />
|
||||
<span>Stundenplan</span>
|
||||
</a>
|
||||
{#if authStore.isTeacher}
|
||||
<a href="/grades" class="nav-link" class:active={isActive('/grades')}>
|
||||
<BarChart3 size={18} />
|
||||
<span>Notenverwaltung</span>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="/feedback" class="nav-link" class:active={isActive('/feedback')}>
|
||||
<MessageSquare size={18} />
|
||||
<span>{authStore.isTeacher ? 'Feedback' : 'Noten'}</span>
|
||||
</a>
|
||||
<a href="/chat" class="nav-link" class:active={isActive('/chat')}>
|
||||
<MessageCircle size={18} />
|
||||
<span>Live Chat</span>
|
||||
</a>
|
||||
<a href="/whiteboard" class="nav-link" class:active={isActive('/whiteboard')}>
|
||||
<Palette size={18} />
|
||||
<span>Whiteboard</span>
|
||||
</a>
|
||||
{#if authStore.isTeacher}
|
||||
<a href="/users" class="nav-link" class:active={isActive('/users')}>
|
||||
<Users size={18} />
|
||||
<span>Nutzerverwaltung</span>
|
||||
</a>
|
||||
{/if}
|
||||
<button onclick={handleLogout} class="btn btn-secondary btn-sm">
|
||||
<LogOut size={16} />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--neutral-200);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.nav-brand h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: var(--neutral-600);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
transition: color var(--transition-fast);
|
||||
padding: var(--space-xs) 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: var(--space-md);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
gap: var(--space-sm);
|
||||
justify-content: center;
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { api, type AttendanceStatus } from '$lib/api';
|
||||
import { Users, UserCheck, UserX, Clock, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
let status = $state<AttendanceStatus | null>(null);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let lastUpdate = $state<Date>(new Date());
|
||||
let intervalId: any;
|
||||
|
||||
async function loadAttendance() {
|
||||
const response = await api.getPresentStudents();
|
||||
if (response.data) {
|
||||
status = response.data;
|
||||
lastUpdate = new Date();
|
||||
error = null;
|
||||
} else {
|
||||
error = response.error || 'Fehler beim Laden der Anwesenheit';
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadAttendance();
|
||||
// Poll every 5 seconds
|
||||
intervalId = setInterval(loadAttendance, 5000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
});
|
||||
|
||||
function formatTime(isoString: string) {
|
||||
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card attendance-card">
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<h3>Anwesenheit</h3>
|
||||
</div>
|
||||
<span class="icon"><Users size={24} /></span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{#if isLoading && !status}
|
||||
<div class="loading-container">
|
||||
<span class="loading-spinner"></span>
|
||||
<p>Lade Anwesenheit...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error">
|
||||
<p>{error}</p>
|
||||
<button class="btn btn-sm btn-secondary" onclick={loadAttendance}>
|
||||
<RefreshCw size={16} /> Neu laden
|
||||
</button>
|
||||
</div>
|
||||
{:else if status}
|
||||
<div class="stats-row">
|
||||
<div class="stat-item present">
|
||||
<span class="stat-label">Anwesend</span>
|
||||
<span class="stat-value">{status.totalPresent}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-container">
|
||||
{#if status.presentStudents.length === 0}
|
||||
<p class="empty-state">Keine Schüler anwesend</p>
|
||||
{:else}
|
||||
<ul class="student-list">
|
||||
{#each status.presentStudents as student}
|
||||
<li class="student-item">
|
||||
<div class="student-info">
|
||||
<span class="student-name">{student.name}</span>
|
||||
<span class="student-class">{student.className || '-'}</span>
|
||||
</div>
|
||||
<div class="checkin-info">
|
||||
<Clock size={14} />
|
||||
<span>{formatTime(student.checkInTime)}</span>
|
||||
<span class="duration">({student.minutesPresent} min)</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="footer-info">
|
||||
Stand: {lastUpdate.toLocaleTimeString()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.attendance-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.header-title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding-bottom: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--neutral-200);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: var(--neutral-50);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item.present {
|
||||
border: 1px solid var(--neutral-200);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--neutral-600);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: bold;
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.student-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.student-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) 0;
|
||||
border-bottom: 1px solid var(--neutral-100);
|
||||
}
|
||||
|
||||
.student-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.student-name {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--neutral-900);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.student-class {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--neutral-500);
|
||||
}
|
||||
|
||||
.checkin-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--success-600);
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: var(--neutral-400);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--neutral-500);
|
||||
padding: var(--space-lg) 0;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
gap: var(--space-md);
|
||||
color: var(--neutral-500);
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
margin-top: var(--space-sm);
|
||||
text-align: right;
|
||||
font-size: 0.7rem;
|
||||
color: var(--neutral-400);
|
||||
}
|
||||
</style>
|
||||
80
Smartes-Klassenzimmer-Frontend/src/lib/stores/auth.svelte.ts
Normal file
80
Smartes-Klassenzimmer-Frontend/src/lib/stores/auth.svelte.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Auth Store mit Svelte 5 Runes
|
||||
import { api, type User } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
class AuthStore {
|
||||
user = $state<User | null>(null);
|
||||
isLoading = $state(true);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
get isAuthenticated() {
|
||||
return this.user !== null;
|
||||
}
|
||||
|
||||
get isTeacher() {
|
||||
return this.user?.role === 'Lehrer';
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await api.getMe();
|
||||
|
||||
if (response.data) {
|
||||
this.user = response.data;
|
||||
} else {
|
||||
this.user = null;
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<boolean> {
|
||||
this.error = null;
|
||||
|
||||
const response = await api.login(username, password);
|
||||
|
||||
if (response.data) {
|
||||
this.user = response.data.user;
|
||||
goto('/dashboard');
|
||||
return true;
|
||||
} else {
|
||||
this.error = response.error || 'Login fehlgeschlagen';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async register(
|
||||
email: string,
|
||||
username: string,
|
||||
password: string,
|
||||
role: 'Student' | 'Lehrer' = 'Student'
|
||||
): Promise<boolean> {
|
||||
this.error = null;
|
||||
|
||||
const response = await api.register(email, username, password, role);
|
||||
|
||||
if (response.data) {
|
||||
this.user = response.data.user;
|
||||
goto('/dashboard');
|
||||
return true;
|
||||
} else {
|
||||
this.error = response.error || 'Registrierung fehlgeschlagen';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await api.logout();
|
||||
this.user = null;
|
||||
this.error = null;
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = new AuthStore();
|
||||
@@ -0,0 +1,94 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
export interface RaisedHand {
|
||||
userId: number;
|
||||
username: string;
|
||||
type: 'normal' | 'question';
|
||||
raisedAt: string; // Date string from JSON
|
||||
}
|
||||
|
||||
export interface ActiveStudent {
|
||||
userId: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
class ClassroomStore {
|
||||
socket: Socket | null = null;
|
||||
raisedHands = $state<RaisedHand[]>([]);
|
||||
activeStudent = $state<ActiveStudent | null>(null);
|
||||
isConnected = $state(false);
|
||||
|
||||
constructor() {
|
||||
// Auto-connect logic could go here if we wanted it global,
|
||||
// but better to call connect() from component mount or check auth
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.socket?.connected) return;
|
||||
if (!authStore.user) return;
|
||||
|
||||
this.socket = io('http://localhost:3000/api/classroom', {
|
||||
withCredentials: true,
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.isConnected = true;
|
||||
console.log('Connected to classroom');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
this.isConnected = false;
|
||||
console.log('Disconnected from classroom');
|
||||
});
|
||||
|
||||
this.socket.on('state-update', (data: { raisedHands: RaisedHand[]; activeStudent: ActiveStudent | null }) => {
|
||||
this.raisedHands = data.raisedHands;
|
||||
this.activeStudent = data.activeStudent;
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
raiseHand(type: 'normal' | 'question') {
|
||||
this.socket?.emit('raise-hand', { type });
|
||||
}
|
||||
|
||||
lowerHand() {
|
||||
this.socket?.emit('lower-hand');
|
||||
}
|
||||
|
||||
lowerAllHands() {
|
||||
this.socket?.emit('lower-all-hands');
|
||||
}
|
||||
|
||||
pickStudent(userId: number) {
|
||||
this.socket?.emit('pick-student', { userId });
|
||||
}
|
||||
|
||||
pickRandom() {
|
||||
this.socket?.emit('pick-random');
|
||||
}
|
||||
|
||||
resetActive() {
|
||||
this.socket?.emit('reset-active');
|
||||
}
|
||||
|
||||
endTurn() {
|
||||
this.socket?.emit('reset-active');
|
||||
}
|
||||
|
||||
get myHand() {
|
||||
if (!authStore.user) return undefined;
|
||||
return this.raisedHands.find(h => h.userId === authStore.user?.id);
|
||||
}
|
||||
}
|
||||
|
||||
export const classroomStore = new ClassroomStore();
|
||||
@@ -0,0 +1,185 @@
|
||||
// Whiteboard WebSocket Store mit Svelte 5 Runes
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type DrawingTool = 'pen' | 'eraser' | 'line' | 'rectangle' | 'circle' | 'text';
|
||||
|
||||
export interface DrawingData {
|
||||
id: string;
|
||||
whiteboardId: string;
|
||||
tool: DrawingTool;
|
||||
points: Point[];
|
||||
color?: string;
|
||||
lineWidth?: number;
|
||||
text?: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface RemoteCursor {
|
||||
userId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
class WhiteboardStore {
|
||||
socket: Socket | null = null;
|
||||
connected = $state(false);
|
||||
whiteboardId = $state<string>('default-room');
|
||||
userId = $state<string>('');
|
||||
activeUsers = $state<string[]>([]);
|
||||
remoteCursors = $state<Map<string, RemoteCursor>>(new Map());
|
||||
drawings = $state<DrawingData[]>([]);
|
||||
|
||||
connect(whiteboardId: string, userId: string) {
|
||||
this.whiteboardId = whiteboardId;
|
||||
this.userId = userId;
|
||||
|
||||
// WebSocket-Verbindung herstellen
|
||||
this.socket = io('http://localhost:3000/whiteboard', {
|
||||
withCredentials: true,
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
|
||||
// Dem Whiteboard beitreten
|
||||
this.socket.emit('join-whiteboard', {
|
||||
whiteboardId,
|
||||
userId
|
||||
});
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('✅ Connected to whiteboard');
|
||||
this.connected = true;
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('❌ Disconnected from whiteboard');
|
||||
this.connected = false;
|
||||
});
|
||||
|
||||
// Empfange aktuelle Whiteboard-Daten
|
||||
this.socket.on('whiteboard-state', (drawings: DrawingData[]) => {
|
||||
console.log('📥 Received whiteboard state:', drawings.length, 'drawings');
|
||||
this.drawings = drawings;
|
||||
});
|
||||
|
||||
// Empfange neue Zeichnungen von anderen Benutzern
|
||||
this.socket.on('drawing', (drawing: DrawingData) => {
|
||||
console.log('🎨 Received drawing from user:', drawing.userId);
|
||||
this.drawings = [...this.drawings, drawing];
|
||||
});
|
||||
|
||||
// Benutzer ist beigetreten
|
||||
this.socket.on('user-joined', (data: { userId: string; activeUsers: string[] }) => {
|
||||
console.log('👋 User joined:', data.userId);
|
||||
this.activeUsers = data.activeUsers;
|
||||
});
|
||||
|
||||
// Benutzer hat verlassen
|
||||
this.socket.on('user-left', (data: { userId: string; activeUsers: string[] }) => {
|
||||
console.log('👋 User left:', data.userId);
|
||||
this.activeUsers = data.activeUsers;
|
||||
// Entferne Cursor des Benutzers
|
||||
this.remoteCursors.delete(data.userId);
|
||||
});
|
||||
|
||||
// Whiteboard wurde gelöscht
|
||||
this.socket.on('whiteboard-cleared', (data: { userId: string }) => {
|
||||
console.log('🗑️ Whiteboard cleared by:', data.userId);
|
||||
this.drawings = [];
|
||||
});
|
||||
|
||||
// Cursor-Position von anderen Benutzern
|
||||
this.socket.on('cursor-position', (data: { userId: string; x: number; y: number }) => {
|
||||
this.remoteCursors.set(data.userId, {
|
||||
userId: data.userId,
|
||||
x: data.x,
|
||||
y: data.y
|
||||
});
|
||||
// Trigger reaktivität
|
||||
this.remoteCursors = new Map(this.remoteCursors);
|
||||
});
|
||||
|
||||
// Undo wurde angefordert
|
||||
this.socket.on('undo-requested', (data: { userId: string }) => {
|
||||
console.log('↩️ Undo requested by:', data.userId);
|
||||
// Hier könnte man Undo-Logik implementieren
|
||||
});
|
||||
}
|
||||
|
||||
sendDrawing(tool: DrawingTool, points: Point[], color: string, lineWidth: number, text?: string, id?: string) {
|
||||
if (!this.socket || !this.connected) {
|
||||
console.error('❌ Not connected to whiteboard');
|
||||
return;
|
||||
}
|
||||
|
||||
const drawingData: DrawingData = {
|
||||
id: id || `${Date.now()}-${Math.random()}`,
|
||||
whiteboardId: this.whiteboardId,
|
||||
tool,
|
||||
points,
|
||||
color,
|
||||
lineWidth,
|
||||
text,
|
||||
userId: this.userId,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log('📤 Sending drawing:', tool, 'points:', points.length);
|
||||
|
||||
// Füge die Zeichnung sofort lokal hinzu (optimistische Update)
|
||||
this.drawings = [...this.drawings, drawingData];
|
||||
|
||||
// Sende an den Server (wird nur an andere Clients weitergeleitet)
|
||||
this.socket.emit('draw', drawingData);
|
||||
}
|
||||
|
||||
clearWhiteboard() {
|
||||
if (!this.socket || !this.connected) {
|
||||
console.error('❌ Not connected to whiteboard');
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('clear-whiteboard', {
|
||||
whiteboardId: this.whiteboardId,
|
||||
userId: this.userId
|
||||
});
|
||||
|
||||
// Lösche lokal
|
||||
this.drawings = [];
|
||||
}
|
||||
|
||||
sendCursorPosition(x: number, y: number) {
|
||||
if (!this.socket || !this.connected) return;
|
||||
|
||||
this.socket.emit('cursor-move', {
|
||||
whiteboardId: this.whiteboardId,
|
||||
userId: this.userId,
|
||||
x,
|
||||
y
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connected = false;
|
||||
this.activeUsers = [];
|
||||
this.remoteCursors = new Map();
|
||||
this.drawings = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const whiteboardStore = new WhiteboardStore();
|
||||
30
Smartes-Klassenzimmer-Frontend/src/routes/+layout.svelte
Normal file
30
Smartes-Klassenzimmer-Frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(async () => {
|
||||
// Authentifizierung beim Laden der App initialisieren
|
||||
await authStore.init();
|
||||
});
|
||||
|
||||
// Seiten ohne Navigation
|
||||
const noNavPages = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
let showNav = $derived(authStore.isAuthenticated && !noNavPages.includes($page.url.pathname));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<title>Smartes Klassenzimmer</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if showNav}
|
||||
<Navigation />
|
||||
{/if}
|
||||
|
||||
{@render children()}
|
||||
33
Smartes-Klassenzimmer-Frontend/src/routes/+layout.ts
Normal file
33
Smartes-Klassenzimmer-Frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
// Routen die NICHT authentifiziert sein müssen
|
||||
const PUBLIC_ROUTES = ['/', '/login', '/register'];
|
||||
|
||||
export const load: LayoutLoad = async ({ url, fetch }) => {
|
||||
// Wenn auf öffentlicher Route, einfach durchlassen
|
||||
if (PUBLIC_ROUTES.includes(url.pathname)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Versuche den User zu laden
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/auth/me', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Nicht authentifiziert
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
const user = await response.json();
|
||||
return { user };
|
||||
} catch (error) {
|
||||
// Bei Fehler zur Login-Seite umleiten
|
||||
if (error instanceof Response && error.status === 303) {
|
||||
throw error;
|
||||
}
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
};
|
||||
53
Smartes-Klassenzimmer-Frontend/src/routes/+page.svelte
Normal file
53
Smartes-Klassenzimmer-Frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import { ArrowRight, GraduationCap, Users, MessageCircle, BarChart3, Calendar } from 'lucide-svelte';
|
||||
|
||||
onMount(() => {
|
||||
// Wenn bereits eingeloggt, direkt zum Dashboard
|
||||
if (authStore.isAuthenticated) {
|
||||
goto('/dashboard');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="landing">
|
||||
<div class="spinner"></div>
|
||||
<p>Lade...</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.landing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
1323
Smartes-Klassenzimmer-Frontend/src/routes/chat/+page.svelte
Normal file
1323
Smartes-Klassenzimmer-Frontend/src/routes/chat/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
569
Smartes-Klassenzimmer-Frontend/src/routes/dashboard/+page.svelte
Normal file
569
Smartes-Klassenzimmer-Frontend/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,569 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import { classroomStore } from '$lib/stores/classroom.svelte.js';
|
||||
import { api, type TimetableEntry } from '$lib/api';
|
||||
import AttendanceList from '$lib/components/dashboard/AttendanceList.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
Mic,
|
||||
Hand,
|
||||
Dices,
|
||||
ArrowDown,
|
||||
Calendar,
|
||||
BookOpen,
|
||||
Zap,
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
MessageCircle,
|
||||
Palette,
|
||||
Users,
|
||||
GraduationCap,
|
||||
HelpCircle
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let timetableEntries = $state<TimetableEntry[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const DAYS = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'];
|
||||
|
||||
let initialized = false;
|
||||
|
||||
$effect(() => {
|
||||
if (!authStore.isLoading && !initialized) {
|
||||
initialized = true;
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
} else {
|
||||
loadTimetable();
|
||||
classroomStore.connect();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
classroomStore.disconnect();
|
||||
});
|
||||
|
||||
async function loadTimetable() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
const response = await api.getTimetable();
|
||||
|
||||
if (response.data) {
|
||||
timetableEntries = response.data.slice(0, 5); // Nur die nächsten 5 Einträge
|
||||
} else {
|
||||
error = response.error || 'Fehler beim Laden des Stundenplans';
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function getTodayEntries(): TimetableEntry[] {
|
||||
const today = new Date().getDay(); // 0 = Sonntag, 1 = Montag, ...
|
||||
const adjustedDay = today === 0 ? 7 : today; // Sonntag auf 7 setzen
|
||||
|
||||
if (adjustedDay > 5) {
|
||||
return []; // Wochenende
|
||||
}
|
||||
|
||||
return timetableEntries.filter((entry) => entry.dayOfWeek === adjustedDay);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<div class="content">
|
||||
<div class="welcome-section">
|
||||
<h2>Willkommen, {authStore.user?.username}!</h2>
|
||||
<span class="badge badge-primary">{authStore.user?.role}</span>
|
||||
</div>
|
||||
|
||||
{#if classroomStore.activeStudent}
|
||||
<div class="active-student-banner">
|
||||
<div class="active-content">
|
||||
<span class="active-icon"><Mic size={24} /></span>
|
||||
<span class="active-label">Aktuell dran:</span>
|
||||
<span class="active-name">{classroomStore.activeStudent.username}</span>
|
||||
</div>
|
||||
{#if authStore.isTeacher}
|
||||
<button class="btn btn-secondary btn-sm" onclick={() => classroomStore.resetActive()}>Beenden</button>
|
||||
{:else if authStore.user?.id === classroomStore.activeStudent.userId}
|
||||
<button class="btn btn-secondary btn-sm" onclick={() => classroomStore.endTurn()}>Fertig</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Virtuelles Klassenzimmer Card -->
|
||||
<div class="card classroom-card">
|
||||
<div class="card-header">
|
||||
<h3>Virtuelles Klassenzimmer</h3>
|
||||
<span class="icon"><Hand size={24} /></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{#if authStore.isTeacher}
|
||||
<div class="teacher-controls">
|
||||
<div class="control-row">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={() => classroomStore.pickRandom()}
|
||||
disabled={classroomStore.raisedHands.length === 0}
|
||||
>
|
||||
<Dices size={18} /> Zufall
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
onclick={() => classroomStore.lowerAllHands()}
|
||||
disabled={classroomStore.raisedHands.length === 0}
|
||||
>
|
||||
<ArrowDown size={18} /> Alle senken
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h4 class="section-title">Meldungen ({classroomStore.raisedHands.length})</h4>
|
||||
|
||||
{#if classroomStore.raisedHands.length === 0}
|
||||
<p class="text-muted">Keine Meldungen</p>
|
||||
{:else}
|
||||
<ul class="raised-list">
|
||||
{#each classroomStore.raisedHands as hand}
|
||||
<li class="raised-item">
|
||||
<div class="student-info">
|
||||
<span class="hand-icon">
|
||||
{#if hand.type === 'normal'}
|
||||
<Hand size={20} />
|
||||
{:else}
|
||||
<HelpCircle size={20} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="student-name">{hand.username}</span>
|
||||
<span class="badge badge-default">{new Date(hand.raisedAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick={() => classroomStore.pickStudent(hand.userId)}>
|
||||
Wählen
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Student View -->
|
||||
<div class="student-controls">
|
||||
{#if classroomStore.myHand}
|
||||
<div class="status-message">
|
||||
<span class="status-icon large">
|
||||
{#if classroomStore.myHand.type === 'normal'}
|
||||
<Hand size={48} />
|
||||
{:else}
|
||||
<HelpCircle size={48} />
|
||||
{/if}
|
||||
</span>
|
||||
<p>Du meldest dich ({classroomStore.myHand.type === 'normal' ? 'Normal' : 'Frage'})</p>
|
||||
<button class="btn btn-secondary w-full" onclick={() => classroomStore.lowerHand()}>
|
||||
Hand senken
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="instruction">Möchtest du etwas sagen?</p>
|
||||
<div class="button-grid">
|
||||
<button class="btn btn-primary raise-btn" onclick={() => classroomStore.raiseHand('normal')}>
|
||||
<span class="btn-icon"><Hand size={32} /></span>
|
||||
<span>Melden</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary raise-btn" onclick={() => classroomStore.raiseHand('question')}>
|
||||
<span class="btn-icon"><HelpCircle size={32} /></span>
|
||||
<span>Frage</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authStore.isTeacher}
|
||||
<AttendanceList />
|
||||
{/if}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Heutige Veranstaltungen</h3>
|
||||
<span class="icon"><Calendar size={24} /></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{#if isLoading}
|
||||
<div class="text-center p-md">
|
||||
<span class="loading-spinner"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{:else}
|
||||
{#each getTodayEntries() as entry}
|
||||
<div class="event-item">
|
||||
<div class="event-time">{entry.startTime} - {entry.endTime}</div>
|
||||
<div class="event-details">
|
||||
<div class="event-subject">{entry.subject.name}</div>
|
||||
{#if entry.teacher}
|
||||
<div class="event-teacher">{entry.teacher.username}</div>
|
||||
{/if}
|
||||
{#if entry.room}
|
||||
<div class="event-room">Raum: {entry.room.number}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted text-center">Heute keine Veranstaltungen</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Kommende Veranstaltungen</h3>
|
||||
<span class="icon"><BookOpen size={24} /></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{#if isLoading}
|
||||
<div class="text-center p-md">
|
||||
<span class="loading-spinner"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{:else}
|
||||
{#each timetableEntries as entry}
|
||||
<div class="event-item">
|
||||
<div class="event-time">
|
||||
{DAYS[entry.dayOfWeek - 1]}, {entry.startTime}
|
||||
</div>
|
||||
<div class="event-details">
|
||||
<div class="event-subject">{entry.subject.name}</div>
|
||||
{#if entry.room}
|
||||
<div class="event-room">Raum: {entry.room.number}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted text-center">Keine kommenden Veranstaltungen</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authStore.isTeacher}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Lehrer-Funktionen</h3>
|
||||
<span class="icon"><GraduationCap size={24} /></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Als Lehrer haben Sie Zugriff auf:</p>
|
||||
<ul class="feature-list">
|
||||
<li>Vollständige Nutzerverwaltung</li>
|
||||
<li>Notenverwaltung mit Gewichtung</li>
|
||||
<li>Alle Stundenpläne einsehen</li>
|
||||
<li>Benutzer erstellen und bearbeiten</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.welcome-section h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
/* Active Student Banner */
|
||||
.active-student-banner {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { transform: translateY(-20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.active-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.active-icon {
|
||||
font-size: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.active-label {
|
||||
font-weight: var(--font-medium);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.active-name {
|
||||
font-weight: var(--font-bold);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* Use global dashboard-grid from app.css/design system if available,
|
||||
but locally defined here to ensure it matches the layout needs */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.classroom-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.classroom-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding-bottom: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--neutral-200);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Classroom Styles */
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--neutral-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.raised-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.raised-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm);
|
||||
background: var(--neutral-50);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--warning-500);
|
||||
}
|
||||
|
||||
.student-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.hand-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.student-name {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
/* Student Controls */
|
||||
.student-controls {
|
||||
text-align: center;
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.instruction {
|
||||
margin-bottom: var(--space-lg);
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.raise-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.status-icon.large {
|
||||
font-size: 3rem;
|
||||
animation: wave 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(-10deg); }
|
||||
75% { transform: rotate(10deg); }
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: var(--space-sm);
|
||||
border-left: 3px solid var(--primary-500);
|
||||
background: var(--neutral-50);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.event-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--primary-500);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.event-subject {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--neutral-900);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.event-teacher,
|
||||
.event-room {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--neutral-50);
|
||||
border: 1px solid var(--neutral-200);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--neutral-900);
|
||||
font-weight: var(--font-medium);
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: white;
|
||||
border-color: var(--primary-500);
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
margin: var(--space-md) 0 0;
|
||||
padding-left: var(--space-lg);
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.welcome-section h2 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
536
Smartes-Klassenzimmer-Frontend/src/routes/feedback/+page.svelte
Normal file
536
Smartes-Klassenzimmer-Frontend/src/routes/feedback/+page.svelte
Normal file
@@ -0,0 +1,536 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import { api, type Feedback, type Grade } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let feedbackList = $state<Feedback[]>([]);
|
||||
let studentGrades = $state<Grade[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStore.isTeacher) {
|
||||
await loadFeedback();
|
||||
} else {
|
||||
await loadStudentGrades();
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
async function loadFeedback() {
|
||||
const response = await api.getFeedback();
|
||||
|
||||
if (response.data) {
|
||||
feedbackList = response.data;
|
||||
} else {
|
||||
error = response.error || 'Fehler beim Laden des Feedbacks';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStudentGrades() {
|
||||
const response = await api.getGrades();
|
||||
|
||||
if (response.data) {
|
||||
// Sortiere nach Datum (neueste zuerst)
|
||||
studentGrades = response.data.sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
} else {
|
||||
error = response.error || 'Fehler beim Laden der Noten';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function getGradeColor(grade: number): string {
|
||||
if (grade <= 2.0) return '#4caf50'; // Grün
|
||||
if (grade <= 3.0) return '#ff9800'; // Orange
|
||||
if (grade <= 4.0) return '#ff5722'; // Orange-Rot
|
||||
return '#f44336'; // Rot
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="feedback-page">
|
||||
<div class="content">
|
||||
<div class="header-section">
|
||||
<h2>{authStore.isTeacher ? 'Feedback' : 'Noten'}</h2>
|
||||
<p class="subtitle">
|
||||
{#if authStore.isTeacher}
|
||||
Übersicht über alle eingegangenen Feedbacks
|
||||
{:else}
|
||||
Ihre Noten im Überblick
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">
|
||||
{error}
|
||||
<button onclick={() => (error = null)} class="close-btn">×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Lädt...</p>
|
||||
</div>
|
||||
{:else if authStore.isTeacher}
|
||||
<!-- Lehrer Ansicht -->
|
||||
<div class="feedback-container">
|
||||
{#if feedbackList.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Noch kein Feedback vorhanden</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="feedback-grid">
|
||||
{#each feedbackList as feedback}
|
||||
<div class="feedback-card">
|
||||
<div class="feedback-header">
|
||||
<h3>{feedback.timetableEntry?.subject.name || 'Unbekanntes Fach'}</h3>
|
||||
{#if feedback.isAnonymous}
|
||||
<span class="badge anonymous">Anonym</span>
|
||||
{:else}
|
||||
<span class="badge student">Von: {feedback.student?.username || 'N/A'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rating-section">
|
||||
<div class="rating-display">
|
||||
<span class="rating-value">{feedback.overallRating}</span>
|
||||
<span class="rating-max">/10</span>
|
||||
<span class="stars">{'⭐'.repeat(feedback.overallRating)}</span>
|
||||
</div>
|
||||
<span class="rating-label">Gesamtbewertung</span>
|
||||
</div>
|
||||
|
||||
<div class="feedback-content">
|
||||
{#if feedback.whatWasGood}
|
||||
<div class="feedback-section">
|
||||
<h4>✅ Was war gut:</h4>
|
||||
<p>{feedback.whatWasGood}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if feedback.whatCanImprove}
|
||||
<div class="feedback-section">
|
||||
<h4>💡 Verbesserungsvorschläge:</h4>
|
||||
<p>{feedback.whatCanImprove}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if feedback.additionalComments}
|
||||
<div class="feedback-section">
|
||||
<h4>💬 Zusätzliche Anmerkungen:</h4>
|
||||
<p>{feedback.additionalComments}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="feedback-footer">
|
||||
<span class="date">{formatDateTime(feedback.createdAt || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Schüler Ansicht - Noten -->
|
||||
<div class="grades-container">
|
||||
{#if studentGrades.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Noch keine Noten vorhanden</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grades-list">
|
||||
{#each studentGrades as grade}
|
||||
<div class="grade-card">
|
||||
<div class="grade-header">
|
||||
<div class="grade-subject-info">
|
||||
<h3>{grade.timetableEntry?.subject.name || 'Unbekanntes Fach'}</h3>
|
||||
<p class="grade-teacher">Lehrer: {grade.timetableEntry?.teacher?.username || 'N/A'}</p>
|
||||
</div>
|
||||
<div class="grade-value" style="background-color: {getGradeColor(grade.grade)}">
|
||||
{grade.grade.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grade-details">
|
||||
<div class="grade-info-row">
|
||||
<span class="grade-label">Art:</span>
|
||||
<span class="grade-info">{grade.gradeType}</span>
|
||||
</div>
|
||||
|
||||
{#if grade.title}
|
||||
<div class="grade-info-row">
|
||||
<span class="grade-label">Titel:</span>
|
||||
<span class="grade-info">{grade.title}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if grade.description}
|
||||
<div class="grade-info-row">
|
||||
<span class="grade-label">Beschreibung:</span>
|
||||
<span class="grade-info">{grade.description}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if grade.achievedPoints && grade.maxPoints}
|
||||
<div class="grade-info-row">
|
||||
<span class="grade-label">Punkte:</span>
|
||||
<span class="grade-info">{grade.achievedPoints} / {grade.maxPoints}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if grade.weight && grade.weight !== 1}
|
||||
<div class="grade-info-row">
|
||||
<span class="grade-label">Gewichtung:</span>
|
||||
<span class="grade-info">{grade.weight}x</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grade-footer">
|
||||
<span class="grade-date">{formatDate(grade.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.feedback-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-section h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Lehrer - Feedback Container */
|
||||
.feedback-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.feedback-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.feedback-card {
|
||||
background: #f9f9f9;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border-left: 4px solid #667eea;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.feedback-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.feedback-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.feedback-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.anonymous {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.student {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rating-section {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.rating-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.rating-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.rating-max {
|
||||
font-size: 1.25rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stars {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.rating-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.feedback-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.feedback-section h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.feedback-section p {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feedback-footer {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Schüler - Noten Container */
|
||||
.grades-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.grades-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grade-card {
|
||||
background: #f9f9f9;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border-left: 4px solid #667eea;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.grade-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.grade-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grade-subject-info h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
color: #333;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.grade-teacher {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.grade-value {
|
||||
min-width: 70px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.grade-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grade-info-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.grade-label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.grade-info {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grade-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.grade-date {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header-section h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.feedback-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grade-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.grade-value {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.grade-label {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
Smartes-Klassenzimmer-Frontend/src/routes/files/+page.svelte
Normal file
161
Smartes-Klassenzimmer-Frontend/src/routes/files/+page.svelte
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type FileEntity } from '$lib/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import FileUploader from '$lib/components/FileUploader.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let files = $state<FileEntity[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let showUploader = $state(false);
|
||||
|
||||
async function loadFiles() {
|
||||
loading = true;
|
||||
const res = await api.getGeneralFiles();
|
||||
if (res.data) {
|
||||
files = res.data;
|
||||
error = null;
|
||||
} else {
|
||||
error = res.error || 'Failed to load files';
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function deleteFile(id: number) {
|
||||
if (!confirm('Are you sure you want to delete this file?')) return;
|
||||
|
||||
const res = await api.deleteFile(id);
|
||||
if (res.data?.success) {
|
||||
files = files.filter(f => f.id !== id);
|
||||
} else {
|
||||
alert(res.error || 'Failed to delete file');
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">File Repository</h1>
|
||||
<p class="text-gray-600 mt-1">General documents and resources</p>
|
||||
</div>
|
||||
|
||||
{#if authStore.user?.role === 'Lehrer'}
|
||||
<button
|
||||
class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition-colors flex items-center gap-2"
|
||||
onclick={() => showUploader = !showUploader}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
{showUploader ? 'Hide Uploader' : 'Upload File'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showUploader && authStore.user?.role === 'Lehrer'}
|
||||
<div class="mb-8" transition:fade>
|
||||
<div class="bg-white p-6 rounded-lg shadow-md border border-gray-200">
|
||||
<h2 class="text-lg font-semibold mb-4">Upload New File</h2>
|
||||
<FileUploader onUploadSuccess={() => {
|
||||
loadFiles();
|
||||
showUploader = false;
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<strong class="font-bold">Error!</strong>
|
||||
<span class="block sm:inline">{error}</span>
|
||||
</div>
|
||||
{:else if files.length === 0}
|
||||
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-16 h-16 mx-auto text-gray-400 mb-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<p class="text-lg text-gray-600">No files found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-white shadow overflow-hidden rounded-lg border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Uploaded By</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th scope="col" class="relative px-6 py-3"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each files as file}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-gray-400 mr-3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<div class="text-sm font-medium text-gray-900">{file.filename}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500">{file.mimetype}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500">{formatSize(file.size)}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">{file.uploadedBy.username}</div>
|
||||
<div class="text-xs text-gray-500">{file.uploadedBy.role}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500">{formatDate(file.createdAt)}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a
|
||||
href={api.getDownloadUrl(file.id)}
|
||||
target="_blank"
|
||||
class="text-primary hover:text-primary-dark mr-4"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
{#if authStore.user?.role === 'Lehrer' || authStore.user?.id === file.uploadedById}
|
||||
<button
|
||||
onclick={() => deleteFile(file.id)}
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let email = $state('');
|
||||
let isLoading = $state(false);
|
||||
let message = $state('');
|
||||
let error = $state('');
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
isLoading = true;
|
||||
message = '';
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const res = await api.post<{ message: string }>('/auth/forgot-password', { email });
|
||||
if (res.data) {
|
||||
message = res.data.message;
|
||||
email = ''; // Clear email field on success
|
||||
} else {
|
||||
error = res.error || 'Ein Fehler ist aufgetreten.';
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Ein Fehler ist aufgetreten.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="card forgot-card">
|
||||
<h1>Passwort vergessen</h1>
|
||||
<p class="subtitle">Gib deine E-Mail-Adresse ein, um dein Passwort zurückzusetzen.</p>
|
||||
|
||||
{#if message}
|
||||
<div class="alert alert-success">
|
||||
<div class="alert-content">
|
||||
{message}
|
||||
</div>
|
||||
<button onclick={() => message = ''} class="btn-ghost" style="padding: 0; min-height: auto; width: auto;">×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert-content">
|
||||
{error}
|
||||
</div>
|
||||
<button onclick={() => error = ''} class="btn-ghost" style="padding: 0; min-height: auto; width: auto;">×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email" class="form-label">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
bind:value={email}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" disabled={isLoading}>
|
||||
{isLoading ? 'Senden...' : 'Passwort zurücksetzen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="back-to-login text-center text-sm mt-md">
|
||||
<a href="/login">Zurück zum Login</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: var(--neutral-50);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.forgot-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-2xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--neutral-500);
|
||||
text-align: center;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
</style>
|
||||
1005
Smartes-Klassenzimmer-Frontend/src/routes/grades/+page.svelte
Normal file
1005
Smartes-Klassenzimmer-Frontend/src/routes/grades/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
111
Smartes-Klassenzimmer-Frontend/src/routes/login/+page.svelte
Normal file
111
Smartes-Klassenzimmer-Frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { LogIn, User, Lock, ArrowRight } from 'lucide-svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username || !password) {
|
||||
authStore.error = 'Bitte alle Felder ausfüllen';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
await authStore.login(username, password);
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-container">
|
||||
<div class="card login-card">
|
||||
<h1>Anmelden</h1>
|
||||
<p class="subtitle">Willkommen im Smarten Klassenzimmer</p>
|
||||
|
||||
{#if authStore.error}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert-content">
|
||||
{authStore.error}
|
||||
</div>
|
||||
<button onclick={() => authStore.clearError()} class="btn-ghost" style="padding: 0; min-height: auto; width: auto;">×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
class="form-input"
|
||||
bind:value={username}
|
||||
placeholder="Benutzername eingeben"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
bind:value={password}
|
||||
placeholder="Passwort eingeben"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<span class="loading-spinner" style="width: 1rem; height: 1rem; border-width: 2px;"></span>
|
||||
{/if}
|
||||
{isLoading ? 'Anmeldung läuft...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="forgot-password-link text-center text-sm mt-md">
|
||||
<a href="/forgot-password">Passwort vergessen?</a>
|
||||
</p>
|
||||
|
||||
<p class="register-link text-center text-sm mt-sm">
|
||||
Noch kein Konto? <a href="/register">Hier registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: var(--neutral-50);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
/* Card styles come from global css */
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-2xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--neutral-500);
|
||||
text-align: center;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
</style>
|
||||
181
Smartes-Klassenzimmer-Frontend/src/routes/register/+page.svelte
Normal file
181
Smartes-Klassenzimmer-Frontend/src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
|
||||
let email = $state('');
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let role = $state<'Student' | 'Lehrer'>('Student');
|
||||
let isLoading = $state(false);
|
||||
let validationError = $state<string | null>(null);
|
||||
|
||||
function validateForm(): boolean {
|
||||
validationError = null;
|
||||
|
||||
// Email-Validierung
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
validationError = 'Bitte geben Sie eine gültige E-Mail-Adresse ein';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Username-Validierung
|
||||
if (username.length < 3) {
|
||||
validationError = 'Benutzername muss mindestens 3 Zeichen lang sein';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Passwort-Validierung
|
||||
if (password.length < 6) {
|
||||
validationError = 'Passwort muss mindestens 6 Zeichen lang sein';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Passwort-Bestätigung
|
||||
if (password !== confirmPassword) {
|
||||
validationError = 'Passwörter stimmen nicht überein';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
authStore.clearError();
|
||||
|
||||
await authStore.register(email, username, password, role);
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-container">
|
||||
<div class="card register-card">
|
||||
<h1>Registrieren</h1>
|
||||
<p class="subtitle">Erstellen Sie Ihr Konto</p>
|
||||
|
||||
{#if authStore.error || validationError}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert-content">
|
||||
{authStore.error || validationError}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => {
|
||||
authStore.clearError();
|
||||
validationError = null;
|
||||
}}
|
||||
class="btn-ghost" style="padding: 0; min-height: auto; width: auto;">×</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email" class="form-label">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
bind:value={email}
|
||||
placeholder="beispiel@email.de"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
class="form-input"
|
||||
bind:value={username}
|
||||
placeholder="Mindestens 3 Zeichen"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
bind:value={password}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword" class="form-label">Passwort bestätigen</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="form-input"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Passwort wiederholen"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role" class="form-label">Rolle</label>
|
||||
<select id="role" class="form-select" bind:value={role} disabled={isLoading}>
|
||||
<option value="Student">Student</option>
|
||||
<option value="Lehrer">Lehrer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<span class="loading-spinner" style="width: 1rem; height: 1rem; border-width: 2px;"></span>
|
||||
{/if}
|
||||
{isLoading ? 'Registrierung läuft...' : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login-link text-center text-sm mt-md">
|
||||
Bereits ein Konto? <a href="/login">Hier anmelden</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.register-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: var(--neutral-50);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-2xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--neutral-500);
|
||||
text-align: center;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let token = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let isLoading = $state(false);
|
||||
let message = $state('');
|
||||
let error = $state('');
|
||||
|
||||
onMount(() => {
|
||||
token = $page.url.searchParams.get('token') || '';
|
||||
if (!token) {
|
||||
error = 'Kein Token zum Zurücksetzen des Passworts gefunden.';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
isLoading = true;
|
||||
message = '';
|
||||
error = '';
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
error = 'Passwörter stimmen nicht überein.';
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
error = 'Token fehlt.';
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post<{ message: string }>('/auth/reset-password', { token, newPassword });
|
||||
if (res.data) {
|
||||
message = res.data.message + ' Du wirst zum Login weitergeleitet...';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
setTimeout(() => {
|
||||
goto('/login');
|
||||
}, 3000);
|
||||
} else {
|
||||
error = res.error || 'Ein Fehler ist aufgetreten.';
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Ein Fehler ist aufgetreten.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="card reset-card">
|
||||
<h1>Passwort zurücksetzen</h1>
|
||||
<p class="subtitle">Gib dein neues Passwort ein.</p>
|
||||
|
||||
{#if message}
|
||||
<div class="alert alert-success">
|
||||
<div class="alert-content">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert-content">
|
||||
{error}
|
||||
</div>
|
||||
<button onclick={() => error = ''} class="btn-ghost" style="padding: 0; min-height: auto; width: auto;">×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if token && !error}
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="newPassword" class="form-label">Neues Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
class="form-input"
|
||||
bind:value={newPassword}
|
||||
placeholder="Neues Passwort eingeben"
|
||||
disabled={isLoading}
|
||||
required
|
||||
minlength="6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword" class="form-label">Passwort bestätigen</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="form-input"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Passwort bestätigen"
|
||||
disabled={isLoading}
|
||||
required
|
||||
minlength="6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" disabled={isLoading}>
|
||||
{isLoading ? 'Zurücksetzen...' : 'Passwort zurücksetzen'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if !token && !error}
|
||||
<p>Lade...</p>
|
||||
{/if}
|
||||
|
||||
<p class="back-to-login text-center text-sm mt-md">
|
||||
<a href="/login">Zurück zum Login</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: var(--neutral-50);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.reset-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-2xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--neutral-500);
|
||||
text-align: center;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
</style>
|
||||
3108
Smartes-Klassenzimmer-Frontend/src/routes/timetable/+page.svelte
Normal file
3108
Smartes-Klassenzimmer-Frontend/src/routes/timetable/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
632
Smartes-Klassenzimmer-Frontend/src/routes/users/+page.svelte
Normal file
632
Smartes-Klassenzimmer-Frontend/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,632 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import { api, type User, type UpdateUserDto } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
Check,
|
||||
Shield,
|
||||
Mail,
|
||||
Search
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let editingUser = $state<User | null>(null);
|
||||
let showEditModal = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let userToDelete = $state<User | null>(null);
|
||||
|
||||
// Edit Form
|
||||
let editEmail = $state('');
|
||||
let editUsername = $state('');
|
||||
let editPassword = $state('');
|
||||
let editRole = $state<'Student' | 'Lehrer'>('Student');
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.isTeacher) {
|
||||
goto('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
const response = await api.getUsers();
|
||||
|
||||
if (response.data) {
|
||||
users = response.data;
|
||||
} else {
|
||||
error = response.error || 'Fehler beim Laden der Benutzer';
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function openEditModal(user: User) {
|
||||
editingUser = user;
|
||||
editEmail = user.email;
|
||||
editUsername = user.username;
|
||||
editPassword = '';
|
||||
editRole = user.role;
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
showEditModal = false;
|
||||
editingUser = null;
|
||||
editEmail = '';
|
||||
editUsername = '';
|
||||
editPassword = '';
|
||||
editRole = 'Student';
|
||||
}
|
||||
|
||||
async function handleUpdateUser(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!editingUser) return;
|
||||
|
||||
const updateData: any = {
|
||||
email: editEmail,
|
||||
username: editUsername,
|
||||
role: editRole
|
||||
};
|
||||
|
||||
if (editPassword) {
|
||||
updateData.password = editPassword;
|
||||
}
|
||||
|
||||
const response = await api.updateUser(editingUser.id, updateData);
|
||||
|
||||
if (response.data) {
|
||||
await loadUsers();
|
||||
closeEditModal();
|
||||
} else {
|
||||
error = response.error || 'Fehler beim Aktualisieren des Benutzers';
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(user: User) {
|
||||
userToDelete = user;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
function closeDeleteConfirm() {
|
||||
showDeleteConfirm = false;
|
||||
userToDelete = null;
|
||||
}
|
||||
|
||||
async function handleDeleteUser() {
|
||||
if (!userToDelete) return;
|
||||
|
||||
const response = await api.deleteUser(userToDelete.id);
|
||||
|
||||
if (response.data) {
|
||||
await loadUsers();
|
||||
closeDeleteConfirm();
|
||||
} else {
|
||||
error = response.error || 'Fehler beim Löschen des Benutzers';
|
||||
closeDeleteConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString?: string): string {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="users-page">
|
||||
<div class="content">
|
||||
<div class="header-section">
|
||||
<h2>Nutzerverwaltung</h2>
|
||||
<p class="subtitle">Verwalten Sie alle Benutzer des Systems</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">
|
||||
{error}
|
||||
<button onclick={() => (error = null)} class="close-btn">×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="users-container">
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Lade Benutzer...</p>
|
||||
</div>
|
||||
{:else if users.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Keine Benutzer gefunden</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Benutzername</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Erstellt am</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user}
|
||||
<tr>
|
||||
<td>{user.id}</td>
|
||||
<td>
|
||||
<div class="username">{user.username}</div>
|
||||
</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
<span class="role-badge {user.role === 'Lehrer' ? 'teacher' : 'student'}">
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatDate(user.createdAt)}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button
|
||||
onclick={() => openEditModal(user)}
|
||||
class="btn-edit"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openDeleteConfirm(user)}
|
||||
class="btn-delete"
|
||||
title="Löschen"
|
||||
disabled={user.id === authStore.user?.id}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
{#if showEditModal && editingUser}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onclick={closeEditModal}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeEditModal()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Schließe das Bearbeiten-Modal"
|
||||
>
|
||||
<div
|
||||
class="modal"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-modal-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h3 id="edit-modal-title">Benutzer bearbeiten</h3>
|
||||
<button onclick={closeEditModal} class="modal-close">×</button>
|
||||
</div>
|
||||
<form onsubmit={handleUpdateUser}>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="edit-email">E-Mail</label>
|
||||
<input type="email" id="edit-email" bind:value={editEmail} required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-username">Benutzername</label>
|
||||
<input type="text" id="edit-username" bind:value={editUsername} required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-password">Neues Passwort (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
id="edit-password"
|
||||
bind:value={editPassword}
|
||||
placeholder="Leer lassen, um nicht zu ändern"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-role">Rolle</label>
|
||||
<select id="edit-role" bind:value={editRole} required>
|
||||
<option value="Student">Student</option>
|
||||
<option value="Lehrer">Lehrer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" onclick={closeEditModal} class="btn-secondary">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if showDeleteConfirm && userToDelete}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onclick={closeDeleteConfirm}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeDeleteConfirm()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Schließe das Löschbestätigung-Modal"
|
||||
>
|
||||
<div
|
||||
class="modal modal-small"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-modal-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h3 id="delete-modal-title">Benutzer löschen</h3>
|
||||
<button onclick={closeDeleteConfirm} class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Möchten Sie den Benutzer <strong>{userToDelete.username}</strong> wirklich löschen?
|
||||
</p>
|
||||
<p class="warning">Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick={closeDeleteConfirm} class="btn-secondary">Abbrechen</button>
|
||||
<button onclick={handleDeleteUser} class="btn-danger">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.users-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-section h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.users-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-badge.teacher {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.role-badge.student {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #e8f5e9;
|
||||
}
|
||||
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e1e1e1;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #ff5252;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-danger {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(60, 69, 240, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e1e1e1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d1d1;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ff5252;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #ff3838;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 82, 82, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header-section h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,880 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte.js';
|
||||
import { whiteboardStore, type DrawingData } from '$lib/stores/whiteboard.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
type Tool = 'pen' | 'rectangle' | 'circle' | 'line' | 'text' | 'eraser' | 'select';
|
||||
|
||||
interface DrawnElement {
|
||||
id?: string;
|
||||
type: 'text' | 'pen' | 'line' | 'rectangle' | 'circle';
|
||||
x: number;
|
||||
y: number;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
color: string;
|
||||
lineWidth?: number;
|
||||
points?: { x: number; y: number }[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let isDrawing = $state(false);
|
||||
let currentTool = $state<Tool>('pen');
|
||||
let currentColor = $state('#000000');
|
||||
let lineWidth = $state(2);
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let snapshot: ImageData | null = null;
|
||||
let textInput = $state('');
|
||||
let showTextInput = $state(false);
|
||||
let textInputX = $state(0);
|
||||
let textInputY = $state(0);
|
||||
let fontSize = $state(20);
|
||||
let textInputElement = $state<HTMLTextAreaElement>();
|
||||
let elements = $state<DrawnElement[]>([]);
|
||||
let selectedElement = $state<DrawnElement | null>(null);
|
||||
let isDragging = $state(false);
|
||||
let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
let currentDrawingPoints: { x: number; y: number }[] = [];
|
||||
|
||||
// Reaktiver Effekt: Zeichne Canvas neu, wenn sich Zeichnungen ändern
|
||||
$effect(() => {
|
||||
// Zugriff auf drawings triggert den Effekt
|
||||
const drawings = whiteboardStore.drawings;
|
||||
// Nur neu zeichnen, wenn gerade nicht aktiv gezeichnet wird
|
||||
if (drawings.length >= 0 && !isDrawing) {
|
||||
redrawCanvas();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Warte bis Auth-Store geladen ist
|
||||
while (authStore.isLoading) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canvas) return;
|
||||
|
||||
ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
}
|
||||
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
// WebSocket-Verbindung herstellen
|
||||
const whiteboardId = 'classroom-1'; // Oder dynamisch aus URL
|
||||
const userId = authStore.user?.username || 'anonymous';
|
||||
whiteboardStore.connect(whiteboardId, userId);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
whiteboardStore.disconnect();
|
||||
});
|
||||
|
||||
function resizeCanvas() {
|
||||
if (!canvas) return;
|
||||
const container = canvas.parentElement;
|
||||
if (container) {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
redrawCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
function redrawCanvas() {
|
||||
if (!ctx || !canvas) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Get IDs of local elements to skip
|
||||
const localIds = new Set(elements.map(e => e.id).filter(Boolean));
|
||||
|
||||
// Zeichne alle Remote-Zeichnungen vom Server
|
||||
whiteboardStore.drawings.forEach((drawing) => {
|
||||
// Skip if this drawing is locally managed (interactive)
|
||||
if (drawing.id && localIds.has(drawing.id)) return;
|
||||
drawRemoteDrawing(drawing);
|
||||
});
|
||||
|
||||
// Zeichne alle lokalen gespeicherten Elemente
|
||||
elements.forEach((element) => {
|
||||
if (element.type === 'text' && element.text) {
|
||||
ctx!.font = `${element.fontSize}px sans-serif`;
|
||||
ctx!.fillStyle = element.color;
|
||||
const lines = element.text.split('\n');
|
||||
lines.forEach((line, index) => {
|
||||
ctx!.fillText(line, element.x, element.y + index * (element.fontSize || 20) * 1.2);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawRemoteDrawing(drawing: DrawingData) {
|
||||
if (!ctx || drawing.points.length === 0) return;
|
||||
|
||||
console.log('🖌️ Drawing remote:', drawing.tool, 'points:', drawing.points.length);
|
||||
|
||||
if (drawing.tool === 'pen') {
|
||||
ctx.strokeStyle = drawing.color || '#000000';
|
||||
ctx.lineWidth = drawing.lineWidth || 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(drawing.points[0].x, drawing.points[0].y);
|
||||
drawing.points.forEach(point => {
|
||||
ctx!.lineTo(point.x, point.y);
|
||||
});
|
||||
ctx.stroke();
|
||||
} else if (drawing.tool === 'eraser') {
|
||||
// Richtiger Radierer: Lösche tatsächlich Pixel
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.lineWidth = (drawing.lineWidth || 2) * 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(drawing.points[0].x, drawing.points[0].y);
|
||||
drawing.points.forEach(point => {
|
||||
ctx!.lineTo(point.x, point.y);
|
||||
});
|
||||
ctx.stroke();
|
||||
// Setze zurück auf normal
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
} else if (drawing.tool === 'rectangle' && drawing.points.length >= 2) {
|
||||
ctx.strokeStyle = drawing.color || '#000000';
|
||||
ctx.lineWidth = drawing.lineWidth || 2;
|
||||
ctx.fillStyle = (drawing.color || '#000000') + '30';
|
||||
const start = drawing.points[0];
|
||||
const end = drawing.points[drawing.points.length - 1];
|
||||
ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y);
|
||||
ctx.fillRect(start.x, start.y, end.x - start.x, end.y - start.y);
|
||||
} else if (drawing.tool === 'circle' && drawing.points.length >= 2) {
|
||||
ctx.strokeStyle = drawing.color || '#000000';
|
||||
ctx.lineWidth = drawing.lineWidth || 2;
|
||||
ctx.fillStyle = (drawing.color || '#000000') + '30';
|
||||
const start = drawing.points[0];
|
||||
const end = drawing.points[drawing.points.length - 1];
|
||||
const radius = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
|
||||
ctx.beginPath();
|
||||
ctx.arc(start.x, start.y, radius, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
ctx.fill();
|
||||
} else if (drawing.tool === 'line' && drawing.points.length >= 2) {
|
||||
ctx.strokeStyle = drawing.color || '#000000';
|
||||
ctx.lineWidth = drawing.lineWidth || 2;
|
||||
const start = drawing.points[0];
|
||||
const end = drawing.points[drawing.points.length - 1];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x, start.y);
|
||||
ctx.lineTo(end.x, end.y);
|
||||
ctx.stroke();
|
||||
} else if (drawing.tool === 'text' && drawing.text) {
|
||||
const start = drawing.points[0];
|
||||
ctx.font = `20px sans-serif`;
|
||||
ctx.fillStyle = drawing.color || '#000000';
|
||||
ctx.fillText(drawing.text, start.x, start.y);
|
||||
}
|
||||
}
|
||||
|
||||
function startDrawing(e: MouseEvent) {
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
startX = e.clientX - rect.left;
|
||||
startY = e.clientY - rect.top;
|
||||
|
||||
if (currentTool === 'select') {
|
||||
// Prüfe, ob ein Text-Element angeklickt wurde
|
||||
const clickedElement = findElementAtPoint(startX, startY);
|
||||
if (clickedElement) {
|
||||
selectedElement = clickedElement;
|
||||
isDragging = true;
|
||||
dragOffsetX = startX - clickedElement.x;
|
||||
dragOffsetY = startY - clickedElement.y;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTool === 'text') {
|
||||
// Text-Werkzeug: Zeige Eingabefeld an der Klickposition
|
||||
textInputX = e.clientX;
|
||||
textInputY = e.clientY;
|
||||
textInput = '';
|
||||
showTextInput = true;
|
||||
|
||||
// Focus auf das Textfeld nach einem kurzen Delay
|
||||
setTimeout(() => {
|
||||
if (textInputElement) {
|
||||
textInputElement.focus();
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawing = true;
|
||||
currentDrawingPoints = [{ x: startX, y: startY }];
|
||||
|
||||
if (currentTool === 'pen' || currentTool === 'eraser') {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, startY);
|
||||
} else {
|
||||
snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
function findElementAtPoint(x: number, y: number): DrawnElement | null {
|
||||
// Gehe durch alle Elemente rückwärts (zuletzt gezeichnete zuerst)
|
||||
for (let i = elements.length - 1; i >= 0; i--) {
|
||||
const element = elements[i];
|
||||
if (element.type === 'text' && element.text) {
|
||||
const fontSize = element.fontSize || 20;
|
||||
const lines = element.text.split('\n');
|
||||
const textHeight = lines.length * fontSize * 1.2;
|
||||
// Schätze die Textbreite (sehr grob)
|
||||
const textWidth = Math.max(...lines.map((l) => l.length)) * fontSize * 0.6;
|
||||
|
||||
if (
|
||||
x >= element.x &&
|
||||
x <= element.x + textWidth &&
|
||||
y >= element.y - fontSize &&
|
||||
y <= element.y + textHeight
|
||||
) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function draw(e: MouseEvent) {
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const currentX = e.clientX - rect.left;
|
||||
const currentY = e.clientY - rect.top;
|
||||
|
||||
// Sende Cursor-Position an andere Benutzer
|
||||
if (whiteboardStore.connected) {
|
||||
whiteboardStore.sendCursorPosition(currentX, currentY);
|
||||
}
|
||||
|
||||
// Wenn wir ein Element verschieben
|
||||
if (isDragging && selectedElement) {
|
||||
selectedElement.x = currentX - dragOffsetX;
|
||||
selectedElement.y = currentY - dragOffsetY;
|
||||
redrawCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDrawing || !ctx) return;
|
||||
|
||||
// Füge Punkt zur aktuellen Zeichnung hinzu
|
||||
currentDrawingPoints.push({ x: currentX, y: currentY });
|
||||
|
||||
if (currentTool === 'pen') {
|
||||
// Für Stift: Zeichne nur das neue Segment (nicht das ganze Canvas neu)
|
||||
ctx.strokeStyle = currentColor;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.lineTo(currentX, currentY);
|
||||
ctx.stroke();
|
||||
} else if (currentTool === 'eraser') {
|
||||
// Für Radierer: Lösche tatsächlich (nicht nur weiße Farbe)
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.lineWidth = lineWidth * 3;
|
||||
ctx.lineTo(currentX, currentY);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Für Formen: Zeichne Canvas neu mit allen Remote-Zeichnungen + aktuelle Form
|
||||
redrawCanvas();
|
||||
|
||||
// Zeichne die aktuelle Form darüber
|
||||
ctx.strokeStyle = currentColor;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.fillStyle = currentColor + '30';
|
||||
|
||||
if (currentTool === 'rectangle') {
|
||||
ctx.strokeRect(startX, startY, currentX - startX, currentY - startY);
|
||||
ctx.fillRect(startX, startY, currentX - startX, currentY - startY);
|
||||
} else if (currentTool === 'circle') {
|
||||
const radius = Math.sqrt(Math.pow(currentX - startX, 2) + Math.pow(currentY - startY, 2));
|
||||
ctx.beginPath();
|
||||
ctx.arc(startX, startY, radius, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
ctx.fill();
|
||||
} else if (currentTool === 'line') {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, startY);
|
||||
ctx.lineTo(currentX, currentY);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopDrawing() {
|
||||
if (isDrawing && currentDrawingPoints.length > 0 && currentTool !== 'select') {
|
||||
// Sende die abgeschlossene Zeichnung an den Server
|
||||
whiteboardStore.sendDrawing(
|
||||
currentTool as any,
|
||||
currentDrawingPoints,
|
||||
currentColor,
|
||||
lineWidth
|
||||
);
|
||||
}
|
||||
|
||||
isDrawing = false;
|
||||
isDragging = false;
|
||||
snapshot = null;
|
||||
currentDrawingPoints = [];
|
||||
|
||||
// Zeichne Canvas neu, damit alle Zeichnungen (inkl. eigene) vom Server korrekt angezeigt werden
|
||||
setTimeout(() => {
|
||||
redrawCanvas();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
if (!ctx || !canvas) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
elements = [];
|
||||
whiteboardStore.clearWhiteboard();
|
||||
}
|
||||
|
||||
function downloadCanvas() {
|
||||
if (!canvas) return;
|
||||
const link = document.createElement('a');
|
||||
link.download = `whiteboard-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
}
|
||||
|
||||
function addText() {
|
||||
if (!ctx || !canvas || !textInput.trim()) {
|
||||
showTextInput = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasX = textInputX - rect.left;
|
||||
const canvasY = textInputY - rect.top;
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
|
||||
// Füge Text als Element hinzu
|
||||
const textElement: DrawnElement = {
|
||||
id,
|
||||
type: 'text',
|
||||
x: canvasX,
|
||||
y: canvasY,
|
||||
text: textInput,
|
||||
fontSize: fontSize,
|
||||
color: currentColor
|
||||
};
|
||||
|
||||
elements.push(textElement);
|
||||
|
||||
// Zeichne den Text auf dem Canvas
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
ctx.fillStyle = currentColor;
|
||||
|
||||
// Multi-line Text Support
|
||||
const lines = textInput.split('\n');
|
||||
lines.forEach((line, index) => {
|
||||
ctx!.fillText(line, canvasX, canvasY + index * fontSize * 1.2);
|
||||
});
|
||||
|
||||
// Sende Text an den Server
|
||||
whiteboardStore.sendDrawing(
|
||||
'text',
|
||||
[{ x: canvasX, y: canvasY }],
|
||||
currentColor,
|
||||
lineWidth,
|
||||
textInput,
|
||||
id
|
||||
);
|
||||
|
||||
// Reset
|
||||
textInput = '';
|
||||
showTextInput = false;
|
||||
}
|
||||
|
||||
function handleTextKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
textInput = '';
|
||||
showTextInput = false;
|
||||
}
|
||||
// Bei Enter ohne Shift: Text hinzufügen
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addText();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="whiteboard-page">
|
||||
<div class="whiteboard-container">
|
||||
<!-- Floating Toolbar - Excalidraw Style -->
|
||||
<div class="floating-toolbar">
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:active={currentTool === 'select'}
|
||||
onclick={() => (currentTool = 'select')}
|
||||
title="Auswählen & Verschieben"
|
||||
>
|
||||
👆
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:active={currentTool === 'pen'}
|
||||
onclick={() => (currentTool = 'pen')}
|
||||
title="Stift"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:active={currentTool === 'line'}
|
||||
onclick={() => (currentTool = 'line')}
|
||||
title="Linie"
|
||||
>
|
||||
📏
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:active={currentTool === 'rectangle'}
|
||||
onclick={() => (currentTool = 'rectangle')}
|
||||
title="Rechteck"
|
||||
>
|
||||
⬜
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:active={currentTool === 'circle'}
|
||||
onclick={() => (currentTool = 'circle')}
|
||||
title="Kreis"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="8" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="12" cy="12" r="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:active={currentTool === 'text'}
|
||||
onclick={() => (currentTool = 'text')}
|
||||
title="Text"
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:active={currentTool === 'eraser'}
|
||||
onclick={() => (currentTool = 'eraser')}
|
||||
title="Radiergummi"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.5L13 21"/>
|
||||
<path d="M22 21H7"/>
|
||||
<path d="m5 11 9 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="color-picker-wrapper">
|
||||
<button class="color-indicator" style="background: {currentColor};" title="Farbe wählen"></button>
|
||||
<input type="color" bind:value={currentColor} class="color-input" />
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button class="tool-btn" onclick={clearCanvas} title="Alles löschen">🗑️</button>
|
||||
<button class="tool-btn" onclick={downloadCanvas} title="Herunterladen">💾</button>
|
||||
</div>
|
||||
|
||||
<!-- Property Panel - Excalidraw Style -->
|
||||
<div class="property-panel">
|
||||
<div class="property-item">
|
||||
<span class="property-label">Stärke:</span>
|
||||
<input type="range" bind:value={lineWidth} min="1" max="20" class="slider" />
|
||||
<span class="property-value">{lineWidth}</span>
|
||||
<div class="size-preview">
|
||||
<div
|
||||
class="size-indicator"
|
||||
style="width: {currentTool === 'eraser' ? lineWidth * 3 : lineWidth}px; height: {currentTool === 'eraser' ? lineWidth * 3 : lineWidth}px; background: {currentTool === 'eraser' ? '#e0e0e0' : currentColor};"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="property-item">
|
||||
<span class="property-label">Schrift:</span>
|
||||
<input type="range" bind:value={fontSize} min="12" max="72" class="slider" />
|
||||
<span class="property-value">{fontSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
onmousedown={startDrawing}
|
||||
onmousemove={draw}
|
||||
onmouseup={stopDrawing}
|
||||
onmouseleave={stopDrawing}
|
||||
class="drawing-canvas"
|
||||
class:select-mode={currentTool === 'select'}
|
||||
></canvas>
|
||||
|
||||
<!-- Remote Cursors -->
|
||||
{#each Array.from(whiteboardStore.remoteCursors.values()) as cursor}
|
||||
<div
|
||||
class="remote-cursor"
|
||||
style="left: {cursor.x}px; top: {cursor.y}px;"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M5.65376 12.3673L10.3941 16.2024L12 17.8135L15.4885 10.6213L15.8524 9.73688L5.65376 12.3673Z"
|
||||
fill="#667eea"
|
||||
/>
|
||||
</svg>
|
||||
<span class="cursor-label">{cursor.userId}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Active Users Panel -->
|
||||
{#if whiteboardStore.connected}
|
||||
<div class="active-users-panel">
|
||||
<div class="panel-header">
|
||||
<span class="status-indicator"></span>
|
||||
Online: {whiteboardStore.activeUsers.length}
|
||||
</div>
|
||||
<div class="users-list">
|
||||
{#each whiteboardStore.activeUsers as user}
|
||||
<div class="user-badge">{user}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Text Input Overlay - Excalidraw Style -->
|
||||
{#if showTextInput}
|
||||
<textarea
|
||||
bind:this={textInputElement}
|
||||
bind:value={textInput}
|
||||
onkeydown={handleTextKeyDown}
|
||||
onblur={addText}
|
||||
class="text-input-direct"
|
||||
style="left: {textInputX}px; top: {textInputY}px; font-size: {fontSize}px; color: {currentColor};"
|
||||
placeholder="Text eingeben..."
|
||||
rows="1"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.whiteboard-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.whiteboard-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Floating Toolbar - Excalidraw Style */
|
||||
.floating-toolbar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: #e0e0e0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.color-indicator {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.color-indicator:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Property Panel - Excalidraw Style */
|
||||
.property-panel {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.property-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #ddd;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.property-value {
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.size-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.size-indicator {
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Canvas */
|
||||
.drawing-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
display: block;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.drawing-canvas.select-mode {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Text Input - Excalidraw Style */
|
||||
.text-input-direct {
|
||||
position: fixed;
|
||||
background: transparent;
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-family: sans-serif;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
z-index: 200;
|
||||
min-width: 100px;
|
||||
line-height: 1.2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.text-input-direct::placeholder {
|
||||
color: #999;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Remote Cursors */
|
||||
.remote-cursor {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 150;
|
||||
transition: left 0.1s ease, top 0.1s ease;
|
||||
}
|
||||
|
||||
.cursor-label {
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
top: 0;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Active Users Panel */
|
||||
.active-users-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
padding: 12px 16px;
|
||||
z-index: 100;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #4caf50;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.users-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.floating-toolbar {
|
||||
flex-wrap: wrap;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.property-panel {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
3
Smartes-Klassenzimmer-Frontend/static/robots.txt
Normal file
3
Smartes-Klassenzimmer-Frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
12
Smartes-Klassenzimmer-Frontend/svelte.config.js
Normal file
12
Smartes-Klassenzimmer-Frontend/svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
kit: { adapter: adapter() }
|
||||
};
|
||||
|
||||
export default config;
|
||||
19
Smartes-Klassenzimmer-Frontend/tsconfig.json
Normal file
19
Smartes-Klassenzimmer-Frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
6
Smartes-Klassenzimmer-Frontend/vite.config.ts
Normal file
6
Smartes-Klassenzimmer-Frontend/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user