import { hfService } from './api/hfService.js?v=6'; import { stateManager } from './state/stateManager.js'; import { UIRenderer } from './ui/uiRenderer.js'; import { getFileUrl, isImage, isPDF, isText } from './utils/formatters.js'; class App { constructor() { this.ui = new UIRenderer(stateManager, hfService); this.state = stateManager; this.hf = hfService; this.pendingDelete = null; this.pendingRename = null; this.pendingLinkEdit = null; this.cachedFolders = []; this.currentPreviewObjectUrl = null; this.init(); } async init() { this.setupEventListeners(); this.setupNetworkHandling(); this.setupDragAndDrop(); this.state.subscribe(() => this.render()); this.fetchAndRender(); } setupNetworkHandling() { window.addEventListener('online', () => { this.ui.showToast('Back online! Syncing...', 'success'); if (this.state.currentBrowse === 'links') { this.fetchAndRenderLinks(); } else { this.fetchAndRender(); } }); window.addEventListener('offline', () => { this.ui.showToast('You are offline. Some features may be limited.', 'warning'); }); } setupDragAndDrop() { const area = document.getElementById('contentArea'); if (!area) return; ['dragenter', 'dragover'].forEach((evt) => { area.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); if (this.state.currentBrowse !== 'links') { area.classList.add('drag-over'); } }); }); ['dragleave', 'drop'].forEach((evt) => { area.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); area.classList.remove('drag-over'); }); }); area.addEventListener('drop', (e) => { if (this.state.currentBrowse === 'links') return; const files = e.dataTransfer.files; if (files.length > 0) { this.uploadFiles(files); } }); } setupEventListeners() { const sidebar = document.querySelector('.sidebar'); const menuToggle = document.getElementById('menuToggle'); const contentArea = document.getElementById('contentArea'); const isCompactViewport = () => window.matchMedia('(max-width: 768px)').matches; const closeSidebarOnCompactView = () => { if (sidebar && isCompactViewport()) { sidebar.classList.remove('mobile-open'); } }; document.getElementById('navMyFiles').onclick = (e) => { e.preventDefault(); this.state.setBrowseMode('files'); this.state.setPath([]); this.fetchAndRender(); }; document.getElementById('navRecent').onclick = (e) => { e.preventDefault(); this.state.setBrowseMode('recent'); this.render(); }; document.getElementById('navStarred').onclick = (e) => { e.preventDefault(); this.state.setBrowseMode('starred'); this.render(); }; document.getElementById('navLinks').onclick = (e) => { e.preventDefault(); this.state.setBrowseMode('links'); this.fetchAndRenderLinks(); }; document.getElementById('viewGrid').onclick = () => this.state.setViewMode('grid'); document.getElementById('viewList').onclick = () => this.state.setViewMode('list'); let searchDebounce; document.getElementById('searchInput').oninput = (e) => { const value = e.target.value.trim(); clearTimeout(searchDebounce); searchDebounce = setTimeout(() => { if (this.state.currentBrowse === 'links') { this.state.setLinksSearchQuery(value); this.render(); return; } this.state.setSearchQuery(value); this.fetchAndRender(); }, 250); }; document.getElementById('linksSearchInput').oninput = (e) => { this.state.setLinksSearchQuery(e.target.value.trim()); this.render(); }; document.getElementById('newBtn').onclick = (e) => { e.stopPropagation(); document.getElementById('newDropdown').classList.toggle('active'); }; document.getElementById('uploadFileBtn').onclick = (e) => { e.preventDefault(); e.stopPropagation(); document.getElementById('newDropdown').classList.remove('active'); document.getElementById('fileInput').click(); }; document.getElementById('createFolderBtn').onclick = (e) => { e.preventDefault(); e.stopPropagation(); document.getElementById('newDropdown').classList.remove('active'); document.getElementById('createFolderModal').classList.add('active'); document.getElementById('folderNameInput').value = ''; document.getElementById('folderNameInput').focus(); }; document.getElementById('addLinkBtn').onclick = (e) => { e.preventDefault(); e.stopPropagation(); document.getElementById('newDropdown').classList.remove('active'); this.openLinkModal(); }; document.getElementById('heroAddLinkBtn').onclick = () => this.openLinkModal(); document.getElementById('fileInput').onchange = (e) => { this.uploadFiles(e.target.files); e.target.value = ''; }; if (contentArea) { contentArea.addEventListener('click', () => closeSidebarOnCompactView()); contentArea.addEventListener( 'touchstart', () => closeSidebarOnCompactView(), { passive: true } ); } document.getElementById('confirmFolderBtn').onclick = () => this.createFolder(); document.getElementById('cancelFolderBtn').onclick = () => document.getElementById('createFolderModal').classList.remove('active'); document.getElementById('folderNameInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') this.createFolder(); }); document.getElementById('confirmDeleteBtn').onclick = () => this.confirmDelete(); document.getElementById('cancelDeleteBtn').onclick = () => document.getElementById('deleteModal').classList.remove('active'); document.getElementById('confirmRenameBtn').onclick = () => this.renameItem(); document.getElementById('cancelRenameBtn').onclick = () => document.getElementById('renameModal').classList.remove('active'); document.getElementById('renameInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') this.renameItem(); if (e.key === 'Escape') document.getElementById('renameModal').classList.remove('active'); }); document.getElementById('confirmLinkBtn').onclick = () => this.saveLink(); document.getElementById('cancelLinkBtn').onclick = () => document.getElementById('addLinkModal').classList.remove('active'); document.getElementById('linkUrlInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('linkTitleInput').focus(); }); document.getElementById('linkUrlInput').addEventListener('input', () => this.updateLinkPreview()); document.getElementById('linkTitleInput').addEventListener('input', () => this.updateLinkPreview()); document.getElementById('linkDescriptionInput').addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { this.saveLink(); } }); document.addEventListener('click', () => { document.getElementById('newDropdown').classList.remove('active'); document.querySelectorAll('.dropdown-menu.open').forEach((m) => m.classList.remove('open')); }); if (menuToggle && sidebar) { menuToggle.onclick = (e) => { e.stopPropagation(); sidebar.classList.toggle('mobile-open'); }; } document.querySelectorAll('.nav-item').forEach((item) => { item.addEventListener('click', () => closeSidebarOnCompactView()); }); document.querySelectorAll('.close-modal').forEach((btn) => { btn.onclick = () => { const modal = btn.closest('.modal-overlay'); if (modal && modal.id === 'previewModal') { this.closePreviewModal(); return; } modal.classList.remove('active'); }; }); document.querySelectorAll('.modal-overlay').forEach((overlay) => { overlay.addEventListener('click', (e) => { if (e.target === overlay) { if (overlay.id === 'previewModal') { this.closePreviewModal(); } else { overlay.classList.remove('active'); } closeSidebarOnCompactView(); } }); }); } setContentView(mode) { const filesView = document.getElementById('filesView'); const linksView = document.getElementById('linksView'); const searchInput = document.getElementById('searchInput'); const isLinksMode = mode === 'links'; if (filesView) filesView.style.display = isLinksMode ? 'none' : 'block'; if (linksView) linksView.style.display = isLinksMode ? 'block' : 'none'; if (searchInput) { searchInput.placeholder = isLinksMode ? 'Search stored links...' : 'Search files or folders...'; searchInput.value = isLinksMode ? this.state.linksSearchQuery : this.state.searchQuery; } } async fetchAndRender() { if (this.state.isFetching) return; this.state.isFetching = true; this.setContentView('files'); this.ui.showSkeletons(); try { const path = this.state.getFolderPath(); const { files, folders } = await this.hf.listFiles(path); this.state.cachedFiles = files; this.cachedFolders = folders; this.render(); this.updateStorageStats(); } catch (err) { console.error('Fetch error:', err); this.state.cachedFiles = []; this.cachedFolders = []; this.render(); this.ui.showError(err.message || 'Failed to reach the DocVault backend. Start the Flask server or open the app through the backend URL.'); this.ui.showToast(err.message || 'Failed to load files', 'error'); } finally { this.state.isFetching = false; } } async fetchAndRenderLinks() { this.setContentView('links'); const linksContainer = document.getElementById('linksContainer'); if (linksContainer) { linksContainer.innerHTML = ` `; } try { this.state.cachedLinks = await this.hf.listLinks(); this.render(); } catch (err) { console.error('Fetch links error:', err); this.state.cachedLinks = []; this.render(); this.ui.showToast(err.message || 'Failed to load links', 'error'); } } async updateStorageStats() { try { const { files } = await this.hf.listFiles(''); const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0); const count = files.length; document.getElementById('storageUsageText').textContent = `${count} files • ${this.formatSize(totalSize)} used`; const MAX_STORAGE = 10 * 1024 * 1024 * 1024; const pct = Math.min((totalSize / MAX_STORAGE) * 100, 100); document.getElementById('storageProgress').style.width = `${pct}%`; } catch (err) { console.error('Storage stats error:', err); } } formatSize(bytes) { if (!bytes || bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } formatRelativeDate(value) { if (!value) return 'Just now'; const diffMs = Date.now() - new Date(value).getTime(); if (Number.isNaN(diffMs)) return 'Just now'; const minutes = Math.floor(diffMs / 60000); if (minutes < 1) return 'Just now'; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 7) return `${days}d ago`; return new Date(value).toLocaleDateString(); } parseUrl(url) { if (!url) return null; try { return new URL(this.normalizeUrl(url)); } catch (err) { return null; } } getLinkHostname(url) { const parsed = this.parseUrl(url); if (!parsed) return 'Unknown host'; return parsed.hostname.replace(/^www\./, ''); } updateLinkPreview() { const rawUrl = document.getElementById('linkUrlInput')?.value.trim() || ''; const rawTitle = document.getElementById('linkTitleInput')?.value.trim() || ''; const previewTitle = document.getElementById('linkPreviewTitle'); const previewUrl = document.getElementById('linkPreviewUrl'); const previewPanel = document.getElementById('linkPreviewPanel'); if (!previewTitle || !previewUrl || !previewPanel) return; const parsed = this.parseUrl(rawUrl); if (!rawUrl) { previewPanel.classList.remove('is-valid', 'is-invalid'); previewTitle.textContent = 'Website preview'; previewUrl.textContent = 'A normalized URL preview will appear here.'; return; } if (!parsed) { previewPanel.classList.remove('is-valid'); previewPanel.classList.add('is-invalid'); previewTitle.textContent = rawTitle || 'Invalid link'; previewUrl.textContent = 'Enter a valid website like https://example.com'; return; } previewPanel.classList.remove('is-invalid'); previewPanel.classList.add('is-valid'); previewTitle.textContent = rawTitle || this.getLinkHostname(parsed.href); previewUrl.textContent = parsed.href; } buildStarredDisplayFiles() { const cachedByPath = new Map(this.state.cachedFiles.map((file) => [file.path, file])); const recentByPath = new Map(this.state.recent.map((file) => [file.path, file])); return this.state.starred.map((path) => { const cached = cachedByPath.get(path); if (cached) return cached; const recent = recentByPath.get(path); if (recent) { return { path, name: recent.name || path.split('/').pop(), size: recent.size || 0, type: 'file', lastModified: recent.lastModified }; } return { path, name: path.split('/').pop(), size: 0, type: 'file' }; }); } getFilteredLinks() { const query = this.state.linksSearchQuery.toLowerCase(); const links = Array.isArray(this.state.cachedLinks) ? this.state.cachedLinks : []; if (!query) return links; return links.filter((link) => { const haystack = [link.title, link.url, link.description] .filter(Boolean) .join(' ') .toLowerCase(); return haystack.includes(query); }); } render() { const browseMode = this.state.currentBrowse; this.setContentView(browseMode); if (browseMode === 'links') { document.getElementById('breadcrumbs').innerHTML = 'Links'; this.renderLinks(this.getFilteredLinks()); this.updateActiveNavItem(); return; } let displayFiles = []; let displayFolders = []; if (browseMode === 'files') { displayFiles = this.state.cachedFiles; displayFolders = this.cachedFolders; this.ui.renderBreadcrumbs((path) => { this.state.setPath(path); this.fetchAndRender(); }); } else if (browseMode === 'recent') { displayFiles = this.state.recent; document.getElementById('breadcrumbs').innerHTML = 'Recent'; } else if (browseMode === 'starred') { displayFiles = this.buildStarredDisplayFiles(); document.getElementById('breadcrumbs').innerHTML = 'Starred'; } if (this.state.searchQuery) { const q = this.state.searchQuery.toLowerCase(); displayFiles = displayFiles.filter((f) => f.name.toLowerCase().includes(q)); displayFolders = displayFolders.filter((f) => f.name.toLowerCase().includes(q)); } this.ui.renderFolders( displayFolders, (name) => { this.state.setPath([...this.state.currentPath, name]); this.fetchAndRender(); }, (path, name) => this.openRenameModal(path, name), (path, name) => this.openDeleteModal(path, name) ); this.ui.renderFiles(displayFiles, { onPreview: (file) => this.openPreview(file), onDownload: (url, name) => this.downloadFile(url, name), onRename: (path, name) => this.openRenameModal(path, name), onStar: (path) => { const wasStarred = this.state.starred.includes(path); this.state.toggleStar(path); this.ui.showToast(wasStarred ? 'Removed from Starred' : 'Added to Starred', 'success'); }, onDelete: (path, name) => this.openDeleteModal(path, name), onHistory: (path, name) => this.openHistory(path, name), getUrl: (path) => getFileUrl(this.hf.apiBase || '/api', path) }); this.updateActiveNavItem(); } renderLinks(links) { const linksContainer = document.getElementById('linksContainer'); const linksCount = document.getElementById('linksCount'); const linksUpdatedAt = document.getElementById('linksUpdatedAt'); if (!linksContainer) return; const totalLinks = Array.isArray(this.state.cachedLinks) ? this.state.cachedLinks.length : 0; const latestUpdated = [...(this.state.cachedLinks || [])] .map((link) => link.updated_at || link.created_at) .filter(Boolean) .sort((a, b) => new Date(b) - new Date(a))[0]; if (linksCount) linksCount.textContent = String(totalLinks); if (linksUpdatedAt) linksUpdatedAt.textContent = this.formatRelativeDate(latestUpdated); if (!links.length) { const hasSearch = Boolean(this.state.linksSearchQuery); linksContainer.innerHTML = ` `; const emptyBtn = document.getElementById('emptyStateAddLinkBtn'); if (emptyBtn) emptyBtn.onclick = () => this.openLinkModal(); return; } linksContainer.innerHTML = links .map((link) => ` `) .join(''); linksContainer.querySelectorAll('[data-action="copy"]').forEach((btn) => { btn.addEventListener('click', async (e) => { e.preventDefault(); const url = btn.dataset.linkUrl || ''; try { await navigator.clipboard.writeText(url); this.ui.showToast('Link copied to clipboard', 'success'); } catch (err) { this.ui.showToast('Could not copy the link', 'error'); } }); }); linksContainer.querySelectorAll('[data-action="edit"]').forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); const link = (this.state.cachedLinks || []).find((item) => item.id === btn.dataset.linkId); if (link) this.openLinkModal(link); }); }); linksContainer.querySelectorAll('[data-action="delete"]').forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); this.deleteLink(btn.dataset.linkId); }); }); } updateActiveNavItem() { const items = { files: 'navMyFiles', recent: 'navRecent', starred: 'navStarred', links: 'navLinks' }; Object.values(items).forEach((id) => document.getElementById(id).classList.remove('active')); if (items[this.state.currentBrowse]) { document.getElementById(items[this.state.currentBrowse]).classList.add('active'); } } async uploadFiles(fileList) { const files = Array.from(fileList); const MAX_SIZE = 10 * 1024 * 1024; for (const file of files) { if (!this.isValidName(file.name)) { this.ui.showToast(`Invalid file name: ${file.name}`, 'error'); continue; } if (file.size > MAX_SIZE) { this.ui.showToast(`File too large: ${file.name} (Max 10MB)`, 'warning'); continue; } const path = this.state.getFolderPath(); const destPath = path ? `${path}/${file.name}` : file.name; if (this.state.cachedFiles.some((f) => f.path === destPath)) { this.ui.showToast(`File already exists: ${file.name}`, 'warning'); continue; } this.ui.showProgress(`Uploading ${file.name}...`); try { await this.hf.uploadFile(file, destPath); this.ui.showToast(`Uploaded ${file.name}`, 'success'); } catch (err) { this.ui.showToast(err.message, 'error'); } } this.ui.hideProgress(); this.fetchAndRender(); } async createFolder() { const name = document.getElementById('folderNameInput').value.trim(); if (!name) return; if (!this.isValidName(name)) { this.ui.showToast('Invalid folder name', 'error'); return; } const path = this.state.getFolderPath(); const destPath = path ? `${path}/${name}` : name; if (this.cachedFolders.some((f) => f.name === name)) { this.ui.showToast(`Folder already exists: ${name}`, 'warning'); return; } document.getElementById('createFolderModal').classList.remove('active'); this.ui.showProgress(`Creating folder ${name}...`); try { await this.hf.createFolder(destPath); this.ui.showToast(`Folder "${name}" created`, 'success'); this.fetchAndRender(); } catch (err) { this.ui.showToast(err.message, 'error'); } finally { this.ui.hideProgress(); } } isValidName(name) { const forbidden = /[<>:"\\|?*\x00-\x1F]/; return name && name.length > 0 && !forbidden.test(name) && name.length < 255; } normalizeUrl(url) { if (!url) return ''; if (/^https?:\/\//i.test(url)) return url; return `https://${url}`; } openDeleteModal(path, name) { // Skip the modal and delete immediately this.pendingDelete = path; this.confirmDelete(); } openRenameModal(path, name) { const renameModal = document.getElementById('renameModal'); const renameInput = document.getElementById('renameInput'); const renameTitle = document.querySelector('#renameModal h3'); const isFolder = this.cachedFolders.some((folder) => folder.path === path); this.pendingRename = { path, originalName: name, itemType: isFolder ? 'folder' : 'file' }; if (renameTitle) { renameTitle.innerHTML = ` Rename ${isFolder ? 'Folder' : 'File'}`; } if (renameInput) { renameInput.value = name; } renameModal.classList.add('active'); setTimeout(() => { renameInput.focus(); renameInput.select(); }, 100); } async confirmDelete() { if (!this.pendingDelete) return; const path = this.pendingDelete; const btn = document.getElementById('confirmDeleteBtn'); if (btn) btn.classList.add('loading'); try { const isFolder = this.cachedFolders.some((f) => f.path === path); if (isFolder) { await this.hf.deleteFolder(path); this.state.starred .filter((starredPath) => starredPath === path || starredPath.startsWith(`${path}/`)) .forEach((starredPath) => this.state.removeStar(starredPath)); } else { await this.hf.deleteFile(path); this.state.removeStar(path); this.state.removeRecent(path); } this.ui.showToast('Deleted successfully', 'success'); document.getElementById('deleteModal').classList.remove('active'); this.pendingDelete = null; this.fetchAndRender(); } catch (err) { this.ui.showToast(err.message || 'Delete failed', 'error'); } finally { if (btn) btn.classList.remove('loading'); } } async renameItem() { if (!this.pendingRename) return; const newNameInput = document.getElementById('renameInput'); const btn = document.getElementById('confirmRenameBtn'); if (!newNameInput || !btn) return; const newName = newNameInput.value.trim(); if (!newName) { this.ui.showToast('Please enter a new name', 'warning'); return; } if (newName === this.pendingRename.originalName) { document.getElementById('renameModal').classList.remove('active'); this.pendingRename = null; return; } if (!this.isValidName(newName)) { this.ui.showToast('Invalid name format (avoid < > : " / \\ | ? *)', 'error'); return; } const isConflict = this.state.cachedFiles.some((f) => f.name.toLowerCase() === newName.toLowerCase()) || this.cachedFolders.some((f) => f.name.toLowerCase() === newName.toLowerCase()); if (isConflict) { this.ui.showToast(`An item with name "${newName}" already exists in this folder`, 'warning'); return; } const path = this.pendingRename.path; const oldName = this.pendingRename.originalName; const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; const newPath = parentPath ? `${parentPath}/${newName}` : newName; btn.classList.add('loading'); try { const res = await this.hf.renameItem(path, newName); if (res.success) { this.state.replacePathReferences(path, newPath, newName); this.ui.showToast(`Renamed "${oldName}" to "${newName}"`, 'success'); document.getElementById('renameModal').classList.remove('active'); this.pendingRename = null; this.fetchAndRender(); } else { throw new Error(res.error || 'Rename failed'); } } catch (err) { this.ui.showToast(err.message, 'error'); } finally { btn.classList.remove('loading'); } } async openPreview(file) { const modal = document.getElementById('previewModal'); const body = document.getElementById('previewBody'); const title = document.getElementById('previewFileName'); const renameBtn = document.getElementById('renameFromPreview'); const downloadBtn = document.getElementById('downloadFromPreview'); if (!modal || !body || !title) return; this.state.addToRecent(file); let apiBase = this.hf.apiBase; if (!apiBase) { apiBase = await this.hf.getApiBase(); } const url = getFileUrl(apiBase, file.path); title.textContent = file.name; body.innerHTML = '

Loading preview...

'; modal.classList.add('active'); const clearPreviewObjectUrl = () => { if (this.currentPreviewObjectUrl) { URL.revokeObjectURL(this.currentPreviewObjectUrl); this.currentPreviewObjectUrl = null; } }; if (downloadBtn) { downloadBtn.onclick = () => this.downloadFile(`${url}?download=true`, file.name); } if (renameBtn) { renameBtn.onclick = () => this.openRenameModal(file.path, file.name); } if (isImage(file.name)) { clearPreviewObjectUrl(); const img = new Image(); img.src = url; img.className = 'preview-image'; img.onload = () => { body.innerHTML = ''; body.appendChild(img); }; img.onerror = () => { body.innerHTML = this.previewFallback(file.name, `${url}?download=true`, 'Image preview failed to load.'); }; return; } if (isPDF(file.name)) { try { clearPreviewObjectUrl(); const response = await fetch(`${url}?download=true`, { headers: { 'X-User-ID': 'default_user' } }); if (!response.ok) { throw new Error(`PDF preview failed: ${response.status}`); } clearPreviewObjectUrl(); body.innerHTML = `
`; } catch (err) { body.innerHTML = this.previewFallback( file.name, `${url}?download=true`, err.message || 'PDF preview is unavailable.' ); } return; } if (isText(file.name)) { clearPreviewObjectUrl(); try { const response = await fetch(url, { headers: { 'X-User-ID': 'default_user' } }); if (!response.ok) { throw new Error(`Preview failed: ${response.status}`); } const text = await response.text(); const pre = document.createElement('pre'); pre.className = 'preview-text'; pre.textContent = text; body.innerHTML = ''; body.appendChild(pre); } catch (err) { body.innerHTML = this.previewFallback(file.name, `${url}?download=true`, err.message || 'Could not load text preview.'); } return; } clearPreviewObjectUrl(); body.innerHTML = this.previewFallback( file.name, `${url}?download=true`, 'Preview is not available for this file type yet.' ); } previewFallback(name, url, message) { return `

${message}

Download ${name}
`; } downloadFile(url, name) { const link = document.createElement('a'); link.href = url; link.download = name; link.target = '_blank'; document.body.appendChild(link); link.click(); link.remove(); } closePreviewModal() { if (this.currentPreviewObjectUrl) { URL.revokeObjectURL(this.currentPreviewObjectUrl); this.currentPreviewObjectUrl = null; } const modal = document.getElementById('previewModal'); if (modal) modal.classList.remove('active'); } async openHistory(path, name) { this.ui.showHistoryModal(name); try { const history = await this.hf.getHistory(path); this.ui.renderHistory(history, async (revision, asCopy) => { try { const result = await this.hf.restoreVersion(path, revision, asCopy); if (!result.success) { throw new Error(result.error || 'Restore failed'); } this.ui.showToast(asCopy ? 'Version restored as a copy' : 'Version restored', 'success'); document.getElementById('historyModal').classList.remove('active'); this.fetchAndRender(); } catch (err) { this.ui.showToast(err.message || 'Restore failed', 'error'); } }); } catch (err) { this.ui.renderHistory([], () => { }); this.ui.showToast(err.message || 'Failed to load history', 'error'); } } resetLinkForm() { document.getElementById('linkUrlInput').value = ''; document.getElementById('linkTitleInput').value = ''; document.getElementById('linkDescriptionInput').value = ''; } openLinkModal(link = null) { const modal = document.getElementById('addLinkModal'); const title = document.getElementById('addLinkModalTitle'); const confirmBtn = document.getElementById('confirmLinkBtn'); this.pendingLinkEdit = link; if (link) { title.innerHTML = 'Edit Link'; confirmBtn.innerHTML = ' Update Link'; document.getElementById('linkUrlInput').value = link.url || ''; document.getElementById('linkTitleInput').value = link.title || ''; document.getElementById('linkDescriptionInput').value = link.description || ''; } else { title.innerHTML = 'Add Link'; confirmBtn.innerHTML = ' Save Link'; this.resetLinkForm(); } modal.classList.add('active'); this.updateLinkPreview(); setTimeout(() => document.getElementById('linkUrlInput').focus(), 50); } async saveLink() { const urlInput = document.getElementById('linkUrlInput'); const titleInput = document.getElementById('linkTitleInput'); const descriptionInput = document.getElementById('linkDescriptionInput'); const confirmBtn = document.getElementById('confirmLinkBtn'); const parsedUrl = this.parseUrl(urlInput.value.trim()); const url = parsedUrl ? parsedUrl.href : ''; const title = titleInput.value.trim(); const description = descriptionInput.value.trim(); if (!url) { this.ui.showToast('Enter a valid website URL', 'error'); return; } try { confirmBtn.classList.add('loading'); this.ui.showProgress(this.pendingLinkEdit ? 'Updating link...' : 'Adding link...'); const isEditing = Boolean(this.pendingLinkEdit); const payload = { url, title, description }; const result = isEditing ? await this.hf.updateLink({ link_id: this.pendingLinkEdit.id, ...payload }) : await this.hf.addLink(payload); if (!result.success) { throw new Error(result.error || 'Failed to save link'); } document.getElementById('addLinkModal').classList.remove('active'); this.pendingLinkEdit = null; this.resetLinkForm(); this.ui.showToast(isEditing ? 'Link updated successfully' : 'Link added successfully', 'success'); await this.fetchAndRenderLinks(); } catch (err) { console.error('Save link error:', err); this.ui.showToast(err.message || 'Error saving link', 'error'); } finally { this.ui.hideProgress(); confirmBtn.classList.remove('loading'); } } async deleteLink(linkId) { if (!linkId) return; try { this.ui.showProgress('Deleting link...'); const data = await this.hf.deleteLink(linkId); if (data.success) { this.ui.showToast('Link deleted', 'success'); await this.fetchAndRenderLinks(); } else { this.ui.showToast(data.error || 'Failed to delete link', 'error'); } } catch (err) { console.error('Delete link error:', err); this.ui.showToast(err.message || 'Error deleting link', 'error'); } finally { this.ui.hideProgress(); } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } new App();