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 = `
Loading your links
Pulling saved websites from your vault.
`;
}
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 = `
${hasSearch ? 'No links match your search' : 'No links saved yet'}
${hasSearch ? 'Try a different title, URL, or note.' : 'Add your first site to start building your link vault.'}
${hasSearch ? '' : '
'}
`;
const emptyBtn = document.getElementById('emptyStateAddLinkBtn');
if (emptyBtn) emptyBtn.onclick = () => this.openLinkModal();
return;
}
linksContainer.innerHTML = links
.map((link) => `
${link.description ? `${this.escapeHtml(link.description)}
` : 'No notes added yet.
'}
${this.formatRelativeDate(link.updated_at || link.created_at)}
Saved item
`)
.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 = '';
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 `
`;
}
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();