Datenbank bereinigt / Gitea-Integration gefixt
Dieser Commit ist enthalten in:
committet von
Server Deploy
Ursprung
395598c2b0
Commit
c21be47428
@ -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');
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
777
frontend/js/coding.js
Normale Datei
@ -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;
|
||||
@ -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
696
frontend/js/mobile.js
Normale Datei
@ -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;
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren