- Sidebar mit Baum-Navigation (aufklappbare Kategorien mit Eintraegen) - Content-Bereich zeigt ausgewaehlten Eintrag als volle Seite - Inline-Editor statt Modal (Markdown-Textarea, volle Breite) - Echtzeit-Suche filtert den Baum in der Sidebar - Lese-Modus mit gerendertem Markdown als Standard - Altes Expand/Collapse und Entry-Modal entfernt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1029 Zeilen
34 KiB
JavaScript
1029 Zeilen
34 KiB
JavaScript
/**
|
|
* TASKMATE - Knowledge Manager
|
|
* ============================
|
|
* Wiki-Layout: Baum-Navigation + Content-Bereich
|
|
*/
|
|
|
|
import api from './api.js';
|
|
import { $, $$ } from './utils.js';
|
|
import store from './store.js';
|
|
|
|
class KnowledgeManager {
|
|
constructor() {
|
|
this.categories = [];
|
|
this.allEntries = [];
|
|
this.selectedEntryId = null;
|
|
this.selectedCategoryId = null;
|
|
this.expandedCategories = new Set();
|
|
this.isEditing = false;
|
|
this.editingEntryId = null;
|
|
this.filterQuery = '';
|
|
this.initialized = false;
|
|
this.searchDebounceTimer = null;
|
|
}
|
|
|
|
async init() {
|
|
console.log('[Knowledge] init() called, initialized:', this.initialized);
|
|
|
|
if (this.initialized) {
|
|
await this.loadData();
|
|
return;
|
|
}
|
|
|
|
// DOM Elements - Layout
|
|
this.knowledgeView = $('#view-knowledge');
|
|
this.mobileCategories = $('#knowledge-mobile-categories');
|
|
|
|
// DOM Elements - Sidebar
|
|
this.sidebarSearch = $('#knowledge-sidebar-search');
|
|
this.newCategoryBtn = $('#btn-new-category');
|
|
this.tree = $('#knowledge-tree');
|
|
this.treeEmpty = $('#knowledge-tree-empty');
|
|
|
|
// DOM Elements - Content
|
|
this.emptyState = $('#knowledge-empty');
|
|
this.reader = $('#knowledge-reader');
|
|
this.editor = $('#knowledge-editor');
|
|
|
|
// Reader Elements
|
|
this.readerTitle = $('#knowledge-reader-title');
|
|
this.readerMeta = $('#knowledge-reader-meta');
|
|
this.readerUrl = $('#knowledge-reader-url');
|
|
this.readerBody = $('#knowledge-reader-body');
|
|
this.readerAttachments = $('#knowledge-reader-attachments');
|
|
this.editEntryBtn = $('#btn-edit-entry');
|
|
this.deleteEntryReaderBtn = $('#btn-delete-entry-reader');
|
|
|
|
// Editor Elements
|
|
this.editorForm = $('#knowledge-editor-form');
|
|
this.editorId = $('#knowledge-editor-id');
|
|
this.editorCategoryId = $('#knowledge-editor-category-id');
|
|
this.editorTitle = $('#knowledge-editor-title');
|
|
this.editorTitleInput = $('#knowledge-editor-title-input');
|
|
this.editorUrlInput = $('#knowledge-editor-url-input');
|
|
this.editorNotes = $('#knowledge-editor-notes');
|
|
this.editorAttachments = $('#knowledge-editor-attachments');
|
|
this.editorAttachmentsList = $('#knowledge-editor-attachments-list');
|
|
this.editorFileInput = $('#knowledge-editor-file-input');
|
|
this.btnUploadFile = $('#btn-upload-file');
|
|
this.btnCancelEditor = $('#btn-cancel-editor');
|
|
|
|
// Category Modal Elements
|
|
this.categoryModal = $('#knowledge-category-modal');
|
|
this.categoryForm = $('#knowledge-category-form');
|
|
this.categoryModalTitle = $('#knowledge-category-modal-title');
|
|
this.categoryIdInput = $('#knowledge-category-id');
|
|
this.categoryNameInput = $('#knowledge-category-name');
|
|
this.categoryDescriptionInput = $('#knowledge-category-description');
|
|
this.categoryColorInput = $('#knowledge-category-color');
|
|
this.categoryIconInput = $('#knowledge-category-icon');
|
|
|
|
// Icon Picker Elements
|
|
this.iconPickerPreview = $('#icon-picker-preview');
|
|
this.iconPreviewEmoji = $('#icon-preview-emoji');
|
|
this.iconPickerSection = $('#icon-picker-section');
|
|
|
|
this.bindEvents();
|
|
this.initialized = true;
|
|
console.log('[Knowledge] Initialization complete');
|
|
|
|
await this.loadData();
|
|
}
|
|
|
|
bindEvents() {
|
|
// Sidebar search
|
|
this.sidebarSearch?.addEventListener('input', (e) => {
|
|
this.filterQuery = e.target.value.trim().toLowerCase();
|
|
this.renderTree();
|
|
});
|
|
|
|
// New Category Button
|
|
this.newCategoryBtn?.addEventListener('click', () => {
|
|
this.openCategoryModal();
|
|
});
|
|
|
|
// Category Form Submit
|
|
this.categoryForm?.addEventListener('submit', (e) => this.handleCategorySubmit(e));
|
|
|
|
// Modal close buttons
|
|
this.categoryModal?.querySelectorAll('[data-close-modal]').forEach(btn => {
|
|
btn.addEventListener('click', () => this.closeCategoryModal());
|
|
});
|
|
|
|
// Icon Picker Events
|
|
this.bindIconPickerEvents();
|
|
|
|
// Reader buttons
|
|
this.editEntryBtn?.addEventListener('click', () => {
|
|
if (this.selectedEntryId) {
|
|
this.showEditor(this.selectedEntryId);
|
|
}
|
|
});
|
|
|
|
this.deleteEntryReaderBtn?.addEventListener('click', () => {
|
|
if (this.selectedEntryId) {
|
|
this.deleteEntry(this.selectedEntryId);
|
|
}
|
|
});
|
|
|
|
// Editor form
|
|
this.editorForm?.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.saveEntry();
|
|
});
|
|
|
|
this.btnCancelEditor?.addEventListener('click', () => {
|
|
if (this.editingEntryId) {
|
|
this.showEntry(this.editingEntryId);
|
|
} else {
|
|
this.showEmptyState();
|
|
}
|
|
});
|
|
|
|
// File upload
|
|
this.btnUploadFile?.addEventListener('click', () => {
|
|
this.editorFileInput?.click();
|
|
});
|
|
|
|
this.editorFileInput?.addEventListener('change', (e) => {
|
|
this.handleFileUpload(e);
|
|
});
|
|
|
|
// Store subscription for realtime updates
|
|
store.subscribe('knowledge', () => {
|
|
this.loadData();
|
|
});
|
|
|
|
// Socket.io realtime events
|
|
window.addEventListener('app:refresh', () => {
|
|
if (this.knowledgeView && !this.knowledgeView.classList.contains('hidden')) {
|
|
this.loadData();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// DATA LOADING
|
|
// ==========================================
|
|
|
|
async loadData() {
|
|
try {
|
|
const [categories, entriesResult] = await Promise.all([
|
|
api.getKnowledgeCategories(),
|
|
api.getKnowledgeEntries()
|
|
]);
|
|
|
|
this.categories = categories;
|
|
this.categories.sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
|
|
this.allEntries = Array.isArray(entriesResult) ? entriesResult : [];
|
|
this.allEntries.sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
|
|
this.renderTree();
|
|
this.renderMobileCategories();
|
|
|
|
// Refresh current view if an entry is selected
|
|
if (this.selectedEntryId && !this.isEditing) {
|
|
const entry = this.allEntries.find(e => e.id === this.selectedEntryId);
|
|
if (entry) {
|
|
this.showEntry(this.selectedEntryId);
|
|
} else {
|
|
this.selectedEntryId = null;
|
|
this.showEmptyState();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[Knowledge] Error loading data:', error);
|
|
this.showToast('Fehler beim Laden der Wissensdatenbank', 'error');
|
|
}
|
|
}
|
|
|
|
async loadEntryWithAttachments(entryId) {
|
|
try {
|
|
return await api.getKnowledgeEntry(entryId);
|
|
} catch (error) {
|
|
console.error('Error loading entry:', error);
|
|
this.showToast('Fehler beim Laden des Eintrags', 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// TREE RENDERING (Sidebar)
|
|
// ==========================================
|
|
|
|
getFilteredTree() {
|
|
if (!this.filterQuery) {
|
|
return this.categories.map(cat => ({
|
|
...cat,
|
|
entries: this.allEntries.filter(e => e.categoryId === cat.id)
|
|
}));
|
|
}
|
|
|
|
const q = this.filterQuery;
|
|
return this.categories
|
|
.map(cat => {
|
|
const matchingEntries = this.allEntries.filter(e =>
|
|
e.categoryId === cat.id &&
|
|
(e.title.toLowerCase().includes(q) || (e.notes && e.notes.toLowerCase().includes(q)))
|
|
);
|
|
const catMatches = cat.name.toLowerCase().includes(q);
|
|
if (catMatches || matchingEntries.length > 0) {
|
|
return {
|
|
...cat,
|
|
entries: catMatches
|
|
? this.allEntries.filter(e => e.categoryId === cat.id)
|
|
: matchingEntries
|
|
};
|
|
}
|
|
return null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
renderTree() {
|
|
if (!this.tree) return;
|
|
|
|
const treeData = this.getFilteredTree();
|
|
|
|
if (treeData.length === 0) {
|
|
this.tree.innerHTML = '';
|
|
this.treeEmpty?.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
this.treeEmpty?.classList.add('hidden');
|
|
|
|
// If filtering, auto-expand all categories
|
|
if (this.filterQuery) {
|
|
treeData.forEach(cat => this.expandedCategories.add(cat.id));
|
|
}
|
|
|
|
this.tree.innerHTML = treeData.map(cat => {
|
|
const isExpanded = this.expandedCategories.has(cat.id);
|
|
const entries = cat.entries || [];
|
|
const icon = cat.icon || '\u{1F4C1}';
|
|
|
|
return `
|
|
<div class="knowledge-tree-category ${isExpanded ? 'expanded' : ''}" data-category-id="${cat.id}">
|
|
<div class="knowledge-tree-category-header" data-category-id="${cat.id}">
|
|
<svg class="knowledge-tree-chevron" viewBox="0 0 24 24" width="16" height="16"><path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
<span class="knowledge-tree-category-icon">${icon}</span>
|
|
<span class="knowledge-tree-category-name">${this.escapeHtml(cat.name)}</span>
|
|
<span class="knowledge-tree-category-count">${entries.length}</span>
|
|
<div class="knowledge-tree-category-actions">
|
|
<button class="btn-icon" data-action="edit-category" data-id="${cat.id}" title="Bearbeiten">
|
|
<svg viewBox="0 0 24 24" width="12" height="12"><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.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
</button>
|
|
<button class="btn-icon btn-danger-hover" data-action="delete-category" data-id="${cat.id}" title="Loeschen">
|
|
<svg viewBox="0 0 24 24" width="12" height="12"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="knowledge-tree-entries ${isExpanded ? '' : 'hidden'}">
|
|
${entries.map(entry => `
|
|
<div class="knowledge-tree-entry ${entry.id === this.selectedEntryId ? 'active' : ''}" data-entry-id="${entry.id}">
|
|
<span class="knowledge-tree-entry-title">${this.escapeHtml(entry.title)}</span>
|
|
</div>
|
|
`).join('')}
|
|
<div class="knowledge-tree-new-entry" data-category-id="${cat.id}">
|
|
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
|
|
Neuer Eintrag
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
this.bindTreeEvents();
|
|
}
|
|
|
|
bindTreeEvents() {
|
|
// Category headers - toggle expand
|
|
this.tree.querySelectorAll('.knowledge-tree-category-header').forEach(header => {
|
|
header.addEventListener('click', (e) => {
|
|
if (e.target.closest('.knowledge-tree-category-actions')) return;
|
|
const catId = parseInt(header.dataset.categoryId);
|
|
this.toggleCategory(catId);
|
|
});
|
|
});
|
|
|
|
// Category edit buttons
|
|
this.tree.querySelectorAll('[data-action="edit-category"]').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.openCategoryModal(parseInt(btn.dataset.id));
|
|
});
|
|
});
|
|
|
|
// Category delete buttons
|
|
this.tree.querySelectorAll('[data-action="delete-category"]').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.handleDeleteCategory(parseInt(btn.dataset.id));
|
|
});
|
|
});
|
|
|
|
// Entry items - show entry
|
|
this.tree.querySelectorAll('.knowledge-tree-entry').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const entryId = parseInt(item.dataset.entryId);
|
|
this.showEntry(entryId);
|
|
});
|
|
});
|
|
|
|
// New entry links
|
|
this.tree.querySelectorAll('.knowledge-tree-new-entry').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const categoryId = parseInt(item.dataset.categoryId);
|
|
this.showEditor(null, categoryId);
|
|
});
|
|
});
|
|
}
|
|
|
|
toggleCategory(categoryId) {
|
|
if (this.expandedCategories.has(categoryId)) {
|
|
this.expandedCategories.delete(categoryId);
|
|
} else {
|
|
this.expandedCategories.add(categoryId);
|
|
}
|
|
this.renderTree();
|
|
}
|
|
|
|
// ==========================================
|
|
// MOBILE CATEGORIES
|
|
// ==========================================
|
|
|
|
renderMobileCategories() {
|
|
if (!this.mobileCategories) return;
|
|
|
|
const chips = this.categories.map(cat => `
|
|
<div class="knowledge-mobile-chip ${cat.id === this.selectedCategoryId ? 'active' : ''}"
|
|
data-category-id="${cat.id}">
|
|
<span class="chip-icon">${cat.icon || '\u{1F4C1}'}</span>
|
|
<span class="chip-name">${this.escapeHtml(cat.name)}</span>
|
|
<span class="chip-count">(${cat.entryCount || 0})</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
const addChip = `
|
|
<div class="knowledge-mobile-chip knowledge-mobile-add" data-action="add-category">
|
|
<span>+ Neu</span>
|
|
</div>
|
|
`;
|
|
|
|
this.mobileCategories.innerHTML = chips + addChip;
|
|
this.bindMobileChipEvents();
|
|
}
|
|
|
|
bindMobileChipEvents() {
|
|
this.mobileCategories?.querySelectorAll('.knowledge-mobile-chip').forEach(chip => {
|
|
if (chip.dataset.action === 'add-category') {
|
|
chip.addEventListener('click', () => this.openCategoryModal());
|
|
} else {
|
|
const categoryId = parseInt(chip.dataset.categoryId);
|
|
chip.addEventListener('click', () => {
|
|
this.selectedCategoryId = categoryId;
|
|
this.expandedCategories.add(categoryId);
|
|
this.renderTree();
|
|
this.renderMobileCategories();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// CONTENT STATES
|
|
// ==========================================
|
|
|
|
showEmptyState() {
|
|
this.isEditing = false;
|
|
this.editingEntryId = null;
|
|
this.selectedEntryId = null;
|
|
this.emptyState?.classList.remove('hidden');
|
|
this.reader?.classList.add('hidden');
|
|
this.editor?.classList.add('hidden');
|
|
this.renderTree();
|
|
}
|
|
|
|
async showEntry(entryId) {
|
|
this.isEditing = false;
|
|
this.editingEntryId = null;
|
|
this.selectedEntryId = entryId;
|
|
|
|
// Load full entry with attachments
|
|
const entry = await this.loadEntryWithAttachments(entryId);
|
|
if (!entry) {
|
|
this.showEmptyState();
|
|
return;
|
|
}
|
|
|
|
// Expand parent category
|
|
if (entry.categoryId) {
|
|
this.expandedCategories.add(entry.categoryId);
|
|
this.selectedCategoryId = entry.categoryId;
|
|
}
|
|
|
|
// Update content area
|
|
this.emptyState?.classList.add('hidden');
|
|
this.editor?.classList.add('hidden');
|
|
this.reader?.classList.remove('hidden');
|
|
|
|
// Title
|
|
if (this.readerTitle) {
|
|
this.readerTitle.textContent = entry.title;
|
|
}
|
|
|
|
// Meta
|
|
if (this.readerMeta) {
|
|
const cat = this.categories.find(c => c.id === entry.categoryId);
|
|
const catName = cat ? cat.name : 'Unbekannt';
|
|
const catIcon = cat ? (cat.icon || '\u{1F4C1}') : '\u{1F4C1}';
|
|
const date = entry.updatedAt || entry.createdAt;
|
|
let dateStr = '';
|
|
if (date) {
|
|
const d = new Date(date);
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
const year = d.getFullYear();
|
|
dateStr = `${day}.${month}.${year}`;
|
|
}
|
|
const creator = entry.creatorName || entry.creator_name || '';
|
|
this.readerMeta.innerHTML = `
|
|
<span>${catIcon} ${this.escapeHtml(catName)}</span>
|
|
${creator ? `<span>von ${this.escapeHtml(creator)}</span>` : ''}
|
|
${dateStr ? `<span>${dateStr}</span>` : ''}
|
|
`;
|
|
}
|
|
|
|
// URL
|
|
if (this.readerUrl) {
|
|
if (entry.url && entry.url.trim()) {
|
|
this.readerUrl.classList.remove('hidden');
|
|
this.readerUrl.innerHTML = `
|
|
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" stroke-width="2" fill="none"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
<a href="${this.escapeHtml(entry.url)}" target="_blank" rel="noopener noreferrer">${this.escapeHtml(entry.url)}</a>
|
|
`;
|
|
} else {
|
|
this.readerUrl.classList.add('hidden');
|
|
this.readerUrl.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// Body (Markdown)
|
|
if (this.readerBody) {
|
|
if (entry.notes && entry.notes.trim()) {
|
|
this.readerBody.innerHTML = this.renderMarkdown(this.sanitizeHtml(entry.notes));
|
|
} else {
|
|
this.readerBody.innerHTML = '<p class="text-muted">Kein Inhalt vorhanden.</p>';
|
|
}
|
|
}
|
|
|
|
// Attachments
|
|
if (this.readerAttachments) {
|
|
const attachments = entry.attachments || [];
|
|
if (attachments.length > 0) {
|
|
this.readerAttachments.classList.remove('hidden');
|
|
this.readerAttachments.innerHTML = `
|
|
<h3>Anhaenge (${attachments.length})</h3>
|
|
${attachments.map(att => `
|
|
<a href="${api.getKnowledgeAttachmentDownloadUrl(att.id)}" class="knowledge-attachment-link" download>
|
|
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M14 2v6h6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
<span>${this.escapeHtml(att.originalName || att.original_name || 'Datei')}</span>
|
|
<span class="knowledge-attachment-size">${this.formatFileSize(att.sizeBytes || att.size_bytes || 0)}</span>
|
|
</a>
|
|
`).join('')}
|
|
`;
|
|
} else {
|
|
this.readerAttachments.classList.add('hidden');
|
|
this.readerAttachments.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// Update tree to highlight active entry
|
|
this.renderTree();
|
|
this.renderMobileCategories();
|
|
}
|
|
|
|
async showEditor(entryId = null, categoryId = null) {
|
|
this.isEditing = true;
|
|
this.editingEntryId = entryId;
|
|
|
|
this.emptyState?.classList.add('hidden');
|
|
this.reader?.classList.add('hidden');
|
|
this.editor?.classList.remove('hidden');
|
|
|
|
// Reset form
|
|
this.editorForm?.reset();
|
|
if (this.editorId) this.editorId.value = '';
|
|
if (this.editorCategoryId) this.editorCategoryId.value = '';
|
|
|
|
if (entryId) {
|
|
// Edit existing
|
|
const entry = await this.loadEntryWithAttachments(entryId);
|
|
if (!entry) return;
|
|
|
|
if (this.editorTitle) this.editorTitle.textContent = 'Eintrag bearbeiten';
|
|
if (this.editorId) this.editorId.value = entry.id;
|
|
if (this.editorCategoryId) this.editorCategoryId.value = entry.categoryId;
|
|
if (this.editorTitleInput) this.editorTitleInput.value = entry.title;
|
|
if (this.editorUrlInput) this.editorUrlInput.value = entry.url || '';
|
|
if (this.editorNotes) this.editorNotes.value = entry.notes || '';
|
|
|
|
// Show attachments
|
|
this.renderEditorAttachments(entry.attachments || []);
|
|
} else {
|
|
// New entry
|
|
if (this.editorTitle) this.editorTitle.textContent = 'Neuer Eintrag';
|
|
if (this.editorCategoryId) this.editorCategoryId.value = categoryId || this.selectedCategoryId || '';
|
|
|
|
// Hide attachments for new entries
|
|
if (this.editorAttachments) this.editorAttachments.classList.add('hidden');
|
|
}
|
|
|
|
this.editorTitleInput?.focus();
|
|
}
|
|
|
|
// ==========================================
|
|
// ENTRY CRUD
|
|
// ==========================================
|
|
|
|
async saveEntry() {
|
|
const entryId = this.editorId?.value ? parseInt(this.editorId.value) : null;
|
|
const categoryId = parseInt(this.editorCategoryId?.value);
|
|
const data = {
|
|
categoryId: categoryId,
|
|
title: this.editorTitleInput?.value.trim(),
|
|
url: this.editorUrlInput?.value.trim() || null,
|
|
notes: this.editorNotes?.value.trim() || null
|
|
};
|
|
|
|
if (!data.title) {
|
|
this.showToast('Bitte einen Titel eingeben', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!data.categoryId) {
|
|
this.showToast('Keine Kategorie ausgewaehlt', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let savedEntry;
|
|
if (entryId) {
|
|
savedEntry = await api.updateKnowledgeEntry(entryId, data);
|
|
this.showToast('Eintrag aktualisiert', 'success');
|
|
} else {
|
|
savedEntry = await api.createKnowledgeEntry(data);
|
|
this.showToast('Eintrag erstellt', 'success');
|
|
}
|
|
|
|
// Reload data
|
|
await this.loadData();
|
|
|
|
// Show the saved entry
|
|
const id = savedEntry?.id || entryId;
|
|
if (id) {
|
|
this.showEntry(id);
|
|
} else {
|
|
this.showEmptyState();
|
|
}
|
|
} catch (error) {
|
|
this.showToast(error.message || 'Fehler beim Speichern', 'error');
|
|
}
|
|
}
|
|
|
|
async deleteEntry(entryId) {
|
|
const entry = this.allEntries.find(e => e.id === entryId);
|
|
const title = entry?.title || 'Dieser Eintrag';
|
|
|
|
const confirmDelete = confirm(`"${title}" wirklich loeschen?`);
|
|
if (!confirmDelete) return;
|
|
|
|
try {
|
|
await api.deleteKnowledgeEntry(entryId);
|
|
this.showToast('Eintrag geloescht', 'success');
|
|
|
|
this.selectedEntryId = null;
|
|
await this.loadData();
|
|
this.showEmptyState();
|
|
} catch (error) {
|
|
this.showToast(error.message || 'Fehler beim Loeschen', 'error');
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// EDITOR ATTACHMENTS
|
|
// ==========================================
|
|
|
|
renderEditorAttachments(attachments) {
|
|
if (!this.editorAttachments || !this.editorAttachmentsList) return;
|
|
|
|
if (!attachments || attachments.length === 0) {
|
|
this.editorAttachments.classList.remove('hidden');
|
|
this.editorAttachmentsList.innerHTML = '<p class="text-muted">Keine Anhaenge vorhanden</p>';
|
|
return;
|
|
}
|
|
|
|
this.editorAttachments.classList.remove('hidden');
|
|
this.editorAttachmentsList.innerHTML = attachments.map(att => `
|
|
<div class="knowledge-editor-attachment-item" data-attachment-id="${att.id}">
|
|
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M14 2v6h6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
<span class="attachment-name">${this.escapeHtml(att.originalName || att.original_name || 'Datei')}</span>
|
|
<span class="attachment-size">${this.formatFileSize(att.sizeBytes || att.size_bytes || 0)}</span>
|
|
<button type="button" class="btn-icon btn-danger-hover" data-action="delete-attachment" data-id="${att.id}" title="Loeschen">
|
|
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
</button>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Bind delete events
|
|
this.editorAttachmentsList.querySelectorAll('[data-action="delete-attachment"]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
this.handleDeleteAttachment(parseInt(btn.dataset.id));
|
|
});
|
|
});
|
|
}
|
|
|
|
async handleFileUpload(e) {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
const entryId = parseInt(this.editorId?.value);
|
|
if (!entryId) {
|
|
this.showToast('Speichern Sie zuerst den Eintrag, dann koennen Sie Dateien hochladen', 'info');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
for (const file of files) {
|
|
try {
|
|
await api.uploadKnowledgeAttachment(entryId, file);
|
|
this.showToast(`${file.name} hochgeladen`, 'success');
|
|
} catch (error) {
|
|
this.showToast(`Fehler beim Hochladen von ${file.name}`, 'error');
|
|
}
|
|
}
|
|
|
|
e.target.value = '';
|
|
|
|
// Reload entry attachments
|
|
const entry = await this.loadEntryWithAttachments(entryId);
|
|
if (entry) {
|
|
this.renderEditorAttachments(entry.attachments);
|
|
}
|
|
}
|
|
|
|
async handleDeleteAttachment(attachmentId) {
|
|
const confirmDelete = confirm('Anhang wirklich loeschen?');
|
|
if (!confirmDelete) return;
|
|
|
|
try {
|
|
await api.deleteKnowledgeAttachment(attachmentId);
|
|
this.showToast('Anhang geloescht', 'success');
|
|
|
|
// Reload entry attachments
|
|
const entryId = parseInt(this.editorId?.value);
|
|
if (entryId) {
|
|
const entry = await this.loadEntryWithAttachments(entryId);
|
|
if (entry) {
|
|
this.renderEditorAttachments(entry.attachments);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.showToast(error.message || 'Fehler beim Loeschen', 'error');
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// SEARCH (Header search integration)
|
|
// ==========================================
|
|
|
|
setSearchQuery(query) {
|
|
clearTimeout(this.searchDebounceTimer);
|
|
|
|
if (!query || !query.trim()) {
|
|
this.filterQuery = '';
|
|
if (this.sidebarSearch) this.sidebarSearch.value = '';
|
|
this.renderTree();
|
|
return;
|
|
}
|
|
|
|
this.searchDebounceTimer = setTimeout(() => {
|
|
this.filterQuery = query.trim().toLowerCase();
|
|
if (this.sidebarSearch) this.sidebarSearch.value = query.trim();
|
|
this.renderTree();
|
|
}, 300);
|
|
}
|
|
|
|
// ==========================================
|
|
// CATEGORY CRUD
|
|
// ==========================================
|
|
|
|
openCategoryModal(categoryId = null) {
|
|
const isEdit = !!categoryId;
|
|
this.categoryModalTitle.textContent = isEdit ? 'Kategorie bearbeiten' : 'Neue Kategorie';
|
|
this.categoryForm?.reset();
|
|
|
|
// Hide icon picker section
|
|
this.iconPickerSection?.classList.add('hidden');
|
|
|
|
if (isEdit) {
|
|
const category = this.categories.find(c => c.id === categoryId);
|
|
if (category) {
|
|
this.categoryIdInput.value = category.id;
|
|
this.categoryNameInput.value = category.name;
|
|
this.categoryDescriptionInput.value = category.description || '';
|
|
this.categoryColorInput.value = category.color || '#3B82F6';
|
|
const icon = category.icon || '\u{1F4C1}';
|
|
this.categoryIconInput.value = icon;
|
|
if (this.iconPreviewEmoji) {
|
|
this.iconPreviewEmoji.textContent = icon;
|
|
}
|
|
}
|
|
} else {
|
|
this.categoryIdInput.value = '';
|
|
this.categoryColorInput.value = '#3B82F6';
|
|
this.categoryIconInput.value = '\u{1F4C1}';
|
|
if (this.iconPreviewEmoji) {
|
|
this.iconPreviewEmoji.textContent = '\u{1F4C1}';
|
|
}
|
|
}
|
|
|
|
this.openModal(this.categoryModal, 'knowledge-category-modal');
|
|
this.categoryNameInput?.focus();
|
|
}
|
|
|
|
closeCategoryModal() {
|
|
this.closeModal(this.categoryModal, 'knowledge-category-modal');
|
|
}
|
|
|
|
async handleCategorySubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const categoryId = this.categoryIdInput?.value ? parseInt(this.categoryIdInput.value) : null;
|
|
const data = {
|
|
name: this.categoryNameInput?.value.trim(),
|
|
description: this.categoryDescriptionInput?.value.trim() || null,
|
|
color: this.categoryColorInput?.value || '#3B82F6',
|
|
icon: this.categoryIconInput?.value.trim() || null
|
|
};
|
|
|
|
if (!data.name) {
|
|
this.showToast('Bitte einen Namen eingeben', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (categoryId) {
|
|
await api.updateKnowledgeCategory(categoryId, data);
|
|
this.showToast('Kategorie aktualisiert', 'success');
|
|
} else {
|
|
await api.createKnowledgeCategory(data);
|
|
this.showToast('Kategorie erstellt', 'success');
|
|
}
|
|
|
|
this.closeCategoryModal();
|
|
await this.loadData();
|
|
} catch (error) {
|
|
this.showToast(error.message || 'Fehler beim Speichern', 'error');
|
|
}
|
|
}
|
|
|
|
async handleDeleteCategory(categoryId) {
|
|
const category = this.categories.find(c => c.id === categoryId);
|
|
if (!category) return;
|
|
|
|
const count = category.entryCount || 0;
|
|
const msg = count > 0
|
|
? `Kategorie "${category.name}" mit ${count} Eintraegen loeschen? Dies kann nicht rueckgaengig gemacht werden.`
|
|
: `Kategorie "${category.name}" loeschen? Dies kann nicht rueckgaengig gemacht werden.`;
|
|
const confirmDelete = confirm(msg);
|
|
if (!confirmDelete) return;
|
|
|
|
try {
|
|
await api.deleteKnowledgeCategory(categoryId);
|
|
this.showToast('Kategorie geloescht', 'success');
|
|
|
|
// If any entry from this category was selected, clear
|
|
if (this.selectedCategoryId === categoryId) {
|
|
this.selectedCategoryId = null;
|
|
this.selectedEntryId = null;
|
|
this.showEmptyState();
|
|
}
|
|
|
|
await this.loadData();
|
|
} catch (error) {
|
|
this.showToast(error.message || 'Fehler beim Loeschen', 'error');
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// ICON PICKER
|
|
// ==========================================
|
|
|
|
bindIconPickerEvents() {
|
|
if (!this.iconPickerPreview || !this.iconPickerSection) return;
|
|
|
|
this.iconPickerPreview.addEventListener('click', () => {
|
|
this.iconPickerSection.classList.toggle('hidden');
|
|
});
|
|
|
|
const tabs = this.iconPickerSection.querySelectorAll('.icon-tab');
|
|
tabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
this.switchIconTab(tab.dataset.tab);
|
|
});
|
|
});
|
|
|
|
const iconBtns = this.iconPickerSection.querySelectorAll('.icon-btn');
|
|
iconBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
this.selectIcon(btn.dataset.icon);
|
|
});
|
|
});
|
|
}
|
|
|
|
switchIconTab(tabName) {
|
|
if (!this.iconPickerSection) return;
|
|
|
|
const tabs = this.iconPickerSection.querySelectorAll('.icon-tab');
|
|
tabs.forEach(tab => {
|
|
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
|
});
|
|
|
|
const grids = this.iconPickerSection.querySelectorAll('.icon-grid');
|
|
grids.forEach(grid => {
|
|
grid.classList.toggle('hidden', grid.dataset.tabContent !== tabName);
|
|
});
|
|
}
|
|
|
|
selectIcon(icon) {
|
|
if (!icon) return;
|
|
|
|
if (this.iconPreviewEmoji) {
|
|
this.iconPreviewEmoji.textContent = icon;
|
|
}
|
|
|
|
if (this.categoryIconInput) {
|
|
this.categoryIconInput.value = icon;
|
|
}
|
|
|
|
if (this.iconPickerSection) {
|
|
this.iconPickerSection.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// MODAL HELPERS
|
|
// ==========================================
|
|
|
|
openModal(modal, modalId) {
|
|
if (modal) {
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('visible');
|
|
}
|
|
const overlay = $('#modal-overlay');
|
|
if (overlay) {
|
|
overlay.classList.remove('hidden');
|
|
overlay.classList.add('visible');
|
|
}
|
|
store.openModal(modalId);
|
|
}
|
|
|
|
closeModal(modal, modalId) {
|
|
if (modal) {
|
|
modal.classList.remove('visible');
|
|
modal.classList.add('hidden');
|
|
}
|
|
const openModals = store.get('openModals').filter(id => id !== modalId);
|
|
if (openModals.length === 0) {
|
|
const overlay = $('#modal-overlay');
|
|
if (overlay) {
|
|
overlay.classList.remove('visible');
|
|
overlay.classList.add('hidden');
|
|
}
|
|
}
|
|
store.closeModal(modalId);
|
|
}
|
|
|
|
// ==========================================
|
|
// MARKDOWN RENDERING
|
|
// ==========================================
|
|
|
|
sanitizeHtml(html) {
|
|
if (!html) return '';
|
|
let clean = html.replace(/<\s*(script|iframe|object|embed|form|style|link|meta|base)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '');
|
|
clean = clean.replace(/<\s*(script|iframe|object|embed|form|style|link|meta|base)\b[^>]*\/?>/gi, '');
|
|
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
|
clean = clean.replace(/href\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, 'href="#"');
|
|
clean = clean.replace(/src\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, 'src=""');
|
|
return clean;
|
|
}
|
|
|
|
renderMarkdown(text) {
|
|
if (!text) return '';
|
|
|
|
let html = text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
// Code blocks
|
|
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
|
|
return `<pre><code>${code.trim()}</code></pre>`;
|
|
});
|
|
|
|
// Inline code
|
|
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
|
|
|
// Headings
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
|
|
// Bold
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
|
|
// Italic
|
|
html = html.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, '<em>$1</em>');
|
|
html = html.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, '<em>$1</em>');
|
|
|
|
// Links
|
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
|
|
// Unordered lists
|
|
html = html.replace(/^(?:- |\* )(.+)$/gm, '<li>$1</li>');
|
|
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
|
|
|
// Ordered lists
|
|
html = html.replace(/^\d+\. (.+)$/gm, '<oli>$1</oli>');
|
|
html = html.replace(/((?:<oli>.*<\/oli>\n?)+)/g, (match) => {
|
|
return '<ol>' + match.replace(/<\/?oli>/g, (tag) => tag.replace('oli', 'li')) + '</ol>';
|
|
});
|
|
|
|
// Paragraphs
|
|
html = html.replace(/\n\n+/g, '</p><p>');
|
|
|
|
// Line breaks
|
|
html = html.replace(/(?<!<\/h[123]>|<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/p>|<p>)\n(?!<h[123]|<li>|<ul>|<ol>|<pre>|<\/p>|<p>)/g, '<br>');
|
|
|
|
// Wrap in paragraphs
|
|
html = '<p>' + html + '</p>';
|
|
|
|
// Clean empty paragraphs
|
|
html = html.replace(/<p>\s*<\/p>/g, '');
|
|
|
|
// Don't wrap block elements in <p>
|
|
html = html.replace(/<p>\s*(<(?:h[123]|ul|ol|pre|blockquote)[\s>])/g, '$1');
|
|
html = html.replace(/(<\/(?:h[123]|ul|ol|pre|blockquote)>)\s*<\/p>/g, '$1');
|
|
|
|
return html;
|
|
}
|
|
|
|
// ==========================================
|
|
// UTILITIES
|
|
// ==========================================
|
|
|
|
formatFileSize(bytes) {
|
|
if (!bytes) return '0 B';
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
|
}
|
|
|
|
escapeHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
showToast(message, type = 'info') {
|
|
window.dispatchEvent(new CustomEvent('toast:show', {
|
|
detail: { message, type }
|
|
}));
|
|
}
|
|
|
|
// ==========================================
|
|
// SHOW/HIDE
|
|
// ==========================================
|
|
|
|
show() {
|
|
this.knowledgeView?.classList.remove('hidden');
|
|
this.knowledgeView?.classList.add('active');
|
|
}
|
|
|
|
hide() {
|
|
this.knowledgeView?.classList.add('hidden');
|
|
this.knowledgeView?.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
const knowledgeManager = new KnowledgeManager();
|
|
|
|
export { knowledgeManager };
|
|
export default knowledgeManager;
|