From 0d7c8bc0192b486097f08a13be43c9d900f126c9 Mon Sep 17 00:00:00 2001 From: Diocrafts Date: Wed, 18 Feb 2026 10:53:43 +0100 Subject: [PATCH] perf: optimize UI rendering and event lifecycle --- src/application/dtos/display_helpers.rs | 432 +++++++++++++--- src/application/dtos/favorites_dto.rs | 9 +- src/application/dtos/file_dto.rs | 11 +- src/application/dtos/recent_dto.rs | 9 +- src/application/dtos/search_dto.rs | 4 + src/application/services/search_service.rs | 114 +---- static/css/favorites.css | 20 +- static/css/style.css | 257 ++++++---- static/index.html | 4 +- static/js/app.js | 407 +++++++-------- static/js/components/sharedView.js | 14 +- static/js/contextMenus.js | 69 ++- static/js/favorites.js | 10 - static/js/fileOperations.js | 554 ++++++++++++++++----- static/js/i18n.js | 33 +- static/js/icons.js | 11 +- static/js/notifications.js | 95 ++-- static/js/shared.js | 9 +- static/js/ui.js | 428 ++++++++++++++-- static/locales/de.json | 13 +- static/locales/en.json | 13 +- static/locales/es.json | 13 +- static/locales/fa.json | 13 +- static/locales/fr.json | 13 +- static/locales/it.json | 13 +- static/locales/pt.json | 13 +- static/locales/zh.json | 13 +- static/shared.html | 4 - 28 files changed, 1832 insertions(+), 766 deletions(-) diff --git a/src/application/dtos/display_helpers.rs b/src/application/dtos/display_helpers.rs index c44f86e8..ebc92f41 100644 --- a/src/application/dtos/display_helpers.rs +++ b/src/application/dtos/display_helpers.rs @@ -3,62 +3,340 @@ /// 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" +/// The approach is: try MIME first (specific matches beat prefix matches), +/// then fall back to the file extension when the MIME is generic +/// (`application/octet-stream` or empty). + +// ─── Private: extract lowercase extension from a filename ──────────── + +fn ext_of(name: &str) -> Option<&str> { + let name = name.rsplit('/').next().unwrap_or(name); // strip path + let after_dot = name.rsplit('.').next()?; + // Reject the whole name (no dot) or empty after dot + if after_dot.len() == name.len() || after_dot.is_empty() { + return None; } + Some(after_dot) } -/// Returns the CSS class used to colour/style the icon container. +// ─── Icon class (FontAwesome) ──────────────────────────────────────── + +/// Returns the FontAwesome icon class for a file, considering both MIME +/// and filename extension as fallback. /// -/// 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 { - "" +/// Use this instead of the old `mime_to_icon_class` whenever the filename +/// is available. +pub fn icon_class_for(name: &str, mime: &str) -> &'static str { + // 1. Try specific MIME matches first + match mime { + "application/pdf" => return "fas fa-file-pdf", + // MS Office & OpenDocument – Word + "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.oasis.opendocument.text" => return "fas fa-file-word", + // Excel + "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.oasis.opendocument.spreadsheet" + | "text/csv" => return "fas fa-file-excel", + // PowerPoint + "application/vnd.ms-powerpoint" + | "application/vnd.openxmlformats-officedocument.presentationml.presentation" + | "application/vnd.oasis.opendocument.presentation" => return "fas fa-file-powerpoint", + // Archives + "application/zip" + | "application/x-rar-compressed" + | "application/vnd.rar" + | "application/x-7z-compressed" + | "application/gzip" + | "application/x-gzip" + | "application/x-tar" + | "application/x-bzip2" + | "application/x-xz" + | "application/x-compress" => return "fas fa-file-archive", + // JSON / JavaScript / code transported as application/* + "application/json" + | "application/ld+json" + | "application/javascript" + | "application/typescript" + | "application/x-httpd-php" + | "application/xml" + | "application/xhtml+xml" + | "application/sql" + | "application/x-yaml" + | "application/toml" + | "application/x-sh" + | "application/x-shellscript" + | "application/x-csh" => return "fas fa-file-code", + // Installers / disk images + "application/x-apple-diskimage" + | "application/x-ms-dos-executable" + | "application/x-msdownload" + | "application/x-msi" + | "application/vnd.debian.binary-package" + | "application/x-rpm" + | "application/vnd.appimage" => return "fas fa-hdd", + _ => {} } + + // 2. MIME prefix matches + if mime.starts_with("image/") { + return "fas fa-file-image"; + } else if mime.starts_with("video/") { + return "fas fa-file-video"; + } else if mime.starts_with("audio/") { + return "fas fa-file-audio"; + } else if mime.starts_with("text/x-script") + || mime.starts_with("text/x-python") + || mime.starts_with("text/x-java") + || mime.starts_with("text/x-c") + || mime.starts_with("text/x-rust") + || mime.starts_with("text/x-go") + || mime.starts_with("text/x-ruby") + || mime.starts_with("text/x-shellscript") + || mime.starts_with("text/x-php") + || mime.contains("javascript") + || mime.contains("typescript") + { + return "fas fa-file-code"; + } else if mime.starts_with("text/markdown") { + return "fas fa-file-alt"; + } else if mime.starts_with("text/") { + return "fas fa-file-alt"; + } + + // 3. Extension-based fallback (for application/octet-stream, empty, etc.) + if let Some(ext) = ext_of(name) { + return match ext.to_ascii_lowercase().as_str() { + "pdf" => "fas fa-file-pdf", + "doc" | "docx" | "odt" | "rtf" => "fas fa-file-word", + "xls" | "xlsx" | "ods" | "csv" => "fas fa-file-excel", + "ppt" | "pptx" | "odp" | "key" => "fas fa-file-powerpoint", + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif" | "heic" | "heif" | "avif" => "fas fa-file-image", + "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "fas fa-file-video", + "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "fas fa-file-audio", + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => "fas fa-file-archive", + "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => "fas fa-hdd", + "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" + | "py" | "pyw" | "rs" | "go" | "java" | "kt" | "kts" | "scala" + | "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" | "cs" + | "rb" | "php" | "swift" | "r" | "lua" | "pl" | "pm" + | "html" | "htm" | "css" | "scss" | "sass" | "less" + | "json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" + | "sql" | "graphql" | "proto" | "vue" | "svelte" => "fas fa-file-code", + "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-terminal", + "md" | "markdown" | "rst" | "txt" => "fas fa-file-alt", + _ => "fas fa-file", + }; + } + + "fas fa-file" } -/// Returns a human-readable category label for a MIME type. +// ─── Icon special class (CSS styling) ──────────────────────────────── + +/// Returns the CSS class for styling the icon container, considering both +/// MIME and filename extension. /// -/// 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" +/// The returned class maps to CSS rules in `style.css` that set colours, +/// backgrounds and decorative pseudo-elements per file type. +pub fn icon_special_class_for(name: &str, mime: &str) -> &'static str { + // 1. Specific MIME matches + match mime { + "application/pdf" => return "pdf-icon", + "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.oasis.opendocument.text" => return "doc-icon", + "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.oasis.opendocument.spreadsheet" + | "text/csv" => return "spreadsheet-icon", + "application/vnd.ms-powerpoint" + | "application/vnd.openxmlformats-officedocument.presentationml.presentation" + | "application/vnd.oasis.opendocument.presentation" => return "presentation-icon", + "application/zip" + | "application/x-rar-compressed" + | "application/vnd.rar" + | "application/x-7z-compressed" + | "application/gzip" + | "application/x-gzip" + | "application/x-tar" + | "application/x-bzip2" + | "application/x-xz" + | "application/x-compress" => return "archive-icon", + "application/json" | "application/ld+json" => return "code-icon json-icon", + "application/javascript" => return "code-icon js-icon", + "application/typescript" => return "code-icon ts-icon", + "application/xml" | "application/xhtml+xml" => return "code-icon html-icon", + "application/sql" => return "code-icon sql-icon", + "application/x-yaml" | "application/toml" => return "code-icon config-icon", + "application/x-httpd-php" => return "code-icon php-icon", + "application/x-sh" | "application/x-shellscript" | "application/x-csh" => { + return "script-icon" + } + "application/x-apple-diskimage" + | "application/x-ms-dos-executable" + | "application/x-msdownload" + | "application/x-msi" + | "application/vnd.debian.binary-package" + | "application/x-rpm" + | "application/vnd.appimage" => return "installer-icon", + _ => {} } + + // 2. MIME prefix matches + if mime.starts_with("image/") { + return "image-icon"; + } else if mime.starts_with("video/") { + return "video-icon"; + } else if mime.starts_with("audio/") { + return "audio-icon"; + } else if mime.starts_with("text/x-python") { + return "code-icon py-icon"; + } else if mime.starts_with("text/x-rust") { + return "code-icon rust-icon"; + } else if mime.starts_with("text/x-java") || mime.starts_with("text/x-c") { + return "code-icon"; + } else if mime.starts_with("text/x-go") { + return "code-icon go-icon"; + } else if mime.starts_with("text/x-ruby") { + return "code-icon ruby-icon"; + } else if mime.starts_with("text/x-shellscript") { + return "script-icon"; + } else if mime.starts_with("text/x-script") || mime.starts_with("text/x-php") { + return "code-icon"; + } else if mime.starts_with("text/markdown") { + return "code-icon md-icon"; + } else if mime.starts_with("text/html") { + return "code-icon html-icon"; + } else if mime.starts_with("text/css") { + return "code-icon css-icon"; + } else if mime.contains("javascript") { + return "code-icon js-icon"; + } else if mime.contains("typescript") { + return "code-icon ts-icon"; + } else if mime.starts_with("text/") { + return "doc-icon"; + } + + // 3. Extension-based fallback + if let Some(ext) = ext_of(name) { + return match ext.to_ascii_lowercase().as_str() { + "pdf" => "pdf-icon", + "doc" | "docx" | "odt" | "rtf" => "doc-icon", + "xls" | "xlsx" | "ods" | "csv" => "spreadsheet-icon", + "ppt" | "pptx" | "odp" | "key" => "presentation-icon", + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif" | "heic" | "heif" | "avif" => "image-icon", + "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "video-icon", + "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "audio-icon", + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => "archive-icon", + "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => "installer-icon", + "py" | "pyw" => "code-icon py-icon", + "rs" => "code-icon rust-icon", + "go" => "code-icon go-icon", + "java" | "kt" | "kts" | "scala" => "code-icon java-icon", + "js" | "jsx" | "mjs" | "cjs" => "code-icon js-icon", + "ts" | "tsx" => "code-icon ts-icon", + "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" => "code-icon c-icon", + "cs" => "code-icon cs-icon", + "rb" => "code-icon ruby-icon", + "php" => "code-icon php-icon", + "swift" => "code-icon swift-icon", + "r" | "lua" | "pl" | "pm" => "code-icon", + "html" | "htm" => "code-icon html-icon", + "css" | "scss" | "sass" | "less" => "code-icon css-icon", + "json" => "code-icon json-icon", + "xml" => "code-icon html-icon", + "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => "code-icon config-icon", + "sql" | "graphql" | "proto" => "code-icon sql-icon", + "vue" | "svelte" => "code-icon js-icon", + "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "script-icon", + "md" | "markdown" | "rst" => "code-icon md-icon", + "txt" => "doc-icon", + _ => "", + }; + } + + "" +} + +// ─── Category label ────────────────────────────────────────────────── + +/// Returns a human-readable category label, considering MIME + extension. +pub fn category_for(name: &str, mime: &str) -> &'static str { + // 1. Specific MIME matches + match mime { + "application/pdf" => return "PDF", + "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.oasis.opendocument.text" => return "Document", + "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.oasis.opendocument.spreadsheet" + | "text/csv" => return "Spreadsheet", + "application/vnd.ms-powerpoint" + | "application/vnd.openxmlformats-officedocument.presentationml.presentation" + | "application/vnd.oasis.opendocument.presentation" => return "Presentation", + "application/zip" + | "application/x-rar-compressed" + | "application/vnd.rar" + | "application/x-7z-compressed" + | "application/gzip" + | "application/x-tar" => return "Archive", + "application/json" + | "application/javascript" + | "application/typescript" + | "application/xml" + | "application/sql" + | "application/x-sh" + | "application/x-shellscript" => return "Code", + "application/x-apple-diskimage" + | "application/x-ms-dos-executable" + | "application/x-msdownload" + | "application/x-msi" => return "Installer", + _ => {} + } + + // 2. MIME prefix + if mime.starts_with("image/") { + return "Image"; + } else if mime.starts_with("video/") { + return "Video"; + } else if mime.starts_with("audio/") { + return "Audio"; + } else if mime.starts_with("text/x-") || mime.contains("script") || mime.contains("javascript") { + return "Code"; + } else if mime.starts_with("text/markdown") { + return "Markdown"; + } else if mime.starts_with("text/html") || mime.starts_with("text/css") { + return "Code"; + } else if mime.starts_with("text/") { + return "Text"; + } + + // 3. Extension fallback + if let Some(ext) = ext_of(name) { + return match ext.to_ascii_lowercase().as_str() { + "pdf" => "PDF", + "doc" | "docx" | "odt" | "rtf" | "txt" => "Document", + "xls" | "xlsx" | "ods" | "csv" => "Spreadsheet", + "ppt" | "pptx" | "odp" | "key" => "Presentation", + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "heic" | "avif" => "Image", + "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "Video", + "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "Audio", + "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "Archive", + "exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" => "Installer", + "js" | "jsx" | "ts" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs" + | "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" + | "html" | "htm" | "css" | "scss" | "json" | "xml" | "yaml" | "yml" + | "toml" | "sql" | "sh" | "bash" | "bat" | "ps1" | "vue" | "svelte" => "Code", + "md" | "markdown" | "rst" => "Markdown", + _ => "Document", + }; + } + + "Document" } /// Formats a byte count into a human-readable string (1024-based). @@ -105,22 +383,56 @@ mod tests { } #[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"); + fn test_icon_class_for_with_extension_fallback() { + // Specific MIME types + assert_eq!(icon_class_for("doc.pdf", "application/pdf"), "fas fa-file-pdf"); + assert_eq!(icon_class_for("file.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), "fas fa-file-word"); + + // Extension fallback when MIME is generic + assert_eq!(icon_class_for("script.py", "application/octet-stream"), "fas fa-file-code"); + assert_eq!(icon_class_for("app.dmg", "application/octet-stream"), "fas fa-hdd"); + assert_eq!(icon_class_for("archive.zip", "application/octet-stream"), "fas fa-file-archive"); + assert_eq!(icon_class_for("data.xlsx", "application/octet-stream"), "fas fa-file-excel"); + assert_eq!(icon_class_for("run.sh", "application/octet-stream"), "fas fa-terminal"); } #[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"); + fn test_icon_special_class_for() { + // MIME-based + assert_eq!(icon_special_class_for("", "image/png"), "image-icon"); + assert_eq!(icon_special_class_for("", "application/pdf"), "pdf-icon"); + assert_eq!(icon_special_class_for("", "application/json"), "code-icon json-icon"); + + // Extension-based fallback + assert_eq!(icon_special_class_for("main.py", "application/octet-stream"), "code-icon py-icon"); + assert_eq!(icon_special_class_for("lib.rs", "application/octet-stream"), "code-icon rust-icon"); + assert_eq!(icon_special_class_for("style.css", "application/octet-stream"), "code-icon css-icon"); + assert_eq!(icon_special_class_for("data.xlsx", "application/octet-stream"), "spreadsheet-icon"); + assert_eq!(icon_special_class_for("backup.tar", "application/octet-stream"), "archive-icon"); + assert_eq!(icon_special_class_for("setup.dmg", "application/octet-stream"), "installer-icon"); + } + + #[test] + fn test_category_for() { + // MIME-based + assert_eq!(category_for("", "image/jpeg"), "Image"); + assert_eq!(category_for("", "video/webm"), "Video"); + assert_eq!(category_for("", "audio/ogg"), "Audio"); + assert_eq!(category_for("", "application/pdf"), "PDF"); + assert_eq!(category_for("", "application/zip"), "Archive"); + + // Extension-based fallback + assert_eq!(category_for("main.rs", "application/octet-stream"), "Code"); + assert_eq!(category_for("photo.jpg", "application/octet-stream"), "Image"); + assert_eq!(category_for("notes.md", "application/octet-stream"), "Markdown"); + } + + #[test] + fn test_ext_of() { + assert_eq!(ext_of("file.txt"), Some("txt")); + assert_eq!(ext_of("archive.tar.gz"), Some("gz")); + assert_eq!(ext_of("no_extension"), None); + assert_eq!(ext_of(".gitignore"), Some("gitignore")); // dot file treated as having extension + assert_eq!(ext_of("path/to/file.rs"), Some("rs")); } } diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 585e7440..e7a73e5a 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use super::display_helpers::{format_file_size, mime_to_category, mime_to_icon_class, mime_to_icon_special_class}; +use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for}; /// DTO for favorites item, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. @@ -69,10 +69,11 @@ impl FavoriteItemDto { self.category = "Folder".to_string(); self.size_formatted = "--".to_string(); } else { + let name = self.item_name.as_deref().unwrap_or(""); 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.icon_class = icon_class_for(name, mime).to_string(); + self.icon_special_class = icon_special_class_for(name, mime).to_string(); + self.category = category_for(name, 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 2565512a..f1e3a202 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -1,7 +1,7 @@ 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}; +use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for}; /// DTO for file responses #[derive(Debug, Clone, Serialize, Deserialize)] @@ -47,21 +47,22 @@ pub struct FileDto { impl From for FileDto { fn from(file: File) -> Self { + let name = file.name(); let mime = file.mime_type(); let size = file.size(); Self { id: file.id().to_string(), - name: file.name().to_string(), + name: name.to_string(), path: file.path_string().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(), + icon_class: icon_class_for(name, mime).to_string(), + icon_special_class: icon_special_class_for(name, mime).to_string(), + category: category_for(name, mime).to_string(), size_formatted: format_file_size(size), } } diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index e75c1cfe..3abf7ddf 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use super::display_helpers::{format_file_size, mime_to_category, mime_to_icon_class, mime_to_icon_special_class}; +use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for}; /// DTO for recent items, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. @@ -65,10 +65,11 @@ impl RecentItemDto { self.category = "Folder".to_string(); self.size_formatted = "--".to_string(); } else { + let name = self.item_name.as_deref().unwrap_or(""); 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.icon_class = icon_class_for(name, mime).to_string(); + self.icon_special_class = icon_special_class_for(name, mime).to_string(); + self.category = category_for(name, mime).to_string(); self.size_formatted = format_file_size(self.item_size.unwrap_or(0) as u64); } self diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs index b897048c..c1fc45ef 100644 --- a/src/application/dtos/search_dto.rs +++ b/src/application/dtos/search_dto.rs @@ -122,6 +122,8 @@ pub struct SearchFileResultDto { pub size_formatted: String, /// CSS icon class for the file type (e.g., "fas fa-file-pdf") pub icon_class: String, + /// Extra CSS class for icon styling (e.g., "pdf-icon", "code-icon js-icon") + pub icon_special_class: String, /// Content category: "document", "image", "video", "audio", "archive", "code", "other" pub category: String, } @@ -246,6 +248,8 @@ pub struct SearchSuggestionItem { pub path: String, /// CSS icon class pub icon_class: String, + /// Extra CSS class for icon styling + pub icon_special_class: String, /// Relevance score pub relevance_score: u32, } diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index dc14c42e..d87c1f0b 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -7,6 +7,7 @@ use tokio::time; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; +use crate::application::dtos::display_helpers::{icon_class_for, icon_special_class_for, category_for}; use crate::application::dtos::search_dto::{ SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto, SearchSuggestionItem, SearchSuggestionsDto, @@ -105,105 +106,21 @@ fn format_bytes(bytes: u64) -> String { } } -/// Determine content category from MIME type. -fn categorize_mime(mime: &str) -> &'static str { - let m = mime.to_lowercase(); - if m.starts_with("image/") { - "image" - } else if m.starts_with("video/") { - "video" - } else if m.starts_with("audio/") { - "audio" - } else if m.starts_with("text/") - || m.contains("pdf") - || m.contains("document") - || m.contains("spreadsheet") - || m.contains("presentation") - || m.contains("msword") - || m.contains("officedocument") - { - "document" - } else if m.contains("zip") - || m.contains("tar") - || m.contains("gzip") - || m.contains("bzip") - || m.contains("7z") - || m.contains("rar") - || m.contains("compress") - { - "archive" - } else if m.contains("json") - || m.contains("xml") - || m.contains("javascript") - || m.contains("typescript") - || m.contains("x-python") - || m.contains("x-rust") - || m.contains("x-c") - || m.contains("x-java") - || m.contains("x-shellscript") - || m.contains("x-httpd-php") - || m.contains("yaml") - || m.contains("toml") - { - "code" - } else { - "other" - } +/// Get Font Awesome icon class for a file based on extension and MIME type. +/// Delegates to the centralised `display_helpers` so every API surface is +/// consistent. +fn get_icon_class(name: &str, mime: &str) -> String { + icon_class_for(name, mime).to_string() } -/// Get Font Awesome icon class for a file based on extension and MIME type. -fn get_icon_class(name: &str, mime: &str) -> String { - // Try extension first - if let Some(ext) = name.rsplit('.').next() { - let ext_lower = ext.to_lowercase(); - let icon = match ext_lower.as_str() { - // Documents - "pdf" => "fas fa-file-pdf", - "doc" | "docx" => "fas fa-file-word", - "xls" | "xlsx" => "fas fa-file-excel", - "ppt" | "pptx" => "fas fa-file-powerpoint", - "txt" | "rtf" | "md" => "fas fa-file-alt", - "csv" => "fas fa-file-csv", - // Images - "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" => { - "fas fa-file-image" - } - // Video - "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => { - "fas fa-file-video" - } - // Audio - "mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" => "fas fa-file-audio", - // Archives - "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "fas fa-file-archive", - // Code - "js" | "ts" | "jsx" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs" - | "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" | "sh" | "bash" - | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-file-code", - "html" | "htm" | "css" | "scss" | "sass" | "less" => "fas fa-file-code", - "json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => { - "fas fa-file-code" - } - "sql" => "fas fa-database", - _ => "", - }; - if !icon.is_empty() { - return icon.to_string(); - } - } +/// Get CSS special class for icon styling. +fn get_icon_special_class(name: &str, mime: &str) -> String { + icon_special_class_for(name, mime).to_string() +} - // Fallback to MIME type - let category = categorize_mime(mime); - match category { - "image" => "fas fa-file-image", - "video" => "fas fa-file-video", - "audio" => "fas fa-file-audio", - "document" => "fas fa-file-alt", - "archive" => "fas fa-file-archive", - "code" => "fas fa-file-code", - _ => "fas fa-file", - } - .to_string() +/// Get category label from centralised helpers. +fn get_category(name: &str, mime: &str) -> String { + category_for(name, mime).to_string() } // ─── SearchService implementation ─────────────────────────────────────── @@ -334,7 +251,8 @@ impl SearchService { relevance_score: relevance, size_formatted: format_bytes(file.size), icon_class: get_icon_class(&file.name, &file.mime_type), - category: categorize_mime(&file.mime_type).to_string(), + icon_special_class: get_icon_special_class(&file.name, &file.mime_type), + category: get_category(&file.name, &file.mime_type), } } @@ -458,6 +376,7 @@ impl SearchService { id: file_dto.id.clone(), path: file_dto.path.clone(), icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type), + icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type), relevance_score: score, }); } @@ -478,6 +397,7 @@ impl SearchService { id: folder_dto.id.clone(), path: folder_dto.path.clone(), icon_class: "fas fa-folder".to_string(), + icon_special_class: "folder-icon".to_string(), relevance_score: score, }); } diff --git a/static/css/favorites.css b/static/css/favorites.css index aa21dc8e..d729af2e 100644 --- a/static/css/favorites.css +++ b/static/css/favorites.css @@ -100,24 +100,14 @@ /* ── Star badge on file/folder cards (normal view) ── */ -/* Grid view: star below the checkbox */ +/* Grid view: keep interactive star button behavior from style.css */ .file-card .favorite-star { - position: absolute; - top: 10px; - right: 38px; - font-size: 14px; - color: #ffc107; - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); - z-index: 5; - pointer-events: none; - - [dir='rtl'] & { - right: unset; - left: 38px; - } + pointer-events: auto; + z-index: 12; } -.file-card .favorite-star.active i { +.file-card .favorite-star.active i, +.file-card .favorite-star.active svg { animation: favorite-pulse 0.3s ease; } diff --git a/static/css/style.css b/static/css/style.css index 58434932..a97d45f8 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1168,7 +1168,7 @@ select:focus { background-color: white; border-radius: 12px; border: 2px solid #e2e8f0; - padding: 20px; + padding: 16px; display: flex; flex-direction: column; align-items: center; @@ -1176,7 +1176,7 @@ select:focus { cursor: pointer; transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; width: 100%; - min-height: 180px; + min-height: 160px; position: relative; } @@ -1261,6 +1261,46 @@ select:focus { color: #334155; } +/* Favorite star (mirrors file-card-more button pattern) */ +button.favorite-star { + position: absolute; + top: 8px; + right: 38px; + width: 30px; + height: 30px; + border-radius: 8px; + border: none; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + z-index: 12; + cursor: pointer; + color: #cbd5e0; + font-size: 15px; + padding: 0; + line-height: 1; +} + +.file-card:hover .favorite-star { + opacity: 1; +} + +.favorite-star:hover { + color: #fbbf24; +} + +.favorite-star.active { + opacity: 1; + color: #f59e0b; +} + +.favorite-star.active:hover { + color: #d97706; +} + .file-card.dragging { opacity: 0.5; transform: scale(0.95); @@ -1272,6 +1312,15 @@ select:focus { border: 2px dashed #ffc107; } +/* Hide FontAwesome / SVG icons inside grid card thumbnails — the decorative + CSS shapes (::before / ::after) already convey the file type visually. + Icons.js replaces with , so we target both. + List-view icons (.file-item .file-icon) are NOT affected. */ +.file-card .file-icon > i, +.file-card .file-icon > svg { + display: none !important; +} + /* Styles for folders as in the mockup */ .file-icon.folder-icon { width: 100px; @@ -1296,42 +1345,6 @@ select:focus { border-radius: 8px 8px 0 0; } -.file-icon.folder-icon i { - display: none; /* Hide Font Awesome icon */ -} - -/* Style for documents */ -.file-icon.doc-icon { - width: 100px; - height: 70px; - background-color: #e2e8f0; /* Light gray background */ - border-radius: 4px; - position: relative; - margin-bottom: 10px; -} - -.file-icon.doc-icon::before { - content: ""; - position: absolute; - top: 15px; - left: 20px; - right: 20px; - height: 4px; - background-color: #a0aec0; - border-radius: 2px; -} - -.file-icon.doc-icon::after { - content: ""; - position: absolute; - top: 25px; - left: 20px; - right: 30px; - height: 4px; - background-color: #a0aec0; - border-radius: 2px; -} - /* Style for PDF files */ .file-icon.pdf-icon { width: 100px; @@ -1345,10 +1358,13 @@ select:focus { align-items: center; justify-content: center; } -.file-icon.pdf-icon i { - font-size: 28px; +.file-icon.pdf-icon::after { + content: "PDF"; + font-size: 16px; + font-weight: 700; color: #e53e3e; - opacity: 0.7; + opacity: 0.65; + letter-spacing: 1px; } /* Style for document files (doc, docx, txt, rtf, odt) */ @@ -1360,14 +1376,25 @@ select:focus { position: relative; margin-bottom: 10px; border-top: 3px solid #2b6cb0; - display: flex; - align-items: center; - justify-content: center; + overflow: hidden; } -.file-icon.doc-icon i { - font-size: 28px; - color: #2b6cb0; - opacity: 0.7; +.file-icon.doc-icon::before { + content: ""; + position: absolute; + top: 18px; left: 18px; right: 18px; + height: 3px; + background-color: #2b6cb0; + opacity: 0.3; + border-radius: 2px; +} +.file-icon.doc-icon::after { + content: ""; + position: absolute; + top: 28px; left: 18px; right: 28px; + height: 3px; + background-color: #2b6cb0; + opacity: 0.25; + border-radius: 2px; } /* Style for shell/script files */ @@ -1383,10 +1410,13 @@ select:focus { align-items: center; justify-content: center; } -.file-icon.script-icon i { - font-size: 28px; +.file-icon.script-icon::after { + content: ">_"; + font-size: 18px; + font-weight: 700; + font-family: monospace; color: #4eaa25; - opacity: 0.7; + opacity: 0.55; } /* Style for config files */ @@ -1398,14 +1428,25 @@ select:focus { position: relative; margin-bottom: 10px; border-top: 3px solid #718096; - display: flex; - align-items: center; - justify-content: center; + overflow: hidden; } -.file-icon.config-icon i { - font-size: 28px; - color: #718096; - opacity: 0.7; +.file-icon.config-icon::before { + content: ""; + position: absolute; + top: 15px; left: 16px; + width: 10px; height: 10px; + border: 2px solid #718096; + border-radius: 50%; + opacity: 0.4; +} +.file-icon.config-icon::after { + content: ""; + position: absolute; + top: 19px; left: 34px; right: 16px; + height: 3px; + background-color: #718096; + opacity: 0.3; + border-radius: 2px; } /* Style for images */ @@ -1647,7 +1688,6 @@ select:focus { align-items: center; justify-content: center; } -.file-icon.spreadsheet-icon i { display: none; } .file-icon.spreadsheet-icon::before { content: ""; position: absolute; @@ -1673,7 +1713,6 @@ select:focus { align-items: center; justify-content: center; } -.file-icon.presentation-icon i { display: none; } .file-icon.presentation-icon::before { content: ""; position: absolute; @@ -1708,7 +1747,6 @@ select:focus { align-items: center; justify-content: center; } -.file-icon.audio-icon i { display: none; } .file-icon.audio-icon::before { content: "♪"; font-size: 28px; @@ -1730,7 +1768,6 @@ select:focus { align-items: center; justify-content: center; } -.file-icon.archive-icon i { display: none; } .file-icon.archive-icon::before { content: ""; position: absolute; @@ -1754,7 +1791,6 @@ select:focus { align-items: center; justify-content: center; } -.file-icon.installer-icon i { display: none; } .file-icon.installer-icon::before { content: "⬇"; font-size: 24px; @@ -1764,30 +1800,46 @@ select:focus { /* List view icon colors for new types */ .file-item .file-icon.spreadsheet-icon { background-color: #e6f4ea; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } -.file-item .file-icon.spreadsheet-icon i { color: #0d904f; display: flex; } -.file-item .file-icon.spreadsheet-icon::before { display: none; } +.file-item .file-icon.spreadsheet-icon i, +.file-item .file-icon.spreadsheet-icon svg { color: #0d904f; display: flex !important; } +.file-item .file-icon.spreadsheet-icon::before, +.file-item .file-icon.spreadsheet-icon::after { display: none; } .file-item .file-icon.presentation-icon { background-color: #fef3e2; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } -.file-item .file-icon.presentation-icon i { color: #d04423; display: flex; } -.file-item .file-icon.presentation-icon::before, .file-item .file-icon.presentation-icon::after { display: none; } +.file-item .file-icon.presentation-icon i, +.file-item .file-icon.presentation-icon svg { color: #d04423; display: flex !important; } +.file-item .file-icon.presentation-icon::before, +.file-item .file-icon.presentation-icon::after { display: none; } .file-item .file-icon.audio-icon { background-color: #fff3e0; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } -.file-item .file-icon.audio-icon i { color: #f57c00; display: flex; } -.file-item .file-icon.audio-icon::before { display: none; } +.file-item .file-icon.audio-icon i, +.file-item .file-icon.audio-icon svg { color: #f57c00; display: flex !important; } +.file-item .file-icon.audio-icon::before, +.file-item .file-icon.audio-icon::after { display: none; } .file-item .file-icon.archive-icon { background-color: #f5f0eb; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } -.file-item .file-icon.archive-icon i { color: #8d6e63; display: flex; } -.file-item .file-icon.archive-icon::before { display: none; } +.file-item .file-icon.archive-icon i, +.file-item .file-icon.archive-icon svg { color: #8d6e63; display: flex !important; } +.file-item .file-icon.archive-icon::before, +.file-item .file-icon.archive-icon::after { display: none; } .file-item .file-icon.installer-icon { background-color: #f3e8ff; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } -.file-item .file-icon.installer-icon i { color: #7c3aed; display: flex; } -.file-item .file-icon.installer-icon::before { display: none; } +.file-item .file-icon.installer-icon i, +.file-item .file-icon.installer-icon svg { color: #7c3aed; display: flex !important; } +.file-item .file-icon.installer-icon::before, +.file-item .file-icon.installer-icon::after { display: none; } .file-item .file-icon.script-icon { background-color: #e8f5e9; } -.file-item .file-icon.script-icon i { color: #4eaa25; } +.file-item .file-icon.script-icon i, +.file-item .file-icon.script-icon svg { color: #4eaa25; display: flex !important; } +.file-item .file-icon.script-icon::before, +.file-item .file-icon.script-icon::after { display: none; } .file-item .file-icon.config-icon { background-color: #f1f3f5; } -.file-item .file-icon.config-icon i { color: #718096; } +.file-item .file-icon.config-icon i, +.file-item .file-icon.config-icon svg { color: #718096; display: flex !important; } +.file-item .file-icon.config-icon::before, +.file-item .file-icon.config-icon::after { display: none; } .file-name { font-size: 14px; @@ -1906,7 +1958,22 @@ select:focus { border-radius: 8px 8px 0 0; } -.file-item .file-icon.folder-icon i { +.file-item .file-icon.folder-icon i, +.file-item .file-icon.folder-icon svg { + display: none; +} + +/* List view: show FA/SVG icons, hide grid-only decorative pseudo-elements */ +.file-item .file-icon.doc-icon { + background-color: #e0ecff; +} +.file-item .file-icon.doc-icon i, +.file-item .file-icon.doc-icon svg { + color: #3171d8; + display: flex !important; +} +.file-item .file-icon.doc-icon::before, +.file-item .file-icon.doc-icon::after { display: none; } @@ -1915,40 +1982,56 @@ select:focus { background-color: #fee2e2; } -.file-item .file-icon.pdf-icon i { +.file-item .file-icon.pdf-icon i, +.file-item .file-icon.pdf-icon svg { color: #e53e3e; + display: flex !important; +} +.file-item .file-icon.pdf-icon::before, +.file-item .file-icon.pdf-icon::after { + display: none; } .file-item .file-icon.image-icon { background-color: #e0f2fe; } -.file-item .file-icon.image-icon i { +.file-item .file-icon.image-icon i, +.file-item .file-icon.image-icon svg { color: #3b82f6; + display: flex !important; +} +.file-item .file-icon.image-icon::before, +.file-item .file-icon.image-icon::after { + display: none; } .file-item .file-icon.video-icon { background: linear-gradient(135deg, #ede9fe, #fce7f3); } -.file-item .file-icon.video-icon i { +.file-item .file-icon.video-icon i, +.file-item .file-icon.video-icon svg { color: #8b5cf6; + display: flex !important; +} +.file-item .file-icon.video-icon::before, +.file-item .file-icon.video-icon::after { + display: none; } .file-item .file-icon.audio-icon { background-color: #fef3c7; } -.file-item .file-icon.audio-icon i { +.file-item .file-icon.audio-icon i, +.file-item .file-icon.audio-icon svg { color: #f59e0b; + display: flex !important; } - -.file-item .file-icon.text-icon { - background-color: #f3f4f6; -} - -.file-item .file-icon.text-icon i { - color: #6b7280; +.file-item .file-icon.audio-icon::before, +.file-item .file-icon.audio-icon::after { + display: none; } .file-item .date-cell { diff --git a/static/index.html b/static/index.html index 63a2ce46..75250b3a 100644 --- a/static/index.html +++ b/static/index.html @@ -111,8 +111,8 @@
Notifications -
diff --git a/static/js/app.js b/static/js/app.js index 9fd8dd24..a6fdc313 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -46,6 +46,172 @@ const elements = { // Will be populated on initialization }; +// Upload dropdown listener state (prevents accumulated listeners) +let uploadDropdownDocumentClickHandler = null; +let uploadDropdownBindingsController = null; +let actionsBarDelegationBound = false; + +const ACTIONS_BAR_TEMPLATES = { + files: ` +
+
+ +
+ + +
+
+ +
+
+ + +
+ `, + trash: ` +
+ +
+ `, + favorites: ` +
+
+ + +
+ `, + recent: ` +
+ +
+
+ + +
+ ` +}; + +function setActionsBarMode(mode, force = false) { + if (!elements.actionsBar) return; + + if (mode === 'hidden') { + elements.actionsBar.style.display = 'none'; + elements.actionsBar.dataset.mode = 'hidden'; + return; + } + + if (!force && elements.actionsBar.dataset.mode === mode) { + return; + } + + const html = ACTIONS_BAR_TEMPLATES[mode]; + if (!html) return; + + elements.actionsBar.innerHTML = html; + elements.actionsBar.style.display = 'flex'; + elements.actionsBar.dataset.mode = mode; + + // Refresh cached action elements after rebuild + elements.uploadBtn = document.getElementById('upload-btn'); + elements.newFolderBtn = document.getElementById('new-folder-btn'); + elements.gridViewBtn = document.getElementById('grid-view-btn'); + elements.listViewBtn = document.getElementById('list-view-btn'); + + if (window.i18n && window.i18n.translateElement) { + window.i18n.translateElement(elements.actionsBar); + } + + if (mode === 'files') { + setupUploadDropdown(); + } +} + +function setupActionsBarDelegation() { + if (actionsBarDelegationBound || !elements.actionsBar) return; + actionsBarDelegationBound = true; + + elements.actionsBar.addEventListener('click', async (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + + switch (btn.id) { + case 'upload-files-btn': { + e.stopPropagation(); + const menu = document.getElementById('upload-dropdown-menu'); + if (menu) menu.classList.remove('show'); + if (elements.fileInput) elements.fileInput.click(); + break; + } + case 'upload-folder-btn': { + e.stopPropagation(); + const menu = document.getElementById('upload-dropdown-menu'); + if (menu) menu.classList.remove('show'); + const folderInput = document.getElementById('folder-input'); + if (folderInput) folderInput.click(); + break; + } + case 'new-folder-btn': { + const folderName = await window.Modal.promptNewFolder(); + if (folderName) { + fileOps.createFolder(folderName); + } + break; + } + case 'grid-view-btn': + ui.switchToGridView(); + break; + case 'list-view-btn': + ui.switchToListView(); + break; + case 'empty-trash-btn': + if (await fileOps.emptyTrash()) { + loadTrashItems(); + } + break; + case 'clear-recent-btn': + if (window.recent) { + window.recent.clearRecentFiles(); + window.recent.displayRecentFiles(); + window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared'); + } + break; + default: + break; + } + }); +} + /** * Initialize the application */ @@ -328,13 +494,17 @@ async function fetchAppVersion() { * Handles opening/closing the dropdown and triggering file/folder inputs */ function setupUploadDropdown() { - const dropdown = document.getElementById('upload-dropdown'); const uploadBtn = document.getElementById('upload-btn'); const menu = document.getElementById('upload-dropdown-menu'); - const uploadFilesBtn = document.getElementById('upload-files-btn'); - const uploadFolderBtn = document.getElementById('upload-folder-btn'); if (!uploadBtn || !menu) return; + + // Abort any previous local bindings (safe across repeated/rebuilt UI) + if (uploadDropdownBindingsController) { + uploadDropdownBindingsController.abort(); + } + uploadDropdownBindingsController = new AbortController(); + const signal = uploadDropdownBindingsController.signal; // Toggle dropdown on button click uploadBtn.addEventListener('click', (e) => { @@ -345,33 +515,18 @@ function setupUploadDropdown() { if (!isOpen) { menu.classList.add('show'); } - }); - - // Upload files option - if (uploadFilesBtn) { - uploadFilesBtn.addEventListener('click', (e) => { - e.stopPropagation(); - menu.classList.remove('show'); - elements.fileInput.click(); - }); - } - - // Upload folder option - if (uploadFolderBtn) { - uploadFolderBtn.addEventListener('click', (e) => { - e.stopPropagation(); - menu.classList.remove('show'); - const folderInput = document.getElementById('folder-input'); - if (folderInput) { - folderInput.click(); - } - }); - } - + }, { signal }); + // Close dropdown when clicking outside - document.addEventListener('click', () => { + // remove+add stable handler: guarantees exactly one global listener + if (uploadDropdownDocumentClickHandler) { + document.removeEventListener('click', uploadDropdownDocumentClickHandler); + } + uploadDropdownDocumentClickHandler = (e) => { + if (e.target.closest('#upload-dropdown')) return; document.querySelectorAll('.upload-dropdown-menu.show').forEach(m => m.classList.remove('show')); - }); + }; + document.addEventListener('click', uploadDropdownDocumentClickHandler); } /** @@ -435,6 +590,10 @@ function setupEventListeners() { // Upload dropdown setupUploadDropdown(); + setupActionsBarDelegation(); + if (elements.actionsBar) { + elements.actionsBar.dataset.mode = 'files'; + } // File input elements.fileInput.addEventListener('change', (e) => { @@ -455,18 +614,6 @@ function setupEventListeners() { }); } - // New folder button - elements.newFolderBtn.addEventListener('click', async () => { - const folderName = await window.Modal.promptNewFolder(); - if (folderName) { - fileOps.createFolder(folderName); - } - }); - - // View toggle - elements.gridViewBtn.addEventListener('click', ui.switchToGridView); - elements.listViewBtn.addEventListener('click', ui.switchToListView); - // Sidebar navigation elements.navItems.forEach(item => { item.addEventListener('click', () => { @@ -529,22 +676,7 @@ function setupEventListeners() { // Update UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.trash') : 'Trash'; elements.pageTitle.setAttribute('data-i18n', 'nav.trash'); - elements.actionsBar.innerHTML = ` -
- -
- `; - elements.actionsBar.style.display = 'flex'; - - // Add event listener to empty trash button - document.getElementById('empty-trash-btn').addEventListener('click', async () => { - if (await fileOps.emptyTrash()) { - loadTrashItems(); - } - }); + setActionsBarMode('trash'); // Load trash items loadTrashItems(); @@ -572,39 +704,7 @@ function setupEventListeners() { // Reset UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.files') : 'Files'; - elements.actionsBar.innerHTML = ` -
-
- -
- - -
-
- -
-
- - -
- `; - elements.actionsBar.style.display = 'flex'; + setActionsBarMode('files'); // Show files containers const filesGrid = document.getElementById('files-grid'); @@ -612,25 +712,6 @@ function setupEventListeners() { if (filesGrid) filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; if (filesListView) filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; - // Restore event listeners - setupUploadDropdown(); - - document.getElementById('new-folder-btn').addEventListener('click', async () => { - const folderName = await window.Modal.promptNewFolder(); - if (folderName) { - fileOps.createFolder(folderName); - } - }); - - document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); - document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); - - // Restore cached elements - elements.uploadBtn = document.getElementById('upload-btn'); - elements.newFolderBtn = document.getElementById('new-folder-btn'); - elements.gridViewBtn = document.getElementById('grid-view-btn'); - elements.listViewBtn = document.getElementById('list-view-btn'); - // Load regular files app.currentPath = ''; ui.updateBreadcrumb(''); @@ -1188,58 +1269,7 @@ function switchToFilesView() { } // Reset UI - elements.actionsBar.innerHTML = ` -
-
- -
- - -
-
- -
-
- - -
- `; - elements.actionsBar.style.display = 'flex'; - - // Restore event listeners - setupUploadDropdown(); - - document.getElementById('new-folder-btn').addEventListener('click', async () => { - const folderName = await window.Modal.promptNewFolder(); - if (folderName) { - fileOps.createFolder(folderName); - } - }); - - document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); - document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); - - // Restore cached elements - elements.uploadBtn = document.getElementById('upload-btn'); - elements.newFolderBtn = document.getElementById('new-folder-btn'); - elements.gridViewBtn = document.getElementById('grid-view-btn'); - elements.listViewBtn = document.getElementById('list-view-btn'); + setActionsBarMode('files'); // Hide shared view if it exists if (window.sharedView) { @@ -1302,28 +1332,7 @@ function switchToFavoritesView() { } // Configure actions bar for favorites view - elements.actionsBar.innerHTML = ` -
- -
-
- - -
- `; - elements.actionsBar.style.display = 'flex'; - - // Restore view toggle event listeners - document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); - document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); - - // Update cached elements - elements.gridViewBtn = document.getElementById('grid-view-btn'); - elements.listViewBtn = document.getElementById('list-view-btn'); + setActionsBarMode('favorites'); // Show standard files containers const filesGrid = document.getElementById('files-grid'); @@ -1392,39 +1401,7 @@ function switchToRecentFilesView() { } // Configure actions bar for recent view - elements.actionsBar.innerHTML = ` -
- -
-
- - -
- `; - elements.actionsBar.style.display = 'flex'; - - // Add event listener for clear button - document.getElementById('clear-recent-btn').addEventListener('click', () => { - if (window.recent) { - window.recent.clearRecentFiles(); - window.recent.displayRecentFiles(); - window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared'); - } - }); - - // Restore view toggle event listeners - document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); - document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); - - // Update cached elements - elements.gridViewBtn = document.getElementById('grid-view-btn'); - elements.listViewBtn = document.getElementById('list-view-btn'); + setActionsBarMode('recent'); // Show standard files containers const filesGrid = document.getElementById('files-grid'); diff --git a/static/js/components/sharedView.js b/static/js/components/sharedView.js index da7c5408..53e1771c 100644 --- a/static/js/components/sharedView.js +++ b/static/js/components/sharedView.js @@ -79,10 +79,6 @@ const sharedView = { -
@@ -189,14 +185,10 @@ const sharedView = { attachEventListeners() { const filterType = document.getElementById('filter-type'); const sortBy = document.getElementById('sort-by'); - const searchFilter = document.getElementById('shared-search-filter'); - const searchBtn = document.getElementById('shared-search-filter-btn'); const emptyGoToFiles = document.getElementById('empty-go-to-files'); if (filterType) filterType.addEventListener('change', () => this.filterAndSortItems()); if (sortBy) sortBy.addEventListener('change', () => this.filterAndSortItems()); - if (searchFilter) searchFilter.addEventListener('keyup', e => { if (e.key === 'Enter') this.filterAndSortItems(); }); - if (searchBtn) searchBtn.addEventListener('click', () => this.filterAndSortItems()); if (emptyGoToFiles) emptyGoToFiles.addEventListener('click', () => window.switchToFilesView()); // Share dialog (sharedView-specific IDs) @@ -238,11 +230,13 @@ const sharedView = { filterAndSortItems() { const filterType = document.getElementById('filter-type'); const sortBy = document.getElementById('sort-by'); - const searchFilter = document.getElementById('shared-search-filter'); const type = filterType ? filterType.value : 'all'; const sort = sortBy ? sortBy.value : 'date'; - const searchTerm = searchFilter ? searchFilter.value.toLowerCase() : ''; + + // Use the top-bar search if available, otherwise no filter + const topSearch = document.getElementById('shared-search'); + const searchTerm = topSearch ? topSearch.value.toLowerCase() : ''; this.filteredItems = this.items.filter(item => { if (type !== 'all' && item.item_type !== type) return false; diff --git a/static/js/contextMenus.js b/static/js/contextMenus.js index 28dbc6b1..0f62bfe7 100644 --- a/static/js/contextMenus.js +++ b/static/js/contextMenus.js @@ -5,6 +5,33 @@ // Context Menus Module const contextMenus = { + _setFavoriteOptionLabel(optionId, isFavorite) { + const option = document.getElementById(optionId); + if (!option) return; + const label = option.querySelector('span'); + if (!label) return; + label.textContent = window.i18n + ? window.i18n.t(isFavorite ? 'actions.unfavorite' : 'actions.favorite') + : (isFavorite ? 'Remove from favorites' : 'Add to favorites'); + }, + + syncFavoriteOptionLabels() { + if (!window.favorites) return; + + const targetFile = window.app && window.app.contextMenuTargetFile; + const targetFolder = window.app && window.app.contextMenuTargetFolder; + + if (targetFile) { + const isFav = window.favorites.isFavorite(targetFile.id, 'file'); + this._setFavoriteOptionLabel('favorite-file-option', isFav); + } + + if (targetFolder) { + const isFav = window.favorites.isFavorite(targetFolder.id, 'folder'); + this._setFavoriteOptionLabel('favorite-folder-option', isFav); + } + }, + /** * Assign events to menu items and dialogs */ @@ -20,29 +47,30 @@ const contextMenus = { window.ui.closeContextMenu(); }); - document.getElementById('favorite-folder-option').addEventListener('click', () => { + document.getElementById('favorite-folder-option').addEventListener('click', async () => { if (window.app.contextMenuTargetFolder) { const folder = window.app.contextMenuTargetFolder; - + // Check if folder is already in favorites to toggle if (window.favorites && window.favorites.isFavorite(folder.id, 'folder')) { // Remove from favorites - window.favorites.removeFromFavorites(folder.id, 'folder'); - // Update menu item text - document.getElementById('favorite-folder-option').querySelector('span').textContent = - window.i18n ? window.i18n.t('actions.favorite') : 'Add to favorites'; + const ok = await window.favorites.removeFromFavorites(folder.id, 'folder'); + if (ok && window.ui && typeof window.ui.setFavoriteVisualState === 'function') { + window.ui.setFavoriteVisualState(folder.id, 'folder', false); + } } else { // Add to favorites - window.favorites.addToFavorites( + const ok = await window.favorites.addToFavorites( folder.id, folder.name, 'folder', folder.parent_id ); - // Update menu item text - document.getElementById('favorite-folder-option').querySelector('span').textContent = - window.i18n ? window.i18n.t('actions.unfavorite') : 'Remove from favorites'; + if (ok && window.ui && typeof window.ui.setFavoriteVisualState === 'function') { + window.ui.setFavoriteVisualState(folder.id, 'folder', true); + } } + this.syncFavoriteOptionLabels(); } window.ui.closeContextMenu(); }); @@ -120,29 +148,30 @@ const contextMenus = { window.ui.closeFileContextMenu(); }); - document.getElementById('favorite-file-option').addEventListener('click', () => { + document.getElementById('favorite-file-option').addEventListener('click', async () => { if (window.app.contextMenuTargetFile) { const file = window.app.contextMenuTargetFile; - + // Check if file is already in favorites to toggle if (window.favorites && window.favorites.isFavorite(file.id, 'file')) { // Remove from favorites - window.favorites.removeFromFavorites(file.id, 'file'); - // Update menu item text - document.getElementById('favorite-file-option').querySelector('span').textContent = - window.i18n ? window.i18n.t('actions.favorite') : 'Add to favorites'; + const ok = await window.favorites.removeFromFavorites(file.id, 'file'); + if (ok && window.ui && typeof window.ui.setFavoriteVisualState === 'function') { + window.ui.setFavoriteVisualState(file.id, 'file', false); + } } else { // Add to favorites - window.favorites.addToFavorites( + const ok = await window.favorites.addToFavorites( file.id, file.name, 'file', file.folder_id ); - // Update menu item text - document.getElementById('favorite-file-option').querySelector('span').textContent = - window.i18n ? window.i18n.t('actions.unfavorite') : 'Remove from favorites'; + if (ok && window.ui && typeof window.ui.setFavoriteVisualState === 'function') { + window.ui.setFavoriteVisualState(file.id, 'file', true); + } } + this.syncFavoriteOptionLabels(); } window.ui.closeFileContextMenu(); }); diff --git a/static/js/favorites.js b/static/js/favorites.js index e8e5dc9d..97d06773 100644 --- a/static/js/favorites.js +++ b/static/js/favorites.js @@ -114,11 +114,6 @@ const favorites = { ); } - // 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); @@ -154,11 +149,6 @@ const favorites = { ); } - // 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); diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index 2e036bbe..d6e69491 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -26,9 +26,9 @@ const fileOps = { _isUploading: false, // Guard against concurrent upload calls /** Start a new upload batch in the notification bell */ - _initUploadToast(totalFiles) { + _initUploadToast(totalFiles, folderName) { this._currentBatchId = window.notifications - ? window.notifications.addUploadBatch(totalFiles) + ? window.notifications.addUploadBatch(totalFiles, folderName) : null; }, @@ -39,31 +39,115 @@ const fileOps = { } }, + /** + * Some drag-and-drop sources can inject directory placeholders into + * DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED + * when trying to send them as normal files. + */ + _canReadFileBlob(file) { + return new Promise((resolve) => { + try { + const reader = new FileReader(); + reader.onload = () => resolve(true); + reader.onerror = () => resolve(false); + reader.readAsArrayBuffer(file.slice(0, 1)); + } catch (_) { + resolve(false); + } + }); + }, + /** * Upload a single file via XMLHttpRequest with progress events. * Progress is reported to the notification bell via batchId + fileName. * Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }. */ - _uploadFileXHR(formData, batchId, fileName) { + _uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) { return new Promise((resolve) => { const xhr = new XMLHttpRequest(); const notif = window.notifications; + xhr.timeout = timeoutMs; + const hardDeadlineMs = Math.max(timeoutMs * 2, 180000); + let lastProgressPctSent = -1; + + let isSettled = false; + let stallTimer = null; + let hardTimer = null; + + const safeUpdateFile = (pct, status) => { + if (!notif || !batchId) return; + try { + notif.updateFile(batchId, fileName, pct, status); + } catch (e) { + console.warn('Notification update failed for upload row:', fileName, e); + } + }; + + const finalize = (result) => { + if (isSettled) return; + isSettled = true; + if (stallTimer) { + clearTimeout(stallTimer); + stallTimer = null; + } + if (hardTimer) { + clearTimeout(hardTimer); + hardTimer = null; + } + resolve(result); + }; + + const resetStallTimer = () => { + if (stallTimer) clearTimeout(stallTimer); + stallTimer = setTimeout(() => { + try { xhr.abort(); } catch (_) {} + safeUpdateFile(0, 'error'); + finalize({ + ok: false, + isTimeout: true, + errorMsg: `Upload stalled for ${Math.round(timeoutMs / 1000)}s` + }); + }, timeoutMs); + }; + + resetStallTimer(); + hardTimer = setTimeout(() => { + try { xhr.abort(); } catch (_) {} + safeUpdateFile(0, 'error'); + finalize({ + ok: false, + isTimeout: true, + errorMsg: `Upload hard timeout after ${Math.round(hardDeadlineMs / 1000)}s` + }); + }, hardDeadlineMs); xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable && notif && batchId) { + resetStallTimer(); + if (e.lengthComputable) { const pct = Math.round((e.loaded / e.total) * 100); - notif.updateFile(batchId, fileName, pct, 'uploading'); + // Throttle UI updates from very chatty progress events + if (pct === 100 || pct - lastProgressPctSent >= 10) { + lastProgressPctSent = pct; + safeUpdateFile(pct, 'uploading'); + } + } + }); + + xhr.addEventListener('readystatechange', () => { + // Keep watchdog alive while request is actively moving through states + if (xhr.readyState > 1 && xhr.readyState < 4) { + resetStallTimer(); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { - if (notif && batchId) notif.updateFile(batchId, fileName, 100, 'done'); + safeUpdateFile(100, 'done'); let data = null; try { data = JSON.parse(xhr.responseText); } catch (_) {} - resolve({ ok: true, data }); + finalize({ ok: true, data }); } else { - if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error'); + safeUpdateFile(0, 'error'); // Parse error body for quota-exceeded or other messages let errorMsg = null; let isQuotaError = false; @@ -72,13 +156,23 @@ const fileOps = { errorMsg = errBody.error || null; isQuotaError = errBody.error_type === 'QuotaExceeded' || xhr.status === 507; } catch (_) {} - resolve({ ok: false, errorMsg, isQuotaError }); + finalize({ ok: false, errorMsg, isQuotaError }); } }); xhr.addEventListener('error', () => { - if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error'); - resolve({ ok: false }); + safeUpdateFile(0, 'error'); + finalize({ ok: false }); + }); + + xhr.addEventListener('abort', () => { + safeUpdateFile(0, 'error'); + finalize({ ok: false, isTimeout: true, errorMsg: `Upload aborted/stalled: ${fileName}` }); + }); + + xhr.addEventListener('timeout', () => { + safeUpdateFile(0, 'error'); + finalize({ ok: false, isTimeout: true, errorMsg: `Timeout after ${Math.round(timeoutMs / 1000)}s` }); }); xhr.open('POST', '/api/files/upload'); @@ -88,10 +182,69 @@ const fileOps = { if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - xhr.send(formData); + try { + xhr.send(formData); + } catch (e) { + safeUpdateFile(0, 'error'); + finalize({ + ok: false, + errorMsg: `Client send() failed: ${e?.message || 'unknown error'}` + }); + } }); }, + /** + * Upload a single file via fetch + AbortController. + * Used by folder uploads to avoid browser XHR edge-cases with dragged entries. + * Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }. + */ + async _uploadFileFetch(formData, timeoutMs = 60000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch('/api/files/upload', { + method: 'POST', + headers: { + ...getAuthHeaders(), + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }, + body: formData, + signal: controller.signal, + cache: 'no-store' + }); + + // Read body as text first (always consume the response fully) + let rawText = ''; + try { rawText = await response.text(); } catch (_) {} + + let body = null; + try { body = JSON.parse(rawText); } catch (_) {} + + if (response.ok) { + return { ok: true, data: body }; + } + + const errorMsg = body && typeof body === 'object' + ? (body.error || null) + : (rawText || null); + const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507; + return { ok: false, errorMsg, isQuotaError }; + } catch (e) { + const isTimeout = e?.name === 'AbortError'; + return { + ok: false, + isTimeout, + errorMsg: isTimeout + ? `Timeout after ${Math.round(timeoutMs / 1000)}s` + : `Fetch upload failed: ${e?.message || 'network error'}` + }; + } finally { + clearTimeout(timeoutId); + } + }, + // ======================================================================== // Upload files (via button or drag-and-drop) // ======================================================================== @@ -101,8 +254,8 @@ const fileOps = { * @param {FileList} files - Files to upload */ async uploadFiles(files) { - const totalFiles = files.length; - if (totalFiles === 0) return; + const originalFiles = Array.from(files || []); + if (originalFiles.length === 0) return; // Guard: prevent concurrent upload calls (e.g. double drop events) if (this._isUploading) { @@ -118,7 +271,39 @@ const fileOps = { if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; } if (progressBar) { progressBar.style.width = '0%'; } - // Show upload notification + // Filter out unreadable entries (typically dropped folders/placeholders) + const readableFiles = []; + const skippedEntries = []; + for (const f of originalFiles) { + // eslint-disable-next-line no-await-in-loop + const readable = await this._canReadFileBlob(f); + if (readable) readableFiles.push(f); + else skippedEntries.push(f.name || 'Unnamed entry'); + } + + const totalFiles = readableFiles.length; + + if (skippedEntries.length > 0 && window.notifications) { + const locale = window.i18n?.getCurrentLocale?.() || 'en'; + const title = locale.startsWith('es') ? 'Entradas omitidas' : 'Entries skipped'; + const text = locale.startsWith('es') + ? `Se omitieron ${skippedEntries.length} carpeta(s)/entrada(s) no legibles. Usa "Subir carpeta".` + : `${skippedEntries.length} unreadable folder/entry items were skipped. Use "Upload folder".`; + window.notifications.addNotification({ + icon: 'fa-folder-open', + iconClass: 'upload', + title, + text + }); + } + + if (totalFiles === 0) { + if (uploadProgressDiv) uploadProgressDiv.style.display = 'none'; + this._isUploading = false; + return; + } + + // Show upload notification (only for actual readable files) this._initUploadToast(totalFiles); const batchId = this._currentBatchId; @@ -126,7 +311,8 @@ const fileOps = { let successCount = 0; for (let i = 0; i < totalFiles; i++) { - const file = files[i]; + const file = readableFiles[i]; + const formData = new FormData(); const targetFolderId = window.app.currentPath || window.app.userHomeFolderId; @@ -147,7 +333,11 @@ const fileOps = { } // Notify bell of per-file completion if (window.notifications && batchId) { - window.notifications.fileCompleted(batchId, result.ok); + try { + window.notifications.fileCompleted(batchId, result.ok); + } catch (e) { + console.warn('Batch progress update failed:', e); + } } if (result.ok) { @@ -155,6 +345,14 @@ const fileOps = { console.log(`Successfully uploaded ${file.name}`, result.data); } else { console.error(`Upload error for ${file.name}`); + if (result.isTimeout && window.notifications) { + window.notifications.addNotification({ + icon: 'fa-clock', + iconClass: 'error', + title: file.name, + text: result.errorMsg || 'Upload timeout' + }); + } if (result.isQuotaError) { const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded'; if (window.notifications) { @@ -198,135 +396,247 @@ const fileOps = { * @param {FileList} files - Files from folder input (with webkitRelativePath) */ async uploadFolderFiles(files) { - if (!files || files.length === 0) return; - + const entries = Array.from(files || []).map((file) => ({ + file, + relativePath: file.webkitRelativePath || file.name + })); + await this.uploadFolderEntries(entries); + }, + + /** + * Upload folder-like entries preserving relative paths. + * @param {Array<{file: File, relativePath: string}>} entries + */ + async uploadFolderEntries(entries) { + const rawEntries = Array.isArray(entries) ? entries : []; + if (rawEntries.length === 0) return; + + // Guard: prevent concurrent upload calls + if (this._isUploading) { + console.warn('Upload already in progress, ignoring duplicate call'); + return; + } + this._isUploading = true; + const progressBar = document.querySelector('.progress-fill'); const uploadProgressDiv = document.querySelector('.upload-progress'); if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; } if (progressBar) { progressBar.style.width = '0%'; } - const currentFolderId = window.app.currentPath || window.app.userHomeFolderId; - - // Build folder structure from relative paths - const folderMap = new Map(); - folderMap.set('', currentFolderId); - - const folderPaths = new Set(); - for (const file of files) { - const parts = file.webkitRelativePath.split('/'); - for (let i = 1; i < parts.length; i++) { - const path = parts.slice(0, i).join('/'); - folderPaths.add(path); + try { + // Filter unreadable entries + const validEntries = []; + for (const e of rawEntries) { + // eslint-disable-next-line no-await-in-loop + const readable = await this._canReadFileBlob(e.file); + if (readable) validEntries.push(e); + else console.warn(`Skipping unreadable folder entry: ${e.relativePath || e.file?.name}`); } - } - - const sortedPaths = [...folderPaths].sort((a, b) => - a.split('/').length - b.split('/').length - ); - - // Create folders first (no progress toast for folder creation) - for (const folderPath of sortedPaths) { - const parts = folderPath.split('/'); - const folderName = parts[parts.length - 1]; - const parentPath = parts.slice(0, -1).join('/'); - const parentId = folderMap.get(parentPath) || currentFolderId; - - try { - const response = await fetch('/api/folders', { - method: 'POST', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-store, must-revalidate' - }, - body: JSON.stringify({ - name: folderName, - parent_id: parentId - }) - }); - - if (response.ok) { - const folder = await response.json(); - folderMap.set(folderPath, folder.id); - console.log(`Created folder: ${folderPath} -> ${folder.id}`); - } else { - console.error(`Error creating folder ${folderPath}:`, await response.text()); - window.ui.showNotification('Error', `Error creating folder: ${folderName}`); + + const totalFiles = validEntries.length; + if (totalFiles === 0) { + if (uploadProgressDiv) uploadProgressDiv.style.display = 'none'; + return; + } + + const currentFolderId = window.app.currentPath || window.app.userHomeFolderId; + + // Build folder structure from relative paths + const folderMap = new Map(); + folderMap.set('', currentFolderId); + + const folderPaths = new Set(); + for (const entry of validEntries) { + const rel = entry.relativePath || entry.file.name; + const parts = rel.split('/'); + for (let i = 1; i < parts.length; i++) { + const path = parts.slice(0, i).join('/'); + folderPaths.add(path); } - } catch (error) { - console.error(`Network error creating folder ${folderPath}:`, error); } - } - - // Upload files with notification bell - const totalFiles = files.length; - this._initUploadToast(totalFiles); - const batchId = this._currentBatchId; - let uploadedCount = 0; - let successCount = 0; - - for (let i = 0; i < totalFiles; i++) { - const file = files[i]; - const parts = file.webkitRelativePath.split('/'); - const parentPath = parts.slice(0, -1).join('/'); - const targetFolderId = folderMap.get(parentPath) || currentFolderId; - - const formData = new FormData(); - formData.append('folder_id', targetFolderId); - // Use file.name as the explicit filename to prevent the browser - // from sending the full webkitRelativePath as the filename - formData.append('file', file, file.name); + const sortedPaths = [...folderPaths].sort((a, b) => + a.split('/').length - b.split('/').length + ); - const displayName = file.webkitRelativePath || file.name; + // Create folders first + for (const folderPath of sortedPaths) { + const parts = folderPath.split('/'); + const folderName = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join('/'); + const parentId = folderMap.get(parentPath) || currentFolderId; - const result = await this._uploadFileXHR(formData, batchId, displayName); - - uploadedCount++; - if (progressBar) { - progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%'; + try { + const response = await fetch('/api/folders', { + method: 'POST', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }, + body: JSON.stringify({ + name: folderName, + parent_id: parentId + }) + }); + + if (response.ok) { + const folder = await response.json(); + folderMap.set(folderPath, folder.id); + console.log(`Created folder: ${folderPath} -> ${folder.id}`); + } else { + console.error(`Error creating folder ${folderPath}:`, await response.text()); + } + } catch (error) { + console.error(`Network error creating folder ${folderPath}:`, error); + } } - if (window.notifications && batchId) { - window.notifications.fileCompleted(batchId, result.ok); - } - - if (result.ok) { - successCount++; - console.log(`Uploaded: ${file.webkitRelativePath}`); - } else { - console.error(`Error uploading ${file.webkitRelativePath}`); - if (result.isQuotaError) { - const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded'; + + // Detect root folder(s) from entry paths + const rootFolderNames = [...new Set(validEntries.map((entry) => { + const rel = entry.relativePath || entry.file.name; + return rel.split('/')[0] || ''; + }).filter(Boolean))]; + const locale = window.i18n?.getCurrentLocale?.() || 'en'; + const rootFolderLabel = rootFolderNames.length <= 1 + ? (rootFolderNames[0] || '') + : (locale.startsWith('es') + ? `${rootFolderNames.length} carpetas` + : `${rootFolderNames.length} folders`); + + // Upload files — pass folder name for folder-level progress display + this._initUploadToast(totalFiles, rootFolderLabel); + const batchId = this._currentBatchId; + + let uploadedCount = 0; + let successCount = 0; + let quotaStop = false; + + // ── Concurrent upload with limited parallelism ────────── + // FIFOs are pre-caught by the 0-byte arrayBuffer guard, + // so all files reaching fetch() are regular. Keep-alive + // reuses TCP connections across workers for speed. + const CONCURRENCY = 10; + const TIMEOUT_MS = 10000; // 10s for normal files + const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files + + const uploadOneFile = async (idx) => { + if (quotaStop) return; + const entry = validEntries[idx]; + const file = entry.file; + const rel = entry.relativePath || file.name; + + let result = { ok: false, errorMsg: 'Unknown client error' }; + try { + const parts = rel.split('/'); + const parentPath = parts.slice(0, -1).join('/'); + const targetFolderId = folderMap.get(parentPath) || currentFolderId; + + // ── FIFO/pipe guard (0-byte files only) ── + // Named pipes (runit supervise/control) report size=0 + // but block on open(). Pre-read only 0-byte files into + // memory; files with size>0 are always regular files and + // go straight to FormData (zero extra memory copy). + let uploadFile = file; // default: use original File + if (file.size === 0) { + try { + const buf = await Promise.race([ + file.arrayBuffer(), + new Promise((_, rej) => + setTimeout(() => rej(new Error('read-timeout')), 2000)) + ]); + uploadFile = new Blob([buf], { + type: file.type || 'application/octet-stream' + }); + } catch { + console.warn(`[SKIP] #${idx} ${rel} — cannot read 0-byte file (FIFO/pipe?), skipping`); + uploadedCount++; + successCount++; + if (window.notifications && batchId) { + try { window.notifications.fileCompleted(batchId, true); } catch (_) {} + } + return; + } + } + + const formData = new FormData(); + formData.append('folder_id', targetFolderId); + formData.append('file', uploadFile, file.name); + + const thisTimeout = file.size === 0 ? TIMEOUT_MS_ZERO : TIMEOUT_MS; + console.log(`[UPLOAD START] #${idx} ${rel} (${file.size} bytes, timeout=${thisTimeout}ms)`); + + result = await this._uploadFileFetch(formData, thisTimeout); + + console.log(`[UPLOAD END] #${idx} ${rel} ok=${result.ok}${result.errorMsg ? ' err=' + result.errorMsg : ''}`); + } catch (e) { + result = { + ok: false, + errorMsg: `Client exception: ${e?.message || 'unknown'}` + }; + console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e); + } + + uploadedCount++; + + if (window.notifications && batchId) { + try { window.notifications.fileCompleted(batchId, result.ok); } catch (_) {} + } + if (progressBar && uploadedCount % 10 === 0) { + progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%'; + } + if (uploadedCount % 50 === 0 || uploadedCount === totalFiles) { + console.log(`Progress: ${uploadedCount}/${totalFiles} (${successCount} ok)`); + } + + if (result.ok) { + successCount++; + } else if (result.isQuotaError) { + quotaStop = true; if (window.notifications) { window.notifications.addNotification({ icon: 'fa-exclamation-triangle', iconClass: 'error', title: file.name, - text: msg + text: result.errorMsg || 'Storage quota exceeded' }); } - break; } + }; + + // Pool-based concurrency: always keep CONCURRENCY tasks in flight + let nextIdx = 0; + const runNext = async () => { + while (nextIdx < totalFiles && !quotaStop) { + const idx = nextIdx++; + await uploadOneFile(idx); + } + }; + + const workers = []; + for (let w = 0; w < Math.min(CONCURRENCY, totalFiles); w++) { + workers.push(runNext()); } - } - - // Finish - this._finishUploadToast(successCount, totalFiles); + await Promise.all(workers); - // Refresh storage usage display - if (typeof window.refreshUserData === 'function') { - try { await window.refreshUserData(); } catch (_) {} - } + this._finishUploadToast(successCount, totalFiles); - try { - await window.loadFiles({ forceRefresh: true }); - } catch (reloadError) { - console.error('Error reloading files:', reloadError); - } + if (typeof window.refreshUserData === 'function') { + try { await window.refreshUserData(); } catch (_) {} + } - const dropzone = document.getElementById('dropzone'); - if (dropzone) dropzone.style.display = 'none'; - if (uploadProgressDiv) uploadProgressDiv.style.display = 'none'; + try { + await window.loadFiles({ forceRefresh: true }); + } catch (reloadError) { + console.error('Error reloading files:', reloadError); + } + + const dropzone = document.getElementById('dropzone'); + if (dropzone) dropzone.style.display = 'none'; + if (uploadProgressDiv) uploadProgressDiv.style.display = 'none'; + } finally { + this._isUploading = false; + } }, /** diff --git a/static/js/i18n.js b/static/js/i18n.js index 7b777b1e..c46f958f 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -116,11 +116,40 @@ function t(key, params = {}) { } // Get the translation value - const value = getNestedValue(localeData, key); + let value = getNestedValue(localeData, key); + + // Compatibility aliases for legacy share.* keys used in some views + if (!value) { + const aliasMap = { + 'share.enablePassword': 'share.password', + 'share.enableExpiration': 'share.expiration', + 'share.notifyEmail': 'share.notifyEmailLabel', + 'share.notifyMessage': 'share.notifyMessageLabel' + }; + const aliasKey = aliasMap[key]; + if (aliasKey) { + value = getNestedValue(localeData, aliasKey); + } + } + if (!value) { // Try fallback to English if (currentLocale !== 'en' && translations['en']) { - const fallbackValue = getNestedValue(translations['en'], key); + let fallbackValue = getNestedValue(translations['en'], key); + + if (!fallbackValue) { + const aliasMap = { + 'share.enablePassword': 'share.password', + 'share.enableExpiration': 'share.expiration', + 'share.notifyEmail': 'share.notifyEmailLabel', + 'share.notifyMessage': 'share.notifyMessageLabel' + }; + const aliasKey = aliasMap[key]; + if (aliasKey) { + fallbackValue = getNestedValue(translations['en'], aliasKey); + } + } + if (fallbackValue) { return interpolate(fallbackValue, params); } diff --git a/static/js/icons.js b/static/js/icons.js index a20d1bb4..3c114643 100644 --- a/static/js/icons.js +++ b/static/js/icons.js @@ -85,6 +85,7 @@ const _ICONS = { "sliders-h": [512, "M0 416c0 17.7 14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-246.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384c-17.7 0-32 14.3-32 32zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM320 256a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm32-80c-32.8 0-61 19.7-73.3 48L32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48l54.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48zM192 128a32 32 0 1 1 0-64 32 32 0 1 1 0 64zm73.3-64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32L265.3 64z"], "spinner": [512, "M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z"], "star": [576, "M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"], + "star-outline": [576, "M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L439.6 319.9l24.6 145.7c1.5 9-2.2 18.1-9.7 23.5s-17.3 6-25.3 1.7l-137-73.2L155.2 490.8c-8 4.3-17.8 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l24.6-145.7L39.6 218.2c-6.4-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L274.3 13.5C278.3 5.2 286.7 0 295.9 0h-8zm0 79L235.4 187.2c-3.5 7.1-10.2 12.1-18.1 13.3L99 218.9l85.8 85.1c5.5 5.5 8.1 13.3 6.8 21L171.3 444.7l111.5-59.5c7-3.7 15.3-3.7 22.3 0l111.5 59.5-20.3-119.7c-1.3-7.7 1.2-15.5 6.8-21l85.8-85.1-118.3-17.4c-7.8-1.2-14.6-6.1-18.1-13.3L287.9 79z"], "terminal": [576, "M9.4 86.6C-3.1 74.1-3.1 53.9 9.4 41.4s32.8-12.5 45.3 0l192 192c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L178.7 256 9.4 86.6zM256 416l288 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-288 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"], "th": [512, "M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm88 64l0 64-88 0 0-64 88 0zm56 0l88 0 0 64-88 0 0-64zm240 0l0 64-88 0 0-64 88 0zM64 224l88 0 0 64-88 0 0-64zm232 0l0 64-88 0 0-64 88 0zm64 0l88 0 0 64-88 0 0-64zM152 352l0 64-88 0 0-64 88 0zm56 0l88 0 0 64-88 0 0-64zm240 0l0 64-88 0 0-64 88 0z"], "times": [384, "M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"], @@ -165,15 +166,23 @@ function replaceIconsInElement(container) { let isSpin = false; const extraClasses = []; + let isRegular = false; // far = outline variant + for (const cls of classes) { if (cls === "fa-spin") { isSpin = true; continue; } + if (cls === "far") { isRegular = true; continue; } if (cls.startsWith("fa-") && cls !== "fa") { iconName = cls.substring(3); // strip "fa-" - } else if (cls !== "fas" && cls !== "fab" && cls !== "far" && cls !== "fa") { + } else if (cls !== "fas" && cls !== "fab" && cls !== "fa") { extraClasses.push(cls); } } + // Use outline variant if available and element uses "far" + if (isRegular && _ICONS[iconName + "-outline"]) { + iconName = iconName + "-outline"; + } + if (!iconName || !_ICONS[iconName]) continue; const [w, d] = _ICONS[iconName]; diff --git a/static/js/notifications.js b/static/js/notifications.js index d3e2377e..efad7cc5 100644 --- a/static/js/notifications.js +++ b/static/js/notifications.js @@ -130,9 +130,11 @@ const notifications = (() => { /** * Start tracking a new upload batch. Returns a batchId string. - * This also auto-opens the panel so users see progress. + * Always uses compact folder-level display: one progress bar + counter. + * @param {number} totalFiles + * @param {string} [folderName] root folder name (for folder uploads) */ - function addUploadBatch(totalFiles) { + function addUploadBatch(totalFiles, folderName) { const batchId = 'batch-' + (++_batchSeq); const body = $('notif-panel-body'); if (!body) return batchId; @@ -141,17 +143,22 @@ const notifications = (() => { item.className = 'notif-item'; item.id = batchId; - const uploadingText = (window.i18n && window.i18n.t) ? window.i18n.t('upload.uploading') : 'Uploading…'; + const locale = window.i18n?.getCurrentLocale?.() || 'en'; + const uploadingText = folderName + ? (locale.startsWith('es') ? `📁 Subiendo ${_esc(folderName)}…` : `📁 Uploading ${_esc(folderName)}…`) + : (locale.startsWith('es') ? 'Subiendo…' : 'Uploading…'); + const filesLabel = locale.startsWith('es') ? 'archivos' : 'files'; + item.innerHTML = `
-
${_esc(uploadingText)}
-
+
${uploadingText}
+
0% - 0 / ${totalFiles} + 0 / ${totalFiles} ${filesLabel}
${_timeAgo()}
@@ -161,7 +168,15 @@ const notifications = (() => { // Insert at top body.insertBefore(item, body.firstChild); - _batches[batchId] = { el: item, files: {}, totalFiles, completed: 0, successCount: 0 }; + _batches[batchId] = { + el: item, + totalFiles, + completed: 0, + successCount: 0, + errorCount: 0, + lastLabelUpdateTs: 0, + lastLabelFile: '' + }; _showEmptyIfNeeded(); // Auto open @@ -176,7 +191,8 @@ const notifications = (() => { } /** - * Add / update a single file row inside a batch. + * Update the current-file label inside a batch. + * This does NOT create any DOM rows — just a single text update. * @param {string} batchId * @param {string} fileName * @param {number} pct 0-100 @@ -186,39 +202,29 @@ const notifications = (() => { const batch = _batches[batchId]; if (!batch) return; - const filesEl = $(batchId + '-files'); - if (!filesEl) return; + if (status === 'error') batch.errorCount = (batch.errorCount || 0) + 1; - let row = batch.files[fileName]; - if (!row) { - row = document.createElement('div'); - row.className = 'notif-upload-file-row'; - row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:2px 0;font-size:12px;'; - row.innerHTML = ` - - ${_esc(fileName)} - 0% - `; - filesEl.appendChild(row); - batch.files[fileName] = row; - } + // Only update the current-file label (single DOM element) + const curEl = $(batchId + '-current'); + if (curEl && status === 'uploading') { + const now = Date.now(); + const fileChanged = batch.lastLabelFile !== fileName; + const shouldUpdate = fileChanged || now - (batch.lastLabelUpdateTs || 0) >= 300 || pct >= 100; + if (!shouldUpdate) return; - const iconEl = row.querySelector('.notif-file-icon'); - const pctEl = row.querySelector('.notif-file-pct'); - - pctEl.textContent = pct + '%'; - - if (status === 'done') { - iconEl.innerHTML = ''; - pctEl.textContent = '100%'; - } else if (status === 'error') { - iconEl.innerHTML = ''; - pctEl.textContent = 'ERR'; + // Show just the file name being uploaded (truncate long paths) + const shortName = fileName.length > 50 + ? '…' + fileName.slice(-49) + : fileName; + curEl.textContent = shortName; + batch.lastLabelFile = fileName; + batch.lastLabelUpdateTs = now; } } /** * Mark a file as completed within a batch (updates overall bar). + * DOM updates are throttled to every 5 files to avoid reflow starvation. */ function fileCompleted(batchId, success) { const batch = _batches[batchId]; @@ -226,14 +232,21 @@ const notifications = (() => { batch.completed++; if (success) batch.successCount++; + // Throttle DOM updates: every 5 files, or on the very last file + const isLast = batch.completed >= batch.totalFiles; + if (!isLast && batch.completed % 5 !== 0) return; + const pctVal = Math.round((batch.completed / batch.totalFiles) * 100); const fillEl = $(batchId + '-fill'); const pctEl = $(batchId + '-pct'); const statsEl = $(batchId + '-stats'); + const locale = window.i18n?.getCurrentLocale?.() || 'en'; + const filesLabel = locale.startsWith('es') ? 'archivos' : 'files'; + if (fillEl) fillEl.style.width = pctVal + '%'; if (pctEl) pctEl.textContent = pctVal + '%'; - if (statsEl) statsEl.textContent = `${batch.completed} / ${batch.totalFiles}`; + if (statsEl) statsEl.textContent = `${batch.completed} / ${batch.totalFiles} ${filesLabel}`; } /** @@ -252,9 +265,15 @@ const notifications = (() => { const titleEl = batch.el.querySelector('.notif-item-title'); const iconEl = batch.el.querySelector('.notif-item-icon'); - const completeText = (window.i18n && window.i18n.t) - ? window.i18n.t('upload.complete', { count: successCount, total: totalFiles }) - : `${successCount} / ${totalFiles} uploaded`; + // Clear the current-file label + const curEl = $(batchId + '-current'); + if (curEl) curEl.textContent = ''; + + const locale = window.i18n?.getCurrentLocale?.() || 'en'; + const filesLabel = locale.startsWith('es') ? 'archivos' : 'files'; + const completeText = locale.startsWith('es') + ? `✅ ${successCount} / ${totalFiles} ${filesLabel} subidos` + : `✅ ${successCount} / ${totalFiles} ${filesLabel} uploaded`; if (titleEl) titleEl.textContent = completeText; if (iconEl) { diff --git a/static/js/shared.js b/static/js/shared.js index d92fa268..d2812fa1 100644 --- a/static/js/shared.js +++ b/static/js/shared.js @@ -82,7 +82,14 @@ document.addEventListener('DOMContentLoaded', async () => { if (filterType) filterType.addEventListener('change', filterAndSortItems); if (sortBy) sortBy.addEventListener('change', filterAndSortItems); if (sharedSearchBtn) sharedSearchBtn.addEventListener('click', filterAndSortItems); - if (sharedSearch) sharedSearch.addEventListener('keyup', e => { if (e.key === 'Enter') filterAndSortItems(); }); + if (sharedSearch) { + let searchDebounce; + sharedSearch.addEventListener('input', () => { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(filterAndSortItems, 250); + }); + sharedSearch.addEventListener('keyup', e => { if (e.key === 'Enter') { clearTimeout(searchDebounce); filterAndSortItems(); } }); + } if (goToFilesBtn) goToFilesBtn.addEventListener('click', () => window.location.href = '/'); if (shareDialogCloseBtn) shareDialogCloseBtn.addEventListener('click', closeShareDialog); diff --git a/static/js/ui.js b/static/js/ui.js index 28515c1c..0c3b06d2 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -275,6 +275,57 @@ const ui = { setupDragAndDrop() { const dropzone = document.getElementById('dropzone'); + const collectDroppedEntries = async (dataTransfer) => { + const items = Array.from(dataTransfer?.items || []); + const rootEntries = items + .map(it => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)) + .filter(Boolean); + + if (rootEntries.length === 0) return null; + + const out = []; + + const walkEntry = async (entry, prefix = '') => { + if (!entry) return; + + if (entry.isFile) { + await new Promise((resolve) => { + entry.file( + (file) => { + out.push({ file, relativePath: `${prefix}${file.name}` }); + resolve(); + }, + () => resolve() + ); + }); + return; + } + + if (entry.isDirectory) { + const dirPrefix = `${prefix}${entry.name}/`; + const reader = entry.createReader(); + + while (true) { + const children = await new Promise((resolve) => { + reader.readEntries(resolve, () => resolve([])); + }); + if (!children || children.length === 0) break; + for (const child of children) { + // eslint-disable-next-line no-await-in-loop + await walkEntry(child, dirPrefix); + } + } + } + }; + + for (const root of rootEntries) { + // eslint-disable-next-line no-await-in-loop + await walkEntry(root, ''); + } + + return out; + }; + // Dropzone events dropzone.addEventListener('dragover', (e) => { e.preventDefault(); @@ -285,12 +336,27 @@ const ui = { dropzone.classList.remove('active'); }); - dropzone.addEventListener('drop', (e) => { + dropzone.addEventListener('drop', async (e) => { e.preventDefault(); e.stopPropagation(); // Prevent bubbling to document's drop handler (avoids double upload) e._oxiHandled = true; // Mark as handled for document-level fallback dropzone.classList.remove('active'); if (e.dataTransfer.files.length > 0) { + // First try directory-aware extraction (Finder folder drag & drop) + const droppedEntries = await collectDroppedEntries(e.dataTransfer); + if (droppedEntries && droppedEntries.length > 0) { + const hasFolderStructure = droppedEntries.some(x => x.relativePath && x.relativePath.includes('/')); + if (hasFolderStructure) { + fileOps.uploadFolderEntries(droppedEntries); + } else { + fileOps.uploadFiles(droppedEntries.map(x => x.file)); + } + setTimeout(() => { + dropzone.style.display = 'none'; + }, 500); + return; + } + // Detect folder drops: files from folder drops have webkitRelativePath set const hasRelativePaths = Array.from(e.dataTransfer.files).some( f => f.webkitRelativePath && f.webkitRelativePath.includes('/') @@ -327,7 +393,7 @@ const ui = { } }); - document.addEventListener('drop', (e) => { + document.addEventListener('drop', async (e) => { e.preventDefault(); dropzone.classList.remove('active'); @@ -335,6 +401,21 @@ const ui = { if (e._oxiHandled) return; if (e.dataTransfer.files.length > 0) { + // First try directory-aware extraction (Finder folder drag & drop) + const droppedEntries = await collectDroppedEntries(e.dataTransfer); + if (droppedEntries && droppedEntries.length > 0) { + const hasFolderStructure = droppedEntries.some(x => x.relativePath && x.relativePath.includes('/')); + if (hasFolderStructure) { + fileOps.uploadFolderEntries(droppedEntries); + } else { + fileOps.uploadFiles(droppedEntries.map(x => x.file)); + } + setTimeout(() => { + dropzone.style.display = 'none'; + }, 500); + return; + } + // Detect folder drops: files from folder drops have webkitRelativePath set const hasRelativePaths = Array.from(e.dataTransfer.files).some( f => f.webkitRelativePath && f.webkitRelativePath.includes('/') @@ -361,6 +442,8 @@ const ui = { const gridViewBtn = document.getElementById('grid-view-btn'); const listViewBtn = document.getElementById('list-view-btn'); + this._hydrateViewIfNeeded('grid'); + filesGrid.style.display = 'grid'; filesListView.style.display = 'none'; gridViewBtn.classList.add('active'); @@ -378,6 +461,8 @@ const ui = { const gridViewBtn = document.getElementById('grid-view-btn'); const listViewBtn = document.getElementById('list-view-btn'); + this._hydrateViewIfNeeded('list'); + filesGrid.style.display = 'none'; filesListView.style.display = 'flex'; gridViewBtn.classList.remove('active'); @@ -502,12 +587,80 @@ const ui = { return map[ext] || 'fas fa-file'; }, + /** + * Get CSS special class for icon styling based on filename extension. + * Used as fallback when the backend DTO doesn't include icon_special_class. + */ + getIconSpecialClass(fileName) { + if (!fileName) return ''; + const ext = (fileName.split('.').pop() || '').toLowerCase(); + const map = { + pdf:'pdf-icon', + doc:'doc-icon', docx:'doc-icon', odt:'doc-icon', rtf:'doc-icon', + xls:'spreadsheet-icon', xlsx:'spreadsheet-icon', ods:'spreadsheet-icon', csv:'spreadsheet-icon', + ppt:'presentation-icon', pptx:'presentation-icon', odp:'presentation-icon', key:'presentation-icon', + jpg:'image-icon', jpeg:'image-icon', png:'image-icon', gif:'image-icon', + svg:'image-icon', webp:'image-icon', bmp:'image-icon', ico:'image-icon', + heic:'image-icon', heif:'image-icon', avif:'image-icon', tiff:'image-icon', + mp4:'video-icon', avi:'video-icon', mkv:'video-icon', mov:'video-icon', + wmv:'video-icon', flv:'video-icon', webm:'video-icon', m4v:'video-icon', + mp3:'audio-icon', wav:'audio-icon', ogg:'audio-icon', flac:'audio-icon', + aac:'audio-icon', wma:'audio-icon', m4a:'audio-icon', opus:'audio-icon', + zip:'archive-icon', rar:'archive-icon', '7z':'archive-icon', + tar:'archive-icon', gz:'archive-icon', bz2:'archive-icon', xz:'archive-icon', + exe:'installer-icon', msi:'installer-icon', dmg:'installer-icon', + deb:'installer-icon', rpm:'installer-icon', appimage:'installer-icon', + py:'code-icon py-icon', rs:'code-icon rust-icon', go:'code-icon go-icon', + js:'code-icon js-icon', jsx:'code-icon js-icon', mjs:'code-icon js-icon', + ts:'code-icon ts-icon', tsx:'code-icon ts-icon', + java:'code-icon java-icon', c:'code-icon c-icon', cpp:'code-icon c-icon', + cs:'code-icon cs-icon', rb:'code-icon ruby-icon', php:'code-icon php-icon', + swift:'code-icon swift-icon', + html:'code-icon html-icon', htm:'code-icon html-icon', + css:'code-icon css-icon', scss:'code-icon css-icon', + json:'code-icon json-icon', xml:'code-icon html-icon', + yaml:'code-icon config-icon', yml:'code-icon config-icon', + toml:'code-icon config-icon', ini:'code-icon config-icon', + sql:'code-icon sql-icon', vue:'code-icon js-icon', svelte:'code-icon js-icon', + sh:'script-icon', bash:'script-icon', zsh:'script-icon', bat:'script-icon', + md:'code-icon md-icon', txt:'doc-icon', + }; + return map[ext] || ''; + }, + /** * Show notification * @param {string} title - Notification title * @param {string} message - Notification message */ showNotification(title, message) { + // Prefer the bell notification center + if (window.notifications && typeof window.notifications.addNotification === 'function') { + const t = String(title || '').toLowerCase(); + let icon = 'fa-info-circle'; + let iconClass = 'upload'; + + if (t.includes('error') || t.includes('failed') || t.includes('fail')) { + icon = 'fa-exclamation-circle'; + iconClass = 'error'; + } else if (t.includes('favorite') || t.includes('favorit') || t.includes('fav')) { + icon = 'fa-star'; + iconClass = 'success'; + } else if (t.includes('delete') || t.includes('removed') || t.includes('trash') || t.includes('rename') || t.includes('complete')) { + icon = 'fa-check-circle'; + iconClass = 'success'; + } + + window.notifications.addNotification({ + icon, + iconClass, + title: title || '', + text: message || '' + }); + return; + } + + // Legacy floating toast fallback (pages without bell) let notification = document.querySelector('.notification'); if (!notification) { notification = document.createElement('div'); @@ -560,9 +713,92 @@ const ui = { /** @type {Map} item data keyed by id */ _items: new Map(), + /** @type {Array} last rendered folder dataset */ + _lastFolders: [], + + /** @type {Array} last rendered file dataset */ + _lastFiles: [], + /** @type {boolean} */ _delegationReady: false, + _getActiveView() { + if (window.app && window.app.currentView === 'list') return 'list'; + if (window.app && window.app.currentView === 'grid') return 'grid'; + + const stored = localStorage.getItem('oxicloud-view'); + return stored === 'list' ? 'list' : 'grid'; + }, + + _renderFoldersToView(folders, view) { + if (!Array.isArray(folders) || folders.length === 0) return; + const target = view === 'list' + ? document.getElementById('files-list-view') + : document.getElementById('files-grid'); + if (!target) return; + + const frag = document.createDocumentFragment(); + for (const folder of folders) { + frag.appendChild(view === 'list' + ? this._createFolderItem(folder) + : this._createFolderCard(folder)); + } + target.appendChild(frag); + }, + + _renderFilesToView(files, view) { + if (!Array.isArray(files) || files.length === 0) return; + const target = view === 'list' + ? document.getElementById('files-list-view') + : document.getElementById('files-grid'); + if (!target) return; + + const frag = document.createDocumentFragment(); + for (const file of files) { + frag.appendChild(view === 'list' + ? this._createFileItem(file) + : this._createFileCard(file)); + } + target.appendChild(frag); + }, + + _upsertById(arr, item) { + if (!Array.isArray(arr) || !item || !item.id) return; + const idx = arr.findIndex(x => x && x.id === item.id); + if (idx >= 0) { + arr[idx] = item; + } else { + arr.push(item); + } + }, + + _hydrateViewIfNeeded(view) { + // Only hydrate if there is at least one rendered item in the opposite/current DOM. + // This prevents stale cache hydration in empty-state screens. + const hasAnyRenderedItem = !!document.querySelector('#files-grid .file-card, #files-list-view .file-item'); + if (!hasAnyRenderedItem) return; + + if (view === 'grid') { + const grid = document.getElementById('files-grid'); + if (!grid) return; + if (grid.children.length > 0) return; + + this._renderFoldersToView(this._lastFolders, 'grid'); + this._renderFilesToView(this._lastFiles, 'grid'); + return; + } + + if (view === 'list') { + const list = document.getElementById('files-list-view'); + if (!list) return; + // list view keeps a static header row as first child + if (list.querySelector('.file-item')) return; + + this._renderFoldersToView(this._lastFolders, 'list'); + this._renderFilesToView(this._lastFiles, 'list'); + } + }, + /** * Attach a fixed set of delegated event listeners to the two * container elements (files-grid, files-list-view). @@ -622,7 +858,7 @@ const ui = { } }; - // ── GRID: click (select) ────────────────────────────────── + // ── GRID: click (open / navigate; select only via checkbox) ── grid.addEventListener('click', (e) => { const card = e.target.closest('.file-card'); if (!card) return; @@ -645,26 +881,10 @@ const ui = { return; } - // In favorites/recent view, single-click navigates/opens (like list) - if (window.app.isFavoritesView || window.app.isRecentView) { - const info = itemInfo(card); - if (info) { - if (info.type === 'folder') navigateFolder(card); - else openFile(info.data); - } - return; - } - - toggleCardSelection(card, e); - }); - - // ── GRID: dblclick (navigate / open) ────────────────────── - grid.addEventListener('dblclick', (e) => { - const card = e.target.closest('.file-card'); - if (!card) return; - if (e.target.closest('.file-card-more') || - e.target.closest('.file-card-checkbox')) return; + // Favorite star – handled by direct onclick on the button + if (e.target.closest('.favorite-star')) return; + // Single-click opens/navigates (selection is only via checkbox) const info = itemInfo(card); if (!info) return; @@ -675,6 +895,13 @@ const ui = { } }); + // ── GRID: dblclick (navigate / open) ────────────────────── + grid.addEventListener('dblclick', (e) => { + // Single-click already handles open/navigate. + // Prevent duplicate actions on double-click. + e.preventDefault(); + }); + // ── LIST: click (navigate / open) ───────────────────────── list.addEventListener('click', (e) => { if (e.target.closest('.list-header')) return; @@ -712,6 +939,9 @@ const ui = { const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu'; const menu = document.getElementById(menuId); + if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') { + window.contextMenus.syncFavoriteOptionLabels(); + } menu.style.left = `${e.pageX}px`; menu.style.top = `${e.pageY}px`; menu.style.display = 'block'; @@ -794,7 +1024,99 @@ const ui = { }, /* ================================================================ - * Pure element-creation helpers (no addEventListener) + * Favorite star helper – attaches a direct click handler to a + * star - ${isFav ? '
' : ''} +
@@ -822,6 +1146,7 @@ const ui = { if (window.app.currentPath !== "") { el.setAttribute('draggable', 'true'); } + this._bindStarClick(el); return el; }, @@ -859,8 +1184,8 @@ const ui = { /** Create a grid card for a file */ _createFileCard(file) { - const iconClass = file.icon_class || 'fas fa-file'; - const iconSpecialClass = file.icon_special_class || ''; + const iconClass = file.icon_class || this.getIconClass(file.name); + const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name); const isFileFav = window.favorites && window.favorites.isFavorite(file.id, 'file'); const formattedDate = window.formatDateTime(file.modified_at); @@ -875,20 +1200,23 @@ const ui = { el.innerHTML = `
- ${isFileFav ? '
' : ''} +
${escapeHtml(file.name)}
Modified ${formattedDate.split(' ')[0]}
`; + this._bindStarClick(el); return el; }, /** Create a list row for a file */ _createFileItem(file) { - const iconClass = file.icon_class || 'fas fa-file'; - const iconSpecialClass = file.icon_special_class || ''; + const iconClass = file.icon_class || this.getIconClass(file.name); + const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name); const cat = file.category || ''; const typeLabel = cat ? (window.i18n @@ -935,17 +1263,14 @@ const ui = { */ renderFolders(folders) { if (!this._delegationReady) this.initDelegation(); - const gridFrag = document.createDocumentFragment(); - const listFrag = document.createDocumentFragment(); + const safeFolders = Array.isArray(folders) ? folders : []; + this._lastFolders = safeFolders.slice(); - for (const folder of folders) { + for (const folder of safeFolders) { this._items.set(folder.id, folder); - gridFrag.appendChild(this._createFolderCard(folder)); - listFrag.appendChild(this._createFolderItem(folder)); } - document.getElementById('files-grid').appendChild(gridFrag); - document.getElementById('files-list-view').appendChild(listFrag); + this._renderFoldersToView(safeFolders, this._getActiveView()); }, /** @@ -954,17 +1279,14 @@ const ui = { */ renderFiles(files) { if (!this._delegationReady) this.initDelegation(); - const gridFrag = document.createDocumentFragment(); - const listFrag = document.createDocumentFragment(); + const safeFiles = Array.isArray(files) ? files : []; + this._lastFiles = safeFiles.slice(); - for (const file of files) { + for (const file of safeFiles) { this._items.set(file.id, file); - gridFrag.appendChild(this._createFileCard(file)); - listFrag.appendChild(this._createFileItem(file)); } - document.getElementById('files-grid').appendChild(gridFrag); - document.getElementById('files-list-view').appendChild(listFrag); + this._renderFilesToView(safeFiles, this._getActiveView()); }, /* ================================================================ @@ -972,7 +1294,7 @@ const ui = { * ================================================================ */ /** - * Add a single folder to both views. + * Add a single folder to the active view. * @param {Object} folder - Folder object */ addFolderToView(folder) { @@ -986,14 +1308,12 @@ const ui = { } this._items.set(folder.id, folder); - document.getElementById('files-grid') - .appendChild(this._createFolderCard(folder)); - document.getElementById('files-list-view') - .appendChild(this._createFolderItem(folder)); + this._upsertById(this._lastFolders, folder); + this._renderFoldersToView([folder], this._getActiveView()); }, /** - * Add a single file to both views. + * Add a single file to the active view. * @param {Object} file - File object */ addFileToView(file) { @@ -1007,10 +1327,8 @@ const ui = { } this._items.set(file.id, file); - document.getElementById('files-grid') - .appendChild(this._createFileCard(file)); - document.getElementById('files-list-view') - .appendChild(this._createFileItem(file)); + this._upsertById(this._lastFiles, file); + this._renderFilesToView([file], this._getActiveView()); } }; @@ -1051,6 +1369,10 @@ function showContextMenuAtElement(triggerElement, menuId) { top = rect.top - 4 + window.scrollY; // flip above if no room } + if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') { + window.contextMenus.syncFavoriteOptionLabels(); + } + menu.style.left = `${left}px`; menu.style.top = `${top}px`; menu.style.display = 'block'; diff --git a/static/locales/de.json b/static/locales/de.json index c519b73a..5cf5bd80 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -183,7 +183,12 @@ "audio": "Audio", "pdf": "PDF", "text": "Text", - "folder": "Ordner" + "folder": "Ordner", + "spreadsheet": "Tabelle", + "presentation": "Präsentation", + "archive": "Archiv", + "installer": "Installationsdatei", + "code": "Code" } }, "dialogs": { @@ -320,7 +325,11 @@ "empty_state": "Noch keine Favoriten", "empty_hint": "Markieren Sie Dateien oder Ordner mit einem Stern, um sie zu Ihren Favoriten hinzuzufügen", "add": "Zu Favoriten hinzufügen", - "remove": "Aus Favoriten entfernen" + "remove": "Aus Favoriten entfernen", + "added_title": "Zu Favoriten hinzugefügt", + "added_msg": "zu Favoriten hinzugefügt", + "removed_title": "Aus Favoriten entfernt", + "removed_msg": "aus Favoriten entfernt" }, "recent": { "title": "Zuletzt verwendet", diff --git a/static/locales/en.json b/static/locales/en.json index d60f876b..8dd5f3be 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -183,7 +183,12 @@ "audio": "Audio", "pdf": "PDF", "text": "Text", - "folder": "Folder" + "folder": "Folder", + "spreadsheet": "Spreadsheet", + "presentation": "Presentation", + "archive": "Archive", + "installer": "Installer", + "code": "Code" } }, "dialogs": { @@ -320,7 +325,11 @@ "empty_state": "No favorites yet", "empty_hint": "Star files or folders to add them to your favorites", "add": "Add to favorites", - "remove": "Remove from favorites" + "remove": "Remove from favorites", + "added_title": "Added to favorites", + "added_msg": "added to favorites", + "removed_title": "Removed from favorites", + "removed_msg": "removed from favorites" }, "recent": { "title": "Recent", diff --git a/static/locales/es.json b/static/locales/es.json index 6df334c3..2b991bab 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -183,7 +183,12 @@ "audio": "Audio", "pdf": "PDF", "text": "Texto", - "folder": "Carpeta" + "folder": "Carpeta", + "spreadsheet": "Hoja de cálculo", + "presentation": "Presentación", + "archive": "Archivo comprimido", + "installer": "Instalador", + "code": "Código" } }, "dialogs": { @@ -320,7 +325,11 @@ "empty_state": "Aún no hay favoritos", "empty_hint": "Marca archivos o carpetas con estrella para añadirlos a favoritos", "add": "Añadir a favoritos", - "remove": "Quitar de favoritos" + "remove": "Quitar de favoritos", + "added_title": "Añadido a favoritos", + "added_msg": "añadido a favoritos", + "removed_title": "Quitado de favoritos", + "removed_msg": "quitado de favoritos" }, "recent": { "title": "Recientes", diff --git a/static/locales/fa.json b/static/locales/fa.json index 451f6b1f..5dba0964 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -180,7 +180,12 @@ "audio": "صوتی", "pdf": "PDF", "text": "متن", - "folder": "پوشه" + "folder": "پوشه", + "spreadsheet": "صفحه گسترده", + "presentation": "ارائه", + "archive": "بایگانی", + "installer": "نصب‌کننده", + "code": "کد" } }, "dialogs": { @@ -306,7 +311,11 @@ "empty_state": "هنوز هیچ مورد علاقه‌ای وجود ندارد", "empty_hint": "برای افزودن به موارد علاقه‌مند، پرونده‌ها یا پوشه‌ها را ستاره‌دار کنید", "add": "افزودن به موارد علاقه‌مند", - "remove": "حذف از موارد علاقه‌مند" + "remove": "حذف از موارد علاقه‌مند", + "added_title": "به موارد علاقه‌مند افزوده شد", + "added_msg": "به موارد علاقه‌مند افزوده شد", + "removed_title": "از موارد علاقه‌مند حذف شد", + "removed_msg": "از موارد علاقه‌مند حذف شد" }, "recent": { "title": "اخیر", diff --git a/static/locales/fr.json b/static/locales/fr.json index 7427ced2..67817fbc 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -183,7 +183,12 @@ "audio": "Audio", "pdf": "PDF", "text": "Texte", - "folder": "Dossier" + "folder": "Dossier", + "spreadsheet": "Tableur", + "presentation": "Présentation", + "archive": "Archive", + "installer": "Installateur", + "code": "Code" } }, "dialogs": { @@ -320,7 +325,11 @@ "empty_state": "Aucun favori pour le moment", "empty_hint": "Marquez des fichiers ou dossiers avec une étoile pour les ajouter à vos favoris", "add": "Ajouter aux favoris", - "remove": "Retirer des favoris" + "remove": "Retirer des favoris", + "added_title": "Ajouté aux favoris", + "added_msg": "ajouté aux favoris", + "removed_title": "Retiré des favoris", + "removed_msg": "retiré des favoris" }, "recent": { "title": "Récents", diff --git a/static/locales/it.json b/static/locales/it.json index 550800b8..aa424c34 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -183,7 +183,12 @@ "audio": "Audio", "pdf": "PDF", "text": "Testo", - "folder": "Cartella" + "folder": "Cartella", + "spreadsheet": "Foglio di calcolo", + "presentation": "Presentazione", + "archive": "Archivio", + "installer": "Programma di installazione", + "code": "Codice" } }, "dialogs": { @@ -321,7 +326,11 @@ "empty_state": "Ancora nessun preferito", "empty_hint": "Aggiungi file o cartelle ai preferiti per inserirli qui", "add": "Aggiungi ai preferiti", - "remove": "Rimuovi dai preferiti" + "remove": "Rimuovi dai preferiti", + "added_title": "Aggiunto ai preferiti", + "added_msg": "aggiunto ai preferiti", + "removed_title": "Rimosso dai preferiti", + "removed_msg": "rimosso dai preferiti" }, "recent": { "title": "Recenti", diff --git a/static/locales/pt.json b/static/locales/pt.json index d23fd252..c77f2162 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -183,7 +183,12 @@ "audio": "Áudio", "pdf": "PDF", "text": "Texto", - "folder": "Pasta" + "folder": "Pasta", + "spreadsheet": "Planilha", + "presentation": "Apresentação", + "archive": "Arquivo compactado", + "installer": "Instalador", + "code": "Código" } }, "dialogs": { @@ -320,7 +325,11 @@ "empty_state": "Nenhum favorito ainda", "empty_hint": "Marque arquivos ou pastas com estrela para adicioná-los aos seus favoritos", "add": "Adicionar aos favoritos", - "remove": "Remover dos favoritos" + "remove": "Remover dos favoritos", + "added_title": "Adicionado aos favoritos", + "added_msg": "adicionado aos favoritos", + "removed_title": "Removido dos favoritos", + "removed_msg": "removido dos favoritos" }, "recent": { "title": "Recentes", diff --git a/static/locales/zh.json b/static/locales/zh.json index 4e8c959e..d96f8137 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -142,7 +142,12 @@ "audio": "音频", "pdf": "PDF", "text": "文本", - "folder": "文件夹" + "folder": "文件夹", + "spreadsheet": "电子表格", + "presentation": "演示文稿", + "archive": "压缩文件", + "installer": "安装程序", + "code": "代码" } }, "dialogs": { @@ -268,7 +273,11 @@ "empty_state": "还没有收藏", "empty_hint": "为文件或文件夹添加星标以将其添加到收藏夹", "add": "添加到收藏夹", - "remove": "从收藏夹移除" + "remove": "从收藏夹移除", + "added_title": "已添加到收藏", + "added_msg": "已添加到收藏", + "removed_title": "已从收藏移除", + "removed_msg": "已从收藏移除" }, "recent": { "title": "最近", diff --git a/static/shared.html b/static/shared.html index 86dcce22..6f41d14f 100644 --- a/static/shared.html +++ b/static/shared.html @@ -113,10 +113,6 @@ -