diff --git a/index.js b/index.js
index db7391e..d89440b 100644
--- a/index.js
+++ b/index.js
@@ -25,6 +25,7 @@ app.use(helmet({
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'https://api.open-meteo.com'],
+ workerSrc: ["'self'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
},
@@ -32,6 +33,10 @@ app.use(helmet({
}));
app.use(express.json());
app.use(cookieParser());
+app.get('/sw.js', (req, res, next) => {
+ res.setHeader('Cache-Control', 'no-cache');
+ next();
+});
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api', routes);
app.use('/api/files', filesRouter);
diff --git a/public/admin.html b/public/admin.html
index 0620ac8..7042fa6 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -3,6 +3,13 @@
+
+
+
+
+
+
+
Klassenportal · Admin
@@ -122,6 +129,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
.lucide { display: inline-block; vertical-align: -0.125em; flex-shrink: 0; width: 1em; height: 1em; stroke-width: 2; }
.tab .lucide { width: 14px; height: 14px; }
+
diff --git a/public/app.html b/public/app.html
index 771f009..fc55e9e 100644
--- a/public/app.html
+++ b/public/app.html
@@ -3,6 +3,13 @@
+
+
+
+
+
+
+
Klassenportal · Dashboard
@@ -1010,6 +1017,7 @@ footer {
.btn-sm .lucide { width: 13px; height: 13px; }
.sidebar-close .lucide { width: 16px; height: 16px; }
+
diff --git a/public/datenschutz.html b/public/datenschutz.html
index 2b45828..651ca22 100644
--- a/public/datenschutz.html
+++ b/public/datenschutz.html
@@ -3,6 +3,13 @@
+
+
+
+
+
+
+
Datenschutzerklärung · Klassenportal
@@ -30,6 +37,7 @@
th { text-align: left; padding: 8px 12px; background: #f8fafc; font-weight: 600; color: #374151; border-bottom: 1px solid #e2e8f0; }
td { padding: 8px 12px; color: #334155; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
+
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..a7e97b8
Binary files /dev/null and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..f159411
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..68a969a
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/icon-maskable-192.png b/public/icons/icon-maskable-192.png
new file mode 100644
index 0000000..8046e67
Binary files /dev/null and b/public/icons/icon-maskable-192.png differ
diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png
new file mode 100644
index 0000000..5a5adf2
Binary files /dev/null and b/public/icons/icon-maskable-512.png differ
diff --git a/public/index.html b/public/index.html
index b8fb9f6..17a16d8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,6 +3,13 @@
+
+
+
+
+
+
+
Klassenportal · Das Klassen-Cockpit
@@ -604,6 +611,7 @@ footer {
.brand-sub { display: none; }
}
+
diff --git a/public/login.html b/public/login.html
index 23289fd..a22cfa3 100644
--- a/public/login.html
+++ b/public/login.html
@@ -3,6 +3,13 @@
+
+
+
+
+
+
+
Klassenportal · Anmelden
@@ -186,6 +193,7 @@ footer a:hover { color: #2563eb; }
.lucide { display: inline-block; vertical-align: -0.125em; flex-shrink: 0; width: 1em; height: 1em; stroke-width: 2; }
.notice .lucide { width: 14px; height: 14px; margin-right: 3px; }
+
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..c29e402
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,38 @@
+{
+ "name": "Klassenportal",
+ "short_name": "Klassenportal",
+ "description": "Privates Klassenportal für Schüler und Lehrer",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "theme_color": "#2563eb",
+ "background_color": "#f4f6f9",
+ "lang": "de",
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-maskable-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/icons/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/public/reset-password.html b/public/reset-password.html
index d5d4fd8..055d92d 100644
--- a/public/reset-password.html
+++ b/public/reset-password.html
@@ -3,6 +3,13 @@
+
+
+
+
+
+
+
Klassenportal · Passwort zurücksetzen
@@ -64,6 +71,7 @@ h1 { font-size: 18px; font-weight: 700; margin-bottom: 6px; letter-spacing: -.2p
footer { text-align: center; padding: 18px; font-size: 12px; color: #9ca3af; }
footer a { color: #6b7280; text-decoration: none; }
+
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..eb9d653
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,70 @@
+const CACHE = 'klassenportal-v1';
+const PRECACHE = [
+ '/',
+ '/login.html',
+ '/app.html',
+ '/manifest.json',
+ '/favicon.svg',
+ '/icons/icon-192.png',
+ '/icons/icon-512.png',
+ '/icons/apple-touch-icon.png',
+];
+
+self.addEventListener('install', e => {
+ e.waitUntil(
+ caches.open(CACHE)
+ .then(c => c.addAll(PRECACHE))
+ .then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener('activate', e => {
+ e.waitUntil(
+ caches.keys()
+ .then(keys => Promise.all(
+ keys.filter(k => k !== CACHE).map(k => caches.delete(k))
+ ))
+ .then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', e => {
+ const url = new URL(e.request.url);
+
+ // Only handle same-origin GET requests
+ if (url.origin !== self.location.origin) return;
+ if (e.request.method !== 'GET') return;
+
+ // API calls: always network, never cache
+ if (url.pathname.startsWith('/api/')) return;
+
+ // Page navigations: network-first, fallback to cache
+ if (e.request.mode === 'navigate') {
+ e.respondWith(
+ fetch(e.request)
+ .then(r => {
+ const clone = r.clone();
+ caches.open(CACHE).then(c => c.put(e.request, clone));
+ return r;
+ })
+ .catch(() =>
+ caches.match(e.request).then(r => r || caches.match('/login.html'))
+ )
+ );
+ return;
+ }
+
+ // Static assets: cache-first, populate on miss
+ e.respondWith(
+ caches.match(e.request).then(cached => {
+ if (cached) return cached;
+ return fetch(e.request).then(r => {
+ if (r.ok) {
+ const clone = r.clone();
+ caches.open(CACHE).then(c => c.put(e.request, clone));
+ }
+ return r;
+ });
+ })
+ );
+});