Datenbank bereinigt / Gitea-Integration gefixt

Dieser Commit ist enthalten in:
hendrik_gebhardt@gmx.de
2026-01-04 00:24:11 +00:00
committet von Server Deploy
Ursprung 395598c2b0
Commit c21be47428
37 geänderte Dateien mit 30993 neuen und 809 gelöschten Zeilen

Datei anzeigen

@ -14,7 +14,17 @@ class AdminManager {
this.users = [];
this.currentEditUser = null;
this.uploadSettings = null;
this.allowedExtensions = ['pdf', 'docx', 'txt'];
this.initialized = false;
// Vorschläge für häufige Dateiendungen
this.extensionSuggestions = [
'xlsx', 'pptx', 'doc', 'xls', 'ppt', // Office
'png', 'jpg', 'gif', 'svg', 'webp', // Bilder
'csv', 'json', 'xml', 'md', // Daten
'zip', 'rar', '7z', // Archive
'odt', 'ods', 'rtf' // OpenDocument
];
}
async init() {
@ -52,7 +62,10 @@ class AdminManager {
// Upload Settings Elements
this.uploadMaxSizeInput = $('#upload-max-size');
this.saveUploadSettingsBtn = $('#btn-save-upload-settings');
this.uploadCategories = $$('.upload-category');
this.extensionTagsContainer = $('#extension-tags');
this.extensionInput = $('#extension-input');
this.addExtensionBtn = $('#btn-add-extension');
this.extensionSuggestionsList = $('#extension-suggestions-list');
this.bindEvents();
this.initialized = true;
@ -88,13 +101,20 @@ class AdminManager {
// Upload Settings - Save Button
this.saveUploadSettingsBtn?.addEventListener('click', () => this.saveUploadSettings());
// Upload Settings - Category Toggles
this.uploadCategories?.forEach(category => {
const checkbox = category.querySelector('input[type="checkbox"]');
checkbox?.addEventListener('change', () => {
this.toggleUploadCategory(category, checkbox.checked);
});
// Upload Settings - Add Extension
this.addExtensionBtn?.addEventListener('click', () => this.addExtensionFromInput());
// Enter-Taste im Input-Feld
this.extensionInput?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.addExtensionFromInput();
}
});
// Password-Buttons
$('#edit-password-btn')?.addEventListener('click', () => this.togglePasswordEdit());
$('#generate-password-btn')?.addEventListener('click', () => this.generatePassword());
}
async loadUsers() {
@ -222,8 +242,12 @@ class AdminManager {
this.emailInput.value = user.email || '';
this.emailInput.disabled = false;
// Passwort-Feld bei Bearbeitung ausblenden
this.passwordInput.closest('.form-group').style.display = 'none';
// Passwort-Feld für Bearbeitung vorbereiten
this.passwordInput.closest('.form-group').style.display = 'block';
this.passwordInput.value = '';
this.passwordInput.placeholder = 'Neues Passwort (leer lassen = unverändert)';
this.passwordInput.readOnly = true;
this.passwordHint.textContent = '(optional - leer lassen für unverändert)';
this.roleSelect.value = user.role || 'user';
@ -381,6 +405,7 @@ class AdminManager {
async loadUploadSettings() {
try {
this.uploadSettings = await api.getUploadSettings();
this.allowedExtensions = this.uploadSettings.allowedExtensions || ['pdf', 'docx', 'txt'];
this.renderUploadSettings();
} catch (error) {
console.error('Error loading upload settings:', error);
@ -395,36 +420,108 @@ class AdminManager {
this.uploadMaxSizeInput.value = this.uploadSettings.maxFileSizeMB || 15;
}
// Kategorien setzen
const categoryMap = {
'images': 'upload-cat-images',
'documents': 'upload-cat-documents',
'office': 'upload-cat-office',
'text': 'upload-cat-text',
'archives': 'upload-cat-archives',
'data': 'upload-cat-data'
};
// Extension-Tags rendern
this.renderExtensionTags();
Object.entries(categoryMap).forEach(([category, checkboxId]) => {
const checkbox = $(`#${checkboxId}`);
const categoryEl = $(`.upload-category[data-category="${category}"]`);
// Vorschläge rendern
this.renderExtensionSuggestions();
}
if (checkbox && this.uploadSettings.allowedTypes?.[category]) {
const isEnabled = this.uploadSettings.allowedTypes[category].enabled;
checkbox.checked = isEnabled;
this.toggleUploadCategory(categoryEl, isEnabled);
}
renderExtensionTags() {
if (!this.extensionTagsContainer) return;
if (this.allowedExtensions.length === 0) {
this.extensionTagsContainer.innerHTML = '<span class="extension-empty">Keine Endungen definiert</span>';
return;
}
this.extensionTagsContainer.innerHTML = this.allowedExtensions.map(ext => `
<span class="extension-tag" data-extension="${ext}">
.${ext}
<button type="button" class="extension-tag-remove" data-remove="${ext}" title="Entfernen">
<svg viewBox="0 0 24 24" width="12" height="12"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</span>
`).join('');
// Remove-Buttons Event Listener
this.extensionTagsContainer.querySelectorAll('.extension-tag-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const ext = btn.dataset.remove;
this.removeExtension(ext);
});
});
}
toggleUploadCategory(categoryEl, enabled) {
if (!categoryEl) return;
renderExtensionSuggestions() {
if (!this.extensionSuggestionsList) return;
if (enabled) {
categoryEl.classList.remove('disabled');
} else {
categoryEl.classList.add('disabled');
// Nur Vorschläge anzeigen, die noch nicht aktiv sind
const availableSuggestions = this.extensionSuggestions.filter(
ext => !this.allowedExtensions.includes(ext)
);
if (availableSuggestions.length === 0) {
this.extensionSuggestionsList.innerHTML = '<span class="extension-no-suggestions">Alle Vorschläge bereits hinzugefügt</span>';
return;
}
this.extensionSuggestionsList.innerHTML = availableSuggestions.map(ext => `
<button type="button" class="extension-suggestion" data-suggestion="${ext}">+ ${ext}</button>
`).join('');
// Suggestion-Buttons Event Listener
this.extensionSuggestionsList.querySelectorAll('.extension-suggestion').forEach(btn => {
btn.addEventListener('click', () => {
const ext = btn.dataset.suggestion;
this.addExtension(ext);
});
});
}
addExtensionFromInput() {
const input = this.extensionInput?.value?.trim().toLowerCase();
if (!input) return;
// Punkt am Anfang entfernen falls vorhanden
const ext = input.replace(/^\./, '');
if (this.addExtension(ext)) {
this.extensionInput.value = '';
}
}
addExtension(ext) {
// Validierung: nur alphanumerisch, 1-10 Zeichen
if (!/^[a-z0-9]{1,10}$/.test(ext)) {
this.showToast('Ungültige Dateiendung (nur Buchstaben/Zahlen, max. 10 Zeichen)', 'error');
return false;
}
// Prüfen ob bereits vorhanden
if (this.allowedExtensions.includes(ext)) {
this.showToast(`Endung .${ext} bereits vorhanden`, 'error');
return false;
}
// Hinzufügen
this.allowedExtensions.push(ext);
this.renderExtensionTags();
this.renderExtensionSuggestions();
return true;
}
removeExtension(ext) {
// Prüfen ob mindestens eine Endung übrig bleibt
if (this.allowedExtensions.length <= 1) {
this.showToast('Mindestens eine Dateiendung muss erlaubt sein', 'error');
return;
}
this.allowedExtensions = this.allowedExtensions.filter(e => e !== ext);
this.renderExtensionTags();
this.renderExtensionSuggestions();
}
async saveUploadSettings() {
@ -437,51 +534,17 @@ class AdminManager {
return;
}
// Kategorien sammeln
const allowedTypes = {
images: {
enabled: $('#upload-cat-images')?.checked ?? true,
types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
},
documents: {
enabled: $('#upload-cat-documents')?.checked ?? true,
types: ['application/pdf']
},
office: {
enabled: $('#upload-cat-office')?.checked ?? true,
types: [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
]
},
text: {
enabled: $('#upload-cat-text')?.checked ?? true,
types: ['text/plain', 'text/csv', 'text/markdown']
},
archives: {
enabled: $('#upload-cat-archives')?.checked ?? true,
types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed']
},
data: {
enabled: $('#upload-cat-data')?.checked ?? true,
types: ['application/json']
}
};
// Prüfen ob mindestens eine Kategorie aktiviert ist
const hasEnabledCategory = Object.values(allowedTypes).some(cat => cat.enabled);
if (!hasEnabledCategory) {
this.showToast('Mindestens eine Dateikategorie muss aktiviert sein', 'error');
if (this.allowedExtensions.length === 0) {
this.showToast('Mindestens eine Dateiendung muss erlaubt sein', 'error');
return;
}
await api.updateUploadSettings({ maxFileSizeMB, allowedTypes });
await api.updateUploadSettings({
maxFileSizeMB,
allowedExtensions: this.allowedExtensions
});
this.uploadSettings = { maxFileSizeMB, allowedTypes };
this.uploadSettings = { maxFileSizeMB, allowedExtensions: this.allowedExtensions };
this.showToast('Upload-Einstellungen gespeichert', 'success');
} catch (error) {
console.error('Error saving upload settings:', error);
@ -489,6 +552,72 @@ class AdminManager {
}
}
/**
* Passwort-Bearbeitung umschalten
*/
togglePasswordEdit() {
const passwordInput = $('#user-password');
const editBtn = $('#edit-password-btn');
const hint = $('#password-hint');
if (!passwordInput || !editBtn) return;
if (passwordInput.readOnly) {
// Bearbeitung aktivieren
passwordInput.readOnly = false;
passwordInput.focus();
passwordInput.select();
editBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
editBtn.title = "Bearbeitung bestätigen";
hint.textContent = "(bearbeiten)";
} else {
// Bearbeitung beenden
passwordInput.readOnly = true;
editBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
`;
editBtn.title = "Passwort bearbeiten";
hint.textContent = this.currentEditUser ? "(geändert)" : "(automatisch generiert)";
}
}
/**
* Neues Passwort generieren
*/
generatePassword() {
const passwordInput = $('#user-password');
const hint = $('#password-hint');
if (!passwordInput) return;
// Starkes Passwort generieren (12 Zeichen)
const charset = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%&*';
let password = '';
for (let i = 0; i < 12; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
passwordInput.value = password;
passwordInput.readOnly = false;
if (hint) {
hint.textContent = "(neu generiert)";
}
// Passwort kurz markieren
passwordInput.focus();
passwordInput.select();
this.showToast('Neues Passwort generiert', 'success');
}
show() {
this.adminScreen?.classList.add('active');
}

Datei anzeigen

@ -7,9 +7,25 @@ class ApiClient {
constructor() {
this.baseUrl = '/api';
this.token = null;
this.refreshToken = null;
this.csrfToken = null;
this.refreshingToken = false;
this.requestQueue = [];
this.refreshTimer = null;
this.init();
}
init() {
// Token aus Storage laden
this.token = localStorage.getItem('auth_token');
this.refreshToken = localStorage.getItem('refresh_token');
this.csrfToken = sessionStorage.getItem('csrf_token');
console.log('[API] init() - Token loaded:', this.token ? this.token.substring(0, 20) + '...' : 'NULL');
// Starte Timer wenn Token und Refresh-Token vorhanden sind
if (this.token && this.refreshToken) {
this.startTokenRefreshTimer();
}
}
// Token Management
@ -18,10 +34,22 @@ class ApiClient {
this.token = token;
if (token) {
localStorage.setItem('auth_token', token);
// Starte proaktiven Token-Refresh Timer (nach 10 Minuten)
this.startTokenRefreshTimer();
} else {
this.token = null;
localStorage.removeItem('auth_token');
localStorage.removeItem('current_user');
this.clearTokenRefreshTimer();
}
}
setRefreshToken(token) {
this.refreshToken = token;
if (token) {
localStorage.setItem('refresh_token', token);
} else {
localStorage.removeItem('refresh_token');
}
}
@ -49,6 +77,94 @@ class ApiClient {
return token;
}
// Refresh Access Token using Refresh Token
async refreshAccessToken() {
if (this.refreshingToken) {
// Warte auf laufenden Refresh
return new Promise((resolve) => {
const checkRefresh = () => {
if (!this.refreshingToken) {
resolve();
} else {
setTimeout(checkRefresh, 100);
}
};
checkRefresh();
});
}
this.refreshingToken = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
throw new Error('Kein Refresh-Token vorhanden');
}
console.log('[API] Refreshing access token...');
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken })
});
if (!response.ok) {
throw new Error(`Refresh failed: ${response.status}`);
}
const data = await response.json();
this.setToken(data.token);
if (data.csrfToken) {
this.setCsrfToken(data.csrfToken);
}
console.log('[API] Token refresh successful');
window.dispatchEvent(new CustomEvent('auth:token-refreshed', {
detail: { token: data.token }
}));
} catch (error) {
console.log('[API] Token refresh error:', error.message);
throw error;
} finally {
this.refreshingToken = false;
}
}
// Handle authentication failure
handleAuthFailure() {
console.log('[API] Authentication failed - clearing tokens');
this.setToken(null);
this.setRefreshToken(null);
this.setCsrfToken(null);
window.dispatchEvent(new CustomEvent('auth:logout'));
}
// Proaktiver Token-Refresh Timer
startTokenRefreshTimer() {
this.clearTokenRefreshTimer();
// Refresh nach 10 Minuten (Token läuft nach 15 Minuten ab)
this.refreshTimer = setTimeout(async () => {
if (this.refreshToken && !this.refreshingToken) {
try {
console.log('[API] Proactive token refresh...');
await this.refreshAccessToken();
} catch (error) {
console.log('[API] Proactive refresh failed:', error.message);
// Bei Fehler nicht automatisch ausloggen, warten bis Token wirklich abläuft
}
}
}, 10 * 60 * 1000); // 10 Minuten
}
clearTokenRefreshTimer() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
// Base Request Method
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
@ -58,6 +174,11 @@ class ApiClient {
...options.headers
};
// Sicherstellen, dass Token aktuell ist
if (!this.token && localStorage.getItem('auth_token')) {
this.init();
}
// Add auth token
const token = this.getToken();
console.log('[API] Request:', endpoint, 'Token:', token ? token.substring(0, 20) + '...' : 'NULL');
@ -103,22 +224,25 @@ class ApiClient {
// Handle 401 Unauthorized
if (response.status === 401) {
// Token der für diesen Request verwendet wurde
const requestToken = token;
const currentToken = localStorage.getItem('auth_token');
console.log('[API] 401 received for:', endpoint);
console.log('[API] Request token:', requestToken ? requestToken.substring(0, 20) + '...' : 'NULL');
console.log('[API] Current token:', currentToken ? currentToken.substring(0, 20) + '...' : 'NULL');
// Nur ausloggen wenn der Token der gleiche ist (kein neuer Login in der Zwischenzeit)
if (!currentToken || currentToken === requestToken) {
console.log('[API] Token invalid, triggering logout');
this.setToken(null);
window.dispatchEvent(new CustomEvent('auth:logout'));
} else {
console.log('[API] 401 ignored - new login occurred while request was in flight');
// Versuche Token mit Refresh-Token zu erneuern
if (this.refreshToken && !this.refreshingToken && !options._tokenRefreshAttempted) {
console.log('[API] Attempting token refresh...');
try {
await this.refreshAccessToken();
// Wiederhole original Request mit neuem Token
return this.request(endpoint, { ...options, _tokenRefreshAttempted: true });
} catch (refreshError) {
console.log('[API] Token refresh failed:', refreshError.message);
// Fallback zum Logout
this.handleAuthFailure();
throw new ApiError('Sitzung abgelaufen', 401);
}
}
// Kein Refresh-Token oder Refresh bereits versucht
this.handleAuthFailure();
throw new ApiError('Sitzung abgelaufen', 401);
}
@ -297,6 +421,12 @@ class ApiClient {
const response = await this.post('/auth/login', { username, password });
console.log('[API] login() response:', response ? 'OK' : 'NULL', 'token:', response?.token ? 'EXISTS' : 'MISSING');
this.setToken(response.token);
// Store refresh token if provided (new auth system)
if (response.refreshToken) {
this.setRefreshToken(response.refreshToken);
}
// Store CSRF token from login response
if (response.csrfToken) {
this.setCsrfToken(response.csrfToken);
@ -309,6 +439,7 @@ class ApiClient {
await this.post('/auth/logout', {});
} finally {
this.setToken(null);
this.setRefreshToken(null);
this.setCsrfToken(null);
}
}
@ -1071,6 +1202,62 @@ class ApiClient {
async searchKnowledge(query) {
return this.get(`/knowledge/search?q=${encodeURIComponent(query)}`);
}
// =====================
// CODING
// =====================
async getCodingDirectories() {
return this.get('/coding/directories');
}
async createCodingDirectory(data) {
return this.post('/coding/directories', data);
}
async updateCodingDirectory(id, data) {
return this.put(`/coding/directories/${id}`, data);
}
async deleteCodingDirectory(id) {
return this.delete(`/coding/directories/${id}`);
}
async getCodingDirectoryStatus(id) {
return this.get(`/coding/directories/${id}/status`);
}
async codingGitFetch(id) {
return this.post(`/coding/directories/${id}/fetch`);
}
async codingGitPull(id) {
return this.post(`/coding/directories/${id}/pull`);
}
async codingGitPush(id, force = false) {
return this.post(`/coding/directories/${id}/push`, { force });
}
async codingGitCommit(id, message) {
return this.post(`/coding/directories/${id}/commit`, { message });
}
async getCodingDirectoryBranches(id) {
return this.get(`/coding/directories/${id}/branches`);
}
async codingGitCheckout(id, branch) {
return this.post(`/coding/directories/${id}/checkout`, { branch });
}
async getCodingDirectoryCommits(id, limit = 20) {
return this.get(`/coding/directories/${id}/commits?limit=${limit}`);
}
async validateCodingPath(path) {
return this.post('/coding/validate-path', { path });
}
}
// Custom API Error Class

Datei anzeigen

@ -21,6 +21,8 @@ import proposalsManager from './proposals.js';
import notificationManager from './notifications.js';
import giteaManager from './gitea.js';
import knowledgeManager from './knowledge.js';
import codingManager from './coding.js';
import mobileManager from './mobile.js';
import { $, $$, debounce, getFromStorage, setToStorage } from './utils.js';
class App {
@ -80,11 +82,20 @@ class App {
// Initialize gitea manager
await giteaManager.init();
// Initialize coding manager
await codingManager.init();
// Initialize knowledge manager
await knowledgeManager.init();
// Initialize mobile features
mobileManager.init();
// Update UI
this.updateUserMenu();
// Dispatch event for mobile menu
document.dispatchEvent(new CustomEvent('projects:loaded'));
}
async initializeAdminApp() {
@ -321,6 +332,32 @@ class App {
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
// Mobile events
document.addEventListener('project:selected', (e) => {
const projectId = e.detail?.projectId;
if (projectId) {
this.loadProject(projectId);
}
});
document.addEventListener('auth:logout', () => {
authManager.logout();
});
document.addEventListener('admin:open', () => {
// Redirect to admin screen for admins
if (authManager.isAdmin()) {
this.showAdminScreen();
}
});
document.addEventListener('task:move', async (e) => {
const { taskId, columnId, position } = e.detail;
if (taskId && columnId !== undefined) {
await boardManager.moveTask(taskId, columnId, position);
}
});
// Close modal on overlay click
$('.modal-overlay')?.addEventListener('click', () => {
// Check if task-modal is open - let it handle its own close (with auto-save)
@ -617,11 +654,11 @@ class App {
proposalsManager.resetToActiveView();
}
// Show/hide gitea manager
if (view === 'gitea') {
giteaManager.show();
// Show/hide coding manager
if (view === 'coding') {
codingManager.show();
} else {
giteaManager.hide();
codingManager.hide();
}
// Show/hide knowledge manager
@ -978,6 +1015,9 @@ class App {
const userRole = $('#user-role');
if (userRole) userRole.textContent = user.role === 'admin' ? 'Administrator' : 'Benutzer';
// Notify mobile menu
document.dispatchEvent(new CustomEvent('user:updated'));
}
toggleUserMenu() {

777
frontend/js/coding.js Normale Datei
Datei anzeigen

@ -0,0 +1,777 @@
/**
* TASKMATE - Coding Manager
* =========================
* Verwaltung von Server-Anwendungen mit Claude/Codex Integration
*/
import api from './api.js';
import { escapeHtml } from './utils.js';
// Toast-Funktion (verwendet das globale Toast-Event)
function showToast(message, type = 'info') {
window.dispatchEvent(new CustomEvent('toast:show', {
detail: { message, type }
}));
}
// Basis-Pfad für alle Anwendungen auf dem Server
const BASE_PATH = '/home/claude-dev';
// Farb-Presets für Anwendungen
const COLOR_PRESETS = [
'#4F46E5', // Indigo
'#7C3AED', // Violet
'#EC4899', // Pink
'#EF4444', // Red
'#F59E0B', // Amber
'#10B981', // Emerald
'#06B6D4', // Cyan
'#3B82F6', // Blue
'#8B5CF6', // Purple
'#6366F1' // Indigo Light
];
class CodingManager {
constructor() {
this.initialized = false;
this.directories = [];
this.refreshInterval = null;
this.editingDirectory = null;
this.giteaRepos = [];
}
/**
* Manager initialisieren
*/
async init() {
if (this.initialized) return;
this.bindEvents();
this.initialized = true;
console.log('[CodingManager] Initialisiert');
}
/**
* Event-Listener binden
*/
bindEvents() {
// Add-Button
const addBtn = document.getElementById('add-coding-directory-btn');
if (addBtn) {
addBtn.addEventListener('click', () => this.openModal());
}
// Modal Events
const modal = document.getElementById('coding-modal');
if (modal) {
// Close-Button
modal.querySelector('.modal-close')?.addEventListener('click', () => this.closeModal());
modal.querySelector('.modal-cancel')?.addEventListener('click', () => this.closeModal());
// Save-Button
document.getElementById('coding-save-btn')?.addEventListener('click', () => this.handleSave());
// Delete-Button
document.getElementById('coding-delete-btn')?.addEventListener('click', () => this.handleDelete());
// Backdrop-Click
modal.addEventListener('click', (e) => {
if (e.target === modal) this.closeModal();
});
// Farb-Presets
this.renderColorPresets();
// Name-Eingabe für Pfad-Preview
const nameInput = document.getElementById('coding-name');
if (nameInput) {
nameInput.addEventListener('input', () => this.updatePathPreview());
}
// CLAUDE.md Link Event
const claudeLink = document.getElementById('coding-claude-link');
if (claudeLink) {
claudeLink.addEventListener('click', () => this.openClaudeModal());
}
}
// Command-Modal Events
const cmdModal = document.getElementById('coding-command-modal');
if (cmdModal) {
cmdModal.querySelector('.modal-close')?.addEventListener('click', () => this.closeCommandModal());
cmdModal.addEventListener('click', (e) => {
if (e.target === cmdModal) this.closeCommandModal();
});
document.getElementById('coding-copy-command')?.addEventListener('click', () => this.copyCommand());
}
// Gitea-Repo Dropdown laden bei Details-Toggle
const giteaSection = document.querySelector('.coding-gitea-section');
if (giteaSection) {
giteaSection.addEventListener('toggle', (e) => {
if (e.target.open) {
this.loadGiteaRepos();
}
});
}
// CLAUDE.md Modal Events
const claudeModal = document.getElementById('claude-md-modal');
if (claudeModal) {
// Close-Button
claudeModal.querySelector('.modal-close')?.addEventListener('click', () => this.closeClaudeModal());
// Backdrop-Click
claudeModal.addEventListener('click', (e) => {
if (e.target === claudeModal) this.closeClaudeModal();
});
// ESC-Taste
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !claudeModal.classList.contains('hidden')) {
this.closeClaudeModal();
}
});
}
}
/**
* Farb-Presets rendern
*/
renderColorPresets() {
const container = document.getElementById('coding-color-presets');
if (!container) return;
container.innerHTML = COLOR_PRESETS.map(color => `
<button type="button" class="color-preset" data-color="${color}" style="background-color: ${color};" title="${color}"></button>
`).join('') + `
<input type="color" id="coding-color-custom" class="color-picker-custom" value="#4F46E5" title="Eigene Farbe">
`;
// Event-Listener für Presets
container.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', () => {
container.querySelectorAll('.color-preset').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
document.getElementById('coding-color-custom').value = btn.dataset.color;
});
});
// Custom Color Input
document.getElementById('coding-color-custom')?.addEventListener('input', (e) => {
container.querySelectorAll('.color-preset').forEach(b => b.classList.remove('selected'));
});
}
/**
* Pfad-Preview aktualisieren
*/
updatePathPreview() {
const nameInput = document.getElementById('coding-name');
const preview = document.getElementById('coding-path-preview');
if (!nameInput || !preview) return;
const name = nameInput.value.trim();
preview.textContent = name || '...';
}
// switchClaudeTab entfernt - CLAUDE.md ist jetzt nur readonly
/**
* CLAUDE.md Link aktualisieren
*/
updateClaudeLink(content, projectName) {
const link = document.getElementById('coding-claude-link');
const textSpan = link?.querySelector('.claude-text');
// Debug entfernt
if (!link || !textSpan) {
console.error('CLAUDE.md link elements not found!');
return;
}
// Content für Modal speichern
this.currentClaudeContent = content;
this.currentProjectName = projectName;
if (content) {
link.disabled = false;
textSpan.textContent = `CLAUDE.md anzeigen (${Math.round(content.length / 1024)}KB)`;
} else {
link.disabled = true;
textSpan.textContent = 'Keine CLAUDE.md vorhanden';
}
}
/**
* CLAUDE.md Modal öffnen
*/
openClaudeModal() {
if (!this.currentClaudeContent) {
console.warn('No CLAUDE.md content to display');
return;
}
const modal = document.getElementById('claude-md-modal');
const overlay = document.querySelector('.modal-overlay');
const content = document.getElementById('claude-md-content');
const title = modal?.querySelector('.modal-header h3');
if (!modal || !content) {
console.error('CLAUDE.md modal elements not found!');
return;
}
// Titel setzen
if (title && this.currentProjectName) {
title.textContent = `CLAUDE.md - ${this.currentProjectName}`;
}
// Content setzen
content.textContent = this.currentClaudeContent;
// Modal anzeigen
modal.classList.remove('hidden');
modal.classList.add('visible');
if (overlay) {
overlay.classList.remove('hidden');
overlay.classList.add('visible');
}
// Modal opened
}
/**
* CLAUDE.md Modal schließen
*/
closeClaudeModal() {
const modal = document.getElementById('claude-md-modal');
const overlay = document.querySelector('.modal-overlay');
if (modal) {
modal.classList.remove('visible');
setTimeout(() => modal.classList.add('hidden'), 200);
}
if (overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.classList.add('hidden'), 200);
}
// Modal closed
}
/**
* Gitea-Repositories laden
*/
async loadGiteaRepos() {
try {
const select = document.getElementById('coding-gitea-repo');
if (!select) return;
// Lade-Indikator
select.innerHTML = '<option value="">Laden...</option>';
const result = await api.getGiteaRepositories();
console.log('Gitea API Response:', result);
this.giteaRepos = result?.repositories || [];
select.innerHTML = '<option value="">-- Kein Repository --</option>' +
this.giteaRepos.map(repo => `
<option value="${repo.cloneUrl}" data-owner="${repo.owner || ''}" data-name="${repo.name}">
${escapeHtml(repo.fullName)}
</option>
`).join('');
// Wenn Editing, vorhandenen Wert setzen
if (this.editingDirectory?.giteaRepoUrl) {
select.value = this.editingDirectory.giteaRepoUrl;
}
} catch (error) {
console.error('Fehler beim Laden der Gitea-Repos:', error);
const select = document.getElementById('coding-gitea-repo');
if (select) {
select.innerHTML = '<option value="">Fehler beim Laden</option>';
}
}
}
/**
* Anwendungen laden
*/
async loadDirectories() {
try {
this.directories = await api.getCodingDirectories();
this.render();
} catch (error) {
console.error('Fehler beim Laden der Anwendungen:', error);
showToast('Fehler beim Laden der Anwendungen', 'error');
}
}
/**
* View rendern
*/
render() {
const grid = document.getElementById('coding-grid');
const empty = document.getElementById('coding-empty');
if (!grid) return;
if (this.directories.length === 0) {
grid.innerHTML = '';
grid.classList.add('hidden');
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
grid.classList.remove('hidden');
grid.innerHTML = this.directories.map(dir => this.renderTile(dir)).join('');
// Event-Listener für Tiles
this.bindTileEvents();
// Git-Status für jede Anwendung laden
this.directories.forEach(dir => this.updateTileStatus(dir.id));
}
/**
* Einzelne Kachel rendern
*/
renderTile(directory) {
const hasGitea = !!directory.giteaRepoUrl;
return `
<div class="coding-tile" data-id="${directory.id}">
<div class="coding-tile-color" style="background-color: ${directory.color || '#4F46E5'}"></div>
<div class="coding-tile-header">
<span class="coding-tile-icon">📁</span>
</div>
<div class="coding-tile-content">
<div class="coding-tile-name">${escapeHtml(directory.name)}</div>
<div class="coding-tile-path">${escapeHtml(directory.localPath)}</div>
${directory.description ? `<div class="coding-tile-description">${escapeHtml(directory.description)}</div>` : ''}
${directory.hasCLAUDEmd ? '<div class="coding-tile-badge">CLAUDE.md</div>' : ''}
</div>
<div class="coding-tile-status" id="coding-status-${directory.id}">
<span class="git-status-badge loading">Lade...</span>
</div>
<div class="coding-tile-actions">
<button class="btn-claude" data-id="${directory.id}" data-path="${escapeHtml(directory.localPath)}" title="SSH-Befehl für Claude kopieren">
Claude starten
</button>
</div>
${hasGitea ? `
<div class="coding-tile-git">
<button class="btn btn-sm btn-secondary coding-git-fetch" data-id="${directory.id}">Fetch</button>
<button class="btn btn-sm btn-secondary coding-git-pull" data-id="${directory.id}">Pull</button>
<button class="btn btn-sm btn-secondary coding-git-push" data-id="${directory.id}">Push</button>
<button class="btn btn-sm btn-secondary coding-git-commit" data-id="${directory.id}">Commit</button>
</div>
` : ''}
</div>
`;
}
/**
* Event-Listener für Tiles binden
*/
bindTileEvents() {
// Kachel-Klick für Modal
document.querySelectorAll('.coding-tile').forEach(tile => {
tile.addEventListener('click', (e) => {
// Nicht triggern wenn Button-Kind geklickt wird
if (e.target.closest('button')) return;
const id = parseInt(tile.dataset.id);
const dir = this.directories.find(d => d.id === id);
if (dir) this.openModal(dir);
});
});
// Claude-Buttons
document.querySelectorAll('.btn-claude').forEach(btn => {
btn.addEventListener('click', () => this.launchClaude(btn.dataset.path));
});
// Git-Buttons
document.querySelectorAll('.coding-git-fetch').forEach(btn => {
btn.addEventListener('click', () => this.gitFetch(parseInt(btn.dataset.id)));
});
document.querySelectorAll('.coding-git-pull').forEach(btn => {
btn.addEventListener('click', () => this.gitPull(parseInt(btn.dataset.id)));
});
document.querySelectorAll('.coding-git-push').forEach(btn => {
btn.addEventListener('click', () => this.gitPush(parseInt(btn.dataset.id)));
});
document.querySelectorAll('.coding-git-commit').forEach(btn => {
btn.addEventListener('click', () => this.promptCommit(parseInt(btn.dataset.id)));
});
}
/**
* Git-Status für eine Kachel aktualisieren
*/
async updateTileStatus(id) {
const statusEl = document.getElementById(`coding-status-${id}`);
if (!statusEl) return;
try {
const status = await api.getCodingDirectoryStatus(id);
if (!status.isGitRepo) {
statusEl.innerHTML = '<span class="git-status-badge">Kein Git-Repo</span>';
return;
}
const statusClass = status.isClean ? 'clean' : 'dirty';
const statusText = status.isClean ? 'Clean' : `${status.changes?.length || 0} Änderungen`;
statusEl.innerHTML = `
<span class="git-branch-badge">${escapeHtml(status.branch)}</span>
<span class="git-status-badge ${statusClass}">${statusText}</span>
${status.ahead > 0 ? `<span class="git-status-badge ahead">↑${status.ahead}</span>` : ''}
${status.behind > 0 ? `<span class="git-status-badge behind">↓${status.behind}</span>` : ''}
`;
} catch (error) {
statusEl.innerHTML = '<span class="git-status-badge error">Fehler</span>';
}
}
/**
* Claude Code starten - SSH-Befehl kopieren
*/
async launchClaude(path) {
const command = `ssh claude-dev@91.99.192.14 -t "cd ${path} && claude"`;
try {
await navigator.clipboard.writeText(command);
this.showCommandModal(
command,
'Befehl kopiert! Öffne Terminal/CMD und füge ein (Strg+V). Passwort: z0E1Al}q2H?Yqd!O'
);
showToast('SSH-Befehl kopiert!', 'success');
} catch (error) {
// Fallback wenn Clipboard nicht verfügbar
this.showCommandModal(
command,
'Kopiere diesen Befehl und füge ihn im Terminal ein. Passwort: z0E1Al}q2H?Yqd!O'
);
}
}
/**
* Command-Modal anzeigen
*/
showCommandModal(command, hint) {
const modal = document.getElementById('coding-command-modal');
const hintEl = document.getElementById('coding-command-hint');
const textEl = document.getElementById('coding-command-text');
if (!modal || !textEl) return;
hintEl.textContent = hint || 'Führe diesen Befehl aus:';
textEl.textContent = command;
this.currentCommand = command;
modal.classList.remove('hidden');
}
/**
* Command-Modal schließen
*/
closeCommandModal() {
const modal = document.getElementById('coding-command-modal');
if (modal) modal.classList.add('hidden');
}
/**
* Befehl in Zwischenablage kopieren
*/
async copyCommand() {
if (!this.currentCommand) return;
try {
await navigator.clipboard.writeText(this.currentCommand);
showToast('Befehl kopiert!', 'success');
} catch (error) {
console.error('Kopieren fehlgeschlagen:', error);
showToast('Kopieren fehlgeschlagen', 'error');
}
}
/**
* Git Fetch
*/
async gitFetch(id) {
try {
showToast('Fetch läuft...', 'info');
await api.codingGitFetch(id);
showToast('Fetch erfolgreich', 'success');
this.updateTileStatus(id);
} catch (error) {
showToast('Fetch fehlgeschlagen', 'error');
}
}
/**
* Git Pull
*/
async gitPull(id) {
try {
showToast('Pull läuft...', 'info');
await api.codingGitPull(id);
showToast('Pull erfolgreich', 'success');
this.updateTileStatus(id);
} catch (error) {
showToast('Pull fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler'), 'error');
}
}
/**
* Git Push
*/
async gitPush(id) {
try {
showToast('Push läuft...', 'info');
await api.codingGitPush(id);
showToast('Push erfolgreich', 'success');
this.updateTileStatus(id);
} catch (error) {
showToast('Push fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler'), 'error');
}
}
/**
* Commit-Dialog anzeigen
*/
promptCommit(id) {
const message = prompt('Commit-Nachricht eingeben:');
if (message && message.trim()) {
this.gitCommit(id, message.trim());
}
}
/**
* Git Commit
*/
async gitCommit(id, message) {
try {
showToast('Commit läuft...', 'info');
await api.codingGitCommit(id, message);
showToast('Commit erfolgreich', 'success');
this.updateTileStatus(id);
} catch (error) {
showToast('Commit fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler'), 'error');
}
}
/**
* Modal öffnen
*/
openModal(directory = null) {
this.editingDirectory = directory;
const modal = document.getElementById('coding-modal');
const overlay = document.querySelector('.modal-overlay');
const title = document.getElementById('coding-modal-title');
const deleteBtn = document.getElementById('coding-delete-btn');
if (!modal) return;
// Titel setzen
title.textContent = directory ? 'Anwendung bearbeiten' : 'Anwendung hinzufügen';
// Delete-Button anzeigen/verstecken
if (deleteBtn) {
deleteBtn.classList.toggle('hidden', !directory);
}
// Felder füllen
document.getElementById('coding-name').value = directory?.name || '';
document.getElementById('coding-description').value = directory?.description || '';
document.getElementById('coding-branch').value = directory?.defaultBranch || 'main';
// CLAUDE.md: Nur aus Dateisystem anzeigen
const claudeContent = directory?.claudeMdFromDisk || '';
this.updateClaudeLink(claudeContent, directory?.name);
// Pfad-Preview aktualisieren
this.updatePathPreview();
// Farbe setzen
const color = directory?.color || '#4F46E5';
document.getElementById('coding-color-custom').value = color;
document.querySelectorAll('.color-preset').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.color === color);
});
// Gitea-Sektion zurücksetzen
const giteaSection = document.querySelector('.coding-gitea-section');
if (giteaSection) {
giteaSection.open = !!directory?.giteaRepoUrl;
}
// Repos laden wenn nötig
if (directory?.giteaRepoUrl) {
this.loadGiteaRepos();
}
// Modal und Overlay anzeigen
modal.classList.remove('hidden');
modal.classList.add('visible');
if (overlay) {
overlay.classList.remove('hidden');
overlay.classList.add('visible');
}
}
/**
* Modal schließen
*/
closeModal() {
const modal = document.getElementById('coding-modal');
const overlay = document.querySelector('.modal-overlay');
if (modal) {
modal.classList.remove('visible');
setTimeout(() => modal.classList.add('hidden'), 200);
this.editingDirectory = null;
}
if (overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.classList.add('hidden'), 200);
}
}
/**
* Speichern-Handler
*/
async handleSave() {
const name = document.getElementById('coding-name').value.trim();
const description = document.getElementById('coding-description').value.trim();
// CLAUDE.md wird nicht mehr gespeichert - nur readonly
const defaultBranch = document.getElementById('coding-branch').value.trim() || 'main';
// Pfad automatisch aus Name generieren
const localPath = `${BASE_PATH}/${name}`;
// Farbe ermitteln
const selectedPreset = document.querySelector('.color-preset.selected');
const color = selectedPreset?.dataset.color || document.getElementById('coding-color-custom').value;
// Gitea-Daten
const giteaSelect = document.getElementById('coding-gitea-repo');
const giteaRepoUrl = giteaSelect?.value || null;
const giteaRepoOwner = giteaSelect?.selectedOptions[0]?.dataset.owner || null;
const giteaRepoName = giteaSelect?.selectedOptions[0]?.dataset.name || null;
if (!name) {
showToast('Anwendungsname ist erforderlich', 'error');
return;
}
const data = {
name,
localPath,
description,
color,
// claudeInstructions entfernt - CLAUDE.md ist readonly
giteaRepoUrl,
giteaRepoOwner,
giteaRepoName,
defaultBranch
};
try {
if (this.editingDirectory) {
await api.updateCodingDirectory(this.editingDirectory.id, data);
showToast('Anwendung aktualisiert', 'success');
} else {
await api.createCodingDirectory(data);
showToast('Anwendung hinzugefügt', 'success');
}
this.closeModal();
await this.loadDirectories();
} catch (error) {
console.error('Fehler beim Speichern:', error);
showToast(error.message || 'Fehler beim Speichern', 'error');
}
}
/**
* Löschen-Handler
*/
async handleDelete() {
if (!this.editingDirectory) return;
if (!confirm(`Anwendung "${this.editingDirectory.name}" wirklich löschen?`)) {
return;
}
try {
await api.deleteCodingDirectory(this.editingDirectory.id);
showToast('Anwendung gelöscht', 'success');
this.closeModal();
await this.loadDirectories();
} catch (error) {
console.error('Fehler beim Löschen:', error);
showToast('Fehler beim Löschen', 'error');
}
}
/**
* Auto-Refresh starten
*/
startAutoRefresh() {
this.stopAutoRefresh();
// Alle 30 Sekunden aktualisieren
this.refreshInterval = setInterval(() => {
this.directories.forEach(dir => this.updateTileStatus(dir.id));
}, 30000);
}
/**
* Auto-Refresh stoppen
*/
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
/**
* View anzeigen
*/
async show() {
await this.loadDirectories();
this.startAutoRefresh();
}
/**
* View verstecken
*/
hide() {
this.stopAutoRefresh();
}
}
// Singleton-Instanz erstellen und exportieren
const codingManager = new CodingManager();
export default codingManager;

Datei anzeigen

@ -86,6 +86,13 @@ class ListViewManager {
this.contentElement.addEventListener('click', (e) => this.handleContentClick(e));
this.contentElement.addEventListener('change', (e) => this.handleContentChange(e));
this.contentElement.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
// Stop editing when clicking outside
document.addEventListener('click', (e) => {
if (this.editingCell && !this.editingCell.contains(e.target)) {
this.stopEditing();
}
});
}
}
@ -407,19 +414,53 @@ class ListViewManager {
const users = store.get('users');
const cell = createElement('div', { className: 'list-cell list-cell-assignee list-cell-editable' });
const assignedUser = users.find(u => u.id === task.assignedTo);
// Avatar
if (assignedUser) {
const avatar = createElement('div', {
className: 'avatar',
style: { backgroundColor: assignedUser.color || '#6366F1' }
}, [getInitials(assignedUser.displayName)]);
cell.appendChild(avatar);
// Sammle alle zugewiesenen Benutzer aus assignees Array
const assignedUserIds = new Set();
// Verwende das assignees Array vom Backend
if (task.assignees && Array.isArray(task.assignees)) {
task.assignees.forEach(assignee => {
if (assignee && assignee.id) {
assignedUserIds.add(assignee.id);
}
});
}
// Fallback: Füge assigned_to hinzu falls assignees leer ist
if (assignedUserIds.size === 0 && task.assignedTo) {
assignedUserIds.add(task.assignedTo);
}
// User dropdown
// Container für mehrere Avatare
const avatarContainer = createElement('div', { className: 'avatar-container' });
if (assignedUserIds.size > 0) {
// Erstelle Avatar für jeden zugewiesenen Benutzer
Array.from(assignedUserIds).forEach(userId => {
const user = users.find(u => u.id === userId);
if (user) {
const avatar = createElement('div', {
className: 'avatar',
style: { backgroundColor: user.color || '#6366F1' },
title: user.displayName // Tooltip zeigt Name beim Hover
}, [getInitials(user.displayName)]);
avatarContainer.appendChild(avatar);
}
});
} else {
// Placeholder für "nicht zugewiesen"
const placeholder = createElement('div', {
className: 'avatar avatar-empty',
title: 'Nicht zugewiesen'
}, ['?']);
avatarContainer.appendChild(placeholder);
}
cell.appendChild(avatarContainer);
// User dropdown (versteckt, nur für Bearbeitung)
const select = createElement('select', {
className: 'assignee-select hidden',
dataset: { field: 'assignedTo', taskId: task.id }
});
@ -445,6 +486,24 @@ class ListViewManager {
// =====================
handleContentClick(e) {
// Handle avatar click for assignee editing
if (e.target.classList.contains('avatar') || e.target.classList.contains('avatar-empty')) {
const cell = e.target.closest('.list-cell-assignee');
if (cell) {
this.startEditingAssignee(cell);
return;
}
}
// Handle click on avatar container (wenn man neben Avatar klickt)
if (e.target.classList.contains('avatar-container')) {
const cell = e.target.closest('.list-cell-assignee');
if (cell) {
this.startEditingAssignee(cell);
return;
}
}
const target = e.target.closest('[data-action]');
if (!target) return;
@ -456,6 +515,35 @@ class ListViewManager {
}
}
/**
* Start editing assignee
*/
startEditingAssignee(cell) {
// Stop any current editing
this.stopEditing();
// Add editing class to show dropdown and hide avatar
cell.classList.add('editing');
// Focus the select element
const select = cell.querySelector('.assignee-select');
if (select) {
select.focus();
}
this.editingCell = cell;
}
/**
* Stop editing
*/
stopEditing() {
if (this.editingCell) {
this.editingCell.classList.remove('editing');
this.editingCell = null;
}
}
handleContentChange(e) {
const target = e.target;
const field = target.dataset.field;
@ -463,6 +551,11 @@ class ListViewManager {
if (field && taskId) {
this.updateTaskField(parseInt(taskId), field, target.value);
// Stop editing after change for assignee field
if (field === 'assignedTo') {
this.stopEditing();
}
}
}

696
frontend/js/mobile.js Normale Datei
Datei anzeigen

@ -0,0 +1,696 @@
/**
* TASKMATE - Mobile Module
* ========================
* Touch-Gesten, Hamburger-Menu, Swipe-Navigation
*/
import { $, $$ } from './utils.js';
class MobileManager {
constructor() {
// State
this.isMenuOpen = false;
this.isMobile = false;
this.currentView = 'board';
// Swipe state
this.touchStartX = 0;
this.touchStartY = 0;
this.touchCurrentX = 0;
this.touchCurrentY = 0;
this.touchStartTime = 0;
this.isSwiping = false;
this.swipeDirection = null;
// Touch drag & drop state
this.touchDraggedElement = null;
this.touchDragPlaceholder = null;
this.touchDragStartX = 0;
this.touchDragStartY = 0;
this.touchDragOffsetX = 0;
this.touchDragOffsetY = 0;
this.touchDragScrollInterval = null;
this.longPressTimer = null;
// Constants
this.SWIPE_THRESHOLD = 50;
this.SWIPE_VELOCITY_THRESHOLD = 0.3;
this.MOBILE_BREAKPOINT = 768;
this.LONG_PRESS_DURATION = 300;
// View order for swipe navigation
this.viewOrder = ['board', 'list', 'calendar', 'proposals', 'gitea', 'knowledge'];
// DOM elements
this.hamburgerBtn = null;
this.mobileMenu = null;
this.mobileOverlay = null;
this.mainContent = null;
this.swipeIndicatorLeft = null;
this.swipeIndicatorRight = null;
}
/**
* Initialize mobile features
*/
init() {
// Check if mobile
this.checkMobile();
window.addEventListener('resize', () => this.checkMobile());
// Cache DOM elements
this.hamburgerBtn = $('#hamburger-btn');
this.mobileMenu = $('#mobile-menu');
this.mobileOverlay = $('#mobile-menu-overlay');
this.mainContent = $('.main-content');
this.swipeIndicatorLeft = $('#swipe-indicator-left');
this.swipeIndicatorRight = $('#swipe-indicator-right');
// Bind events
this.bindMenuEvents();
this.bindSwipeEvents();
this.bindTouchDragEvents();
// Listen for view changes
document.addEventListener('view:changed', (e) => {
this.currentView = e.detail?.view || 'board';
this.updateActiveNavItem(this.currentView);
});
// Listen for project changes
document.addEventListener('projects:loaded', () => {
this.populateMobileProjectSelect();
});
// Listen for user updates
document.addEventListener('user:updated', () => {
this.updateUserInfo();
});
console.log('[Mobile] Initialized');
}
/**
* Check if current viewport is mobile
*/
checkMobile() {
this.isMobile = window.innerWidth <= this.MOBILE_BREAKPOINT;
}
// =====================
// HAMBURGER MENU
// =====================
/**
* Bind menu events
*/
bindMenuEvents() {
// Hamburger button
this.hamburgerBtn?.addEventListener('click', () => this.toggleMenu());
// Close button
$('#mobile-menu-close')?.addEventListener('click', () => this.closeMenu());
// Overlay click
this.mobileOverlay?.addEventListener('click', () => this.closeMenu());
// Navigation items
$$('.mobile-nav-item').forEach(item => {
item.addEventListener('click', () => {
const view = item.dataset.view;
this.switchView(view);
this.closeMenu();
});
});
// Project selector
$('#mobile-project-select')?.addEventListener('change', (e) => {
const projectId = parseInt(e.target.value);
if (projectId) {
document.dispatchEvent(new CustomEvent('project:selected', {
detail: { projectId }
}));
this.closeMenu();
}
});
// Admin button
$('#mobile-admin-btn')?.addEventListener('click', () => {
this.closeMenu();
document.dispatchEvent(new CustomEvent('admin:open'));
});
// Logout button
$('#mobile-logout-btn')?.addEventListener('click', () => {
this.closeMenu();
document.dispatchEvent(new CustomEvent('auth:logout'));
});
// Escape key to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isMenuOpen) {
this.closeMenu();
}
});
}
/**
* Toggle menu open/close
*/
toggleMenu() {
if (this.isMenuOpen) {
this.closeMenu();
} else {
this.openMenu();
}
}
/**
* Open mobile menu
*/
openMenu() {
this.isMenuOpen = true;
this.hamburgerBtn?.classList.add('active');
this.hamburgerBtn?.setAttribute('aria-expanded', 'true');
this.mobileMenu?.classList.add('open');
this.mobileMenu?.setAttribute('aria-hidden', 'false');
this.mobileOverlay?.classList.add('visible');
document.body.classList.add('mobile-menu-open');
// Update user info when menu opens
this.updateUserInfo();
this.populateMobileProjectSelect();
// Focus close button
setTimeout(() => {
$('#mobile-menu-close')?.focus();
}, 300);
}
/**
* Close mobile menu
*/
closeMenu() {
this.isMenuOpen = false;
this.hamburgerBtn?.classList.remove('active');
this.hamburgerBtn?.setAttribute('aria-expanded', 'false');
this.mobileMenu?.classList.remove('open');
this.mobileMenu?.setAttribute('aria-hidden', 'true');
this.mobileOverlay?.classList.remove('visible');
document.body.classList.remove('mobile-menu-open');
// Return focus
this.hamburgerBtn?.focus();
}
/**
* Switch to a different view
*/
switchView(view) {
if (!this.viewOrder.includes(view)) return;
this.currentView = view;
// Update desktop tabs
$$('.view-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.view === view);
});
// Show/hide views
$$('.view').forEach(v => {
const viewName = v.id.replace('view-', '');
const isActive = viewName === view;
v.classList.toggle('active', isActive);
v.classList.toggle('hidden', !isActive);
});
// Update mobile nav
this.updateActiveNavItem(view);
// Dispatch event for other modules
document.dispatchEvent(new CustomEvent('view:changed', {
detail: { view }
}));
}
/**
* Update active nav item in mobile menu
*/
updateActiveNavItem(view) {
$$('.mobile-nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.view === view);
});
}
/**
* Populate project select dropdown
*/
populateMobileProjectSelect() {
const select = $('#mobile-project-select');
const desktopSelect = $('#project-select');
if (!select || !desktopSelect) return;
// Copy options from desktop select
select.innerHTML = desktopSelect.innerHTML;
select.value = desktopSelect.value;
}
/**
* Update user info in mobile menu
*/
updateUserInfo() {
const avatar = $('#mobile-user-avatar');
const name = $('#mobile-user-name');
const role = $('#mobile-user-role');
const adminBtn = $('#mobile-admin-btn');
// Get user info from desktop user dropdown
const desktopAvatar = $('#user-avatar');
const desktopDropdown = $('.user-dropdown');
if (avatar && desktopAvatar) {
avatar.textContent = desktopAvatar.textContent;
avatar.style.backgroundColor = desktopAvatar.style.backgroundColor || 'var(--primary)';
}
if (name) {
const usernameEl = desktopDropdown?.querySelector('.user-info strong');
name.textContent = usernameEl?.textContent || 'Benutzer';
}
if (role) {
const roleEl = desktopDropdown?.querySelector('.user-info span:not(strong)');
role.textContent = roleEl?.textContent || 'Angemeldet';
}
// Show admin button for admins
if (adminBtn) {
const isAdmin = role?.textContent?.toLowerCase().includes('admin');
adminBtn.classList.toggle('hidden', !isAdmin);
}
}
// =====================
// SWIPE NAVIGATION
// =====================
/**
* Bind swipe events
*/
bindSwipeEvents() {
if (!this.mainContent) return;
this.mainContent.addEventListener('touchstart', (e) => this.handleSwipeStart(e), { passive: true });
this.mainContent.addEventListener('touchmove', (e) => this.handleSwipeMove(e), { passive: false });
this.mainContent.addEventListener('touchend', (e) => this.handleSwipeEnd(e), { passive: true });
this.mainContent.addEventListener('touchcancel', () => this.resetSwipe(), { passive: true });
}
/**
* Handle swipe start
*/
handleSwipeStart(e) {
if (!this.isMobile) return;
// Don't swipe if menu is open
if (this.isMenuOpen) return;
// Don't swipe if modal is open
if ($('.modal-overlay:not(.hidden)')) return;
// Don't swipe on scrollable elements
const target = e.target;
if (target.closest('.column-body') ||
target.closest('.modal') ||
target.closest('.calendar-grid') ||
target.closest('.knowledge-entry-list') ||
target.closest('.list-table') ||
target.closest('input') ||
target.closest('textarea') ||
target.closest('select')) {
return;
}
// Only single touch
if (e.touches.length !== 1) return;
this.touchStartX = e.touches[0].clientX;
this.touchStartY = e.touches[0].clientY;
this.touchStartTime = Date.now();
this.isSwiping = false;
this.swipeDirection = null;
}
/**
* Handle swipe move
*/
handleSwipeMove(e) {
if (!this.isMobile || this.touchStartX === 0) 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 on first significant movement
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;
document.body.classList.add('is-swiping');
} else {
this.swipeDirection = 'vertical';
this.resetSwipe();
return;
}
}
if (this.swipeDirection !== 'horizontal') return;
// Prevent scroll
e.preventDefault();
// Show indicators
const currentIndex = this.viewOrder.indexOf(this.currentView);
if (deltaX > this.SWIPE_THRESHOLD && currentIndex > 0) {
this.swipeIndicatorLeft?.classList.add('visible');
this.swipeIndicatorRight?.classList.remove('visible');
} else if (deltaX < -this.SWIPE_THRESHOLD && currentIndex < this.viewOrder.length - 1) {
this.swipeIndicatorRight?.classList.add('visible');
this.swipeIndicatorLeft?.classList.remove('visible');
} else {
this.swipeIndicatorLeft?.classList.remove('visible');
this.swipeIndicatorRight?.classList.remove('visible');
}
}
/**
* Handle swipe end
*/
handleSwipeEnd() {
if (!this.isSwiping || this.swipeDirection !== 'horizontal') {
this.resetSwipe();
return;
}
const deltaX = this.touchCurrentX - this.touchStartX;
const deltaTime = Date.now() - this.touchStartTime;
const velocity = Math.abs(deltaX) / deltaTime;
// Valid swipe?
const isValidSwipe = Math.abs(deltaX) > this.SWIPE_THRESHOLD || velocity > this.SWIPE_VELOCITY_THRESHOLD;
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]);
}
}
this.resetSwipe();
}
/**
* Reset swipe state
*/
resetSwipe() {
this.touchStartX = 0;
this.touchStartY = 0;
this.touchCurrentX = 0;
this.touchCurrentY = 0;
this.touchStartTime = 0;
this.isSwiping = false;
this.swipeDirection = null;
document.body.classList.remove('is-swiping');
this.swipeIndicatorLeft?.classList.remove('visible');
this.swipeIndicatorRight?.classList.remove('visible');
}
// =====================
// TOUCH DRAG & DROP
// =====================
/**
* Bind touch drag events
*/
bindTouchDragEvents() {
const board = $('#board');
if (!board) return;
board.addEventListener('touchstart', (e) => this.handleTouchDragStart(e), { passive: false });
board.addEventListener('touchmove', (e) => this.handleTouchDragMove(e), { passive: false });
board.addEventListener('touchend', (e) => this.handleTouchDragEnd(e), { passive: true });
board.addEventListener('touchcancel', () => this.cancelTouchDrag(), { passive: true });
}
/**
* Handle touch drag start
*/
handleTouchDragStart(e) {
if (!this.isMobile) return;
const taskCard = e.target.closest('.task-card');
if (!taskCard) return;
// Cancel if multi-touch
if (e.touches.length > 1) {
this.cancelTouchDrag();
return;
}
const touch = e.touches[0];
this.touchDragStartX = touch.clientX;
this.touchDragStartY = touch.clientY;
// Long press to start drag
this.longPressTimer = setTimeout(() => {
this.startTouchDrag(taskCard, touch);
}, this.LONG_PRESS_DURATION);
}
/**
* Start touch drag
*/
startTouchDrag(taskCard, touch) {
this.touchDraggedElement = taskCard;
const rect = taskCard.getBoundingClientRect();
// Calculate offset
this.touchDragOffsetX = touch.clientX - rect.left;
this.touchDragOffsetY = touch.clientY - rect.top;
// Create placeholder
this.touchDragPlaceholder = document.createElement('div');
this.touchDragPlaceholder.className = 'task-card touch-drag-placeholder';
this.touchDragPlaceholder.style.height = rect.height + 'px';
taskCard.parentNode.insertBefore(this.touchDragPlaceholder, taskCard);
// Style dragged element
taskCard.classList.add('touch-dragging');
taskCard.style.position = 'fixed';
taskCard.style.left = rect.left + 'px';
taskCard.style.top = rect.top + 'px';
taskCard.style.width = rect.width + 'px';
taskCard.style.zIndex = '1000';
document.body.classList.add('is-touch-dragging');
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
/**
* Handle touch drag move
*/
handleTouchDragMove(e) {
// Cancel long press if finger moved
if (this.longPressTimer && !this.touchDraggedElement) {
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - this.touchDragStartX);
const deltaY = Math.abs(touch.clientY - this.touchDragStartY);
if (deltaX > 10 || deltaY > 10) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
return;
}
if (!this.touchDraggedElement) return;
e.preventDefault();
const touch = e.touches[0];
const taskCard = this.touchDraggedElement;
// Move element
taskCard.style.left = (touch.clientX - this.touchDragOffsetX) + 'px';
taskCard.style.top = (touch.clientY - this.touchDragOffsetY) + 'px';
// Find drop target
taskCard.style.pointerEvents = 'none';
const elemBelow = document.elementFromPoint(touch.clientX, touch.clientY);
taskCard.style.pointerEvents = '';
const columnBody = elemBelow?.closest('.column-body');
// Remove previous indicators
$$('.column-body.touch-drag-over').forEach(el => el.classList.remove('touch-drag-over'));
if (columnBody) {
columnBody.classList.add('touch-drag-over');
}
// Auto-scroll
this.autoScrollWhileDragging(touch);
}
/**
* Auto-scroll while dragging near edges
*/
autoScrollWhileDragging(touch) {
const board = $('#board');
if (!board) return;
const boardRect = board.getBoundingClientRect();
const scrollThreshold = 50;
const scrollSpeed = 8;
// Clear existing interval
if (this.touchDragScrollInterval) {
clearInterval(this.touchDragScrollInterval);
this.touchDragScrollInterval = null;
}
// Scroll left
if (touch.clientX < boardRect.left + scrollThreshold) {
this.touchDragScrollInterval = setInterval(() => {
board.scrollLeft -= scrollSpeed;
}, 16);
}
// Scroll right
else if (touch.clientX > boardRect.right - scrollThreshold) {
this.touchDragScrollInterval = setInterval(() => {
board.scrollLeft += scrollSpeed;
}, 16);
}
}
/**
* Handle touch drag end
*/
handleTouchDragEnd(e) {
// Clear long press timer
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
if (!this.touchDraggedElement) return;
const touch = e.changedTouches[0];
// Find drop target
this.touchDraggedElement.style.pointerEvents = 'none';
const elemBelow = document.elementFromPoint(touch.clientX, touch.clientY);
this.touchDraggedElement.style.pointerEvents = '';
const columnBody = elemBelow?.closest('.column-body');
if (columnBody) {
const columnId = parseInt(columnBody.closest('.column').dataset.columnId);
const taskId = parseInt(this.touchDraggedElement.dataset.taskId);
const position = this.calculateDropPosition(columnBody, touch.clientY);
// Dispatch move event
document.dispatchEvent(new CustomEvent('task:move', {
detail: { taskId, columnId, position }
}));
}
this.cleanupTouchDrag();
}
/**
* Calculate drop position in column
*/
calculateDropPosition(columnBody, mouseY) {
const taskCards = Array.from(columnBody.querySelectorAll('.task-card:not(.touch-dragging):not(.touch-drag-placeholder)'));
let position = taskCards.length;
for (let i = 0; i < taskCards.length; i++) {
const rect = taskCards[i].getBoundingClientRect();
if (mouseY < rect.top + rect.height / 2) {
position = i;
break;
}
}
return position;
}
/**
* Cancel touch drag
*/
cancelTouchDrag() {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.cleanupTouchDrag();
}
/**
* Cleanup after touch drag
*/
cleanupTouchDrag() {
// Clear scroll interval
if (this.touchDragScrollInterval) {
clearInterval(this.touchDragScrollInterval);
this.touchDragScrollInterval = null;
}
// Reset dragged element
if (this.touchDraggedElement) {
this.touchDraggedElement.classList.remove('touch-dragging');
this.touchDraggedElement.style.position = '';
this.touchDraggedElement.style.left = '';
this.touchDraggedElement.style.top = '';
this.touchDraggedElement.style.width = '';
this.touchDraggedElement.style.zIndex = '';
this.touchDraggedElement.style.transform = '';
}
// Remove placeholder
if (this.touchDragPlaceholder) {
this.touchDragPlaceholder.remove();
this.touchDragPlaceholder = null;
}
// Remove indicators
$$('.column-body.touch-drag-over').forEach(el => el.classList.remove('touch-drag-over'));
document.body.classList.remove('is-touch-dragging');
// Reset state
this.touchDraggedElement = null;
this.touchDragStartX = 0;
this.touchDragStartY = 0;
this.touchDragOffsetX = 0;
this.touchDragOffsetY = 0;
}
}
// Create and export singleton
const mobileManager = new MobileManager();
export default mobileManager;

Datei anzeigen

@ -122,6 +122,13 @@ class NotificationManager {
updateBadge(count) {
this.unreadCount = count;
// Sicherstellen, dass badge-Element existiert
if (!this.badge) {
this.badge = document.getElementById('notification-badge');
}
if (!this.badge) return; // Wenn immer noch nicht gefunden, abbrechen
if (count > 0) {
this.badge.textContent = count > 99 ? '99+' : count;
this.badge.classList.remove('hidden');
@ -441,6 +448,12 @@ class NotificationManager {
this.unreadCount = 0;
this.isDropdownOpen = false;
this.closeDropdown();
// Elements neu binden falls nötig
if (!this.badge || !this.bellContainer) {
this.bindElements();
}
this.render();
}
}