Skip to main content
Glama
juanqui
by juanqui
DocumentDetail.js23.4 kB
/** * DocumentDetail Component * Handles detailed document viewing with chunks, metadata, and content */ class DocumentDetail { constructor(app) { this.app = app; this.container = document.getElementById('document-detail-container'); this.currentDocument = null; this.currentOptions = {}; this.expandedChunks = new Set(); this.init(); } /** * Initialize the component */ init() { // Register with app this.app.components.documentDetail = this; console.log('DocumentDetail component initialized'); } /** * Render document detail view */ render(documentDetail, options = {}) { if (!this.container) return; this.currentDocument = documentDetail.document; this.currentOptions = options; const document = documentDetail.document; const chunks = documentDetail.chunks || []; const metadata = documentDetail.metadata || {}; this.container.innerHTML = ` <div class="document-detail"> <!-- Document Header --> <div class="document-detail-header"> <div class="document-detail-title-section"> <h1 class="document-detail-title">${this.escapeHtml(document.title || document.filename)}</h1> <p class="document-detail-path">${this.escapeHtml(document.path)}</p> <div class="document-detail-status ${this.getStatusClass(document)}"> ${this.getStatusIcon(document)} <span>${this.getDocumentStatus(document)}</span> </div> </div> <div class="document-detail-actions"> <button class="btn btn-secondary" id="preview-document"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <circle cx="12" cy="12" r="3"/> </svg> Preview Content </button> <button class="btn btn-secondary" id="export-document"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <polyline points="7,10 12,15 17,10"/> <line x1="12" y1="15" x2="12" y2="3"/> </svg> Export </button> <button class="btn btn-danger" id="remove-document"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <polyline points="3,6 5,6 21,6"/> <path d="m19,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"/> </svg> Remove </button> </div> </div> <!-- Document Stats --> <div class="document-stats"> <div class="document-stat"> <div class="stat-value">${this.app.formatFileSize(document.file_size)}</div> <div class="stat-label">File Size</div> </div> <div class="document-stat"> <div class="stat-value">${document.page_count}</div> <div class="stat-label">Pages</div> </div> <div class="document-stat"> <div class="stat-value">${document.chunk_count}</div> <div class="stat-label">Chunks</div> </div> <div class="document-stat"> <div class="stat-value">${this.app.formatDate(document.added_at)}</div> <div class="stat-label">Added</div> </div> ${document.updated_at ? ` <div class="document-stat"> <div class="stat-value">${this.app.formatDate(document.updated_at)}</div> <div class="stat-label">Updated</div> </div> ` : ''} </div> <!-- Document Metadata (if available) --> ${this.renderMetadata(metadata)} <!-- Document Chunks --> <div class="document-chunks-section"> <div class="section-header"> <h2>Document Chunks (${chunks.length})</h2> <div class="chunk-controls"> <button class="btn btn-secondary btn-sm" id="expand-all-chunks">Expand All</button> <button class="btn btn-secondary btn-sm" id="collapse-all-chunks">Collapse All</button> ${options.searchQuery ? ` <div class="search-highlight-info"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="11" cy="11" r="8"/> <path d="m21 21-4.35-4.35"/> </svg> Highlighting: "${this.escapeHtml(options.searchQuery)}" </div> ` : ''} </div> </div> <div class="document-chunks"> ${this.renderChunks(chunks, options)} </div> </div> </div> `; this.setupEventListeners(); // Scroll to highlighted chunk if specified if (options.highlightChunk) { setTimeout(() => { this.scrollToChunk(options.highlightChunk); }, 100); } } /** * Render document metadata */ renderMetadata(metadata) { const metadataEntries = Object.entries(metadata); if (metadataEntries.length === 0) { return ''; } return ` <div class="document-metadata-section"> <h2>Metadata</h2> <div class="document-metadata"> ${metadataEntries.map(([key, value]) => ` <div class="metadata-item"> <div class="metadata-key">${this.escapeHtml(key)}</div> <div class="metadata-value">${this.escapeHtml(String(value))}</div> </div> `).join('')} </div> </div> `; } /** * Render document chunks */ renderChunks(chunks, options = {}) { if (chunks.length === 0) { return ` <div class="empty-state"> <svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <polyline points="14,2 14,8 20,8"/> </svg> <h3>No chunks available</h3> <p>This document hasn't been processed into chunks yet.</p> </div> `; } return chunks.map((chunk, index) => { const isHighlighted = options.highlightChunk === chunk.id; const isExpanded = this.expandedChunks.has(chunk.id) || isHighlighted; let chunkText = chunk.text; if (options.searchQuery) { chunkText = this.highlightSearchTerms(chunk.text, options.searchQuery); } else { chunkText = this.escapeHtml(chunk.text); } return ` <div class="document-chunk ${isHighlighted ? 'highlighted' : ''}" data-chunk-id="${chunk.id}"> <div class="chunk-header" data-chunk-id="${chunk.id}"> <div class="chunk-info"> <span class="chunk-number">Chunk ${chunk.chunk_index + 1}</span> ${chunk.page_number ? `<span class="chunk-page">Page ${chunk.page_number}</span>` : ''} <span class="chunk-length">${chunk.text.length} chars</span> </div> <button class="chunk-toggle" data-chunk-id="${chunk.id}"> <svg class="chunk-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <polyline points="${isExpanded ? '6,9 12,15 18,9' : '9,18 15,12 9,6'}"/> </svg> </button> </div> <div class="chunk-content ${isExpanded ? 'expanded' : 'collapsed'}"> <div class="chunk-text">${chunkText}</div> ${this.renderChunkMetadata(chunk.metadata)} </div> </div> `; }).join(''); } /** * Render chunk metadata */ renderChunkMetadata(metadata) { const metadataEntries = Object.entries(metadata || {}); if (metadataEntries.length === 0) { return ''; } return ` <div class="chunk-metadata"> <h4>Chunk Metadata</h4> <div class="chunk-metadata-items"> ${metadataEntries.map(([key, value]) => ` <div class="chunk-metadata-item"> <span class="chunk-metadata-key">${this.escapeHtml(key)}:</span> <span class="chunk-metadata-value">${this.escapeHtml(String(value))}</span> </div> `).join('')} </div> </div> `; } /** * Set up event listeners */ setupEventListeners() { // Document actions const previewBtn = document.getElementById('preview-document'); const exportBtn = document.getElementById('export-document'); const removeBtn = document.getElementById('remove-document'); if (previewBtn) { previewBtn.addEventListener('click', () => this.previewDocument()); } if (exportBtn) { exportBtn.addEventListener('click', () => this.exportDocument()); } if (removeBtn) { removeBtn.addEventListener('click', () => this.confirmRemoveDocument()); } // Chunk controls const expandAllBtn = document.getElementById('expand-all-chunks'); const collapseAllBtn = document.getElementById('collapse-all-chunks'); if (expandAllBtn) { expandAllBtn.addEventListener('click', () => this.expandAllChunks()); } if (collapseAllBtn) { collapseAllBtn.addEventListener('click', () => this.collapseAllChunks()); } // Chunk toggles const chunkToggles = this.container.querySelectorAll('.chunk-toggle'); chunkToggles.forEach(toggle => { toggle.addEventListener('click', (e) => { e.stopPropagation(); const chunkId = toggle.dataset.chunkId; this.toggleChunk(chunkId); }); }); // Chunk headers (clickable) const chunkHeaders = this.container.querySelectorAll('.chunk-header'); chunkHeaders.forEach(header => { header.addEventListener('click', () => { const chunkId = header.dataset.chunkId; this.toggleChunk(chunkId); }); }); } /** * Preview document content */ async previewDocument() { if (!this.currentDocument) return; try { const previewResponse = await this.app.apiRequest(`/documents/${this.currentDocument.id}/preview`); this.showPreviewModal(this.currentDocument, previewResponse.content); } catch (error) { console.error('Failed to load document preview:', error); this.app.showToast('error', 'Error', 'Failed to load document preview'); } } /** * Show preview modal */ showPreviewModal(doc, content) { const modal = document.createElement('div'); modal.className = 'modal active'; modal.innerHTML = ` <div class="modal-backdrop"></div> <div class="modal-content" style="max-width: 900px; max-height: 90vh;"> <div class="modal-header"> <h3>Full Content: ${this.escapeHtml(doc.filename)}</h3> <button class="modal-close preview-modal-close"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="18" y1="6" x2="6" y2="18"/> <line x1="6" y1="6" x2="18" y2="18"/> </svg> </button> </div> <div class="modal-body" style="overflow-y: auto; max-height: 70vh;"> <pre style="white-space: pre-wrap; font-family: inherit; font-size: 0.9rem; line-height: 1.6; color: var(--color-text-secondary);">${this.escapeHtml(content)}</pre> </div> <div class="modal-footer"> <button class="btn btn-secondary preview-modal-close">Close</button> <button class="btn btn-primary" onclick="window.pdfkbApp.components.documentDetail.exportDocument()"> Export Content </button> </div> </div> `; // Add close handlers const closeButtons = modal.querySelectorAll('.preview-modal-close'); const backdrop = modal.querySelector('.modal-backdrop'); const closeModal = () => modal.remove(); closeButtons.forEach(btn => btn.addEventListener('click', closeModal)); backdrop.addEventListener('click', closeModal); document.body.appendChild(modal); // ESC key handler const escHandler = (e) => { if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', escHandler); } }; document.addEventListener('keydown', escHandler); } /** * Export document */ async exportDocument() { if (!this.currentDocument) return; try { const previewResponse = await this.app.apiRequest(`/documents/${this.currentDocument.id}/preview`); // Create text file content const content = `Document: ${this.currentDocument.filename} Path: ${this.currentDocument.path} Added: ${this.app.formatDate(this.currentDocument.added_at)} Pages: ${this.currentDocument.page_count} Chunks: ${this.currentDocument.chunk_count} Content: ${previewResponse.content}`; // Download as text file this.downloadTextFile(content, `${this.currentDocument.filename}.txt`); this.app.showToast('success', 'Export Complete', 'Document exported successfully'); } catch (error) { console.error('Failed to export document:', error); this.app.showToast('error', 'Export Failed', 'Failed to export document'); } } /** * Download text file */ downloadTextFile(content, filename) { const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const link = document.createElement('a'); if (link.download !== undefined) { const url = URL.createObjectURL(blob); link.setAttribute('href', url); link.setAttribute('download', filename); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } } /** * Confirm document removal */ confirmRemoveDocument() { if (!this.currentDocument) return; const title = 'Remove Document'; const message = `Are you sure you want to remove "${this.currentDocument.filename}" from the knowledgebase? This action cannot be undone.`; this.app.showConfirmModal(title, message, async () => { await this.removeDocument(); }); } /** * Remove document */ async removeDocument() { if (!this.currentDocument) return; try { await this.app.removeDocument(this.currentDocument.id); // Navigate back to documents list this.app.navigateTo('documents'); // Note: Don't show toast here - the app.removeDocument() handles the client-initiated action marking // and prevents duplicate notifications from WebSocket events } catch (error) { console.error('Failed to remove document:', error); this.app.showToast('error', 'Remove Failed', 'Failed to remove document. Please try again.'); } } /** * Toggle chunk expansion */ toggleChunk(chunkId) { const chunkEl = this.container.querySelector(`[data-chunk-id="${chunkId}"].document-chunk`); const contentEl = chunkEl?.querySelector('.chunk-content'); const toggleIcon = chunkEl?.querySelector('.chunk-toggle-icon polyline'); if (!chunkEl || !contentEl) return; const isExpanded = this.expandedChunks.has(chunkId); if (isExpanded) { this.expandedChunks.delete(chunkId); contentEl.classList.remove('expanded'); contentEl.classList.add('collapsed'); if (toggleIcon) { toggleIcon.setAttribute('points', '9,18 15,12 9,6'); } } else { this.expandedChunks.add(chunkId); contentEl.classList.remove('collapsed'); contentEl.classList.add('expanded'); if (toggleIcon) { toggleIcon.setAttribute('points', '6,9 12,15 18,9'); } } } /** * Expand all chunks */ expandAllChunks() { const chunkEls = this.container.querySelectorAll('.document-chunk'); chunkEls.forEach(chunkEl => { const chunkId = chunkEl.dataset.chunkId; if (!this.expandedChunks.has(chunkId)) { this.expandedChunks.add(chunkId); const contentEl = chunkEl.querySelector('.chunk-content'); const toggleIcon = chunkEl.querySelector('.chunk-toggle-icon polyline'); if (contentEl) { contentEl.classList.remove('collapsed'); contentEl.classList.add('expanded'); } if (toggleIcon) { toggleIcon.setAttribute('points', '6,9 12,15 18,9'); } } }); } /** * Collapse all chunks */ collapseAllChunks() { const chunkEls = this.container.querySelectorAll('.document-chunk'); chunkEls.forEach(chunkEl => { const chunkId = chunkEl.dataset.chunkId; if (this.expandedChunks.has(chunkId)) { this.expandedChunks.delete(chunkId); const contentEl = chunkEl.querySelector('.chunk-content'); const toggleIcon = chunkEl.querySelector('.chunk-toggle-icon polyline'); if (contentEl) { contentEl.classList.remove('expanded'); contentEl.classList.add('collapsed'); } if (toggleIcon) { toggleIcon.setAttribute('points', '9,18 15,12 9,6'); } } }); } /** * Scroll to specific chunk */ scrollToChunk(chunkId) { const chunkEl = this.container.querySelector(`[data-chunk-id="${chunkId}"].document-chunk`); if (chunkEl) { chunkEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Expand the chunk if it's not already expanded if (!this.expandedChunks.has(chunkId)) { this.toggleChunk(chunkId); } } } /** * Highlight search terms in text */ highlightSearchTerms(text, query) { if (!query || !text) return this.escapeHtml(text); const terms = query.toLowerCase().split(/\s+/).filter(term => term.length > 0); let highlightedText = this.escapeHtml(text); terms.forEach(term => { const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi'); highlightedText = highlightedText.replace(regex, '<mark>$1</mark>'); }); return highlightedText; } /** * Get document status */ getDocumentStatus(document) { if (!document.has_embeddings) return 'Processing'; if (document.chunk_count === 0) return 'Error'; return 'Indexed'; } /** * Get status CSS class */ getStatusClass(document) { return this.getDocumentStatus(document).toLowerCase(); } /** * Get status icon */ getStatusIcon(document) { const status = this.getDocumentStatus(document); const icons = { 'Indexed': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"/></svg>', 'Processing': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9c1.08 0 2.13.19 3.1.54"/></svg>', 'Error': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>' }; return icons[status] || icons['Error']; } /** * Clear current document */ clear() { this.currentDocument = null; this.currentOptions = {}; this.expandedChunks.clear(); if (this.container) { this.container.innerHTML = ''; } } /** * Utility methods */ escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } } // Initialize when app is ready document.addEventListener('DOMContentLoaded', () => { // Wait for app to be initialized const initComponent = () => { if (window.pdfkbApp) { new DocumentDetail(window.pdfkbApp); } else { setTimeout(initComponent, 100); } }; initComponent(); }); // Export for module usage if (typeof module !== 'undefined' && module.exports) { module.exports = DocumentDetail; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/juanqui/pdfkb-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server