feat(P1+P2): server-authoritative favorites/recent + pre-computed display fields

P1-A: Enrich FavoriteItemDto & RecentItemDto with item_name, item_size,
      item_mime_type, parent_id, modified_at via SQL LEFT JOINs — eliminates
      N+1 per-item fetches.

P1-B: Rewrite favorites.js as server-authoritative (724→380 lines).
      In-memory cache backed by GET /api/favorites; no localStorage.

P1-C: Rewrite recent.js as server-authoritative (341→249 lines).
      GET /api/recent + POST /api/recent/{type}/{id}; no localStorage.

P1 cleanup: Remove dead localStorage cleanup from auth.js logout,
            fix async clearRecentFiles in app.js.

P2: Add icon_class, icon_special_class, category, size_formatted to
    FileDto, FolderDto, FavoriteItemDto, RecentItemDto. New shared
    display_helpers.rs module centralises mime→icon/category/size logic.
    Frontend (ui.js, fileRenderer.js, favorites.js, recent.js) now reads
    pre-computed fields from the API with fallback defaults — eliminates
    5 duplicated mime→icon mapping blocks (~89 lines removed).

Also fixes: synthetic FolderDto in webdav_handler.rs, pre-existing
            missing item_name field in share_service.rs test.

Net: -307 lines across 15 files. cargo check: 0 errors, 0 warnings.
     cargo test display_helpers: 3/3 pass.
This commit is contained in:
Dionisio
2026-02-16 01:09:28 +01:00
parent 5a679dfc90
commit fb652c07e3
16 changed files with 802 additions and 983 deletions
+126
View File
@@ -0,0 +1,126 @@
/// Shared display helpers for DTOs.
///
/// These functions centralise the mime→icon / mime→category / size→human-string
/// logic so that every API response carries pre-computed display fields and the
/// frontend does **not** need to duplicate these mappings.
/// Returns the FontAwesome icon class for a given MIME type.
///
/// Examples: `"fas fa-file-image"`, `"fas fa-file-pdf"`, `"fas fa-file"` (default).
pub fn mime_to_icon_class(mime: &str) -> &'static str {
if mime.starts_with("image/") {
"fas fa-file-image"
} else if mime.starts_with("text/") {
"fas fa-file-alt"
} else if mime.starts_with("video/") {
"fas fa-file-video"
} else if mime.starts_with("audio/") {
"fas fa-file-audio"
} else if mime == "application/pdf" {
"fas fa-file-pdf"
} else {
"fas fa-file"
}
}
/// Returns the CSS class used to colour/style the icon container.
///
/// Examples: `"image-icon"`, `"pdf-icon"`, `""` (default).
pub fn mime_to_icon_special_class(mime: &str) -> &'static str {
if mime.starts_with("image/") {
"image-icon"
} else if mime.starts_with("text/") {
"text-icon"
} else if mime.starts_with("video/") {
"video-icon"
} else if mime.starts_with("audio/") {
"audio-icon"
} else if mime == "application/pdf" {
"pdf-icon"
} else {
""
}
}
/// Returns a human-readable category label for a MIME type.
///
/// Examples: `"Image"`, `"Text"`, `"Document"` (default).
pub fn mime_to_category(mime: &str) -> &'static str {
if mime.starts_with("image/") {
"Image"
} else if mime.starts_with("text/") {
"Text"
} else if mime.starts_with("video/") {
"Video"
} else if mime.starts_with("audio/") {
"Audio"
} else if mime == "application/pdf" {
"PDF"
} else {
"Document"
}
}
/// Formats a byte count into a human-readable string (1024-based).
///
/// Matches the JavaScript `formatFileSize()` output exactly so the frontend
/// does not need its own per-file formatting.
///
/// Examples: `"0 Bytes"`, `"1.5 KB"`, `"3.27 MB"`.
pub fn format_file_size(bytes: u64) -> String {
if bytes == 0 {
return "0 Bytes".to_string();
}
const K: f64 = 1024.0;
const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"];
let i = ((bytes as f64).ln() / K.ln()).floor() as usize;
let i = i.min(SIZES.len() - 1);
let value = bytes as f64 / K.powi(i as i32);
// Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour)
let formatted = format!("{:.2}", value);
let formatted = formatted
.trim_end_matches('0')
.trim_end_matches('.');
format!("{} {}", formatted, SIZES[i])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_file_size() {
assert_eq!(format_file_size(0), "0 Bytes");
assert_eq!(format_file_size(500), "500 Bytes");
assert_eq!(format_file_size(1024), "1 KB");
assert_eq!(format_file_size(1536), "1.5 KB");
assert_eq!(format_file_size(1_048_576), "1 MB");
assert_eq!(format_file_size(3_423_744), "3.27 MB");
assert_eq!(format_file_size(1_073_741_824), "1 GB");
}
#[test]
fn test_mime_to_icon_class() {
assert_eq!(mime_to_icon_class("image/png"), "fas fa-file-image");
assert_eq!(mime_to_icon_class("text/plain"), "fas fa-file-alt");
assert_eq!(mime_to_icon_class("video/mp4"), "fas fa-file-video");
assert_eq!(mime_to_icon_class("audio/mpeg"), "fas fa-file-audio");
assert_eq!(mime_to_icon_class("application/pdf"), "fas fa-file-pdf");
assert_eq!(mime_to_icon_class("application/octet-stream"), "fas fa-file");
}
#[test]
fn test_mime_to_category() {
assert_eq!(mime_to_category("image/jpeg"), "Image");
assert_eq!(mime_to_category("text/html"), "Text");
assert_eq!(mime_to_category("video/webm"), "Video");
assert_eq!(mime_to_category("audio/ogg"), "Audio");
assert_eq!(mime_to_category("application/pdf"), "PDF");
assert_eq!(mime_to_category("application/zip"), "Document");
}
}