From fb652c07e3ad58c618088ae07d2db70c995617e8 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Mon, 16 Feb 2026 01:09:28 +0100 Subject: [PATCH] feat(P1+P2): server-authoritative favorites/recent + pre-computed display fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-A: Enrich FavoriteItemDto & RecentItemDto with item_name, item_size, item_mime_type, parent_id, modified_at via SQL LEFT JOINs — eliminates N+1 per-item fetches. P1-B: Rewrite favorites.js as server-authoritative (724→380 lines). In-memory cache backed by GET /api/favorites; no localStorage. P1-C: Rewrite recent.js as server-authoritative (341→249 lines). GET /api/recent + POST /api/recent/{type}/{id}; no localStorage. P1 cleanup: Remove dead localStorage cleanup from auth.js logout, fix async clearRecentFiles in app.js. P2: Add icon_class, icon_special_class, category, size_formatted to FileDto, FolderDto, FavoriteItemDto, RecentItemDto. New shared display_helpers.rs module centralises mime→icon/category/size logic. Frontend (ui.js, fileRenderer.js, favorites.js, recent.js) now reads pre-computed fields from the API with fallback defaults — eliminates 5 duplicated mime→icon mapping blocks (~89 lines removed). Also fixes: synthetic FolderDto in webdav_handler.rs, pre-existing missing item_name field in share_service.rs test. Net: -307 lines across 15 files. cargo check: 0 errors, 0 warnings. cargo test display_helpers: 3/3 pass. --- src/application/dtos/display_helpers.rs | 126 +++ src/application/dtos/favorites_dto.rs | 61 +- src/application/dtos/file_dto.rs | 36 +- src/application/dtos/folder_dto.rs | 21 +- src/application/dtos/mod.rs | 1 + src/application/dtos/recent_dto.rs | 57 +- src/application/services/share_service.rs | 1 + .../pg/favorites_pg_repository.rs | 37 +- .../pg/recent_items_pg_repository.rs | 35 +- src/interfaces/api/handlers/webdav_handler.rs | 3 + static/js/app.js | 6 +- static/js/auth.js | 14 - static/js/favorites.js | 855 ++++++------------ static/js/fileRenderer.js | 59 +- static/js/recent.js | 437 ++++----- static/js/ui.js | 36 +- 16 files changed, 802 insertions(+), 983 deletions(-) create mode 100644 src/application/dtos/display_helpers.rs diff --git a/src/application/dtos/display_helpers.rs b/src/application/dtos/display_helpers.rs new file mode 100644 index 00000000..c44f86e8 --- /dev/null +++ b/src/application/dtos/display_helpers.rs @@ -0,0 +1,126 @@ +/// Shared display helpers for DTOs. +/// +/// These functions centralise the mime→icon / mime→category / size→human-string +/// logic so that every API response carries pre-computed display fields and the +/// frontend does **not** need to duplicate these mappings. + +/// Returns the FontAwesome icon class for a given MIME type. +/// +/// Examples: `"fas fa-file-image"`, `"fas fa-file-pdf"`, `"fas fa-file"` (default). +pub fn mime_to_icon_class(mime: &str) -> &'static str { + if mime.starts_with("image/") { + "fas fa-file-image" + } else if mime.starts_with("text/") { + "fas fa-file-alt" + } else if mime.starts_with("video/") { + "fas fa-file-video" + } else if mime.starts_with("audio/") { + "fas fa-file-audio" + } else if mime == "application/pdf" { + "fas fa-file-pdf" + } else { + "fas fa-file" + } +} + +/// Returns the CSS class used to colour/style the icon container. +/// +/// Examples: `"image-icon"`, `"pdf-icon"`, `""` (default). +pub fn mime_to_icon_special_class(mime: &str) -> &'static str { + if mime.starts_with("image/") { + "image-icon" + } else if mime.starts_with("text/") { + "text-icon" + } else if mime.starts_with("video/") { + "video-icon" + } else if mime.starts_with("audio/") { + "audio-icon" + } else if mime == "application/pdf" { + "pdf-icon" + } else { + "" + } +} + +/// Returns a human-readable category label for a MIME type. +/// +/// Examples: `"Image"`, `"Text"`, `"Document"` (default). +pub fn mime_to_category(mime: &str) -> &'static str { + if mime.starts_with("image/") { + "Image" + } else if mime.starts_with("text/") { + "Text" + } else if mime.starts_with("video/") { + "Video" + } else if mime.starts_with("audio/") { + "Audio" + } else if mime == "application/pdf" { + "PDF" + } else { + "Document" + } +} + +/// Formats a byte count into a human-readable string (1024-based). +/// +/// Matches the JavaScript `formatFileSize()` output exactly so the frontend +/// does not need its own per-file formatting. +/// +/// Examples: `"0 Bytes"`, `"1.5 KB"`, `"3.27 MB"`. +pub fn format_file_size(bytes: u64) -> String { + if bytes == 0 { + return "0 Bytes".to_string(); + } + + const K: f64 = 1024.0; + const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"]; + + let i = ((bytes as f64).ln() / K.ln()).floor() as usize; + let i = i.min(SIZES.len() - 1); + + let value = bytes as f64 / K.powi(i as i32); + + // Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour) + let formatted = format!("{:.2}", value); + let formatted = formatted + .trim_end_matches('0') + .trim_end_matches('.'); + + format!("{} {}", formatted, SIZES[i]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_file_size() { + assert_eq!(format_file_size(0), "0 Bytes"); + assert_eq!(format_file_size(500), "500 Bytes"); + assert_eq!(format_file_size(1024), "1 KB"); + assert_eq!(format_file_size(1536), "1.5 KB"); + assert_eq!(format_file_size(1_048_576), "1 MB"); + assert_eq!(format_file_size(3_423_744), "3.27 MB"); + assert_eq!(format_file_size(1_073_741_824), "1 GB"); + } + + #[test] + fn test_mime_to_icon_class() { + assert_eq!(mime_to_icon_class("image/png"), "fas fa-file-image"); + assert_eq!(mime_to_icon_class("text/plain"), "fas fa-file-alt"); + assert_eq!(mime_to_icon_class("video/mp4"), "fas fa-file-video"); + assert_eq!(mime_to_icon_class("audio/mpeg"), "fas fa-file-audio"); + assert_eq!(mime_to_icon_class("application/pdf"), "fas fa-file-pdf"); + assert_eq!(mime_to_icon_class("application/octet-stream"), "fas fa-file"); + } + + #[test] + fn test_mime_to_category() { + assert_eq!(mime_to_category("image/jpeg"), "Image"); + assert_eq!(mime_to_category("text/html"), "Text"); + assert_eq!(mime_to_category("video/webm"), "Video"); + assert_eq!(mime_to_category("audio/ogg"), "Audio"); + assert_eq!(mime_to_category("application/pdf"), "PDF"); + assert_eq!(mime_to_category("application/zip"), "Document"); + } +} diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 56a9e0ba..35672eb7 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -1,7 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -/// DTO for favorites item +use super::display_helpers::{format_file_size, mime_to_category, mime_to_icon_class, mime_to_icon_special_class}; + +/// DTO for favorites item, enriched with item metadata via SQL JOIN +/// so the frontend does not need N+1 requests to resolve names/sizes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FavoriteItemDto { /// Unique identifier for the favorite entry @@ -18,4 +21,60 @@ pub struct FavoriteItemDto { /// When the item was added to favorites pub created_at: DateTime, + + // ── Enriched metadata (resolved via JOIN) ── + + /// Display name of the file or folder + #[serde(skip_serializing_if = "Option::is_none")] + pub item_name: Option, + + /// Size in bytes (files only; folders → None) + #[serde(skip_serializing_if = "Option::is_none")] + pub item_size: Option, + + /// MIME type (files only) + #[serde(skip_serializing_if = "Option::is_none")] + pub item_mime_type: Option, + + /// Parent folder ID (folder_id for files, parent_id for folders) + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + + /// Last modification timestamp of the item + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option>, + + // ── Pre-computed display fields ── + + /// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder") + pub icon_class: String, + + /// Extra CSS class for icon styling (e.g. "image-icon", "folder-icon") + pub icon_special_class: String, + + /// Human-readable category (e.g. "Image", "Folder") + pub category: String, + + /// Formatted file size (e.g. "3.27 MB"); "--" for folders + pub size_formatted: String, +} + +impl FavoriteItemDto { + /// Populate display fields from the enriched metadata. + /// Call this after constructing from the SQL row. + pub fn with_display_fields(mut self) -> Self { + if self.item_type == "folder" { + self.icon_class = "fas fa-folder".to_string(); + self.icon_special_class = "folder-icon".to_string(); + self.category = "Folder".to_string(); + self.size_formatted = "--".to_string(); + } else { + let mime = self.item_mime_type.as_deref().unwrap_or("application/octet-stream"); + self.icon_class = mime_to_icon_class(mime).to_string(); + self.icon_special_class = mime_to_icon_special_class(mime).to_string(); + self.category = mime_to_category(mime).to_string(); + self.size_formatted = format_file_size(self.item_size.unwrap_or(0) as u64); + } + self + } } diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index d6875038..2565512a 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -1,6 +1,8 @@ use crate::domain::entities::file::File; use serde::{Deserialize, Serialize}; +use super::display_helpers::{format_file_size, mime_to_category, mime_to_icon_class, mime_to_icon_special_class}; + /// DTO for file responses #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileDto { @@ -27,19 +29,40 @@ pub struct FileDto { /// Last modification timestamp pub modified_at: u64, + + // ── Pre-computed display fields ── + + /// FontAwesome icon CSS class (e.g. "fas fa-file-image") + pub icon_class: String, + + /// Extra CSS class for icon styling (e.g. "image-icon", "" when default) + pub icon_special_class: String, + + /// Human-readable file category (e.g. "Image", "Document") + pub category: String, + + /// Human-readable formatted size (e.g. "3.27 MB") + pub size_formatted: String, } impl From for FileDto { fn from(file: File) -> Self { + let mime = file.mime_type(); + let size = file.size(); + Self { id: file.id().to_string(), name: file.name().to_string(), path: file.path_string().to_string(), - size: file.size(), - mime_type: file.mime_type().to_string(), + size, + mime_type: mime.to_string(), folder_id: file.folder_id().map(String::from), created_at: file.created_at(), modified_at: file.modified_at(), + icon_class: mime_to_icon_class(mime).to_string(), + icon_special_class: mime_to_icon_special_class(mime).to_string(), + category: mime_to_category(mime).to_string(), + size_formatted: format_file_size(size), } } } @@ -47,9 +70,8 @@ impl From for FileDto { // To convert from FileDto to File for batch handlers impl From for File { fn from(dto: FileDto) -> Self { - // Use constructor to create an entity from DTO - // Note: this should be simplified if File has a proper constructor - // If not, make the conversion as best as possible + // Display fields (icon_class, icon_special_class, category, size_formatted) + // are not part of the domain entity and are ignored. File::from_dto( dto.id, dto.name, @@ -75,6 +97,10 @@ impl FileDto { folder_id: None, created_at: 0, modified_at: 0, + icon_class: "fas fa-file".to_string(), + icon_special_class: String::new(), + category: "Document".to_string(), + size_formatted: "0 Bytes".to_string(), } } } diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 572d4748..7b67c0de 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -52,6 +52,17 @@ pub struct FolderDto { /// Whether this is a root folder pub is_root: bool, + + // ── Pre-computed display fields ── + + /// FontAwesome icon CSS class (always "fas fa-folder") + pub icon_class: String, + + /// Extra CSS class for icon styling (always "folder-icon") + pub icon_special_class: String, + + /// Human-readable category (always "Folder") + pub category: String, } impl From for FolderDto { @@ -67,6 +78,9 @@ impl From for FolderDto { created_at: folder.created_at(), modified_at: folder.modified_at(), is_root, + icon_class: "fas fa-folder".to_string(), + icon_special_class: "folder-icon".to_string(), + category: "Folder".to_string(), } } } @@ -74,8 +88,8 @@ impl From for FolderDto { // To convert from FolderDto to Folder for batch handlers impl From for Folder { fn from(dto: FolderDto) -> Self { - // Use constructor to create an entity from DTO - // Note: this should be simplified if Folder has a proper constructor + // Display fields (icon_class, icon_special_class, category) + // are not part of the domain entity and are ignored. Folder::from_dto( dto.id, dto.name, @@ -99,6 +113,9 @@ impl FolderDto { created_at: 0, modified_at: 0, is_root: true, + icon_class: "fas fa-folder".to_string(), + icon_special_class: "folder-icon".to_string(), + category: "Folder".to_string(), } } } diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 175331d7..93ad76a5 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -1,6 +1,7 @@ pub mod address_book_dto; pub mod calendar_dto; pub mod contact_dto; +pub mod display_helpers; pub mod favorites_dto; pub mod file_dto; pub mod folder_dto; diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 4e51a81e..e75c1cfe 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -1,7 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -/// DTO for recent items +use super::display_helpers::{format_file_size, mime_to_category, mime_to_icon_class, mime_to_icon_special_class}; + +/// DTO for recent items, enriched with item metadata via SQL JOIN +/// so the frontend does not need N+1 requests to resolve names/sizes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecentItemDto { /// Unique identifier for the recent item @@ -18,4 +21,56 @@ pub struct RecentItemDto { /// When the item was accessed pub accessed_at: DateTime, + + // ── Enriched metadata (resolved via JOIN) ── + + /// Display name of the file or folder + #[serde(skip_serializing_if = "Option::is_none")] + pub item_name: Option, + + /// Size in bytes (files only; folders → None) + #[serde(skip_serializing_if = "Option::is_none")] + pub item_size: Option, + + /// MIME type (files only) + #[serde(skip_serializing_if = "Option::is_none")] + pub item_mime_type: Option, + + /// Parent folder ID (folder_id for files, parent_id for folders) + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + + // ── Pre-computed display fields ── + + /// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder") + pub icon_class: String, + + /// Extra CSS class for icon styling (e.g. "image-icon", "folder-icon") + pub icon_special_class: String, + + /// Human-readable category (e.g. "Image", "Folder") + pub category: String, + + /// Formatted file size (e.g. "3.27 MB"); "--" for folders + pub size_formatted: String, +} + +impl RecentItemDto { + /// Populate display fields from the enriched metadata. + /// Call this after constructing from the SQL row. + pub fn with_display_fields(mut self) -> Self { + if self.item_type == "folder" { + self.icon_class = "fas fa-folder".to_string(); + self.icon_special_class = "folder-icon".to_string(); + self.category = "Folder".to_string(); + self.size_formatted = "--".to_string(); + } else { + let mime = self.item_mime_type.as_deref().unwrap_or("application/octet-stream"); + self.icon_class = mime_to_icon_class(mime).to_string(); + self.icon_special_class = mime_to_icon_special_class(mime).to_string(); + self.category = mime_to_category(mime).to_string(); + self.size_formatted = format_file_size(self.item_size.unwrap_or(0) as u64); + } + self + } } diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 3c1e9d02..f866e0e3 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -742,6 +742,7 @@ mod tests { // Test creating a file share let dto = CreateShareDto { item_id: "test_file_id".to_string(), + item_name: Some("test_file.txt".to_string()), item_type: "file".to_string(), password: Some("secret".to_string()), expires_at: None, diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index ac71ce04..4cb4348d 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -27,14 +27,23 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { let rows = sqlx::query( r#" SELECT - id::TEXT AS "id", - user_id::TEXT AS "user_id", - item_id AS "item_id", - item_type AS "item_type", - created_at AS "created_at" - FROM auth.user_favorites - WHERE user_id = $1::TEXT - ORDER BY created_at DESC + uf.id::TEXT AS "id", + uf.user_id::TEXT AS "user_id", + uf.item_id AS "item_id", + uf.item_type AS "item_type", + uf.created_at AS "created_at", + COALESCE(f.name, fld.name) AS "item_name", + f.size AS "item_size", + f.mime_type AS "item_mime_type", + COALESCE(f.folder_id::TEXT, fld.parent_id::TEXT) AS "parent_id", + COALESCE(f.updated_at, fld.updated_at) AS "modified_at" + FROM auth.user_favorites uf + LEFT JOIN storage.files f ON uf.item_type = 'file' + AND uf.item_id = f.id::TEXT + LEFT JOIN storage.folders fld ON uf.item_type = 'folder' + AND uf.item_id = fld.id::TEXT + WHERE uf.user_id = $1::TEXT + ORDER BY uf.created_at DESC "#, ) .bind(user_uuid) @@ -57,7 +66,17 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { item_id: row.get("item_id"), item_type: row.get("item_type"), created_at: row.get("created_at"), - }) + item_name: row.try_get("item_name").ok(), + item_size: row.try_get("item_size").ok(), + item_mime_type: row.try_get("item_mime_type").ok(), + parent_id: row.try_get("parent_id").ok(), + modified_at: row.try_get("modified_at").ok(), + // Temporary defaults; with_display_fields() computes the real values + icon_class: String::new(), + icon_special_class: String::new(), + category: String::new(), + size_formatted: String::new(), + }.with_display_fields()) .collect(); Ok(favorites) diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index 9d59a90c..fabf4b92 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -27,14 +27,22 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { let rows = sqlx::query( r#" SELECT - id::TEXT AS "id", - user_id::TEXT AS "user_id", - item_id AS "item_id", - item_type AS "item_type", - accessed_at AS "accessed_at" - FROM auth.user_recent_files - WHERE user_id = $1::TEXT - ORDER BY accessed_at DESC + ur.id::TEXT AS "id", + ur.user_id::TEXT AS "user_id", + ur.item_id AS "item_id", + ur.item_type AS "item_type", + ur.accessed_at AS "accessed_at", + COALESCE(f.name, fld.name) AS "item_name", + f.size AS "item_size", + f.mime_type AS "item_mime_type", + COALESCE(f.folder_id::TEXT, fld.parent_id::TEXT) AS "parent_id" + FROM auth.user_recent_files ur + LEFT JOIN storage.files f ON ur.item_type = 'file' + AND ur.item_id = f.id::TEXT + LEFT JOIN storage.folders fld ON ur.item_type = 'folder' + AND ur.item_id = fld.id::TEXT + WHERE ur.user_id = $1::TEXT + ORDER BY ur.accessed_at DESC LIMIT $2 "#, ) @@ -59,7 +67,16 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { item_id: row.get("item_id"), item_type: row.get("item_type"), accessed_at: row.get("accessed_at"), - }) + item_name: row.try_get("item_name").ok(), + item_size: row.try_get("item_size").ok(), + item_mime_type: row.try_get("item_mime_type").ok(), + parent_id: row.try_get("parent_id").ok(), + // Temporary defaults; with_display_fields() computes the real values + icon_class: String::new(), + icon_special_class: String::new(), + category: String::new(), + size_formatted: String::new(), + }.with_display_fields()) .collect(); Ok(items) diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 1e4ac251..20aac22c 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -216,6 +216,9 @@ async fn handle_propfind( created_at: Utc::now().timestamp() as u64, modified_at: Utc::now().timestamp() as u64, is_root: true, + icon_class: "fas fa-folder".to_string(), + icon_special_class: "folder-icon".to_string(), + category: "Folder".to_string(), }; // Generate response diff --git a/static/js/app.js b/static/js/app.js index f258989b..942c293d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1424,10 +1424,10 @@ function switchToRecentFilesView() { elements.actionsBar.style.display = 'flex'; // Add event listener for clear button - document.getElementById('clear-recent-btn').addEventListener('click', () => { + document.getElementById('clear-recent-btn').addEventListener('click', async () => { if (window.recent) { - window.recent.clearRecentFiles(); - window.recent.displayRecentFiles(); + await window.recent.clearRecentFiles(); + await window.recent.displayRecentFiles(); window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared'); } }); diff --git a/static/js/auth.js b/static/js/auth.js index 099b74ac..2481d2e1 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -1106,20 +1106,6 @@ function redirectToMainApp() { * Logout - clear tokens and redirect to login */ function logout() { - // Clear user-specific recent files and favorites before removing user data - try { - const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); - if (userData.username) { - localStorage.removeItem(`oxicloud_recent_files_${userData.username}`); - localStorage.removeItem(`oxicloud_favorites_${userData.username}`); - } - } catch (e) { - // Ignore parse errors during cleanup - } - // Also remove any legacy global keys - localStorage.removeItem('oxicloud_recent_files'); - localStorage.removeItem('oxicloud_favorites'); - localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY); diff --git a/static/js/favorites.js b/static/js/favorites.js index 3a7ee729..23338be9 100644 --- a/static/js/favorites.js +++ b/static/js/favorites.js @@ -1,359 +1,174 @@ /** - * OxiCloud - Favorites Module - * This file handles favoriting files and folders, persisting favorites, and displaying favorite items + * OxiCloud - Favorites Module (server-authoritative) + * + * Source of truth: GET /api/favorites (enriched with name/size/mime via SQL JOIN). + * Local in-memory cache (`_cache`) keeps `isFavorite()` synchronous for the + * rendering path so star icons can be painted without a round-trip. */ -// Favorites Module const favorites = { - // Base key for storing favorites in localStorage (username is appended) - STORAGE_KEY_PREFIX: 'oxicloud_favorites', - - // Legacy key (pre-fix, shared across all users) - LEGACY_STORAGE_KEY: 'oxicloud_favorites', - - // Flag to indicate if backend API is available - backendApiAvailable: false, - + /** @type {Map} key = "file:" | "folder:" */ + _cache: new Map(), + + /** Whether the initial fetch from the server has completed */ + _ready: false, + + // ───────────────────── helpers ───────────────────── + + _authHeaders() { + const token = localStorage.getItem('oxicloud_token'); + const h = {}; + if (token) h['Authorization'] = `Bearer ${token}`; + return h; + }, + + _cacheKey(id, type) { + return `${type}:${id}`; + }, + + // ───────────────────── lifecycle ───────────────────── + /** - * Get the user-specific storage key for favorites. - * Falls back to legacy global key if username is unavailable. - * @returns {string} localStorage key scoped to the current user + * Initialise the module: fetch the full list from the server and populate + * the in-memory cache. Called once from app.js on startup. */ - getStorageKey() { + async init() { + console.log('Initializing favorites module (server-authoritative)'); + await this._fetchFromServer(); + }, + + /** + * Fetch favourites from the backend and rebuild the cache. + */ + async _fetchFromServer() { try { - const userData = JSON.parse(localStorage.getItem('oxicloud_user') || '{}'); - if (userData.username) { - return `${this.STORAGE_KEY_PREFIX}_${userData.username}`; - } - } catch (e) { - console.warn('Could not determine current user for favorites key'); - } - return this.LEGACY_STORAGE_KEY; - }, - - /** - * Initialize favorites module - */ - init() { - console.log('Initializing favorites module'); - this.migrateFromLegacyKey(); - this.loadFavorites(); - - // Check if backend favorites API is available - this.checkBackendAvailability(); - }, - - /** - * Migrate data from the old global key to the user-specific key. - */ - migrateFromLegacyKey() { - const userKey = this.getStorageKey(); - if (userKey === this.LEGACY_STORAGE_KEY) return; - - const legacyData = localStorage.getItem(this.LEGACY_STORAGE_KEY); - if (legacyData && !localStorage.getItem(userKey)) { - console.log('Migrating favorites from legacy global key to user-specific key'); - localStorage.setItem(userKey, legacyData); - } - localStorage.removeItem(this.LEGACY_STORAGE_KEY); - }, - - /** - * Check if backend favorites API is available - */ - async checkBackendAvailability() { - try { - // Add error handling to prevent console errors by catching 500 errors - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout - - const favToken = localStorage.getItem('oxicloud_token'); - const favHeaders = favToken ? { 'Authorization': `Bearer ${favToken}` } : {}; const response = await fetch('/api/favorites', { - method: 'GET', - headers: favHeaders, - signal: controller.signal - }).catch(err => { - console.warn('Network error checking favorites API:', err); - return { ok: false, status: 0 }; + headers: this._authHeaders() }); - - clearTimeout(timeoutId); - - // Check if the response indicates the API is properly implemented - this.backendApiAvailable = response.ok; - + if (!response.ok) { - console.log(`Backend favorites API returned status ${response.status} - using local storage fallback`); - this.backendApiAvailable = false; - } else { - console.log('Backend favorites API is available'); - // If backend API is available, sync local favorites with server - this.syncWithServer(); + console.warn(`Favorites API returned ${response.status}`); + this._ready = true; + return; } - } catch (error) { - console.warn('Error checking backend favorites API availability:', error); - this.backendApiAvailable = false; - } - }, - - /** - * Sync local favorites with server - */ - async syncWithServer() { - try { - // Get server favorites - const syncToken = localStorage.getItem('oxicloud_token'); - const syncHeaders = syncToken ? { 'Authorization': `Bearer ${syncToken}` } : {}; - const response = await fetch('/api/favorites', { - headers: syncHeaders - }); - - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); + + const items = await response.json(); + this._cache.clear(); + for (const item of items) { + this._cache.set(this._cacheKey(item.item_id, item.item_type), item); } - - const serverFavorites = await response.json(); - const localFavorites = this.loadFavorites(); - - console.log('Syncing favorites with server', { - serverCount: serverFavorites.length, - localCount: localFavorites.length - }); - - // Create a map of server favorites for quick lookup - const serverFavoritesMap = new Map(); - serverFavorites.forEach(item => { - serverFavoritesMap.set(`${item.item_type}:${item.item_id}`, item); - }); - - // Add local favorites that aren't on server - for (const localItem of localFavorites) { - const key = `${localItem.type}:${localItem.id}`; - if (!serverFavoritesMap.has(key)) { - console.log(`Adding local favorite to server: ${key}`); - await this.addToServerFavorites(localItem.id, localItem.type); - } - } - - // Store server favorites locally (complete sync) - const mergedFavorites = serverFavorites.map(item => ({ - id: item.item_id, - name: '', // Name will be populated when viewing favorites - type: item.item_type, - parentId: null, // Will be determined when viewing - dateAdded: item.created_at - })); - - this.saveFavorites(mergedFavorites); - console.log('Favorites sync completed'); - } catch (error) { - console.error('Error syncing favorites with server:', error); + + this._ready = true; + console.log(`Favorites cache loaded: ${this._cache.size} items`); + } catch (err) { + console.error('Error fetching favorites:', err); + this._ready = true; } }, - + + // ───────────────────── public API ───────────────────── + /** - * Load favorites from localStorage - * @returns {Array} Array of favorite items + * Synchronous check used by ui.js to paint star icons. */ - loadFavorites() { - try { - const stored = localStorage.getItem(this.getStorageKey()); - return stored ? JSON.parse(stored) : []; - } catch (error) { - console.error('Error loading favorites:', error); - return []; - } + isFavorite(id, type) { + return this._cache.has(this._cacheKey(id, type)); }, - + /** - * Save favorites to localStorage - * @param {Array} favorites - Array of favorite items to save + * Add an item to favourites (server-first). */ - saveFavorites(favorites) { + async addToFavorites(id, name, type, _parentId) { try { - localStorage.setItem(this.getStorageKey(), JSON.stringify(favorites)); - } catch (error) { - console.error('Error saving favorites:', error); - } - }, - - /** - * Add a favorite to the server - * @param {string} id - Item ID - * @param {string} type - 'file' or 'folder' - */ - async addToServerFavorites(id, type) { - try { - const addToken = localStorage.getItem('oxicloud_token'); - const addHeaders = { 'Content-Type': 'application/json' }; - if (addToken) addHeaders['Authorization'] = `Bearer ${addToken}`; const response = await fetch(`/api/favorites/${type}/${id}`, { method: 'POST', - headers: addHeaders + headers: this._authHeaders() }); - + if (!response.ok) { throw new Error(`Server returned ${response.status}`); } - - return true; - } catch (error) { - console.error('Error adding favorite to server:', error); - return false; - } - }, - - /** - * Remove a favorite from the server - * @param {string} id - Item ID - * @param {string} type - 'file' or 'folder' - */ - async removeFromServerFavorites(id, type) { - try { - const rmToken = localStorage.getItem('oxicloud_token'); - const rmHeaders = rmToken ? { 'Authorization': `Bearer ${rmToken}` } : {}; - const response = await fetch(`/api/favorites/${type}/${id}`, { - method: 'DELETE', - headers: rmHeaders - }); - - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); + + // Refresh cache from server to get enriched data + await this._fetchFromServer(); + + // Notify user + if (window.ui && window.ui.showNotification) { + window.ui.showNotification( + window.i18n ? window.i18n.t('favorites.added_title') : 'Added to favorites', + `"${name}" ${window.i18n ? window.i18n.t('favorites.added_msg') : 'added to favorites'}` + ); } - - return true; - } catch (error) { - console.error('Error removing favorite from server:', error); - return false; - } - }, - - /** - * Add an item to favorites - * @param {string} id - Item ID - * @param {string} name - Item name - * @param {string} type - 'file' or 'folder' - * @param {string} parentId - Parent folder ID (or null for root items) - * @returns {boolean} Success status - */ - async addToFavorites(id, name, type, parentId) { - try { - const favorites = this.loadFavorites(); - - // Check if already in favorites - if (favorites.some(item => item.id === id && item.type === type)) { - console.log(`Item ${id} already in favorites`); - return false; - } - - // Add to favorites - favorites.push({ - id, - name, - type, - parentId: parentId || null, - dateAdded: new Date().toISOString() - }); - - // Save updated favorites locally - this.saveFavorites(favorites); - - // If backend API is available, sync with server - if (this.backendApiAvailable) { - await this.addToServerFavorites(id, type); - } - - // Show success notification - window.ui.showNotification( - 'Added to favorites', - `"${name}" added to favorites` - ); - - // Refresh file view to show star icon - if (window.app.currentSection === 'files' && typeof window.loadFiles === 'function') { + + // Refresh view to update star icons + if (window.app && window.app.currentSection === 'files' && typeof window.loadFiles === 'function') { window.loadFiles(); } - + return true; } catch (error) { console.error('Error adding to favorites:', error); return false; } }, - + /** - * Remove an item from favorites - * @param {string} id - Item ID - * @param {string} type - 'file' or 'folder' - * @returns {boolean} Success status + * Remove an item from favourites (server-first). */ async removeFromFavorites(id, type) { try { - let favorites = this.loadFavorites(); - const initialLength = favorites.length; - - // Find the item to get its name for notification - const item = favorites.find(item => item.id === id && item.type === type); - - // Filter out the item - favorites = favorites.filter(item => !(item.id === id && item.type === type)); - - // Save updated favorites locally - this.saveFavorites(favorites); - - // If backend API is available, sync with server - if (this.backendApiAvailable) { - await this.removeFromServerFavorites(id, type); + // Remember name for notification before removing from cache + const cached = this._cache.get(this._cacheKey(id, type)); + const itemName = cached?.item_name || id; + + const response = await fetch(`/api/favorites/${type}/${id}`, { + method: 'DELETE', + headers: this._authHeaders() + }); + + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); } - - // Check if anything was removed - if (favorites.length < initialLength) { - // Show success notification if item was found - if (item) { - window.ui.showNotification( - 'Removed from favorites', - `"${item.name}" removed from favorites` - ); - } - - // Refresh file view to remove star icon - if (window.app.currentSection === 'files' && typeof window.loadFiles === 'function') { - window.loadFiles(); - } - - return true; + + // Remove from local cache + this._cache.delete(this._cacheKey(id, type)); + + if (window.ui && window.ui.showNotification) { + window.ui.showNotification( + window.i18n ? window.i18n.t('favorites.removed_title') : 'Removed from favorites', + `"${itemName}" ${window.i18n ? window.i18n.t('favorites.removed_msg') : 'removed from favorites'}` + ); } - - return false; + + // Refresh view to update star icons + if (window.app && window.app.currentSection === 'files' && typeof window.loadFiles === 'function') { + window.loadFiles(); + } + + return true; } catch (error) { console.error('Error removing from favorites:', error); return false; } }, - + + // ───────────────────── display ───────────────────── + /** - * Check if an item is in favorites - * @param {string} id - Item ID - * @param {string} type - 'file' or 'folder' - * @returns {boolean} True if item is in favorites - */ - isFavorite(id, type) { - const favorites = this.loadFavorites(); - return favorites.some(item => item.id === id && item.type === type); - }, - - /** - * Load and display favorite items in the UI + * Render the favourites view. All data comes from the in-memory cache + * (which was populated from the enriched backend response — zero extra + * fetches). */ async displayFavorites() { try { - const favorites = this.loadFavorites(); - - // Clear existing content + // Ensure cache is fresh + if (!this._ready) { + await this._fetchFromServer(); + } + const filesGrid = document.getElementById('files-grid'); const filesListView = document.getElementById('files-list-view'); - + filesGrid.innerHTML = ''; filesListView.innerHTML = `
@@ -364,12 +179,10 @@ const favorites = {
Modified
`; - - // Update breadcrumb - just show Home + window.ui.updateBreadcrumb(''); - - // Show empty state if no favorites - if (favorites.length === 0) { + + if (this._cache.size === 0) { const emptyState = document.createElement('div'); emptyState.className = 'empty-state'; emptyState.innerHTML = ` @@ -380,346 +193,188 @@ const favorites = { filesGrid.appendChild(emptyState); return; } - - // Load details for each favorite item - let loadedItems = 0; - const totalItems = favorites.length; - - // Process each favorite item - for (const favorite of favorites) { - try { - if (favorite.type === 'folder') { - await this.loadFolderDetails(favorite, filesGrid, filesListView); - } else { - await this.loadFileDetails(favorite, filesGrid, filesListView); - } - } catch (error) { - console.error(`Error loading favorite ${favorite.type} ${favorite.id}:`, error); + + for (const item of this._cache.values()) { + if (item.item_type === 'folder') { + this._renderFolder(item, filesGrid, filesListView); + } else { + this._renderFile(item, filesGrid, filesListView); } - - // Update progress - could be used for loading indicator - loadedItems++; - console.log(`Loaded ${loadedItems}/${totalItems} favorite items`); } - - // Update file icons + window.ui.updateFileIcons(); - } catch (error) { console.error('Error displaying favorites:', error); - window.ui.showNotification('Error', 'Error loading favorite items'); - } - }, - - /** - * Load folder details and add to view - * @param {Object} favorite - Favorite folder item - * @param {HTMLElement} filesGrid - Grid view container - * @param {HTMLElement} filesListView - List view container - */ - async loadFolderDetails(favorite, filesGrid, filesListView) { - try { - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - const response = await fetch(`/api/folders/${favorite.id}`, { headers }); - - if (response.ok) { - const folder = await response.json(); - - // Create UI element with favorite indicator - this.createFavoriteFolderElement(folder, filesGrid, filesListView); - } else if (response.status === 404) { - // Folder not found, might be deleted - console.log(`Favorite folder ${favorite.id} not found, removing from favorites`); - this.removeFromFavorites(favorite.id, 'folder'); - } else { - console.error(`Error loading folder ${favorite.id}:`, response.statusText); + if (window.ui && window.ui.showNotification) { + window.ui.showNotification('Error', 'Error loading favorite items'); } - } catch (error) { - console.error(`Error loading folder details for ${favorite.id}:`, error); } }, - - /** - * Load file details and add to view - * @param {Object} favorite - Favorite file item - * @param {HTMLElement} filesGrid - Grid view container - * @param {HTMLElement} filesListView - List view container - */ - async loadFileDetails(favorite, filesGrid, filesListView) { - try { - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - const response = await fetch(`/api/files/${favorite.id}?metadata=true`, { headers }); - - if (response.ok) { - const file = await response.json(); - // Create UI element with favorite indicator - this.createFavoriteFileElement(file, filesGrid, filesListView); - } else if (response.status === 404) { - // File not found, might be deleted - console.log(`Favorite file ${favorite.id} not found, removing from favorites`); - this.removeFromFavorites(favorite.id, 'file'); - } else { - console.error(`Error loading file ${favorite.id}:`, response.statusText); - } - } catch (error) { - console.error(`Error loading file details for ${favorite.id}:`, error); - - // Create a fallback file element to prevent the favorite from disappearing - const fallbackFile = { - id: favorite.id, - name: favorite.name || `File ${favorite.id}`, - mime_type: 'application/octet-stream', - size: 0, - modified_at: Math.floor(Date.now() / 1000) - }; - - this.createFavoriteFileElement(fallbackFile, filesGrid, filesListView); - } - }, - - /** - * Create a folder element with favorite indicator - * @param {Object} folder - Folder object - * @param {HTMLElement} filesGrid - Grid view container - * @param {HTMLElement} filesListView - List view container - */ - createFavoriteFolderElement(folder, filesGrid, filesListView) { - // Create standard folder element - const folderGridElement = document.createElement('div'); - folderGridElement.className = 'file-card favorite-item'; - folderGridElement.dataset.folderId = folder.id; - folderGridElement.dataset.folderName = folder.name; - folderGridElement.dataset.parentId = folder.parent_id || ""; - - // Add favorite star - folderGridElement.innerHTML = ` -
- -
-
- -
-
${escapeHtml(folder.name)}
+ + // ───────────────────── renderers ───────────────────── + + _renderFolder(item, filesGrid, filesListView) { + const name = item.item_name || item.item_id || 'Unknown'; + 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' }); + + // --- grid element --- + const gridEl = document.createElement('div'); + gridEl.className = 'file-card favorite-item'; + gridEl.dataset.folderId = folderId; + gridEl.dataset.folderName = name; + gridEl.dataset.parentId = parentId; + gridEl.innerHTML = ` +
+
+
${escapeHtml(name)}
Folder
`; - - // Click to navigate - folderGridElement.addEventListener('click', () => { - window.app.currentPath = folder.id; - window.ui.updateBreadcrumb(folder.name); + gridEl.addEventListener('click', () => { + window.app.currentPath = folderId; + window.ui.updateBreadcrumb(name); window.loadFiles(); }); - - // Context menu - folderGridElement.addEventListener('contextmenu', (e) => { + gridEl.addEventListener('contextmenu', (e) => { e.preventDefault(); - - window.app.contextMenuTargetFolder = { - id: folder.id, - name: folder.name, - parent_id: folder.parent_id || "" - }; - - let folderContextMenu = document.getElementById('folder-context-menu'); - folderContextMenu.style.left = `${e.pageX}px`; - folderContextMenu.style.top = `${e.pageY}px`; - folderContextMenu.style.display = 'block'; + window.app.contextMenuTargetFolder = { id: folderId, name, parent_id: parentId }; + const cm = document.getElementById('folder-context-menu'); + cm.style.left = `${e.pageX}px`; + cm.style.top = `${e.pageY}px`; + cm.style.display = 'block'; }); + filesGrid.appendChild(gridEl); - filesGrid.appendChild(folderGridElement); - - // Format date - const modifiedDate = new Date(folder.modified_at * 1000); - const formattedDate = modifiedDate.toLocaleDateString() + ' ' + - modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); - - // List view element - const folderListElement = document.createElement('div'); - folderListElement.className = 'file-item favorite-item'; - folderListElement.dataset.folderId = folder.id; - folderListElement.dataset.folderName = folder.name; - folderListElement.dataset.parentId = folder.parent_id || ""; - - folderListElement.innerHTML = ` -
- -
+ // --- list element --- + const listEl = document.createElement('div'); + listEl.className = 'file-item favorite-item'; + listEl.dataset.folderId = folderId; + listEl.dataset.folderName = name; + listEl.dataset.parentId = parentId; + listEl.innerHTML = ` +
-
- -
- ${escapeHtml(folder.name)} +
+ ${escapeHtml(name)}
${window.i18n ? window.i18n.t('files.file_types.folder') : 'Folder'}
--
${formattedDate}
`; - - // Click to navigate - folderListElement.addEventListener('click', () => { - window.app.currentPath = folder.id; - window.ui.updateBreadcrumb(folder.name); + listEl.addEventListener('click', () => { + window.app.currentPath = folderId; + window.ui.updateBreadcrumb(name); window.loadFiles(); }); - - // Context menu - folderListElement.addEventListener('contextmenu', (e) => { + listEl.addEventListener('contextmenu', (e) => { e.preventDefault(); - - window.app.contextMenuTargetFolder = { - id: folder.id, - name: folder.name, - parent_id: folder.parent_id || "" - }; - - let folderContextMenu = document.getElementById('folder-context-menu'); - folderContextMenu.style.left = `${e.pageX}px`; - folderContextMenu.style.top = `${e.pageY}px`; - folderContextMenu.style.display = 'block'; + window.app.contextMenuTargetFolder = { id: folderId, name, parent_id: parentId }; + const cm = document.getElementById('folder-context-menu'); + cm.style.left = `${e.pageX}px`; + cm.style.top = `${e.pageY}px`; + cm.style.display = 'block'; }); - - filesListView.appendChild(folderListElement); + filesListView.appendChild(listEl); }, - - /** - * Create a file element with favorite indicator - * @param {Object} file - File object - * @param {HTMLElement} filesGrid - Grid view container - * @param {HTMLElement} filesListView - List view container - */ - createFavoriteFileElement(file, filesGrid, filesListView) { - // Determine icon and type - let iconClass = 'fas fa-file'; - let iconSpecialClass = ''; - let typeLabel = window.i18n ? window.i18n.t('files.file_types.document') : 'Document'; - if (file.mime_type) { - if (file.mime_type.startsWith('image/')) { - iconClass = 'fas fa-file-image'; - iconSpecialClass = 'image-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.image') : 'Image'; - } else if (file.mime_type.startsWith('text/')) { - iconClass = 'fas fa-file-alt'; - iconSpecialClass = 'text-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.text') : 'Text'; - } else if (file.mime_type.startsWith('video/')) { - iconClass = 'fas fa-file-video'; - iconSpecialClass = 'video-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.video') : 'Video'; - } else if (file.mime_type.startsWith('audio/')) { - iconClass = 'fas fa-file-audio'; - iconSpecialClass = 'audio-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.audio') : 'Audio'; - } else if (file.mime_type === 'application/pdf') { - iconClass = 'fas fa-file-pdf'; - iconSpecialClass = 'pdf-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.pdf') : 'PDF'; - } - } + _renderFile(item, filesGrid, filesListView) { + const name = item.item_name || item.item_id || 'Unknown'; + const fileId = item.item_id; + const folderId = item.parent_id || ''; + const mimeType = item.item_mime_type || 'application/octet-stream'; - // Format size and date - const fileSize = window.formatFileSize(file.size); - const modifiedDate = new Date(file.modified_at * 1000); - const formattedDate = modifiedDate.toLocaleDateString() + ' ' + - modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + // Build a minimal file object for click handlers + const fileObj = { + id: fileId, + name, + folder_id: folderId, + mime_type: mimeType, + size: item.item_size || 0 + }; - // Grid view element - const fileGridElement = document.createElement('div'); - fileGridElement.className = 'file-card favorite-item'; - fileGridElement.dataset.fileId = file.id; - fileGridElement.dataset.fileName = file.name; - fileGridElement.dataset.folderId = file.folder_id || ""; + // Use pre-computed display fields from the enriched API response + const iconClass = item.icon_class || 'fas fa-file'; + const iconSpecialClass = item.icon_special_class || ''; + const typeLabel = item.category + ? (window.i18n ? window.i18n.t(`files.file_types.${item.category.toLowerCase()}`) || item.category : item.category) + : (window.i18n ? window.i18n.t('files.file_types.document') : 'Document'); - fileGridElement.innerHTML = ` -
- -
-
- -
-
${escapeHtml(file.name)}
+ 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' }); + + // --- grid element --- + const gridEl = document.createElement('div'); + gridEl.className = 'file-card favorite-item'; + gridEl.dataset.fileId = fileId; + gridEl.dataset.fileName = name; + gridEl.dataset.folderId = folderId; + gridEl.innerHTML = ` +
+
+
${escapeHtml(name)}
Modified ${formattedDate.split(' ')[0]}
`; - - // View or download on click - fileGridElement.addEventListener('click', () => { - if (window.ui && window.ui.isViewableFile(file) && window.inlineViewer) { - window.inlineViewer.openFile(file); + gridEl.addEventListener('click', () => { + if (window.ui && window.ui.isViewableFile(fileObj) && window.inlineViewer) { + window.inlineViewer.openFile(fileObj); } else if (window.fileOps) { - window.fileOps.downloadFile(file.id, file.name); + window.fileOps.downloadFile(fileId, name); } }); - - // Context menu - fileGridElement.addEventListener('contextmenu', (e) => { + gridEl.addEventListener('contextmenu', (e) => { e.preventDefault(); - - window.app.contextMenuTargetFile = { - id: file.id, - name: file.name, - folder_id: file.folder_id || "" - }; - - let fileContextMenu = document.getElementById('file-context-menu'); - fileContextMenu.style.left = `${e.pageX}px`; - fileContextMenu.style.top = `${e.pageY}px`; - fileContextMenu.style.display = 'block'; + window.app.contextMenuTargetFile = { id: fileId, name, folder_id: folderId }; + const cm = document.getElementById('file-context-menu'); + cm.style.left = `${e.pageX}px`; + cm.style.top = `${e.pageY}px`; + cm.style.display = 'block'; }); + filesGrid.appendChild(gridEl); - filesGrid.appendChild(fileGridElement); - - // List view element - const fileListElement = document.createElement('div'); - fileListElement.className = 'file-item favorite-item'; - fileListElement.dataset.fileId = file.id; - fileListElement.dataset.fileName = file.name; - fileListElement.dataset.folderId = file.folder_id || ""; - - fileListElement.innerHTML = ` -
- -
+ // --- list element --- + const listEl = document.createElement('div'); + listEl.className = 'file-item favorite-item'; + listEl.dataset.fileId = fileId; + listEl.dataset.fileName = name; + listEl.dataset.folderId = folderId; + listEl.innerHTML = ` +
-
- -
- ${escapeHtml(file.name)} +
+ ${escapeHtml(name)}
${escapeHtml(typeLabel)}
${fileSize}
${formattedDate}
`; - - // View or download on click - fileListElement.addEventListener('click', () => { - if (window.ui && window.ui.isViewableFile(file) && window.inlineViewer) { - window.inlineViewer.openFile(file); + listEl.addEventListener('click', () => { + if (window.ui && window.ui.isViewableFile(fileObj) && window.inlineViewer) { + window.inlineViewer.openFile(fileObj); } else if (window.fileOps) { - window.fileOps.downloadFile(file.id, file.name); + window.fileOps.downloadFile(fileId, name); } }); - - // Context menu - fileListElement.addEventListener('contextmenu', (e) => { + listEl.addEventListener('contextmenu', (e) => { e.preventDefault(); - - window.app.contextMenuTargetFile = { - id: file.id, - name: file.name, - folder_id: file.folder_id || "" - }; - - let fileContextMenu = document.getElementById('file-context-menu'); - fileContextMenu.style.left = `${e.pageX}px`; - fileContextMenu.style.top = `${e.pageY}px`; - fileContextMenu.style.display = 'block'; + window.app.contextMenuTargetFile = { id: fileId, name, folder_id: folderId }; + const cm = document.getElementById('file-context-menu'); + cm.style.left = `${e.pageX}px`; + cm.style.top = `${e.pageY}px`; + cm.style.display = 'block'; }); - - filesListView.appendChild(fileListElement); + filesListView.appendChild(listEl); } }; -// Expose favorites module globally -window.favorites = favorites; \ No newline at end of file +// Expose globally +window.favorites = favorites; diff --git a/static/js/fileRenderer.js b/static/js/fileRenderer.js index 95187ad9..9aefcf9f 100644 --- a/static/js/fileRenderer.js +++ b/static/js/fileRenderer.js @@ -217,8 +217,8 @@ class FileRenderer { elem.dataset.parentId = item.parent_id || ""; elem.innerHTML = ` -
- +
+
${window.escapeHtml(item.name)}
`; @@ -253,22 +253,8 @@ class FileRenderer { elem.dataset.fileName = item.name; elem.dataset.folderId = item.folder_id || ""; - // Determine icon based on MIME type - let iconClass = 'fas fa-file'; - - if (item.mime_type) { - if (item.mime_type.startsWith('image/')) { - iconClass = 'fas fa-file-image'; - } else if (item.mime_type.startsWith('text/')) { - iconClass = 'fas fa-file-alt'; - } else if (item.mime_type.startsWith('video/')) { - iconClass = 'fas fa-file-video'; - } else if (item.mime_type.startsWith('audio/')) { - iconClass = 'fas fa-file-audio'; - } else if (item.mime_type === 'application/pdf') { - iconClass = 'fas fa-file-pdf'; - } - } + // Use pre-computed icon class from the API response + const iconClass = item.icon_class || 'fas fa-file'; elem.innerHTML = `
@@ -340,12 +326,12 @@ class FileRenderer { elem.innerHTML = `
-
- +
+
${window.escapeHtml(item.name)}
-
${this.i18n.t('files.file_types.folder')}
+
${item.category ? (this.i18n.t(`files.file_types.${item.category.toLowerCase()}`) || item.category) : this.i18n.t('files.file_types.folder')}
--
${formattedDate}
`; @@ -380,31 +366,14 @@ class FileRenderer { elem.dataset.fileName = item.name; elem.dataset.folderId = item.folder_id || ""; - // Determine file type label based on MIME type - let typeLabel = this.i18n.t('files.file_types.document'); - let iconClass = 'fas fa-file'; + // Use pre-computed display fields from the API response + const iconClass = item.icon_class || 'fas fa-file'; + const typeLabel = item.category + ? (this.i18n.t(`files.file_types.${item.category.toLowerCase()}`) || item.category) + : this.i18n.t('files.file_types.document'); - if (item.mime_type) { - if (item.mime_type.startsWith('image/')) { - iconClass = 'fas fa-file-image'; - typeLabel = this.i18n.t('files.file_types.image'); - } else if (item.mime_type.startsWith('text/')) { - iconClass = 'fas fa-file-alt'; - typeLabel = this.i18n.t('files.file_types.text'); - } else if (item.mime_type.startsWith('video/')) { - iconClass = 'fas fa-file-video'; - typeLabel = this.i18n.t('files.file_types.video'); - } else if (item.mime_type.startsWith('audio/')) { - iconClass = 'fas fa-file-audio'; - typeLabel = this.i18n.t('files.file_types.audio'); - } else if (item.mime_type === 'application/pdf') { - iconClass = 'fas fa-file-pdf'; - typeLabel = this.i18n.t('files.file_types.pdf'); - } - } - - // Format file size - const fileSize = this.formatFileSize(item.size); + // Format file size and date + const fileSize = item.size_formatted || this.formatFileSize(item.size); // Format date const modifiedDate = new Date(item.modified_at * 1000); diff --git a/static/js/recent.js b/static/js/recent.js index a894ddfb..66ff4f16 100644 --- a/static/js/recent.js +++ b/static/js/recent.js @@ -1,154 +1,97 @@ /** - * OxiCloud - Recent Files Module - * This file handles tracking and displaying recently accessed files + * OxiCloud - Recent Files Module (server-authoritative) + * + * Source of truth: GET /api/recent (enriched with name/size/mime via SQL JOIN). + * File-access events are forwarded to the backend with POST /api/recent/{type}/{id}. + * No localStorage usage — the server persists and prunes recent items. */ -// Recent Files Module const recent = { - // Base key for storing recent files in localStorage (username is appended) - STORAGE_KEY_PREFIX: 'oxicloud_recent_files', - - // Legacy key (pre-fix, shared across all users) - LEGACY_STORAGE_KEY: 'oxicloud_recent_files', - - // Maximum number of recent files to store + /** Maximum items to request from the server */ MAX_RECENT_FILES: 20, - - /** - * Get the user-specific storage key for recent files. - * Falls back to legacy global key if username is unavailable. - * @returns {string} localStorage key scoped to the current user - */ - getStorageKey() { - try { - const userData = JSON.parse(localStorage.getItem('oxicloud_user') || '{}'); - if (userData.username) { - return `${this.STORAGE_KEY_PREFIX}_${userData.username}`; - } - } catch (e) { - console.warn('Could not determine current user for recent files key'); - } - // Should not happen in normal flow — user must be logged in - return this.LEGACY_STORAGE_KEY; + + // ───────────────────── helpers ───────────────────── + + _authHeaders() { + const token = localStorage.getItem('oxicloud_token'); + const h = {}; + if (token) h['Authorization'] = `Bearer ${token}`; + return h; }, - + + // ───────────────────── lifecycle ───────────────────── + /** - * Initialize recent files module + * Initialise the module. Called once from app.js on startup. */ init() { - console.log('Initializing recent files module'); - this.migrateFromLegacyKey(); - this.ensureRecentFilesStorage(); + console.log('Initializing recent files module (server-authoritative)'); this.setupEventListeners(); }, - + /** - * Migrate data from the old global key to the user-specific key. - * This runs once: if the legacy key has data and the user-specific key - * does not yet exist, the data is moved. - */ - migrateFromLegacyKey() { - const userKey = this.getStorageKey(); - // Only migrate if the key is actually user-specific - if (userKey === this.LEGACY_STORAGE_KEY) return; - - const legacyData = localStorage.getItem(this.LEGACY_STORAGE_KEY); - if (legacyData && !localStorage.getItem(userKey)) { - console.log('Migrating recent files from legacy global key to user-specific key'); - localStorage.setItem(userKey, legacyData); - } - // Always remove the legacy key so other users don't see stale data - localStorage.removeItem(this.LEGACY_STORAGE_KEY); - }, - - /** - * Make sure the recent files storage is initialized - */ - ensureRecentFilesStorage() { - const key = this.getStorageKey(); - if (!localStorage.getItem(key)) { - localStorage.setItem(key, JSON.stringify([])); - } - }, - - /** - * Set up event listeners to track file access + * Listen for file-accessed events dispatched by ui.js and forward + * them to the backend. */ setupEventListeners() { - // Listen for custom event when a file is accessed document.addEventListener('file-accessed', (event) => { if (event.detail && event.detail.file) { - this.addRecentFile(event.detail.file); + const file = event.detail.file; + const itemType = file.item_type || 'file'; + this._recordAccess(file.id, itemType); } }); }, - + /** - * Add a file to recent files - * @param {Object} file - File object containing id, name, folder_id, etc. + * Record an access event on the server. */ - addRecentFile(file) { - // Don't add if no file or no ID - if (!file || !file.id) { - return; - } - - // Get current recent files - const recentFiles = this.getRecentFiles(); - - // Remove if file already exists in recent files - const existingIndex = recentFiles.findIndex(item => item.id === file.id); - if (existingIndex !== -1) { - recentFiles.splice(existingIndex, 1); - } - - // Add file with timestamp to the beginning of the array - const fileWithTimestamp = { - ...file, - accessedAt: Date.now() - }; - - recentFiles.unshift(fileWithTimestamp); - - // Keep only the most recent files (limit to MAX_RECENT_FILES) - const trimmedFiles = recentFiles.slice(0, this.MAX_RECENT_FILES); - - // Save back to localStorage (user-scoped key) - localStorage.setItem(this.getStorageKey(), JSON.stringify(trimmedFiles)); - }, - - /** - * Get recent files from localStorage - * @returns {Array} Array of recent file objects with timestamps - */ - getRecentFiles() { + async _recordAccess(itemId, itemType) { try { - const recentFilesJson = localStorage.getItem(this.getStorageKey()); - return recentFilesJson ? JSON.parse(recentFilesJson) : []; - } catch (error) { - console.error('Error loading recent files:', error); - return []; + await fetch(`/api/recent/${itemType}/${itemId}`, { + method: 'POST', + headers: this._authHeaders() + }); + } catch (err) { + console.warn('Failed to record recent access:', err); } }, - + + // ───────────────────── public API ───────────────────── + /** - * Clear all recent files + * Clear all recent items (delegates to the server). */ - clearRecentFiles() { - localStorage.setItem(this.getStorageKey(), JSON.stringify([])); + async clearRecentFiles() { + try { + await fetch('/api/recent/clear', { + method: 'DELETE', + headers: this._authHeaders() + }); + } catch (err) { + console.error('Error clearing recent files:', err); + } }, - + /** - * Display recent files in the UI + * Fetch and display recent files. Data comes directly from the + * enriched backend response — zero extra per-item fetches. */ async displayRecentFiles() { try { - const recentFiles = this.getRecentFiles(); - - // Clear existing content + const response = await fetch(`/api/recent?limit=${this.MAX_RECENT_FILES}`, { + headers: this._authHeaders() + }); + + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); + } + + const recentItems = await response.json(); + const filesGrid = document.getElementById('files-grid'); const filesListView = document.getElementById('files-list-view'); - + filesGrid.innerHTML = ''; filesListView.innerHTML = `
@@ -159,12 +102,10 @@ const recent = {
Accessed
`; - - // Update breadcrumb - just show Home + window.ui.updateBreadcrumb(''); - - // Show empty state if no recent files - if (recentFiles.length === 0) { + + if (recentItems.length === 0) { const emptyState = document.createElement('div'); emptyState.className = 'empty-state'; emptyState.innerHTML = ` @@ -175,168 +116,134 @@ const recent = { filesGrid.appendChild(emptyState); return; } - - // Process each recent file - for (const recentFile of recentFiles) { - this.createRecentFileElement(recentFile, filesGrid, filesListView); + + for (const item of recentItems) { + this._renderRecentItem(item, filesGrid, filesListView); } - - // Update file icons + window.ui.updateFileIcons(); - } catch (error) { console.error('Error displaying recent files:', error); - window.ui.showNotification('Error', 'Error loading recent files'); + if (window.ui && window.ui.showNotification) { + window.ui.showNotification('Error', 'Error loading recent files'); + } } }, - - /** - * Create a file element for a recent file - * @param {Object} file - Recent file object - * @param {HTMLElement} filesGrid - Grid view container - * @param {HTMLElement} filesListView - List view container - */ - createRecentFileElement(file, filesGrid, filesListView) { - // Determine icon and type - let iconClass = 'fas fa-file'; - let iconSpecialClass = ''; - let typeLabel = window.i18n ? window.i18n.t('files.file_types.document') : 'Document'; - if (file.mime_type) { - if (file.mime_type.startsWith('image/')) { - iconClass = 'fas fa-file-image'; - iconSpecialClass = 'image-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.image') : 'Image'; - } else if (file.mime_type.startsWith('text/')) { - iconClass = 'fas fa-file-alt'; - iconSpecialClass = 'text-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.text') : 'Text'; - } else if (file.mime_type.startsWith('video/')) { - iconClass = 'fas fa-file-video'; - iconSpecialClass = 'video-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.video') : 'Video'; - } else if (file.mime_type.startsWith('audio/')) { - iconClass = 'fas fa-file-audio'; - iconSpecialClass = 'audio-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.audio') : 'Audio'; - } else if (file.mime_type === 'application/pdf') { - iconClass = 'fas fa-file-pdf'; - iconSpecialClass = 'pdf-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.pdf') : 'PDF'; - } - } + // ───────────────────── renderer ───────────────────── - // Format size and date - const fileSize = window.formatFileSize ? window.formatFileSize(file.size || 0) : '0 B'; - const accessedDate = new Date(file.accessedAt); + _renderRecentItem(item, filesGrid, filesListView) { + const name = item.item_name || item.item_id || 'Unknown'; + const itemId = item.item_id; + const folderId = item.parent_id || ''; + const mimeType = item.item_mime_type || 'application/octet-stream'; + const isFolder = item.item_type === 'folder'; + + // Build a minimal file object for click handlers + const fileObj = { + id: itemId, + name, + folder_id: folderId, + mime_type: mimeType, + size: item.item_size || 0 + }; + + // Use pre-computed display fields from the enriched API response + const iconClass = item.icon_class || (isFolder ? 'fas fa-folder' : 'fas fa-file'); + const iconSpecialClass = item.icon_special_class || (isFolder ? 'folder-icon' : ''); + const typeLabel = item.category + ? (window.i18n ? window.i18n.t(`files.file_types.${item.category.toLowerCase()}`) || item.category : item.category) + : (isFolder + ? (window.i18n ? window.i18n.t('files.file_types.folder') : 'Folder') + : (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'}); + accessedDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - // Grid view element - const fileGridElement = document.createElement('div'); - fileGridElement.className = 'file-card recent-item'; - fileGridElement.dataset.fileId = file.id; - fileGridElement.dataset.fileName = file.name; - fileGridElement.dataset.folderId = file.folder_id || ""; + // Click handler + const onClick = () => { + if (isFolder) { + window.app.currentPath = itemId; + window.ui.updateBreadcrumb(name); + window.loadFiles(); + } else { + // Re-record access + document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file: fileObj } })); + if (window.ui && window.ui.isViewableFile(fileObj) && window.inlineViewer) { + window.inlineViewer.openFile(fileObj); + } else if (window.fileOps) { + window.fileOps.downloadFile(itemId, name); + } + } + }; - fileGridElement.innerHTML = ` -
- -
-
- -
-
${escapeHtml(file.name)}
+ // Context menu handler + const onContextMenu = (e) => { + e.preventDefault(); + if (isFolder) { + window.app.contextMenuTargetFolder = { id: itemId, name, parent_id: folderId }; + const cm = document.getElementById('folder-context-menu'); + cm.style.left = `${e.pageX}px`; + cm.style.top = `${e.pageY}px`; + cm.style.display = 'block'; + } else { + window.app.contextMenuTargetFile = { id: itemId, name, folder_id: folderId }; + const cm = document.getElementById('file-context-menu'); + cm.style.left = `${e.pageX}px`; + cm.style.top = `${e.pageY}px`; + cm.style.display = 'block'; + } + }; + + // --- grid element --- + const gridEl = document.createElement('div'); + gridEl.className = `file-card recent-item`; + if (isFolder) { + gridEl.dataset.folderId = itemId; + gridEl.dataset.folderName = name; + } else { + gridEl.dataset.fileId = itemId; + gridEl.dataset.fileName = name; + gridEl.dataset.folderId = folderId; + } + gridEl.innerHTML = ` +
+
+
${escapeHtml(name)}
Accessed ${formattedDate.split(' ')[0]}
`; + gridEl.addEventListener('click', onClick); + gridEl.addEventListener('contextmenu', onContextMenu); + filesGrid.appendChild(gridEl); - // View or download on click - fileGridElement.addEventListener('click', () => { - if (window.ui && window.ui.isViewableFile(file) && window.inlineViewer) { - window.inlineViewer.openFile(file); - } else if (window.fileOps) { - window.fileOps.downloadFile(file.id, file.name); - } - - // Dispatch custom event to update recent files - document.dispatchEvent(new CustomEvent('file-accessed', { - detail: { file } - })); - }); - - // Context menu - fileGridElement.addEventListener('contextmenu', (e) => { - e.preventDefault(); - - window.app.contextMenuTargetFile = { - id: file.id, - name: file.name, - folder_id: file.folder_id || "" - }; - - let fileContextMenu = document.getElementById('file-context-menu'); - fileContextMenu.style.left = `${e.pageX}px`; - fileContextMenu.style.top = `${e.pageY}px`; - fileContextMenu.style.display = 'block'; - }); - - filesGrid.appendChild(fileGridElement); - - // List view element - const fileListElement = document.createElement('div'); - fileListElement.className = 'file-item recent-item'; - fileListElement.dataset.fileId = file.id; - fileListElement.dataset.fileName = file.name; - fileListElement.dataset.folderId = file.folder_id || ""; - - fileListElement.innerHTML = ` -
- -
+ // --- list element --- + const listEl = document.createElement('div'); + listEl.className = `file-item recent-item`; + if (isFolder) { + listEl.dataset.folderId = itemId; + listEl.dataset.folderName = name; + } else { + listEl.dataset.fileId = itemId; + listEl.dataset.fileName = name; + listEl.dataset.folderId = folderId; + } + listEl.innerHTML = ` +
-
- -
- ${escapeHtml(file.name)} +
+ ${escapeHtml(name)}
${escapeHtml(typeLabel)}
${fileSize}
${formattedDate}
`; - - // View or download on click - fileListElement.addEventListener('click', () => { - if (window.ui && window.ui.isViewableFile(file) && window.inlineViewer) { - window.inlineViewer.openFile(file); - } else if (window.fileOps) { - window.fileOps.downloadFile(file.id, file.name); - } - - // Dispatch custom event to update recent files - document.dispatchEvent(new CustomEvent('file-accessed', { - detail: { file } - })); - }); - - // Context menu - fileListElement.addEventListener('contextmenu', (e) => { - e.preventDefault(); - - window.app.contextMenuTargetFile = { - id: file.id, - name: file.name, - folder_id: file.folder_id || "" - }; - - let fileContextMenu = document.getElementById('file-context-menu'); - fileContextMenu.style.left = `${e.pageX}px`; - fileContextMenu.style.top = `${e.pageY}px`; - fileContextMenu.style.display = 'block'; - }); - - filesListView.appendChild(fileListElement); + listEl.addEventListener('click', onClick); + listEl.addEventListener('contextmenu', onContextMenu); + filesListView.appendChild(listEl); } }; -// Expose recent module globally -window.recent = recent; \ No newline at end of file +// Expose globally +window.recent = recent; diff --git a/static/js/ui.js b/static/js/ui.js index 4178e10c..907d8bd8 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -903,37 +903,15 @@ const ui = { console.log(`Adding file to the view: ${file.name} (${file.id})`); - // Determine icon and type - let iconClass = 'fas fa-file'; - let iconSpecialClass = ''; - let typeLabel = 'Document'; - - if (file.mime_type) { - if (file.mime_type.startsWith('image/')) { - iconClass = 'fas fa-file-image'; - iconSpecialClass = 'image-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.image') : 'Image'; - } else if (file.mime_type.startsWith('text/')) { - iconClass = 'fas fa-file-alt'; - iconSpecialClass = 'text-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.text') : 'Text'; - } else if (file.mime_type.startsWith('video/')) { - iconClass = 'fas fa-file-video'; - iconSpecialClass = 'video-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.video') : 'Video'; - } else if (file.mime_type.startsWith('audio/')) { - iconClass = 'fas fa-file-audio'; - iconSpecialClass = 'audio-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.audio') : 'Audio'; - } else if (file.mime_type === 'application/pdf') { - iconClass = 'fas fa-file-pdf'; - iconSpecialClass = 'pdf-icon'; - typeLabel = window.i18n ? window.i18n.t('files.file_types.pdf') : 'PDF'; - } - } + // Use pre-computed display fields from the API response + 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) + : (window.i18n ? window.i18n.t('files.file_types.document') : 'Document'); // Format size and date - const fileSize = window.formatFileSize(file.size); + 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'});