feat: batch favorites endpoint + frontend dedup fixes

Backend:
- Add POST /api/favorites/batch endpoint (single multi-row INSERT)
- Add BatchFavoritesResult/BatchFavoritesStats DTOs
- Add batch methods to ports, service, PG repository
- Transaction-based insert with ON CONFLICT DO NOTHING, chunking at 5000

Frontend:
- Rewrite batchFavorites() to single API call (40 requests → 1)
- Add _replaceCacheFromResponse() to avoid extra GET round-trip
- Centralize formatFileSize, isTextViewable, formatDateTime, formatDateShort
- Remove duplicate icon mapping from app.js
- Fix inconsistent quota defaults (10GB everywhere)
This commit is contained in:
Dionisio
2026-02-16 09:17:54 +01:00
parent fb652c07e3
commit d6c4eb884d
17 changed files with 433 additions and 141 deletions
+69 -37
View File
@@ -307,7 +307,7 @@ function updateUserMenuData() {
// Storage info
const usedBytes = userData.storage_used_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || 10737418240;
const quotaBytes = userData.storage_quota_bytes || (10 * 1024 * 1024 * 1024); // 10 GB default
const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0;
if (storageFill) storageFill.style.width = percentage + '%';
@@ -945,41 +945,23 @@ function addTrashItemToView(item) {
const isFile = item.item_type === 'file';
// Format date - backend sends trashed_at as ISO 8601 string
const deletedDate = new Date(item.trashed_at);
const formattedDate = deletedDate.toLocaleDateString() + ' ' +
deletedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const formattedDate = window.formatDateTime(item.trashed_at);
// Determine type label and icon from extension (trash DTO has no mime_type)
let typeLabel;
// Use icon_class from the trash DTO if available, otherwise fall back to
// the comprehensive icon map exposed by ui.js.
let iconClass;
let typeLabel;
if (!isFile) {
iconClass = 'fas fa-folder';
iconClass = item.icon_class || 'fas fa-folder';
typeLabel = window.i18n ? window.i18n.t('files.file_types.folder') : 'Folder';
} else {
const ext = (item.name.split('.').pop() || '').toLowerCase();
const imageExts = ['jpg','jpeg','png','gif','bmp','svg','webp','ico','tiff'];
const videoExts = ['mp4','avi','mkv','mov','wmv','flv','webm'];
const audioExts = ['mp3','wav','ogg','flac','aac','wma','m4a'];
const textExts = ['txt','md','csv','log','ini','cfg','conf'];
if (ext === 'pdf') {
iconClass = 'fas fa-file-pdf';
typeLabel = window.i18n ? window.i18n.t('files.file_types.pdf') : 'PDF';
} else if (imageExts.includes(ext)) {
iconClass = 'fas fa-file-image';
typeLabel = window.i18n ? window.i18n.t('files.file_types.image') : 'Image';
} else if (videoExts.includes(ext)) {
iconClass = 'fas fa-file-video';
typeLabel = window.i18n ? window.i18n.t('files.file_types.video') : 'Video';
} else if (audioExts.includes(ext)) {
iconClass = 'fas fa-file-audio';
typeLabel = window.i18n ? window.i18n.t('files.file_types.audio') : 'Audio';
} else if (textExts.includes(ext)) {
iconClass = 'fas fa-file-alt';
typeLabel = window.i18n ? window.i18n.t('files.file_types.text') : 'Text';
} else {
iconClass = 'fas fa-file';
typeLabel = window.i18n ? window.i18n.t('files.file_types.document') : 'Document';
}
iconClass = item.icon_class || (window.ui && window.ui.getIconClass
? window.ui.getIconClass(item.name)
: 'fas fa-file');
const cat = item.category || '';
typeLabel = cat
? (window.i18n ? window.i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : cat)
: (window.i18n ? window.i18n.t('files.file_types.document') : 'Document');
}
// Grid view element
@@ -1135,6 +1117,55 @@ window.loadTrashItems = loadTrashItems;
window.formatFileSize = formatFileSize;
window.performSearch = performSearch;
/**
* Centralised date+time formatter — use this everywhere instead of inline
* toLocaleDateString/toLocaleTimeString calls.
* @param {Date|number|string} value Date object, unix-seconds number, or ISO string
* @returns {string} e.g. "16/02/2026 14:35"
*/
window.formatDateTime = function formatDateTime(value) {
if (!value) return '';
let d;
if (value instanceof Date) {
d = value;
} else if (typeof value === 'number') {
// Heuristic: values < 1e12 are unix seconds, otherwise ms
d = new Date(value < 1e12 ? value * 1000 : value);
} else {
d = new Date(value);
}
if (isNaN(d.getTime())) return String(value);
return d.toLocaleDateString() + ' ' +
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
/**
* Short date formatter (no time) for shares / expiration dates.
* @param {Date|number|string} value
* @returns {string} e.g. "Feb 16, 2026"
*/
window.formatDateShort = function formatDateShort(value) {
if (!value) return 'N/A';
const d = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
if (isNaN(d.getTime())) return String(value);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
/**
* Centralised "is this MIME type text-viewable?" check.
* Used by fileViewer, inlineViewer, and ui.isViewableFile.
*/
window.isTextViewable = function isTextViewable(mimeType) {
if (!mimeType) return false;
if (mimeType.startsWith('text/')) return true;
const textTypes = [
'application/json', 'application/xml', 'application/javascript',
'application/x-sh', 'application/x-yaml', 'application/toml',
'application/x-toml', 'application/sql',
];
return textTypes.includes(mimeType);
};
// Set up global selectFolder function for navigation
window.selectFolder = (id, name) => {
app.currentPath = id;
@@ -1424,10 +1455,10 @@ function switchToRecentFilesView() {
elements.actionsBar.style.display = 'flex';
// Add event listener for clear button
document.getElementById('clear-recent-btn').addEventListener('click', async () => {
document.getElementById('clear-recent-btn').addEventListener('click', () => {
if (window.recent) {
await window.recent.clearRecentFiles();
await window.recent.displayRecentFiles();
window.recent.clearRecentFiles();
window.recent.displayRecentFiles();
window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
}
});
@@ -1542,7 +1573,7 @@ function showUserProfileModal() {
const role = userData.role || 'user';
const initials = username.substring(0, 2).toUpperCase();
const usedBytes = userData.storage_used_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || (10 * 1024 * 1024 * 1024); // 10 GB default
const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0;
const barColor = percentage > 90 ? '#ef4444' : percentage > 70 ? '#f59e0b' : '#22c55e';
@@ -1829,14 +1860,15 @@ function logout() {
*/
function updateStorageUsageDisplay(userData) {
// Default values
const DEFAULT_QUOTA = 10 * 1024 * 1024 * 1024; // 10 GB
let usedBytes = 0;
let quotaBytes = 10737418240; // Default 10GB
let quotaBytes = DEFAULT_QUOTA;
let usagePercentage = 0;
// Get values from user data if available
if (userData) {
usedBytes = userData.storage_used_bytes || 0;
quotaBytes = userData.storage_quota_bytes || 10737418240;
quotaBytes = userData.storage_quota_bytes || DEFAULT_QUOTA;
// Calculate percentage (avoid division by zero)
if (quotaBytes > 0) {