DocVault-app / js /main.js
mohsin-devs's picture
Fix file preview URLs by routing through backend download endpoint
a158e71
import { hfService } from './api/hfService.js?v=2';
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.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');
// Click outside closes dropdown
document.addEventListener('click', () => {
document.getElementById('newDropdown').classList.remove('active');
// Close all dropdown menus
document.querySelectorAll('.dropdown-menu.open').forEach(m => m.classList.remove('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');
}
});
});
}
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.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.openDeleteModal(path, name));
this.ui.renderFiles(displayFiles, {
onPreview: (file) => this.openPreview(file),
onDownload: (url, name) => this.downloadFile(url, name),
onStar: (path) => this.state.toggleStar(path),
onDelete: (path, name) => this.openDeleteModal(path, name),
getUrl: (path) => getFileUrl(this.hf.apiBase, 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 {
const keepPath = `${destPath}/.gitkeep`;
await this.hf.uploadFile(new File([''], '.gitkeep'), keepPath);
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');
}
async confirmDelete() {
if (!this.pendingDelete) return;
const path = this.pendingDelete;
this.pendingDelete = null;
document.getElementById('deleteModal').classList.remove('active');
this.ui.showProgress('Deleting...');
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');
this.fetchAndRender();
} catch (err) {
this.ui.showToast(err.message || 'Delete failed', 'error');
} finally {
this.ui.hideProgress();
}
}
openPreview(file) {
this.state.addToRecent(file);
const url = getFileUrl(this.hf.apiBase, file.path);
const modal = document.getElementById('previewModal');
const body = document.getElementById('previewBody');
const title = document.getElementById('previewFileName');
title.textContent = file.name;
body.innerHTML = '<div class="loading-state"><div class="spinner"></div></div>';
modal.classList.add('active');
// Download button
document.getElementById('downloadFromPreview').onclick = () => this.downloadFile(url, file.name);
if (isImage(file.name)) {
body.innerHTML = `<img src="${url}" class="preview-image" alt="${file.name}">`;
} else if (isPDF(file.name)) {
body.innerHTML = `<iframe src="${url}" class="preview-iframe"></iframe>`;
} else if (isText(file.name)) {
fetch(url).then(r => r.text()).then(text => {
body.innerHTML = `<pre class="preview-text">${this.escapeHtml(text)}</pre>`;
}).catch(() => {
body.innerHTML = '<div class="preview-fallback"><i class="ph-fill ph-file-x"></i><p>Could not load file preview</p></div>';
});
} else {
body.innerHTML = `<div class="preview-fallback"><i class="ph-fill ph-file"></i><p>No preview available</p><a href="${url}" download="${file.name}" class="btn-primary" style="padding: 10px 24px; text-decoration: none; border-radius: 8px; margin-top: 12px;">Download</a></div>`;
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
downloadFile(url, name) {
const a = document.createElement('a');
a.href = url;
a.download = name;
a.target = '_blank';
a.click();
}
}
new App();