perf: optimize UI rendering and event lifecycle
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<File> 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+170
-87
@@ -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 <i> with <svg class="oxi-icon">, 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 {
|
||||
|
||||
+2
-2
@@ -111,8 +111,8 @@
|
||||
<div class="notif-panel" id="notif-panel">
|
||||
<div class="notif-panel-header">
|
||||
<span class="notif-panel-title" data-i18n="notifications.title">Notifications</span>
|
||||
<button class="notif-clear-btn" id="notif-clear-btn" title="Clear all">
|
||||
<i class="fas fa-check-double"></i>
|
||||
<button class="notif-clear-btn" id="notif-clear-btn" title="Clear all" aria-label="Clear all notifications">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="notif-panel-body" id="notif-panel-body">
|
||||
|
||||
+192
-215
@@ -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: `
|
||||
<div class="action-buttons">
|
||||
<div class="upload-dropdown" id="upload-dropdown">
|
||||
<button class="btn btn-primary" id="upload-btn">
|
||||
<i class="fas fa-cloud-upload-alt" style="margin-right: 5px;"></i>
|
||||
<span data-i18n="actions.upload">Upload</span>
|
||||
<i class="fas fa-caret-down" style="margin-left: 4px; font-size: 12px;"></i>
|
||||
</button>
|
||||
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
|
||||
<button class="upload-dropdown-item" id="upload-files-btn">
|
||||
<i class="fas fa-file"></i>
|
||||
<span data-i18n="actions.upload_files">Upload files</span>
|
||||
</button>
|
||||
<button class="upload-dropdown-item" id="upload-folder-btn">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span data-i18n="actions.upload_folder">Upload folder</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="new-folder-btn">
|
||||
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i>
|
||||
<span data-i18n="actions.new_folder">New folder</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
<button class="toggle-btn" id="list-view-btn" title="List view">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
trash: `
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-danger" id="empty-trash-btn">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
<span data-i18n="trash.empty_trash">Empty trash</span>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
favorites: `
|
||||
<div class="action-buttons"></div>
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
<button class="toggle-btn" id="list-view-btn" title="List view">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
recent: `
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-secondary" id="clear-recent-btn">
|
||||
<i class="fas fa-broom" style="margin-right: 5px;"></i>
|
||||
<span data-i18n="actions.clear_recent">Clear recent</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
<button class="toggle-btn" id="list-view-btn" title="List view">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
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 = `
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-danger" id="empty-trash-btn">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
<span>${window.i18n ? window.i18n.t('trash.empty_trash') : 'Empty trash'}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="action-buttons">
|
||||
<div class="upload-dropdown" id="upload-dropdown">
|
||||
<button class="btn btn-primary" id="upload-btn">
|
||||
<i class="fas fa-cloud-upload-alt" style="margin-right: 5px;"></i>
|
||||
<span data-i18n="actions.upload">Upload</span>
|
||||
<i class="fas fa-caret-down" style="margin-left: 4px; font-size: 12px;"></i>
|
||||
</button>
|
||||
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
|
||||
<button class="upload-dropdown-item" id="upload-files-btn">
|
||||
<i class="fas fa-file"></i>
|
||||
<span data-i18n="actions.upload_files">Upload files</span>
|
||||
</button>
|
||||
<button class="upload-dropdown-item" id="upload-folder-btn">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span data-i18n="actions.upload_folder">Upload folder</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="new-folder-btn">
|
||||
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i> <span data-i18n="actions.new_folder">New folder</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
<button class="toggle-btn" id="list-view-btn" title="List view">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="action-buttons">
|
||||
<div class="upload-dropdown" id="upload-dropdown">
|
||||
<button class="btn btn-primary" id="upload-btn">
|
||||
<i class="fas fa-cloud-upload-alt" style="margin-right: 5px;"></i>
|
||||
<span data-i18n="actions.upload">Upload</span>
|
||||
<i class="fas fa-caret-down" style="margin-left: 4px; font-size: 12px;"></i>
|
||||
</button>
|
||||
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
|
||||
<button class="upload-dropdown-item" id="upload-files-btn">
|
||||
<i class="fas fa-file"></i>
|
||||
<span data-i18n="actions.upload_files">Upload files</span>
|
||||
</button>
|
||||
<button class="upload-dropdown-item" id="upload-folder-btn">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span data-i18n="actions.upload_folder">Upload folder</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="new-folder-btn">
|
||||
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i> <span data-i18n="actions.new_folder">New folder</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
<button class="toggle-btn" id="list-view-btn" title="List view">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="action-buttons">
|
||||
<!-- No actions needed for favorites view -->
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
<button class="toggle-btn" id="list-view-btn" title="List view">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-secondary" id="clear-recent-btn">
|
||||
<i class="fas fa-broom" style="margin-right: 5px;"></i> <span data-i18n="actions.clear_recent">Clear recent</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
<button class="toggle-btn" id="list-view-btn" title="List view">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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');
|
||||
|
||||
@@ -79,10 +79,6 @@ const sharedView = {
|
||||
<option value="name" data-i18n="shared_sortByName">Sort by name</option>
|
||||
<option value="expiration" data-i18n="shared_sortByExpiration">Sort by expiration</option>
|
||||
</select>
|
||||
<div class="shared-search-box">
|
||||
<input type="text" id="shared-search-filter" data-i18n-placeholder="shared_search" placeholder="Search...">
|
||||
<button id="shared-search-filter-btn" class="search-btn">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+49
-20
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
+432
-122
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+31
-2
@@ -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);
|
||||
}
|
||||
|
||||
+10
-1
@@ -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];
|
||||
|
||||
+57
-38
@@ -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 = `
|
||||
<div class="notif-item-icon upload"><i class="fas fa-cloud-upload-alt"></i></div>
|
||||
<div class="notif-item-body">
|
||||
<div class="notif-item-title">${_esc(uploadingText)}</div>
|
||||
<div class="notif-upload-files" id="${batchId}-files"></div>
|
||||
<div class="notif-item-title">${uploadingText}</div>
|
||||
<div class="notif-upload-current" id="${batchId}-current" style="font-size:11px;color:#64748b;margin:3px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></div>
|
||||
<div class="notif-upload-progress">
|
||||
<div class="notif-upload-bar"><div class="notif-upload-fill" id="${batchId}-fill"></div></div>
|
||||
<div class="notif-upload-detail">
|
||||
<span class="notif-upload-pct" id="${batchId}-pct">0%</span>
|
||||
<span class="notif-upload-stats" id="${batchId}-stats">0 / ${totalFiles}</span>
|
||||
<span class="notif-upload-stats" id="${batchId}-stats">0 / ${totalFiles} ${filesLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-item-time">${_timeAgo()}</div>
|
||||
@@ -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 = `
|
||||
<span class="notif-file-icon" style="width:16px;text-align:center;color:#999;flex-shrink:0;"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
<span class="notif-file-name" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#64748b;" title="${_esc(fileName)}">${_esc(fileName)}</span>
|
||||
<span class="notif-file-pct" style="width:34px;text-align:right;color:#94a3b8;flex-shrink:0;">0%</span>
|
||||
`;
|
||||
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 = '<i class="fas fa-check-circle" style="color:#34c759"></i>';
|
||||
pctEl.textContent = '100%';
|
||||
} else if (status === 'error') {
|
||||
iconEl.innerHTML = '<i class="fas fa-exclamation-circle" style="color:#ff3b30"></i>';
|
||||
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) {
|
||||
|
||||
+8
-1
@@ -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);
|
||||
|
||||
+375
-53
@@ -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<string, Object>} item data keyed by id */
|
||||
_items: new Map(),
|
||||
|
||||
/** @type {Array<Object>} last rendered folder dataset */
|
||||
_lastFolders: [],
|
||||
|
||||
/** @type {Array<Object>} 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 <button> so the event never bubbles to the card.
|
||||
* ================================================================ */
|
||||
_bindStarClick(el) {
|
||||
const star = el.querySelector('.favorite-star');
|
||||
if (!star) return;
|
||||
|
||||
star.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (!window.favorites) return;
|
||||
|
||||
const itemId = star.dataset.itemId;
|
||||
const itemType = star.dataset.itemType;
|
||||
const itemName = star.dataset.itemName;
|
||||
|
||||
const isActive = star.classList.contains('active');
|
||||
|
||||
if (isActive) {
|
||||
this.setFavoriteVisualState(itemId, itemType, false);
|
||||
window.favorites.removeFromFavorites(itemId, itemType);
|
||||
} else {
|
||||
this.setFavoriteVisualState(itemId, itemType, true);
|
||||
window.favorites.addToFavorites(itemId, itemName, itemType);
|
||||
}
|
||||
|
||||
// Keep context-menu label in sync if available
|
||||
if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') {
|
||||
window.contextMenus.syncFavoriteOptionLabels();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sync favorite visuals for a file/folder across grid and list views.
|
||||
*/
|
||||
setFavoriteVisualState(itemId, itemType, isFavorite) {
|
||||
const cardSelector = itemType === 'folder'
|
||||
? `.file-card[data-folder-id="${itemId}"]`
|
||||
: `.file-card[data-file-id="${itemId}"]`;
|
||||
|
||||
const listSelector = itemType === 'folder'
|
||||
? `.file-item[data-folder-id="${itemId}"]`
|
||||
: `.file-item[data-file-id="${itemId}"]`;
|
||||
|
||||
const card = document.querySelector(cardSelector);
|
||||
const starBtn = card ? card.querySelector('.favorite-star') : null;
|
||||
|
||||
if (starBtn) {
|
||||
starBtn.classList.toggle('active', !!isFavorite);
|
||||
|
||||
// SVG icon path (after icons.js replacement)
|
||||
const svg = starBtn.querySelector('svg');
|
||||
const filledPath = window.OxiIcons && window.OxiIcons['star'];
|
||||
const outlinePath = window.OxiIcons && window.OxiIcons['star-outline'];
|
||||
const targetPath = isFavorite ? filledPath : outlinePath;
|
||||
if (svg && targetPath) {
|
||||
const p = svg.querySelector('path');
|
||||
if (p) p.setAttribute('d', targetPath[1]);
|
||||
svg.setAttribute('viewBox', `0 0 ${targetPath[0]} 512`);
|
||||
}
|
||||
|
||||
// Fallback <i> icon (before icons.js replacement)
|
||||
const i = starBtn.querySelector('i');
|
||||
if (i) {
|
||||
i.classList.remove('fas', 'far');
|
||||
i.classList.add(isFavorite ? 'fas' : 'far');
|
||||
}
|
||||
}
|
||||
|
||||
const listItem = document.querySelector(listSelector);
|
||||
if (listItem) {
|
||||
const nameCell = listItem.querySelector('.name-cell');
|
||||
if (nameCell) {
|
||||
let inlineStar = nameCell.querySelector('.favorite-star-inline');
|
||||
if (isFavorite && !inlineStar) {
|
||||
inlineStar = document.createElement('i');
|
||||
inlineStar.className = 'fas fa-star favorite-star-inline';
|
||||
nameCell.appendChild(inlineStar);
|
||||
if (window.OxiIcons && typeof window.OxiIcons.replaceIconsInElement === 'function') {
|
||||
window.OxiIcons.replaceIconsInElement(nameCell);
|
||||
}
|
||||
} else if (!isFavorite && inlineStar) {
|
||||
inlineStar.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/* ================================================================
|
||||
* Element-creation helpers
|
||||
* ================================================================ */
|
||||
|
||||
/** Create a grid card for a folder */
|
||||
@@ -811,7 +1133,9 @@ const ui = {
|
||||
el.innerHTML = `
|
||||
<div class="file-card-checkbox"><i class="fas fa-check"></i></div>
|
||||
<button class="file-card-more"><i class="fas fa-ellipsis-v"></i></button>
|
||||
${isFav ? '<div class="favorite-star active"><i class="fas fa-star"></i></div>' : ''}
|
||||
<button class="favorite-star${isFav ? ' active' : ''}" data-item-id="${folder.id}" data-item-type="folder" data-item-name="${escapeHtml(folder.name)}">
|
||||
<i class="${isFav ? 'fas' : 'far'} fa-star"></i>
|
||||
</button>
|
||||
<div class="file-icon folder-icon">
|
||||
<i class="fas fa-folder"></i>
|
||||
</div>
|
||||
@@ -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 = `
|
||||
<div class="file-card-checkbox"><i class="fas fa-check"></i></div>
|
||||
<button class="file-card-more"><i class="fas fa-ellipsis-v"></i></button>
|
||||
${isFileFav ? '<div class="favorite-star active"><i class="fas fa-star"></i></div>' : ''}
|
||||
<button class="favorite-star${isFileFav ? ' active' : ''}" data-item-id="${file.id}" data-item-type="file" data-item-name="${escapeHtml(file.name)}">
|
||||
<i class="${isFileFav ? 'fas' : 'far'} fa-star"></i>
|
||||
</button>
|
||||
<div class="file-icon ${iconSpecialClass}">
|
||||
<i class="${iconClass}"></i>
|
||||
</div>
|
||||
<div class="file-name">${escapeHtml(file.name)}</div>
|
||||
<div class="file-info">Modified ${formattedDate.split(' ')[0]}</div>
|
||||
`;
|
||||
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';
|
||||
|
||||
+11
-2
@@ -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",
|
||||
|
||||
+11
-2
@@ -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",
|
||||
|
||||
+11
-2
@@ -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",
|
||||
|
||||
+11
-2
@@ -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": "اخیر",
|
||||
|
||||
+11
-2
@@ -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",
|
||||
|
||||
+11
-2
@@ -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",
|
||||
|
||||
+11
-2
@@ -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",
|
||||
|
||||
+11
-2
@@ -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": "最近",
|
||||
|
||||
@@ -113,10 +113,6 @@
|
||||
<option value="expiration" data-i18n="shared.sortByExpiration">Expiration</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="shared-search-filter" placeholder="Search shared items...">
|
||||
<button id="shared-search-filter-btn" class="btn btn-primary" data-i18n="shared.search">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shared-list-container">
|
||||
|
||||
Reference in New Issue
Block a user