DocVault-app / js /main.js
mohsin-devs's picture
Deploy HF-ready DocVault with HF storage backend
2fe2727
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.cachedFolders = [];
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');
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();
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) => {
const files = e.dataTransfer.files;
if (files.length > 0) {
this.uploadFiles(files);
}
});
}
setupEventListeners() {
// Nav
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();
};
// View Toggles
document.getElementById('viewGrid').onclick = () => this.state.setViewMode('grid');
document.getElementById('viewList').onclick = () => this.state.setViewMode('list');
// Search
let searchDebounce;
document.getElementById('searchInput').oninput = (e) => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
this.state.setSearchQuery(e.target.value.trim());
this.fetchAndRender();
}, 400);
};
// New actions
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();
};
// File Input
document.getElementById('fileInput').onchange = (e) => {
this.uploadFiles(e.target.files);
e.target.value = '';
};
// Create Folder Modal
document.getElementById('confirmFolderBtn').onclick = () => this.createFolder();
document.getElementById('cancelFolderBtn').onclick = () => document.getElementById('createFolderModal').classList.remove('active');
// Enter on folder name input
document.getElementById('folderNameInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.createFolder();
});
// Delete Modal
document.getElementById('confirmDeleteBtn').onclick = () => this.confirmDelete();
document.getElementById('cancelDeleteBtn').onclick = () => document.getElementById('deleteModal').classList.remove('active');
// Rename Modal
document.getElementById('confirmRenameBtn').onclick = () => this.renameItem();
document.getElementById('cancelRenameBtn').onclick = () => document.getElementById('renameModal').classList.remove('active');
// Enter on rename input
document.getElementById('renameInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.renameItem();
if (e.key === 'Escape') document.getElementById('renameModal').classList.remove('active');
});
document.addEventListener('click', () => {
document.getElementById('newDropdown').classList.remove('active');
// Close all dropdown menus
document.querySelectorAll('.dropdown-menu.open').forEach(m => m.classList.remove('open'));
});
// Mobile Toggle
const menuToggle = document.getElementById('menuToggle');
const sidebar = document.querySelector('.sidebar');
if (menuToggle && sidebar) {
menuToggle.onclick = (e) => {
e.stopPropagation();
sidebar.classList.toggle('mobile-open');
};
}
// Close sidebar on navigation (mobile)
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
});
});
// Modals Close via X button
document.querySelectorAll('.close-modal').forEach(btn => {
btn.onclick = () => {
btn.closest('.modal-overlay').classList.remove('active');
};
});
// Modals close on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.classList.remove('active');
sidebar.classList.remove('mobile-open');
}
});
});
}
async fetchAndRender() {
if (this.state.isFetching) return;
this.state.isFetching = true;
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 updateStorageStats() {
try {
const { files } = await this.hf.listFiles('', true);
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; // 10GB
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];
}
render() {
const browseMode = this.state.currentBrowse;
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 = '<span class="breadcrumb-item active">Recent</span>';
} else if (browseMode === 'starred') {
displayFiles = this.state.cachedFiles.filter(f => this.state.starred.includes(f.path));
document.getElementById('breadcrumbs').innerHTML = '<span class="breadcrumb-item active">Starred</span>';
}
// Filter by search
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) => this.state.toggleStar(path),
onDelete: (path, name) => this.openDeleteModal(path, name),
onHistory: (path, name) => this.openHistory(path, name),
getUrl: (path) => getFileUrl(this.hf.apiBase || '/api', path)
});
this.updateActiveNavItem();
}
updateActiveNavItem() {
const items = {
files: 'navMyFiles',
recent: 'navRecent',
starred: 'navStarred'
};
Object.values(items).forEach(id => document.getElementById(id).classList.remove('active'));
document.getElementById(items[this.state.currentBrowse]).classList.add('active');
}
async uploadFiles(fileList) {
const files = Array.from(fileList);
const MAX_SIZE = 10 * 1024 * 1024; // 10MB limit for simple API
for (const file of files) {
// 1. Validation
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;
// 2. Duplicate Check
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;
// Check if folder name is already taken
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;
}
openDeleteModal(path, name) {
this.pendingDelete = path;
const strong = document.querySelector('#deleteModal p strong');
if (strong) strong.textContent = name;
document.getElementById('deleteModal').classList.add('active');
}
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 = `<i class="ph-fill ph-pencil-simple" style="color:var(--primary-color)"></i> 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');
// Check if item still exists in local set to avoid stale deletes
const exists = this.state.cachedFiles.some(f => f.path === path) || this.cachedFolders.some(f => f.path === path);
if (!exists) {
this.ui.showToast('Item no longer exists', 'warning');
this.pendingDelete = null;
document.getElementById('deleteModal').classList.remove('active');
return;
}
if (btn) btn.classList.add('loading');
try {
const isFolder = this.cachedFolders.some(f => f.path === path);
if (isFolder) {
await this.hf.deleteFolder(path);
} else {
await this.hf.deleteFile(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;
}
// Client-side Conflict Check
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;
btn.classList.add('loading');
try {
const res = await this.hf.renameItem(path, newName);
if (res.success) {
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 = '<div class="loading-state"><div class="spinner"></div><p>Loading preview...</p></div>';
modal.classList.add('active');
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)) {
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)) {
body.innerHTML = `<iframe src="${url}" class="preview-iframe" title="${file.name}"></iframe>`;
return;
}
if (isText(file.name)) {
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;
}
body.innerHTML = this.previewFallback(
file.name,
`${url}?download=true`,
'Preview is not available for this file type yet.'
);
}
previewFallback(name, url, message) {
return `
<div class="preview-fallback">
<i class="ph-fill ph-file"></i>
<p>${message}</p>
<a href="${url}" class="btn-primary" style="padding: 10px 24px; text-decoration: none; border-radius: 8px; margin-top: 12px;">
Download ${name}
</a>
</div>
`;
}
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();
}
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');
}
}
}
new App();