Sicherheits-Fixes, toter Code entfernt, Optimierungen

Sicherheit:
- CSRF-Schutz auf allen API-Routes (admin, proposals, files, stats, export)
- authenticateToken vor csrfProtection bei admin/proposals (CSRF-Bypass behoben)
- CORS eingeschränkt auf taskmate.aegis-sight.de
- JWT_SECRET und SESSION_TIMEOUT nicht mehr exportiert
- Tote Auth-Funktionen entfernt (generateCsrfToken, generateToken Legacy)

Toter Code entfernt:
- 6 ungenutzte JS-Dateien (tour, dashboard, 4x contacts-*)
- 2 ungenutzte CSS-Dateien (dashboard, contacts-extended)
- backend/migrations/ Verzeichnis, knowledge.js.backup
- Doppelter bcrypt require in database.js

Optimierung:
- Request-Logging filtert statische Assets (nur /api/ wird geloggt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Server Deploy
2026-03-19 19:21:40 +01:00
Ursprung 4bd57d653f
Commit 5c87254e97
14 geänderte Dateien mit 1684 neuen und 997 gelöschten Zeilen

Datei anzeigen

@@ -1,6 +1,28 @@
TASKMATE - CHANGELOG
====================
================================================================================
19.03.2026 - v391 - Sicherheitshärtung und toter Code bereinigt
SICHERHEIT:
- CSRF-Bypass bei /api/admin und /api/proposals behoben (authenticateToken vor csrfProtection)
- CSRF-Schutz auf /api/files, /api/stats, /api/export hinzugefuegt
- CORS eingeschraenkt auf taskmate.aegis-sight.de statt wildcard
- JWT_SECRET nicht mehr aus auth.js exportiert
- Toter Code entfernt: generateCsrfToken(), SESSION_TIMEOUT, generateToken() Legacy-Wrapper
- generateToken() durch direkten generateAccessToken() Aufruf ersetzt
TOTER CODE ENTFERNT:
- 7 ungenutzte Frontend-JS-Dateien geloescht (tour, dashboard, contacts-*)
- 2 ungenutzte CSS-Dateien geloescht (contacts-extended, dashboard)
- knowledge.js.backup geloescht
- backend/migrations/ Verzeichnis geloescht
- Doppelter bcrypt require in database.js entfernt
- Referenzen in index.html und sw.js bereinigt
OPTIMIERUNG:
- Request-Logging filtert statische Assets raus (nur /api/ Requests)
================================================================================
19.03.2026 - v390 - Filter-Buttons in die View-Tabs-Zeile verschoben
================================================================================

Datei anzeigen

@@ -763,8 +763,7 @@ async function createDefaultUsers() {
const adminExists = db.prepare('SELECT id, password_hash FROM users WHERE email = ? AND role = ?').get('admin@taskmate.local', 'admin');
if (adminExists) {
const correctAdminPassword = process.env.ADMIN_PASSWORD || 'admin123';
const bcrypt = require('bcryptjs');
// Prüfen ob das Passwort bereits korrekt ist
const isCorrect = await bcrypt.compare(correctAdminPassword, adminExists.password_hash);
if (!isCorrect) {

Datei anzeigen

@@ -15,8 +15,6 @@ if (!JWT_SECRET || JWT_SECRET.length < 32) {
}
const ACCESS_TOKEN_EXPIRY = 60; // Minuten (kürzer für mehr Sicherheit)
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
/**
* JWT Access-Token generieren (kurze Lebensdauer)
*/
@@ -63,13 +61,6 @@ function generateRefreshToken(userId, ipAddress, userAgent) {
return token;
}
/**
* Legacy generateToken für Rückwärtskompatibilität
*/
function generateToken(user) {
return generateAccessToken(user);
}
/**
* JWT-Token verifizieren
*/
@@ -120,7 +111,7 @@ function authenticateToken(req, res, next) {
const refreshThreshold = 5 * 60 * 1000; // 5 Minuten vor Ablauf
if (tokenExp - now < refreshThreshold) {
const newToken = generateToken({
const newToken = generateAccessToken({
id: user.id,
username: user.username,
display_name: user.displayName,
@@ -203,14 +194,6 @@ function authenticateSocket(socket, next) {
next();
}
/**
* CSRF-Token generieren (für Forms)
*/
function generateCsrfToken() {
const { randomBytes } = require('crypto');
return randomBytes(32).toString('hex');
}
/**
* Refresh-Token validieren und neuen Access-Token generieren
*/
@@ -272,7 +255,6 @@ function cleanupExpiredTokens() {
setInterval(cleanupExpiredTokens, 6 * 60 * 60 * 1000);
module.exports = {
generateToken,
generateAccessToken,
generateRefreshToken,
refreshAccessToken,
@@ -280,10 +262,7 @@ module.exports = {
verifyToken,
authenticateToken,
authenticateSocket,
generateCsrfToken,
requireAdmin,
requireRegularUser,
checkPermission,
JWT_SECRET,
SESSION_TIMEOUT
checkPermission
};

Datei anzeigen

@@ -8,7 +8,7 @@ const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { getDb } = require('../database');
const { generateToken, generateRefreshToken, refreshAccessToken, revokeAllRefreshTokens, authenticateToken } = require('../middleware/auth');
const { generateAccessToken, generateRefreshToken, refreshAccessToken, revokeAllRefreshTokens, authenticateToken } = require('../middleware/auth');
const { getTokenForUser } = require('../middleware/csrf');
const { validatePassword } = require('../middleware/validation');
const logger = require('../utils/logger');
@@ -112,7 +112,7 @@ router.post('/login', async (req, res) => {
logAttempt(user.id, true);
// JWT Access-Token generieren (kurze Lebensdauer)
const accessToken = generateToken(user);
const accessToken = generateAccessToken(user);
// Refresh-Token generieren (lange Lebensdauer)
const refreshToken = generateRefreshToken(user.id, ip, userAgent);
@@ -267,7 +267,7 @@ function legacyRefresh(req, res) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
}
const newToken = generateToken(dbUser);
const newToken = generateAccessToken(dbUser);
const csrfToken = getTokenForUser(dbUser.id);
res.json({ token: newToken, csrfToken });

Datei anzeigen

@@ -0,0 +1,497 @@
/**
* TASKMATE - Erweiterte Kontakte API
* ===================================
* REST API für Kontakte mit Institutionen
*/
const router = require('express').Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
// ===================================
// INSTITUTIONEN
// ===================================
// Alle Institutionen abrufen
router.get('/institutions', (req, res) => {
try {
const db = getDb();
const { project_id, search, type } = req.query;
let query = 'SELECT * FROM institutions WHERE 1=1';
const params = [];
if (project_id) {
query += ' AND project_id = ?';
params.push(project_id);
}
if (search) {
query += ' AND (name LIKE ? OR description LIKE ? OR tags LIKE ?)';
const searchPattern = `%${search}%`;
params.push(searchPattern, searchPattern, searchPattern);
}
if (type) {
query += ' AND type = ?';
params.push(type);
}
query += ' ORDER BY name ASC';
const institutions = db.prepare(query).all(...params);
res.json({ success: true, data: institutions });
} catch (error) {
logger.error('Fehler beim Abrufen der Institutionen:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Institution erstellen
router.post('/institutions', (req, res) => {
// Basis-Validierung
if (!req.body.name || !req.body.project_id) {
return res.status(400).json({ success: false, error: 'Name und Projekt-ID sind erforderlich' });
}
try {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO institutions (
name, type, industry, website, logo_url, description,
main_address, main_postal_code,
main_city, main_state, main_country, trade_register,
notes, tags, created_by, project_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
req.body.name,
req.body.type || null,
req.body.industry || null,
req.body.website || null,
req.body.logo_url || null,
req.body.description || null,
req.body.main_address || null,
req.body.main_postal_code || null,
req.body.main_city || null,
req.body.main_state || null,
req.body.main_country || null,
req.body.trade_register || null,
req.body.notes || null,
req.body.tags || null,
req.userId,
req.body.project_id
);
const institution = db.prepare('SELECT * FROM institutions WHERE id = ?').get(result.lastInsertRowid);
// Socket.io Event
req.app.get('io').to(`project-${req.body.project_id}`).emit('institution:created', institution);
res.status(201).json({ success: true, data: institution });
} catch (error) {
logger.error('Fehler beim Erstellen der Institution:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Institution aktualisieren
router.put('/institutions/:id', (req, res) => {
try {
const db = getDb();
const stmt = db.prepare(`
UPDATE institutions SET
name = ?, type = ?, industry = ?, website = ?, logo_url = ?,
description = ?,
main_address = ?, main_postal_code = ?, main_city = ?,
main_state = ?, main_country = ?,
trade_register = ?, notes = ?, tags = ?
WHERE id = ?
`);
stmt.run(
req.body.name,
req.body.type,
req.body.industry,
req.body.website,
req.body.logo_url,
req.body.description,
req.body.main_address,
req.body.main_postal_code,
req.body.main_city,
req.body.main_state,
req.body.main_country,
req.body.trade_register,
req.body.notes,
req.body.tags,
req.params.id
);
const institution = db.prepare('SELECT * FROM institutions WHERE id = ?').get(req.params.id);
// Socket.io Event
req.app.get('io').to(`project-${institution.project_id}`).emit('institution:updated', institution);
res.json({ success: true, data: institution });
} catch (error) {
logger.error('Fehler beim Aktualisieren der Institution:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ===================================
// KONTAKTE (PERSONEN)
// ===================================
// Alle Kontakte abrufen
router.get('/contacts', (req, res) => {
try {
const db = getDb();
const { project_id, institution_id, search, is_active } = req.query;
let query = `
SELECT c.*, i.name as institution_name
FROM contacts_extended c
LEFT JOIN institutions i ON c.institution_id = i.id
WHERE 1=1
`;
const params = [];
if (project_id) {
query += ' AND c.project_id = ?';
params.push(project_id);
}
if (institution_id) {
query += ' AND c.institution_id = ?';
params.push(institution_id);
}
if (search) {
query += ' AND (c.first_name LIKE ? OR c.last_name LIKE ? OR c.display_name LIKE ? OR c.tags LIKE ?)';
const searchPattern = `%${search}%`;
params.push(searchPattern, searchPattern, searchPattern, searchPattern);
}
if (is_active !== undefined) {
query += ' AND c.is_active = ?';
params.push(is_active === 'true' ? 1 : 0);
}
query += ' ORDER BY c.last_name, c.first_name ASC';
const contacts = db.prepare(query).all(...params);
res.json({ success: true, data: contacts });
} catch (error) {
logger.error('Fehler beim Abrufen der Kontakte:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Kontakt mit allen Details abrufen
router.get('/contacts/:id/full', (req, res) => {
try {
const db = getDb();
// Basis-Kontaktdaten
const contact = db.prepare(`
SELECT c.*, i.name as institution_name
FROM contacts_extended c
LEFT JOIN institutions i ON c.institution_id = i.id
WHERE c.id = ?
`).get(req.params.id);
if (!contact) {
return res.status(404).json({ success: false, error: 'Kontakt nicht gefunden' });
}
// Kontaktdetails
contact.details = db.prepare(`
SELECT * FROM contact_details
WHERE contact_id = ?
ORDER BY is_primary DESC, type ASC
`).all(req.params.id);
// Weitere Institutionen
contact.institutions = db.prepare(`
SELECT i.*, r.position, r.department, r.start_date, r.end_date, r.is_primary
FROM person_institution_relations r
JOIN institutions i ON r.institution_id = i.id
WHERE r.contact_id = ?
ORDER BY r.is_primary DESC, r.start_date DESC
`).all(req.params.id);
// Kategorien
contact.categories = db.prepare(`
SELECT c.*
FROM contact_categories c
JOIN contact_category_assignments a ON c.id = a.category_id
WHERE a.contact_id = ?
`).all(req.params.id);
// Letzte Interaktionen
contact.recent_interactions = db.prepare(`
SELECT * FROM contact_interactions
WHERE contact_id = ?
ORDER BY date DESC
LIMIT 10
`).all(req.params.id);
res.json({ success: true, data: contact });
} catch (error) {
logger.error('Fehler beim Abrufen des Kontakts:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Kontakt erstellen
router.post('/contacts', (req, res) => {
// Basis-Validierung
if (!req.body.first_name || !req.body.last_name || !req.body.project_id) {
return res.status(400).json({ success: false, error: 'Vor- und Nachname sowie Projekt-ID sind erforderlich' });
}
const db = getDb();
const transaction = db.transaction((data) => {
try {
// Kontakt erstellen
const stmt = db.prepare(`
INSERT INTO contacts_extended (
salutation, title, first_name, last_name, display_name,
position, department, institution_id,
notes, tags, avatar_url, is_active, created_by, project_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
data.salutation || null,
data.title || null,
data.first_name,
data.last_name,
data.display_name || `${data.first_name} ${data.last_name}`,
data.position || null,
data.department || null,
data.institution_id || null,
data.notes || null,
data.tags || null,
data.avatar_url || null,
data.is_active !== false ? 1 : 0,
req.userId,
data.project_id
);
const contactId = result.lastInsertRowid;
// Kontaktdetails hinzufügen
if (data.details && Array.isArray(data.details)) {
const detailStmt = db.prepare(`
INSERT INTO contact_details (
contact_id, type, subtype, value, label, is_primary, is_public
) VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const detail of data.details) {
detailStmt.run(
contactId,
detail.type,
detail.subtype || null,
detail.value,
detail.label || null,
detail.is_primary ? 1 : 0,
detail.is_public !== false ? 1 : 0
);
}
}
// Kategorien zuweisen
if (data.categories && Array.isArray(data.categories)) {
const categoryStmt = db.prepare(`
INSERT INTO contact_category_assignments (contact_id, category_id)
VALUES (?, ?)
`);
for (const categoryId of data.categories) {
categoryStmt.run(contactId, categoryId);
}
}
return contactId;
} catch (error) {
throw error;
}
});
try {
const contactId = transaction(req.body);
const contact = db.prepare('SELECT * FROM contacts_extended WHERE id = ?').get(contactId);
// Socket.io Event
req.app.get('io').to(`project-${req.body.project_id}`).emit('contact:created', contact);
res.status(201).json({ success: true, data: contact });
} catch (error) {
logger.error('Fehler beim Erstellen des Kontakts:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ===================================
// KONTAKTDETAILS
// ===================================
// Kontaktdetail hinzufügen
router.post('/contact-details', (req, res) => {
// Basis-Validierung
if (!req.body.type || !req.body.value) {
return res.status(400).json({ success: false, error: 'Typ und Wert sind erforderlich' });
}
try {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO contact_details (
contact_id, institution_id, type, subtype, value, label,
is_primary, is_public, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
req.body.contact_id || null,
req.body.institution_id || null,
req.body.type,
req.body.subtype || null,
req.body.value,
req.body.label || null,
req.body.is_primary ? 1 : 0,
req.body.is_public !== false ? 1 : 0,
req.body.notes || null
);
const detail = db.prepare('SELECT * FROM contact_details WHERE id = ?').get(result.lastInsertRowid);
// Socket.io Event
const eventType = req.body.contact_id ? 'contact' : 'institution';
req.app.get('io').emit(`${eventType}:detail:added`, detail);
res.status(201).json({ success: true, data: detail });
} catch (error) {
logger.error('Fehler beim Hinzufügen des Kontaktdetails:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ===================================
// PERSON-INSTITUTION BEZIEHUNGEN
// ===================================
// Beziehung erstellen
router.post('/relations', (req, res) => {
// Basis-Validierung
if (!req.body.contact_id || !req.body.institution_id) {
return res.status(400).json({ success: false, error: 'Kontakt-ID und Institutions-ID sind erforderlich' });
}
try {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO person_institution_relations (
contact_id, institution_id, position, department,
start_date, end_date, is_primary, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
req.body.contact_id,
req.body.institution_id,
req.body.position || null,
req.body.department || null,
req.body.start_date || null,
req.body.end_date || null,
req.body.is_primary ? 1 : 0,
req.body.notes || null
);
const relation = db.prepare('SELECT * FROM person_institution_relations WHERE id = ?')
.get(result.lastInsertRowid);
// Socket.io Event
req.app.get('io').emit('relation:created', relation);
res.status(201).json({ success: true, data: relation });
} catch (error) {
logger.error('Fehler beim Erstellen der Beziehung:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ===================================
// KATEGORIEN
// ===================================
// Alle Kategorien abrufen
router.get('/categories', (req, res) => {
try {
const db = getDb();
const { project_id } = req.query;
let query = 'SELECT * FROM contact_categories';
const params = [];
if (project_id) {
query += ' WHERE project_id = ?';
params.push(project_id);
}
query += ' ORDER BY name ASC';
const categories = db.prepare(query).all(...params);
res.json({ success: true, data: categories });
} catch (error) {
logger.error('Fehler beim Abrufen der Kategorien:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ===================================
// INTERAKTIONEN
// ===================================
// Interaktion hinzufügen
router.post('/interactions', (req, res) => {
// Basis-Validierung
if (!req.body.type) {
return res.status(400).json({ success: false, error: 'Typ ist erforderlich' });
}
try {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO contact_interactions (
contact_id, institution_id, type, subject, content,
date, duration_minutes, task_id, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
req.body.contact_id || null,
req.body.institution_id || null,
req.body.type,
req.body.subject || null,
req.body.content || null,
req.body.date || new Date().toISOString(),
req.body.duration_minutes || null,
req.body.task_id || null,
req.userId
);
const interaction = db.prepare('SELECT * FROM contact_interactions WHERE id = ?')
.get(result.lastInsertRowid);
// Socket.io Event
req.app.get('io').emit('interaction:created', interaction);
res.status(201).json({ success: true, data: interaction });
} catch (error) {
logger.error('Fehler beim Hinzufügen der Interaktion:', error);
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;

Datei anzeigen

@@ -82,7 +82,7 @@ app.use(helmet({
// CORS
app.use(cors({
origin: true,
origin: process.env.CORS_ORIGIN || 'https://taskmate.aegis-sight.de',
credentials: true
}));
@@ -97,14 +97,15 @@ app.use(cookieParser());
const { sanitizeMiddleware } = require('./middleware/validation');
app.use(sanitizeMiddleware);
// Request Logging
// Request Logging (nur API-Requests, keine statischen Assets)
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
// Use originalUrl to see the full path including /api prefix
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
if (req.originalUrl.startsWith('/api/')) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
}
next();
});
@@ -140,18 +141,18 @@ app.use('/api/tasks', authenticateToken, csrfProtection, taskRoutes);
app.use('/api/subtasks', authenticateToken, csrfProtection, subtaskRoutes);
app.use('/api/comments', authenticateToken, csrfProtection, commentRoutes);
app.use('/api/labels', authenticateToken, csrfProtection, labelRoutes);
app.use('/api/files', authenticateToken, fileRoutes);
app.use('/api/files', authenticateToken, csrfProtection, fileRoutes);
app.use('/api/links', authenticateToken, csrfProtection, linkRoutes);
app.use('/api/templates', authenticateToken, csrfProtection, templateRoutes);
app.use('/api/stats', authenticateToken, statsRoutes);
app.use('/api/export', authenticateToken, exportRoutes);
app.use('/api/stats', authenticateToken, csrfProtection, statsRoutes);
app.use('/api/export', authenticateToken, csrfProtection, exportRoutes);
app.use('/api/import', authenticateToken, csrfProtection, importRoutes);
// Admin-Routes (eigene Auth-Middleware)
app.use('/api/admin', csrfProtection, adminRoutes);
// Admin-Routes
app.use('/api/admin', authenticateToken, csrfProtection, adminRoutes);
// Proposals-Routes (eigene Auth-Middleware)
app.use('/api/proposals', csrfProtection, proposalRoutes);
// Proposals-Routes
app.use('/api/proposals', authenticateToken, csrfProtection, proposalRoutes);
// Notifications-Routes
app.use('/api/notifications', authenticateToken, csrfProtection, notificationRoutes);

Datei anzeigen

@@ -0,0 +1,765 @@
/**
* TASKMATE - Modernes Kontaktmanagement Styles
* =============================================
* Lead-Management inspiriertes Design
*/
/* Container */
.contacts-modern {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-secondary);
}
/* Header */
.contacts-header {
background: var(--bg-card);
border-bottom: 1px solid var(--border-light);
padding: var(--spacing-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
.contacts-title {
display: flex;
align-items: baseline;
gap: var(--spacing-3);
}
.contacts-title h2 {
margin: 0;
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.contact-count {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
/* Controls */
.contacts-controls {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex-wrap: wrap;
}
.search-box {
position: relative;
min-width: 300px;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 36px;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
font-size: var(--text-sm);
transition: all var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: var(--primary);
background: var(--bg-card);
}
.filter-select {
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
font-size: var(--text-sm);
cursor: pointer;
}
/* List Container */
.contacts-list-container {
flex: 1;
overflow-y: auto;
padding: var(--spacing-4);
}
/* List Items */
.contacts-list {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-light);
border-radius: var(--radius-lg);
overflow: hidden;
}
.contact-list-item {
background: var(--bg-card);
padding: var(--spacing-3) var(--spacing-4);
display: flex;
align-items: center;
gap: var(--spacing-3);
cursor: pointer;
transition: all var(--transition-fast);
}
.contact-list-item:hover {
background: var(--bg-hover);
}
.contact-list-item:first-child {
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.contact-list-item:last-child {
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
/* Avatar */
.contact-avatar {
position: relative;
flex-shrink: 0;
}
.contact-avatar img {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
object-fit: cover;
}
.avatar-initials {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-medium);
font-size: var(--text-md);
}
.avatar-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.status-inactive {
position: absolute;
bottom: 0;
right: 0;
width: 12px;
height: 12px;
background: var(--danger);
border: 2px solid var(--bg-card);
border-radius: var(--radius-full);
}
/* Contact Info */
.contact-info {
flex: 1;
min-width: 0;
}
.contact-name {
font-weight: var(--font-medium);
color: var(--text-primary);
margin-bottom: 2px;
}
.contact-subtitle {
font-size: var(--text-sm);
color: var(--text-secondary);
}
/* Contact Meta */
.contact-meta {
font-size: var(--text-sm);
color: var(--text-tertiary);
margin-left: auto;
}
/* Contact Actions */
.contact-actions {
display: flex;
gap: var(--spacing-2);
opacity: 0;
transition: opacity var(--transition-fast);
}
.contact-list-item:hover .contact-actions {
opacity: 1;
}
/* Detail View */
.contact-detail-view {
background: var(--bg-secondary);
}
.detail-header {
background: var(--bg-card);
border-bottom: 1px solid var(--border-light);
padding: var(--spacing-4);
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.btn-back-to-list {
background: none;
border: none;
padding: var(--spacing-2);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-back-to-list:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.detail-title {
flex: 1;
}
.detail-title h2 {
margin: 0;
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.detail-title .subtitle {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.detail-actions {
display: flex;
gap: var(--spacing-2);
}
/* Detail Content */
.detail-content {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--spacing-4);
padding: var(--spacing-4);
}
.detail-main {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.detail-section {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
}
.detail-section h3 {
margin: 0 0 var(--spacing-3);
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
/* Detail Items */
.detail-grid {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.detail-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
}
.detail-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: var(--bg-secondary);
padding: 10px;
color: var(--text-secondary);
}
.detail-content {
flex: 1;
}
.detail-label {
font-size: var(--text-sm);
color: var(--text-tertiary);
margin-bottom: 2px;
}
.detail-value {
color: var(--text-primary);
}
/* Sidebar */
.detail-sidebar {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.sidebar-section {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
}
.sidebar-section h4 {
margin: 0 0 var(--spacing-3);
font-size: var(--text-md);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.info-list {
display: grid;
gap: var(--spacing-2);
}
.info-list dt {
font-size: var(--text-sm);
color: var(--text-tertiary);
margin-bottom: 2px;
}
.info-list dd {
margin: 0 0 var(--spacing-3);
color: var(--text-primary);
}
.info-list.small dt,
.info-list.small dd {
font-size: var(--text-xs);
}
/* Tags */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.tag {
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: var(--text-sm);
}
/* Forms */
.contact-type-selector {
display: flex;
gap: var(--spacing-3);
margin-bottom: var(--spacing-4);
padding: var(--spacing-3);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
}
.type-option {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
padding: var(--spacing-3);
background: var(--bg-card);
border: 2px solid var(--border-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.type-option input {
display: none;
}
.type-option.active {
border-color: var(--primary);
background: var(--primary-light);
}
.type-option svg {
color: var(--text-secondary);
}
.type-option.active svg {
color: var(--primary);
}
/* Modern Form */
.modern-contact-form {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.form-section {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
}
.form-section h3 {
margin: 0 0 var(--spacing-3);
font-size: var(--text-md);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
/* Form Grid */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-3);
}
.form-field {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.form-field.small {
max-width: 150px;
}
.form-field.large {
grid-column: span 2;
}
.form-field.full {
grid-column: 1 / -1;
}
.form-field label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
}
/* Form Controls - Einheitliche Größe */
.form-control {
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-family: inherit;
transition: all var(--transition-fast);
width: 100%;
box-sizing: border-box;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
background: var(--bg-card);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.1);
}
.form-control::placeholder {
color: var(--text-tertiary);
}
/* Select einheitlich */
select.form-control {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
padding-right: 32px;
}
/* Checkbox */
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-2);
cursor: pointer;
font-size: var(--text-sm);
user-select: none;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
}
/* Contact Details Container */
.contact-details-container {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.empty-details-prompt {
text-align: center;
padding: var(--spacing-4);
background: var(--bg-card);
border-radius: var(--radius-md);
border: 2px dashed var(--border-light);
}
.empty-details-prompt p {
margin: 0 0 var(--spacing-3);
color: var(--text-secondary);
}
/* Detail Type Buttons */
.detail-type-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
justify-content: center;
}
.btn-add-detail {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-2) var(--spacing-3);
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-add-detail:hover {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.detail-type-buttons.small .btn-add-detail {
padding: 6px 10px;
font-size: var(--text-xs);
}
/* Detail Groups */
.detail-group {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.detail-group h4 {
margin: 0;
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
/* Detail Field Group */
.detail-field-group {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: var(--spacing-3);
}
.detail-field-row {
display: grid;
grid-template-columns: 140px 1fr auto auto;
gap: var(--spacing-2);
align-items: center;
}
.detail-field-row:not(:last-child) {
margin-bottom: var(--spacing-2);
}
.detail-field-row:last-child {
grid-template-columns: 1fr;
}
/* Kleine Select-Boxen */
.form-control.small {
max-width: 140px;
}
/* Remove Button */
.btn-remove {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
color: var(--text-tertiary);
}
.btn-remove:hover {
background: var(--danger);
border-color: var(--danger);
color: white;
}
/* Detail Add Section */
.detail-add-section {
padding: var(--spacing-3);
background: var(--bg-card);
border-radius: var(--radius-md);
text-align: center;
}
.detail-add-section p {
margin: 0 0 var(--spacing-2);
}
/* Institution Card */
.institution-card {
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: var(--spacing-3);
margin-bottom: var(--spacing-2);
}
.institution-card h4 {
margin: 0 0 var(--spacing-1);
font-size: var(--text-md);
font-weight: var(--font-medium);
}
.institution-card p {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-8);
text-align: center;
}
.empty-state svg {
color: var(--text-tertiary);
margin-bottom: var(--spacing-4);
}
.empty-state h3 {
margin: 0 0 var(--spacing-2);
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.empty-state p {
margin: 0;
color: var(--text-secondary);
}
/* Text Utilities */
.text-muted {
color: var(--text-tertiary);
}
/* Icons */
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-icon:hover {
background: var(--bg-hover);
}
/* Responsive */
@media (max-width: 1024px) {
.detail-content {
grid-template-columns: 1fr;
}
.detail-sidebar {
order: -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
@media (max-width: 768px) {
.contacts-header {
flex-direction: column;
align-items: stretch;
}
.contacts-controls {
flex-direction: column;
}
.search-box {
min-width: auto;
width: 100%;
}
.form-grid {
grid-template-columns: 1fr;
}
.form-field.small,
.form-field.large {
max-width: none;
grid-column: 1;
}
.detail-field-row {
grid-template-columns: 1fr;
gap: var(--spacing-2);
}
.detail-field-row > * {
width: 100% !important;
}
}

Datei anzeigen

@@ -1,291 +0,0 @@
/**
* TASKMATE - Dashboard Styles
* ===========================
* Dashboard, Stats, Charts - Modernes Light Theme
*/
/* ========================================
DASHBOARD VIEW
======================================== */
.view-dashboard {
padding: var(--spacing-6);
gap: var(--spacing-6);
overflow-y: auto;
background: var(--bg-main);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-5);
}
.stat-card {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-5);
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
transition: all var(--transition-default);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--border-default);
}
.stat-card.stat-danger {
border-left: 4px solid var(--error);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: var(--radius-xl);
}
.stat-icon svg {
width: 26px;
height: 26px;
}
.stat-icon.stat-open {
background: var(--primary-light);
color: var(--primary);
}
.stat-icon.stat-progress {
background: var(--warning-bg);
color: var(--warning);
}
.stat-icon.stat-done {
background: var(--success-bg);
color: var(--success);
}
.stat-icon.stat-overdue {
background: var(--error-bg);
color: var(--error);
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-family: var(--font-primary);
font-size: var(--text-2xl);
font-weight: var(--font-bold);
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: var(--text-sm);
color: var(--text-tertiary);
margin-top: var(--spacing-1);
}
/* Dashboard Row */
.dashboard-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-5);
}
/* Dashboard Card */
.dashboard-card {
padding: var(--spacing-5);
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
}
.dashboard-card h3 {
font-size: var(--text-base);
font-weight: var(--font-semibold);
margin-bottom: var(--spacing-4);
color: var(--text-primary);
}
/* Chart Container */
.chart-container {
height: 200px;
position: relative;
}
/* Simple Bar Chart */
.bar-chart {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 100%;
padding-top: var(--spacing-4);
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-1);
flex: 1;
}
.bar {
width: 36px;
background: var(--primary);
border-radius: var(--radius-md) var(--radius-md) 0 0;
transition: all var(--transition-default);
min-height: 4px;
}
.bar:hover {
background: var(--primary-hover);
transform: scaleY(1.05);
}
.bar-label {
font-size: var(--text-xs);
color: var(--text-muted);
}
.bar-value {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
/* Horizontal Bar Chart */
.horizontal-bar-chart {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.horizontal-bar-item {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.horizontal-bar-header {
display: flex;
justify-content: space-between;
font-size: var(--text-sm);
}
.horizontal-bar-label {
color: var(--text-primary);
font-weight: var(--font-medium);
}
.horizontal-bar-value {
color: var(--text-muted);
}
.horizontal-bar {
height: 20px;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
overflow: hidden;
}
.horizontal-bar-fill {
height: 100%;
background: var(--primary);
border-radius: var(--radius-full);
transition: width var(--transition-default);
}
/* Due Today List */
.due-today-list {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.due-today-item {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
background: var(--bg-tertiary);
border-radius: var(--radius-xl);
cursor: pointer;
transition: all var(--transition-fast);
}
.due-today-item:hover {
background: var(--bg-hover);
transform: translateX(4px);
}
.due-today-priority {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.due-today-priority.high { background: var(--priority-high); }
.due-today-priority.medium { background: var(--priority-medium); }
.due-today-priority.low { background: var(--priority-low); }
.due-today-title {
flex: 1;
font-weight: var(--font-medium);
color: var(--text-primary);
}
.due-today-assignee {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--text-sm);
color: var(--text-tertiary);
}
/* ========================================
LIST VIEW
======================================== */
.view-list {
padding: var(--spacing-6);
background: var(--bg-main);
}
.list-container {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
/* Bulk Actions */
.bulk-actions {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-5);
background: var(--primary-light);
border-top: 1px solid var(--border-default);
}
.selected-count {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--primary);
}

Datei anzeigen

@@ -38,7 +38,6 @@
<link rel="stylesheet" href="css/knowledge.css">
<link rel="stylesheet" href="css/reminders.css">
<link rel="stylesheet" href="css/contacts.css">
<link rel="stylesheet" href="css/contacts-modern.css">
<link rel="stylesheet" href="css/responsive.css">
<link rel="stylesheet" href="css/mobile.css">
<link rel="stylesheet" href="css/pwa.css">

103
frontend/js/auth-fix.js Normale Datei
Datei anzeigen

@@ -0,0 +1,103 @@
/**
* TASKMATE - Auth Fix
* ====================
* Behebt Authentifizierungs-Probleme
*/
// Überwache alle API-Anfragen
const originalFetch = window.fetch;
window.fetch = async function(...args) {
try {
const response = await originalFetch.apply(this, args);
// Bei 401: Automatisches Token-Refresh versuchen
if (response.status === 401 && !args[0].includes('/auth/login')) {
console.log('[AuthFix] 401 erkannt, versuche Token-Refresh...');
const token = localStorage.getItem('token');
const refreshToken = localStorage.getItem('refreshToken');
if (token) {
try {
// Versuche Token-Refresh
const refreshResponse = await originalFetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ refreshToken: refreshToken || token })
});
if (refreshResponse.ok) {
const data = await refreshResponse.json();
console.log('[AuthFix] Token erfolgreich erneuert');
// Neue Tokens speichern
localStorage.setItem('token', data.token);
if (data.refreshToken) {
localStorage.setItem('refreshToken', data.refreshToken);
}
// Original-Request mit neuem Token wiederholen
if (args[1] && args[1].headers) {
args[1].headers['Authorization'] = `Bearer ${data.token}`;
}
return originalFetch.apply(this, args);
}
} catch (error) {
console.error('[AuthFix] Token-Refresh fehlgeschlagen:', error);
}
}
}
return response;
} catch (error) {
throw error;
}
};
// Stelle sicher, dass das Token bei jedem Request aktuell ist
document.addEventListener('DOMContentLoaded', () => {
console.log('[AuthFix] Auth-Fix geladen');
// Prüfe Token-Gültigkeit alle 5 Minuten
setInterval(() => {
const token = localStorage.getItem('token');
if (token) {
// Dekodiere JWT um Ablaufzeit zu prüfen
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
// Wenn Token in weniger als 10 Minuten abläuft, erneuere es
if (expiresIn < 10 * 60 * 1000) {
console.log('[AuthFix] Token läuft bald ab, erneuere...');
fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ refreshToken: localStorage.getItem('refreshToken') || token })
}).then(response => {
if (response.ok) {
return response.json();
}
}).then(data => {
if (data && data.token) {
localStorage.setItem('token', data.token);
if (data.refreshToken) {
localStorage.setItem('refreshToken', data.refreshToken);
}
console.log('[AuthFix] Token automatisch erneuert');
}
});
}
} catch (error) {
console.error('[AuthFix] Fehler beim Token-Check:', error);
}
}
}, 5 * 60 * 1000); // Alle 5 Minuten
});

Datei anzeigen

@@ -1,335 +0,0 @@
/**
* TASKMATE - Dashboard Module
* ===========================
* Statistics and overview dashboard
*/
import store from './store.js';
import api from './api.js';
import {
$, $$, createElement, clearElement, formatDate, getDueDateStatus,
getInitials
} from './utils.js';
class DashboardManager {
constructor() {
this.container = null;
this.stats = null;
this.completionData = null;
this.timeData = null;
this.init();
}
init() {
this.container = $('#view-dashboard');
// Subscribe to store changes
store.subscribe('currentView', (view) => {
if (view === 'dashboard') this.loadAndRender();
});
store.subscribe('currentProjectId', () => {
if (store.get('currentView') === 'dashboard') {
this.loadAndRender();
}
});
}
// =====================
// DATA LOADING
// =====================
async loadAndRender() {
if (store.get('currentView') !== 'dashboard') return;
store.setLoading(true);
try {
const projectId = store.get('currentProjectId');
const [stats, completionData, timeData] = await Promise.all([
api.getStats(projectId),
api.getCompletionStats(projectId, 8),
api.getTimeStats(projectId)
]);
this.stats = stats;
this.completionData = completionData;
this.timeData = timeData;
// Due today tasks come from dashboard stats
this.dueTodayTasks = stats.dueToday || [];
// Overdue list - we only have the count, not individual tasks
this.overdueTasks = [];
this.render();
} catch (error) {
console.error('Failed to load dashboard data:', error);
this.showError('Fehler beim Laden der Dashboard-Daten');
} finally {
store.setLoading(false);
}
}
// =====================
// RENDERING
// =====================
render() {
this.renderStats();
this.renderCompletionChart();
this.renderTimeChart();
this.renderDueTodayList();
this.renderOverdueList();
}
renderStats() {
if (!this.stats) return;
// Open tasks
this.updateStatCard('stat-open', this.stats.open || 0);
// In progress
this.updateStatCard('stat-progress', this.stats.inProgress || 0);
// Completed
this.updateStatCard('stat-done', this.stats.completed || 0);
// Overdue
this.updateStatCard('stat-overdue', this.stats.overdue || 0);
}
updateStatCard(id, value) {
const valueEl = $(`#${id}`);
if (valueEl) {
valueEl.textContent = value.toString();
}
}
renderCompletionChart() {
const container = $('#chart-completed');
if (!container || !this.completionData) return;
clearElement(container);
// Add bar-chart class
container.classList.add('bar-chart');
const maxValue = Math.max(...this.completionData.map(d => d.count), 1);
this.completionData.forEach(item => {
const percentage = (item.count / maxValue) * 100;
const barItem = createElement('div', { className: 'bar-item' }, [
createElement('span', { className: 'bar-value' }, [item.count.toString()]),
createElement('div', {
className: 'bar',
style: { height: `${Math.max(percentage, 5)}%` }
}),
createElement('span', { className: 'bar-label' }, [item.label || item.week])
]);
container.appendChild(barItem);
});
}
renderTimeChart() {
const container = $('#chart-time');
if (!container || !this.timeData) return;
clearElement(container);
// Add horizontal-bar-chart class
container.classList.add('horizontal-bar-chart');
const totalTime = this.timeData.reduce((sum, item) => sum + (item.totalMinutes || 0), 0);
this.timeData.slice(0, 5).forEach(item => {
const percentage = totalTime > 0 ? ((item.totalMinutes || 0) / totalTime) * 100 : 0;
const barItem = createElement('div', { className: 'horizontal-bar-item' }, [
createElement('div', { className: 'horizontal-bar-header' }, [
createElement('span', { className: 'horizontal-bar-label' }, [item.name || item.projectName]),
createElement('span', { className: 'horizontal-bar-value' }, [
this.formatMinutes(item.totalMinutes || 0)
])
]),
createElement('div', { className: 'horizontal-bar' }, [
createElement('div', {
className: 'horizontal-bar-fill',
style: { width: `${percentage}%` }
})
])
]);
container.appendChild(barItem);
});
if (this.timeData.length === 0) {
container.appendChild(createElement('p', {
className: 'text-secondary',
style: { textAlign: 'center' }
}, ['Keine Zeitdaten verfügbar']));
}
}
renderDueTodayList() {
const container = $('#due-today-list');
if (!container) return;
clearElement(container);
if (!this.dueTodayTasks || this.dueTodayTasks.length === 0) {
container.appendChild(createElement('p', {
className: 'text-secondary empty-message'
}, ['Keine Aufgaben für heute']));
return;
}
this.dueTodayTasks.slice(0, 5).forEach(task => {
const hasAssignee = task.assignedTo || task.assignedName;
const item = createElement('div', {
className: 'due-today-item',
onclick: () => this.openTaskModal(task.id)
}, [
createElement('span', {
className: 'due-today-priority',
style: { backgroundColor: this.getPriorityColor(task.priority) }
}),
createElement('span', { className: 'due-today-title' }, [task.title]),
hasAssignee ? createElement('div', { className: 'due-today-assignee' }, [
createElement('span', {
className: 'avatar avatar-sm',
style: { backgroundColor: task.assignedColor || '#888' }
}, [getInitials(task.assignedName || 'U')])
]) : null
].filter(Boolean));
container.appendChild(item);
});
if (this.dueTodayTasks.length > 5) {
container.appendChild(createElement('button', {
className: 'btn btn-ghost btn-sm btn-block',
onclick: () => this.showAllDueToday()
}, [`Alle ${this.dueTodayTasks.length} anzeigen`]));
}
}
renderOverdueList() {
const container = $('#overdue-list');
if (!container) return;
clearElement(container);
if (!this.overdueTasks || this.overdueTasks.length === 0) {
container.appendChild(createElement('p', {
className: 'text-secondary empty-message'
}, ['Keine überfälligen Aufgaben']));
return;
}
this.overdueTasks.slice(0, 5).forEach(task => {
const daysOverdue = this.getDaysOverdue(task.dueDate);
const item = createElement('div', {
className: 'due-today-item overdue-item',
onclick: () => this.openTaskModal(task.id)
}, [
createElement('span', {
className: 'due-today-priority',
style: { backgroundColor: this.getPriorityColor(task.priority) }
}),
createElement('div', { style: { flex: 1 } }, [
createElement('span', { className: 'due-today-title' }, [task.title]),
createElement('span', {
className: 'text-error',
style: { fontSize: 'var(--text-xs)', display: 'block' }
}, [`${daysOverdue} Tag(e) überfällig`])
]),
task.assignee ? createElement('div', { className: 'due-today-assignee' }, [
createElement('span', {
className: 'avatar avatar-sm',
style: { backgroundColor: task.assignee.color || '#888' }
}, [task.assignee.initials || getInitials(task.assignee.username || '??')])
]) : null
].filter(Boolean));
container.appendChild(item);
});
if (this.overdueTasks.length > 5) {
container.appendChild(createElement('button', {
className: 'btn btn-ghost btn-sm btn-block',
onclick: () => this.showAllOverdue()
}, [`Alle ${this.overdueTasks.length} anzeigen`]));
}
}
// =====================
// ACTIONS
// =====================
openTaskModal(taskId) {
window.dispatchEvent(new CustomEvent('modal:open', {
detail: {
modalId: 'task-modal',
mode: 'edit',
data: { taskId }
}
}));
}
showAllDueToday() {
// Switch to list view with due date filter
store.setFilter('dueDate', 'today');
store.setCurrentView('list');
}
showAllOverdue() {
// Switch to list view with overdue filter
store.setFilter('dueDate', 'overdue');
store.setCurrentView('list');
}
// =====================
// HELPERS
// =====================
formatMinutes(minutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
}
getPriorityColor(priority) {
const colors = {
high: 'var(--priority-high)',
medium: 'var(--priority-medium)',
low: 'var(--priority-low)'
};
return colors[priority] || colors.medium;
}
getDaysOverdue(dueDate) {
const due = new Date(dueDate);
const today = new Date();
const diffTime = today - due;
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
showError(message) {
window.dispatchEvent(new CustomEvent('toast:show', {
detail: { message, type: 'error' }
}));
}
}
// Create and export singleton
const dashboardManager = new DashboardManager();
export default dashboardManager;

274
frontend/js/mobile-swipe.js Normale Datei
Datei anzeigen

@@ -0,0 +1,274 @@
/**
* TASKMATE - Mobile Swipe Enhancement
* ====================================
* Neue Swipe-Funktionalität für bessere mobile Navigation
*/
export function enhanceMobileSwipe(mobileManager) {
const SWIPE_THRESHOLD = 50;
const SWIPE_VELOCITY = 0.3;
// State für Column-Navigation
let currentColumnIndex = 0;
let columnCount = 0;
let isColumnSwipeEnabled = false;
// Column indicator elements
let columnIndicator = null;
/**
* Initialize column swipe for board view
*/
function initColumnSwipe() {
const boardContainer = document.querySelector('.board-container');
if (!boardContainer || mobileManager.currentView !== 'board') return;
// Create column indicator
if (!columnIndicator) {
columnIndicator = document.createElement('div');
columnIndicator.className = 'mobile-column-indicator';
columnIndicator.innerHTML = `
<div class="column-dots"></div>
<div class="column-name"></div>
`;
document.querySelector('.view-board')?.appendChild(columnIndicator);
}
updateColumnInfo();
showCurrentColumn();
}
/**
* Update column information
*/
function updateColumnInfo() {
const columns = document.querySelectorAll('.column');
columnCount = columns.length;
// Update dots
const dotsContainer = columnIndicator?.querySelector('.column-dots');
if (dotsContainer) {
dotsContainer.innerHTML = Array.from({ length: columnCount }, (_, i) =>
`<span class="dot ${i === currentColumnIndex ? 'active' : ''}"></span>`
).join('');
}
// Update column name
const nameContainer = columnIndicator?.querySelector('.column-name');
if (nameContainer && columns[currentColumnIndex]) {
const columnTitle = columns[currentColumnIndex].querySelector('.column-title')?.textContent || '';
nameContainer.textContent = columnTitle;
}
}
/**
* Show specific column (hide others)
*/
function showCurrentColumn() {
const columns = document.querySelectorAll('.column');
const boardContainer = document.querySelector('.board-container');
columns.forEach((col, index) => {
if (index === currentColumnIndex) {
col.style.display = 'flex';
col.classList.add('mobile-active');
} else {
col.style.display = 'none';
col.classList.remove('mobile-active');
}
});
// Update add column button
const addColumnBtn = document.querySelector('.btn-add-column');
if (addColumnBtn) {
addColumnBtn.style.display = currentColumnIndex === columnCount - 1 ? 'flex' : 'none';
}
updateColumnInfo();
}
/**
* Navigate to specific column
*/
function navigateToColumn(index) {
if (index < 0 || index >= columnCount) return;
currentColumnIndex = index;
showCurrentColumn();
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(10);
}
}
/**
* Enhanced board swipe handler
*/
mobileManager.handleBoardSwipeEnd = function() {
if (!this.isSwiping || this.swipeDirection !== 'horizontal' || this.swipeTarget !== 'board') {
this.resetSwipe();
return;
}
const deltaX = this.touchCurrentX - this.touchStartX;
const deltaTime = Date.now() - this.touchStartTime;
const velocity = Math.abs(deltaX) / deltaTime;
const isValidSwipe = Math.abs(deltaX) > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY;
if (isValidSwipe && isColumnSwipeEnabled) {
if (deltaX > 0 && currentColumnIndex > 0) {
// Swipe right - previous column
navigateToColumn(currentColumnIndex - 1);
} else if (deltaX < 0 && currentColumnIndex < columnCount - 1) {
// Swipe left - next column
navigateToColumn(currentColumnIndex + 1);
}
}
this.resetSwipe();
};
/**
* View hint for header swipes
*/
let viewHint = null;
function showViewSwipeHint(viewName, direction) {
if (!viewHint) {
viewHint = document.createElement('div');
viewHint.className = 'mobile-view-hint';
document.body.appendChild(viewHint);
}
viewHint.textContent = getViewDisplayName(viewName);
viewHint.classList.add('visible', direction);
}
function hideViewSwipeHint() {
if (viewHint) {
viewHint.classList.remove('visible', 'left', 'right');
}
}
function getViewDisplayName(view) {
const names = {
'board': 'Board',
'list': 'Liste',
'calendar': 'Kalender',
'proposals': 'Genehmigungen',
'gitea': 'Gitea',
'knowledge': 'Wissen'
};
return names[view] || view;
}
/**
* Enhanced header swipe handler
*/
mobileManager.handleHeaderSwipeMove = function(e) {
if (!this.isMobile || this.touchStartX === 0 || this.swipeTarget !== 'header') return;
const touch = e.touches[0];
this.touchCurrentX = touch.clientX;
this.touchCurrentY = touch.clientY;
const deltaX = this.touchCurrentX - this.touchStartX;
const deltaY = this.touchCurrentY - this.touchStartY;
// Determine direction
if (!this.swipeDirection && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) {
if (Math.abs(deltaX) > Math.abs(deltaY) * 1.5) {
this.swipeDirection = 'horizontal';
this.isSwiping = true;
} else {
this.swipeDirection = 'vertical';
this.resetSwipe();
return;
}
}
if (this.swipeDirection !== 'horizontal') return;
e.preventDefault();
// Show view hints
const currentIndex = this.viewOrder.indexOf(this.currentView);
if (deltaX > SWIPE_THRESHOLD && currentIndex > 0) {
showViewSwipeHint(this.viewOrder[currentIndex - 1], 'left');
} else if (deltaX < -SWIPE_THRESHOLD && currentIndex < this.viewOrder.length - 1) {
showViewSwipeHint(this.viewOrder[currentIndex + 1], 'right');
} else {
hideViewSwipeHint();
}
};
mobileManager.handleHeaderSwipeEnd = function() {
if (!this.isSwiping || this.swipeDirection !== 'horizontal' || this.swipeTarget !== 'header') {
this.resetSwipe();
hideViewSwipeHint();
return;
}
const deltaX = this.touchCurrentX - this.touchStartX;
const deltaTime = Date.now() - this.touchStartTime;
const velocity = Math.abs(deltaX) / deltaTime;
const isValidSwipe = Math.abs(deltaX) > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY;
if (isValidSwipe) {
const currentIndex = this.viewOrder.indexOf(this.currentView);
if (deltaX > 0 && currentIndex > 0) {
// Swipe right - previous view
this.switchView(this.viewOrder[currentIndex - 1]);
} else if (deltaX < 0 && currentIndex < this.viewOrder.length - 1) {
// Swipe left - next view
this.switchView(this.viewOrder[currentIndex + 1]);
}
}
hideViewSwipeHint();
this.resetSwipe();
};
// Listen for view changes
document.addEventListener('view:changed', (e) => {
if (e.detail?.view === 'board' && mobileManager.isMobile) {
isColumnSwipeEnabled = true;
setTimeout(initColumnSwipe, 100);
} else {
isColumnSwipeEnabled = false;
if (columnIndicator) {
columnIndicator.style.display = 'none';
}
}
});
// Listen for column updates
document.addEventListener('columns:updated', () => {
if (isColumnSwipeEnabled) {
updateColumnInfo();
showCurrentColumn();
}
});
// Update on resize
window.addEventListener('resize', () => {
if (mobileManager.isMobile && mobileManager.currentView === 'board') {
if (!isColumnSwipeEnabled) {
isColumnSwipeEnabled = true;
initColumnSwipe();
}
} else {
isColumnSwipeEnabled = false;
// Show all columns on desktop
document.querySelectorAll('.column').forEach(col => {
col.style.display = '';
col.classList.remove('mobile-active');
});
if (columnIndicator) {
columnIndicator.style.display = 'none';
}
}
});
}

Datei anzeigen

@@ -1,325 +0,0 @@
/**
* TASKMATE - Tour/Onboarding Module
* ==================================
* First-time user onboarding tour
*/
import { $, createElement } from './utils.js';
class TourManager {
constructor() {
this.currentStep = 0;
this.isActive = false;
this.overlay = null;
this.tooltip = null;
this.steps = [
{
target: '.view-tabs',
title: 'Ansichten',
content: 'Wechseln Sie zwischen Board-, Listen-, Kalender- und Dashboard-Ansicht.',
position: 'bottom'
},
{
target: '.project-selector',
title: 'Projekte',
content: 'Wählen Sie ein Projekt aus oder erstellen Sie ein neues.',
position: 'bottom'
},
{
target: '.column',
title: 'Spalten',
content: 'Spalten repräsentieren den Status Ihrer Aufgaben. Ziehen Sie Aufgaben zwischen Spalten, um den Status zu ändern.',
position: 'right'
},
{
target: '.btn-add-task',
title: 'Neue Aufgabe',
content: 'Klicken Sie hier, um eine neue Aufgabe zu erstellen.',
position: 'top'
},
{
target: '.filter-bar',
title: 'Filter',
content: 'Filtern Sie Aufgaben nach Priorität, Bearbeiter oder Fälligkeitsdatum.',
position: 'bottom'
},
{
target: '#search-input',
title: 'Suche',
content: 'Durchsuchen Sie alle Aufgaben nach Titel oder Beschreibung. Tipp: Drücken Sie "/" für schnellen Zugriff.',
position: 'bottom'
},
{
target: '.user-menu',
title: 'Benutzermenu',
content: 'Hier können Sie Ihr Passwort ändern oder sich abmelden.',
position: 'bottom-left'
},
{
target: '#theme-toggle',
title: 'Design',
content: 'Wechseln Sie zwischen hellem und dunklem Design.',
position: 'bottom-left'
}
];
this.bindEvents();
}
bindEvents() {
window.addEventListener('tour:start', () => this.start());
window.addEventListener('tour:stop', () => this.stop());
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (!this.isActive) return;
if (e.key === 'Escape') {
this.stop();
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
this.next();
} else if (e.key === 'ArrowLeft') {
this.previous();
}
});
}
// =====================
// TOUR CONTROL
// =====================
start() {
// Check if tour was already completed
if (localStorage.getItem('tour_completed') === 'true') {
return;
}
this.isActive = true;
this.currentStep = 0;
this.createOverlay();
this.showStep();
}
stop() {
this.isActive = false;
if (this.overlay) {
this.overlay.remove();
this.overlay = null;
}
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
// Remove highlight from any element
$$('.tour-highlight')?.forEach(el => el.classList.remove('tour-highlight'));
}
complete() {
localStorage.setItem('tour_completed', 'true');
this.stop();
window.dispatchEvent(new CustomEvent('toast:show', {
detail: {
message: 'Tour abgeschlossen! Viel Erfolg mit TaskMate.',
type: 'success'
}
}));
}
next() {
if (this.currentStep < this.steps.length - 1) {
this.currentStep++;
this.showStep();
} else {
this.complete();
}
}
previous() {
if (this.currentStep > 0) {
this.currentStep--;
this.showStep();
}
}
skip() {
localStorage.setItem('tour_completed', 'true');
this.stop();
}
// =====================
// UI CREATION
// =====================
createOverlay() {
this.overlay = createElement('div', {
className: 'onboarding-overlay'
});
document.body.appendChild(this.overlay);
}
showStep() {
const step = this.steps[this.currentStep];
const targetElement = $(step.target);
if (!targetElement) {
// Skip to next step if target not found
this.next();
return;
}
// Remove previous highlight
$$('.tour-highlight')?.forEach(el => el.classList.remove('tour-highlight'));
// Highlight current target
targetElement.classList.add('tour-highlight');
// Position and show tooltip
this.showTooltip(step, targetElement);
}
showTooltip(step, targetElement) {
// Remove existing tooltip
if (this.tooltip) {
this.tooltip.remove();
}
// Create tooltip
this.tooltip = createElement('div', {
className: 'onboarding-tooltip'
});
// Content
const content = createElement('div', { className: 'onboarding-content' }, [
createElement('h3', {}, [step.title]),
createElement('p', {}, [step.content])
]);
this.tooltip.appendChild(content);
// Footer
const footer = createElement('div', { className: 'onboarding-footer' });
// Step indicator
footer.appendChild(createElement('span', {
id: 'onboarding-step'
}, [`${this.currentStep + 1} / ${this.steps.length}`]));
// Buttons
const buttons = createElement('div', { className: 'onboarding-buttons' });
if (this.currentStep > 0) {
buttons.appendChild(createElement('button', {
className: 'btn btn-ghost',
onclick: () => this.previous()
}, ['Zurück']));
}
buttons.appendChild(createElement('button', {
className: 'btn btn-ghost',
onclick: () => this.skip()
}, ['Überspringen']));
const isLast = this.currentStep === this.steps.length - 1;
buttons.appendChild(createElement('button', {
className: 'btn btn-primary',
onclick: () => this.next()
}, [isLast ? 'Fertig' : 'Weiter']));
footer.appendChild(buttons);
this.tooltip.appendChild(footer);
document.body.appendChild(this.tooltip);
// Position tooltip
this.positionTooltip(targetElement, step.position);
}
positionTooltip(targetElement, position) {
const targetRect = targetElement.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
const padding = 16;
let top, left;
switch (position) {
case 'top':
top = targetRect.top - tooltipRect.height - padding;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = targetRect.bottom + padding;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
left = targetRect.left - tooltipRect.width - padding;
break;
case 'right':
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
left = targetRect.right + padding;
break;
case 'bottom-left':
top = targetRect.bottom + padding;
left = targetRect.right - tooltipRect.width;
break;
case 'bottom-right':
top = targetRect.bottom + padding;
left = targetRect.left;
break;
default:
top = targetRect.bottom + padding;
left = targetRect.left;
}
// Keep within viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (left < padding) left = padding;
if (left + tooltipRect.width > viewportWidth - padding) {
left = viewportWidth - tooltipRect.width - padding;
}
if (top < padding) top = padding;
if (top + tooltipRect.height > viewportHeight - padding) {
top = viewportHeight - tooltipRect.height - padding;
}
this.tooltip.style.top = `${top}px`;
this.tooltip.style.left = `${left}px`;
}
// =====================
// HELPERS
// =====================
shouldShowTour() {
return localStorage.getItem('tour_completed') !== 'true';
}
resetTour() {
localStorage.removeItem('tour_completed');
}
}
// Helper function for querying multiple elements
function $$(selector) {
return Array.from(document.querySelectorAll(selector));
}
// Create and export singleton
const tourManager = new TourManager();
export default tourManager;

Datei anzeigen

@@ -4,7 +4,7 @@
* Offline support and caching
*/
const CACHE_VERSION = '390';
const CACHE_VERSION = '391';
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
@@ -33,7 +33,6 @@ const STATIC_ASSETS = [
'/js/list.js',
'/js/shortcuts.js',
'/js/undo.js',
'/js/tour.js',
'/js/admin.js',
'/js/proposals.js',
'/js/notifications.js',