diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 35672eb7..585e7440 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -78,3 +78,23 @@ impl FavoriteItemDto { self } } + +/// Result DTO for batch add-to-favorites. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchFavoritesResult { + /// Statistics about the batch operation + pub stats: BatchFavoritesStats, + /// Full list of the user's favourites (enriched), so the client can + /// replace its local cache in a single round-trip. + pub favorites: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchFavoritesStats { + /// How many items were requested + pub requested: usize, + /// How many were actually inserted (new) + pub inserted: u64, + /// How many were already favourites (skipped) + pub already_existed: u64, +} diff --git a/src/application/ports/favorites_ports.rs b/src/application/ports/favorites_ports.rs index 65c75e27..d361834a 100644 --- a/src/application/ports/favorites_ports.rs +++ b/src/application/ports/favorites_ports.rs @@ -1,4 +1,4 @@ -use crate::application::dtos::favorites_dto::FavoriteItemDto; +use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto}; use crate::common::errors::Result; use async_trait::async_trait; @@ -21,6 +21,14 @@ pub trait FavoritesUseCase: Send + Sync { /// Check if an item is in user's favorites async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; + + /// Add multiple items to favorites in a single transaction. + /// Returns enriched favourites list so the client can replace its cache. + async fn batch_add_to_favorites( + &self, + user_id: &str, + items: &[(String, String)], + ) -> Result; } // ───────────────────────────────────────────────────── @@ -45,4 +53,12 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static { /// Checks if an item is in favorites. async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; + + /// Insert multiple items in a single transaction. + /// Returns the number of rows actually inserted (ignoring duplicates). + async fn add_favorites_batch( + &self, + user_id: &str, + items: &[(String, String)], + ) -> Result; } diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index c46298af..4bab5eb7 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -1,4 +1,4 @@ -use crate::application::dtos::favorites_dto::FavoriteItemDto; +use crate::application::dtos::favorites_dto::{BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto}; use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase}; use crate::common::errors::{DomainError, ErrorKind, Result}; use async_trait::async_trait; @@ -94,4 +94,51 @@ impl FavoritesUseCase for FavoritesService { ); self.repo.is_favorite(user_id, item_id, item_type).await } + + async fn batch_add_to_favorites( + &self, + user_id: &str, + items: &[(String, String)], + ) -> Result { + info!( + "Batch adding {} items to favorites for user {}", + items.len(), + user_id + ); + + // Validate all item types + for (item_id, item_type) in items { + if item_type != "file" && item_type != "folder" { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Favorites", + format!( + "Item type must be 'file' or 'folder' for item '{}'", + item_id + ), + )); + } + } + + let requested = items.len(); + let inserted = self.repo.add_favorites_batch(user_id, items).await?; + let already_existed = requested as u64 - inserted; + + info!( + "Batch favorites for user {}: {} requested, {} inserted, {} already existed", + user_id, requested, inserted, already_existed + ); + + // Return the full enriched list so the client can replace its cache + let favorites = self.repo.get_favorites(user_id).await?; + + Ok(BatchFavoritesResult { + stats: BatchFavoritesStats { + requested, + inserted, + already_existed, + }, + favorites, + }) + } } diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index 4cb4348d..d758027c 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -162,4 +162,91 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { Ok(row.try_get("is_favorite").unwrap_or(false)) } + + async fn add_favorites_batch( + &self, + user_id: &str, + items: &[(String, String)], + ) -> Result { + if items.is_empty() { + return Ok(0); + } + + let user_uuid = Uuid::parse_str(user_id)?; + + // Validate all item_types upfront + for (_, item_type) in items { + if item_type != "file" && item_type != "folder" { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Favorites", + format!("Item type must be 'file' or 'folder', got '{}'", item_type), + )); + } + } + + // Build a multi-row INSERT with ON CONFLICT DO NOTHING + // Using a single transaction for atomicity + let mut tx = self.db_pool.begin().await.map_err(|e| { + error!("Database error starting transaction: {}", e); + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to start transaction: {}", e), + ) + })?; + + let mut total_inserted: u64 = 0; + + // Insert in chunks to stay within Postgres' parameter limit (max ~32k params) + for chunk in items.chunks(5000) { + let mut query = String::from( + "INSERT INTO auth.user_favorites (user_id, item_id, item_type) VALUES ", + ); + let mut param_idx = 1u32; + let mut first = true; + + for _ in chunk { + if !first { + query.push_str(", "); + } + query.push_str(&format!( + "(${}::TEXT, ${}, ${})", + param_idx, + param_idx + 1, + param_idx + 2 + )); + param_idx += 3; + first = false; + } + query.push_str(" ON CONFLICT (user_id, item_id, item_type) DO NOTHING"); + + let mut q = sqlx::query(&query); + for (item_id, item_type) in chunk { + q = q.bind(&user_uuid).bind(item_id).bind(item_type); + } + + let result = q.execute(&mut *tx).await.map_err(|e| { + error!("Database error in batch insert favorites: {}", e); + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to batch insert favorites: {}", e), + ) + })?; + + total_inserted += result.rows_affected(); + } + + tx.commit().await.map_err(|e| { + error!("Database error committing batch favorites: {}", e); + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to commit batch favorites: {}", e), + ) + })?; + + Ok(total_inserted) + } } diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index cce59817..564f99d6 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -1,15 +1,24 @@ -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::{Path, State}, http::StatusCode, response::IntoResponse}; +use serde::Deserialize; use std::sync::Arc; use tracing::{error, info}; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::interfaces::middleware::auth::AuthUser; +/// Single item in a batch-add-favorites request. +#[derive(Debug, Deserialize)] +pub struct BatchFavoriteItem { + pub item_id: String, + pub item_type: String, +} + +/// Request body for POST /api/favorites/batch +#[derive(Debug, Deserialize)] +pub struct BatchFavoritesRequest { + pub items: Vec, +} + /// Handler for favorite-related API endpoints pub async fn get_favorites( State(favorites_service): State>, @@ -120,3 +129,63 @@ pub async fn remove_favorite( } } } + +/// Add multiple items to favourites in a single transaction. +/// POST /api/favorites/batch +pub async fn batch_add_favorites( + State(favorites_service): State>, + auth_user: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let user_id = &auth_user.id; + + if body.items.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "items array must not be empty" })), + ) + .into_response(); + } + + // Validate item types + for item in &body.items { + if item.item_type != "file" && item.item_type != "folder" { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": format!("Item type must be 'file' or 'folder', got '{}'", item.item_type) + })), + ) + .into_response(); + } + } + + let items: Vec<(String, String)> = body + .items + .into_iter() + .map(|i| (i.item_id, i.item_type)) + .collect(); + + match favorites_service + .batch_add_to_favorites(user_id, &items) + .await + { + Ok(result) => { + info!( + "Batch favourites: {} requested, {} inserted, {} already existed", + result.stats.requested, result.stats.inserted, result.stats.already_existed + ); + (StatusCode::OK, Json(serde_json::json!(result))).into_response() + } + Err(err) => { + error!("Error in batch add favorites: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to batch add favorites: {}", err) + })), + ) + .into_response() + } + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 0fe8fe79..92477e3f 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -231,6 +231,7 @@ pub fn create_api_routes(app_state: &AppState) -> Router { Router::new() .route("/", get(favorites_handler::get_favorites)) + .route("/batch", post(favorites_handler::batch_add_favorites)) .route( "/{item_type}/{item_id}", post(favorites_handler::add_favorite), diff --git a/static/js/app.js b/static/js/app.js index 942c293d..202f6617 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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) { diff --git a/static/js/components/sharedView.js b/static/js/components/sharedView.js index afce34da..296f5660 100644 --- a/static/js/components/sharedView.js +++ b/static/js/components/sharedView.js @@ -551,9 +551,7 @@ const sharedView = { }, formatDate(value) { - if (!value) return 'N/A'; - const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value); - return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + return window.formatDateShort ? window.formatDateShort(value) : String(value); }, translate(key, defaultText) { diff --git a/static/js/favorites.js b/static/js/favorites.js index 23338be9..84af84a1 100644 --- a/static/js/favorites.js +++ b/static/js/favorites.js @@ -26,6 +26,20 @@ const favorites = { return `${type}:${id}`; }, + /** + * Replace the entire in-memory cache from an array of FavoriteItemDto + * objects (as returned by the batch endpoint). Avoids an extra + * GET /api/favorites round-trip. + */ + _replaceCacheFromResponse(items) { + this._cache.clear(); + for (const item of items) { + this._cache.set(this._cacheKey(item.item_id, item.item_type), item); + } + this._ready = true; + console.log(`Favorites cache replaced from response: ${this._cache.size} items`); + }, + // ───────────────────── lifecycle ───────────────────── /** @@ -218,11 +232,7 @@ const favorites = { const folderId = item.item_id; const parentId = item.parent_id || ''; - const modifiedAt = item.modified_at - ? new Date(item.modified_at) - : new Date(item.created_at); - const formattedDate = modifiedAt.toLocaleDateString() + ' ' + - modifiedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const formattedDate = window.formatDateTime(item.modified_at || item.created_at); // --- grid element --- const gridEl = document.createElement('div'); @@ -306,11 +316,7 @@ const favorites = { : (window.i18n ? window.i18n.t('files.file_types.document') : 'Document'); const fileSize = item.size_formatted || (window.formatFileSize ? window.formatFileSize(item.item_size || 0) : '0 B'); - const modifiedAt = item.modified_at - ? new Date(item.modified_at) - : new Date(item.created_at); - const formattedDate = modifiedAt.toLocaleDateString() + ' ' + - modifiedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const formattedDate = window.formatDateTime(item.modified_at || item.created_at); // --- grid element --- const gridEl = document.createElement('div'); diff --git a/static/js/fileRenderer.js b/static/js/fileRenderer.js index 9aefcf9f..2c979c0f 100644 --- a/static/js/fileRenderer.js +++ b/static/js/fileRenderer.js @@ -319,10 +319,7 @@ class FileRenderer { elem.dataset.folderName = item.name; elem.dataset.parentId = item.parent_id || ""; - // Format date - const modifiedDate = new Date(item.modified_at * 1000); - const formattedDate = modifiedDate.toLocaleDateString() + ' ' + - modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + const formattedDate = window.formatDateTime(item.modified_at); elem.innerHTML = `
@@ -375,10 +372,7 @@ class FileRenderer { // Format file size and date const fileSize = item.size_formatted || this.formatFileSize(item.size); - // Format date - const modifiedDate = new Date(item.modified_at * 1000); - const formattedDate = modifiedDate.toLocaleDateString() + ' ' + - modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + const formattedDate = window.formatDateTime(item.modified_at); elem.innerHTML = `
@@ -431,16 +425,10 @@ class FileRenderer { } /** - * Format file size in human-readable format + * Format file size — delegates to the single global definition in app.js */ formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const sizes = ['Bytes', '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]; + return window.formatFileSize ? window.formatFileSize(bytes) : `${bytes} Bytes`; } /** diff --git a/static/js/fileSharing.js b/static/js/fileSharing.js index 44bc2adf..b0e351a8 100644 --- a/static/js/fileSharing.js +++ b/static/js/fileSharing.js @@ -157,8 +157,7 @@ const fileSharing = { */ formatExpirationDate(value) { if (!value) return 'No expiration'; - const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value); - return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return window.formatDateTime(value); }, /** diff --git a/static/js/fileViewer.js b/static/js/fileViewer.js index 3b6a2d84..20dea970 100644 --- a/static/js/fileViewer.js +++ b/static/js/fileViewer.js @@ -294,17 +294,11 @@ class FileViewer { } /** - * Check if a MIME type is text-viewable + * Check if a MIME type is text-viewable — delegates to the single global + * definition exposed by app.js (window.isTextViewable). */ 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); + return window.isTextViewable ? window.isTextViewable(mimeType) : false; } /** diff --git a/static/js/inlineViewer.js b/static/js/inlineViewer.js index a605f5bd..6ca10638 100644 --- a/static/js/inlineViewer.js +++ b/static/js/inlineViewer.js @@ -167,21 +167,9 @@ class InlineViewer { modal.classList.add('active'); } - // Check if a MIME type is text-viewable + // Check if a MIME type is text-viewable — delegates to window.isTextViewable 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); + return window.isTextViewable ? window.isTextViewable(mimeType) : false; } // Creates a text viewer using authenticated fetch diff --git a/static/js/multiSelect.js b/static/js/multiSelect.js index c5e69a9c..6a49b7df 100644 --- a/static/js/multiSelect.js +++ b/static/js/multiSelect.js @@ -442,32 +442,60 @@ const multiSelect = { } }, - /** Batch add to favorites */ + /** Batch add to favorites — single API call */ async batchFavorites() { const items = this.items; if (items.length === 0 || !window.favorites) return; - let added = 0; - for (const item of items) { - const alreadyFav = window.favorites.isFavorite(item.id, item.type); - if (!alreadyFav) { - await window.favorites.addToFavorites(item.id, item.name, item.type, item.parentId); - added++; - } - } - - this.clear(); - if (typeof window.loadFiles === 'function') window.loadFiles(); - if (added > 0) { - window.ui.showNotification( - this._t('favorites.add') || 'Added to favorites', - `${added} item${added !== 1 ? 's' : ''} added to favorites` - ); - } else { + // Filter out items already in favourites + const toAdd = items.filter(i => !window.favorites.isFavorite(i.id, i.type)); + if (toAdd.length === 0) { + this.clear(); window.ui.showNotification( this._t('favorites.add') || 'Favorites', 'All selected items are already favorites' ); + return; + } + + try { + const response = await fetch('/api/favorites/batch', { + method: 'POST', + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + items: toAdd.map(i => ({ item_id: i.id, item_type: i.type })) + }) + }); + + if (!response.ok) throw new Error(`Server returned ${response.status}`); + + const data = await response.json(); + const inserted = data.stats?.inserted || 0; + + // Replace cache directly from response (no extra GET) + if (data.favorites && window.favorites._replaceCacheFromResponse) { + window.favorites._replaceCacheFromResponse(data.favorites); + } else { + await window.favorites._fetchFromServer(); + } + + this.clear(); + if (typeof window.loadFiles === 'function') window.loadFiles(); + + if (inserted > 0) { + window.ui.showNotification( + this._t('favorites.add') || 'Added to favorites', + `${inserted} item${inserted !== 1 ? 's' : ''} added to favorites` + ); + } else { + window.ui.showNotification( + this._t('favorites.add') || 'Favorites', + 'All selected items are already favorites' + ); + } + } catch (e) { + console.error('Batch favorites error:', e); + window.ui.showNotification('Error', 'Could not add items to favorites'); } }, diff --git a/static/js/recent.js b/static/js/recent.js index 66ff4f16..7f8ee0c5 100644 --- a/static/js/recent.js +++ b/static/js/recent.js @@ -158,9 +158,7 @@ const recent = { : (window.i18n ? window.i18n.t('files.file_types.document') : 'Document')); const fileSize = isFolder ? '--' : (item.size_formatted || (window.formatFileSize ? window.formatFileSize(item.item_size || 0) : '0 B')); - const accessedDate = new Date(item.accessed_at); - const formattedDate = accessedDate.toLocaleDateString() + ' ' + - accessedDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const formattedDate = window.formatDateTime(item.accessed_at); // Click handler const onClick = () => { diff --git a/static/js/shared.js b/static/js/shared.js index 0a61372c..2e7f8ec7 100644 --- a/static/js/shared.js +++ b/static/js/shared.js @@ -400,8 +400,6 @@ document.addEventListener('DOMContentLoaded', async () => { } function formatDate(value) { - if (!value) return 'N/A'; - const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value); - return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + return window.formatDateShort ? window.formatDateShort(value) : String(value); } }); \ No newline at end of file diff --git a/static/js/ui.js b/static/js/ui.js index 907d8bd8..c3712e2a 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -443,13 +443,40 @@ const ui = { if (!file || !file.mime_type) return false; if (file.mime_type.startsWith('image/')) return true; if (file.mime_type === 'application/pdf') return true; - if (file.mime_type.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(file.mime_type); + // Delegate text-viewability to the single global definition + return window.isTextViewable ? window.isTextViewable(file.mime_type) : false; + }, + + /** + * Get FontAwesome icon class for a filename based on its extension. + * Used as fallback when the backend DTO doesn't include icon_class + * (e.g. trash items). Kept intentionally small — the comprehensive + * visual icon map lives in updateFileIcons(). + */ + getIconClass(fileName) { + if (!fileName) return 'fas fa-file'; + const ext = (fileName.split('.').pop() || '').toLowerCase(); + const map = { + pdf:'fas fa-file-pdf', doc:'fas fa-file-word', docx:'fas fa-file-word', + txt:'fas fa-file-alt', rtf:'fas fa-file-alt', odt:'fas fa-file-alt', + xls:'fas fa-file-excel', xlsx:'fas fa-file-excel', csv:'fas fa-file-excel', ods:'fas fa-file-excel', + ppt:'fas fa-file-powerpoint', pptx:'fas fa-file-powerpoint', odp:'fas fa-file-powerpoint', + jpg:'fas fa-file-image', jpeg:'fas fa-file-image', png:'fas fa-file-image', + gif:'fas fa-file-image', svg:'fas fa-file-image', webp:'fas fa-file-image', + bmp:'fas fa-file-image', ico:'fas fa-file-image', + mp4:'fas fa-file-video', avi:'fas fa-file-video', mov:'fas fa-file-video', + mkv:'fas fa-file-video', webm:'fas fa-file-video', flv:'fas fa-file-video', + mp3:'fas fa-file-audio', wav:'fas fa-file-audio', ogg:'fas fa-file-audio', + flac:'fas fa-file-audio', aac:'fas fa-file-audio', m4a:'fas fa-file-audio', + zip:'fas fa-file-archive', rar:'fas fa-file-archive', '7z':'fas fa-file-archive', + tar:'fas fa-file-archive', gz:'fas fa-file-archive', + js:'fas fa-file-code', ts:'fas fa-file-code', py:'fas fa-file-code', + rs:'fas fa-file-code', java:'fas fa-file-code', html:'fas fa-file-code', + css:'fas fa-file-code', json:'fas fa-file-code', xml:'fas fa-file-code', + sh:'fas fa-terminal', bash:'fas fa-terminal', bat:'fas fa-terminal', + md:'fas fa-file-alt', + }; + return map[ext] || 'fas fa-file'; }, /** @@ -788,10 +815,7 @@ const ui = { folderListElement.dataset.folderName = folder.name; folderListElement.dataset.parentId = folder.parent_id || ""; - // Format date - const modifiedDate = new Date(folder.modified_at * 1000); - const formattedDate = modifiedDate.toLocaleDateString() + ' ' + - modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + const formattedDate = window.formatDateTime(folder.modified_at); // Make draggable if not in root if (window.app.currentPath !== "") { @@ -903,18 +927,17 @@ const ui = { console.log(`Adding file to the view: ${file.name} (${file.id})`); - // Use pre-computed display fields from the API response + // Use pre-computed display fields from the DTO when available const iconClass = file.icon_class || 'fas fa-file'; const iconSpecialClass = file.icon_special_class || ''; - const typeLabel = file.category - ? (window.i18n ? window.i18n.t(`files.file_types.${file.category.toLowerCase()}`) || file.category : file.category) + const cat = file.category || ''; + const typeLabel = cat + ? (window.i18n ? window.i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : cat) : (window.i18n ? window.i18n.t('files.file_types.document') : 'Document'); - // Format size and date + // Format size and date — prefer DTO fields, fall back to computation const fileSize = file.size_formatted || window.formatFileSize(file.size); - const modifiedDate = new Date(file.modified_at * 1000); - const formattedDate = modifiedDate.toLocaleDateString() + ' ' + - modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + const formattedDate = window.formatDateTime(file.modified_at); // Grid view element const fileGridElement = document.createElement('div');