feat: batch favorites endpoint + frontend dedup fixes

Backend:
- Add POST /api/favorites/batch endpoint (single multi-row INSERT)
- Add BatchFavoritesResult/BatchFavoritesStats DTOs
- Add batch methods to ports, service, PG repository
- Transaction-based insert with ON CONFLICT DO NOTHING, chunking at 5000

Frontend:
- Rewrite batchFavorites() to single API call (40 requests → 1)
- Add _replaceCacheFromResponse() to avoid extra GET round-trip
- Centralize formatFileSize, isTextViewable, formatDateTime, formatDateShort
- Remove duplicate icon mapping from app.js
- Fix inconsistent quota defaults (10GB everywhere)
This commit is contained in:
Dionisio
2026-02-16 09:17:54 +01:00
parent fb652c07e3
commit d6c4eb884d
17 changed files with 433 additions and 141 deletions
+20
View File
@@ -78,3 +78,23 @@ impl FavoriteItemDto {
self
}
}
/// Result DTO for batch add-to-favorites.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchFavoritesResult {
/// Statistics about the batch operation
pub stats: BatchFavoritesStats,
/// Full list of the user's favourites (enriched), so the client can
/// replace its local cache in a single round-trip.
pub favorites: Vec<FavoriteItemDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchFavoritesStats {
/// How many items were requested
pub requested: usize,
/// How many were actually inserted (new)
pub inserted: u64,
/// How many were already favourites (skipped)
pub already_existed: u64,
}
+17 -1
View File
@@ -1,4 +1,4 @@
use crate::application::dtos::favorites_dto::FavoriteItemDto;
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto};
use crate::common::errors::Result;
use async_trait::async_trait;
@@ -21,6 +21,14 @@ pub trait FavoritesUseCase: Send + Sync {
/// Check if an item is in user's favorites
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
/// Add multiple items to favorites in a single transaction.
/// Returns enriched favourites list so the client can replace its cache.
async fn batch_add_to_favorites(
&self,
user_id: &str,
items: &[(String, String)],
) -> Result<BatchFavoritesResult>;
}
// ─────────────────────────────────────────────────────
@@ -45,4 +53,12 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
/// Checks if an item is in favorites.
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
/// Insert multiple items in a single transaction.
/// Returns the number of rows actually inserted (ignoring duplicates).
async fn add_favorites_batch(
&self,
user_id: &str,
items: &[(String, String)],
) -> Result<u64>;
}
+48 -1
View File
@@ -1,4 +1,4 @@
use crate::application::dtos::favorites_dto::FavoriteItemDto;
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto};
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
use crate::common::errors::{DomainError, ErrorKind, Result};
use async_trait::async_trait;
@@ -94,4 +94,51 @@ impl FavoritesUseCase for FavoritesService {
);
self.repo.is_favorite(user_id, item_id, item_type).await
}
async fn batch_add_to_favorites(
&self,
user_id: &str,
items: &[(String, String)],
) -> Result<BatchFavoritesResult> {
info!(
"Batch adding {} items to favorites for user {}",
items.len(),
user_id
);
// Validate all item types
for (item_id, item_type) in items {
if item_type != "file" && item_type != "folder" {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"Favorites",
format!(
"Item type must be 'file' or 'folder' for item '{}'",
item_id
),
));
}
}
let requested = items.len();
let inserted = self.repo.add_favorites_batch(user_id, items).await?;
let already_existed = requested as u64 - inserted;
info!(
"Batch favorites for user {}: {} requested, {} inserted, {} already existed",
user_id, requested, inserted, already_existed
);
// Return the full enriched list so the client can replace its cache
let favorites = self.repo.get_favorites(user_id).await?;
Ok(BatchFavoritesResult {
stats: BatchFavoritesStats {
requested,
inserted,
already_existed,
},
favorites,
})
}
}
@@ -162,4 +162,91 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
Ok(row.try_get("is_favorite").unwrap_or(false))
}
async fn add_favorites_batch(
&self,
user_id: &str,
items: &[(String, String)],
) -> Result<u64> {
if items.is_empty() {
return Ok(0);
}
let user_uuid = Uuid::parse_str(user_id)?;
// Validate all item_types upfront
for (_, item_type) in items {
if item_type != "file" && item_type != "folder" {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"Favorites",
format!("Item type must be 'file' or 'folder', got '{}'", item_type),
));
}
}
// Build a multi-row INSERT with ON CONFLICT DO NOTHING
// Using a single transaction for atomicity
let mut tx = self.db_pool.begin().await.map_err(|e| {
error!("Database error starting transaction: {}", e);
DomainError::new(
ErrorKind::InternalError,
"Favorites",
format!("Failed to start transaction: {}", e),
)
})?;
let mut total_inserted: u64 = 0;
// Insert in chunks to stay within Postgres' parameter limit (max ~32k params)
for chunk in items.chunks(5000) {
let mut query = String::from(
"INSERT INTO auth.user_favorites (user_id, item_id, item_type) VALUES ",
);
let mut param_idx = 1u32;
let mut first = true;
for _ in chunk {
if !first {
query.push_str(", ");
}
query.push_str(&format!(
"(${}::TEXT, ${}, ${})",
param_idx,
param_idx + 1,
param_idx + 2
));
param_idx += 3;
first = false;
}
query.push_str(" ON CONFLICT (user_id, item_id, item_type) DO NOTHING");
let mut q = sqlx::query(&query);
for (item_id, item_type) in chunk {
q = q.bind(&user_uuid).bind(item_id).bind(item_type);
}
let result = q.execute(&mut *tx).await.map_err(|e| {
error!("Database error in batch insert favorites: {}", e);
DomainError::new(
ErrorKind::InternalError,
"Favorites",
format!("Failed to batch insert favorites: {}", e),
)
})?;
total_inserted += result.rows_affected();
}
tx.commit().await.map_err(|e| {
error!("Database error committing batch favorites: {}", e);
DomainError::new(
ErrorKind::InternalError,
"Favorites",
format!("Failed to commit batch favorites: {}", e),
)
})?;
Ok(total_inserted)
}
}
@@ -1,15 +1,24 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use axum::{Json, extract::{Path, State}, http::StatusCode, response::IntoResponse};
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::interfaces::middleware::auth::AuthUser;
/// Single item in a batch-add-favorites request.
#[derive(Debug, Deserialize)]
pub struct BatchFavoriteItem {
pub item_id: String,
pub item_type: String,
}
/// Request body for POST /api/favorites/batch
#[derive(Debug, Deserialize)]
pub struct BatchFavoritesRequest {
pub items: Vec<BatchFavoriteItem>,
}
/// Handler for favorite-related API endpoints
pub async fn get_favorites(
State(favorites_service): State<Arc<dyn FavoritesUseCase>>,
@@ -120,3 +129,63 @@ pub async fn remove_favorite(
}
}
}
/// Add multiple items to favourites in a single transaction.
/// POST /api/favorites/batch
pub async fn batch_add_favorites(
State(favorites_service): State<Arc<dyn FavoritesUseCase>>,
auth_user: AuthUser,
Json(body): Json<BatchFavoritesRequest>,
) -> impl IntoResponse {
let user_id = &auth_user.id;
if body.items.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "items array must not be empty" })),
)
.into_response();
}
// Validate item types
for item in &body.items {
if item.item_type != "file" && item.item_type != "folder" {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("Item type must be 'file' or 'folder', got '{}'", item.item_type)
})),
)
.into_response();
}
}
let items: Vec<(String, String)> = body
.items
.into_iter()
.map(|i| (i.item_id, i.item_type))
.collect();
match favorites_service
.batch_add_to_favorites(user_id, &items)
.await
{
Ok(result) => {
info!(
"Batch favourites: {} requested, {} inserted, {} already existed",
result.stats.requested, result.stats.inserted, result.stats.already_existed
);
(StatusCode::OK, Json(serde_json::json!(result))).into_response()
}
Err(err) => {
error!("Error in batch add favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to batch add favorites: {}", err)
})),
)
.into_response()
}
}
}
+1
View File
@@ -231,6 +231,7 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
Router::new()
.route("/", get(favorites_handler::get_favorites))
.route("/batch", post(favorites_handler::batch_add_favorites))
.route(
"/{item_type}/{item_id}",
post(favorites_handler::add_favorite),
+69 -37
View File
@@ -307,7 +307,7 @@ function updateUserMenuData() {
// Storage info
const usedBytes = userData.storage_used_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || 10737418240;
const quotaBytes = userData.storage_quota_bytes || (10 * 1024 * 1024 * 1024); // 10 GB default
const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0;
if (storageFill) storageFill.style.width = percentage + '%';
@@ -945,41 +945,23 @@ function addTrashItemToView(item) {
const isFile = item.item_type === 'file';
// Format date - backend sends trashed_at as ISO 8601 string
const deletedDate = new Date(item.trashed_at);
const formattedDate = deletedDate.toLocaleDateString() + ' ' +
deletedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const formattedDate = window.formatDateTime(item.trashed_at);
// Determine type label and icon from extension (trash DTO has no mime_type)
let typeLabel;
// Use icon_class from the trash DTO if available, otherwise fall back to
// the comprehensive icon map exposed by ui.js.
let iconClass;
let typeLabel;
if (!isFile) {
iconClass = 'fas fa-folder';
iconClass = item.icon_class || 'fas fa-folder';
typeLabel = window.i18n ? window.i18n.t('files.file_types.folder') : 'Folder';
} else {
const ext = (item.name.split('.').pop() || '').toLowerCase();
const imageExts = ['jpg','jpeg','png','gif','bmp','svg','webp','ico','tiff'];
const videoExts = ['mp4','avi','mkv','mov','wmv','flv','webm'];
const audioExts = ['mp3','wav','ogg','flac','aac','wma','m4a'];
const textExts = ['txt','md','csv','log','ini','cfg','conf'];
if (ext === 'pdf') {
iconClass = 'fas fa-file-pdf';
typeLabel = window.i18n ? window.i18n.t('files.file_types.pdf') : 'PDF';
} else if (imageExts.includes(ext)) {
iconClass = 'fas fa-file-image';
typeLabel = window.i18n ? window.i18n.t('files.file_types.image') : 'Image';
} else if (videoExts.includes(ext)) {
iconClass = 'fas fa-file-video';
typeLabel = window.i18n ? window.i18n.t('files.file_types.video') : 'Video';
} else if (audioExts.includes(ext)) {
iconClass = 'fas fa-file-audio';
typeLabel = window.i18n ? window.i18n.t('files.file_types.audio') : 'Audio';
} else if (textExts.includes(ext)) {
iconClass = 'fas fa-file-alt';
typeLabel = window.i18n ? window.i18n.t('files.file_types.text') : 'Text';
} else {
iconClass = 'fas fa-file';
typeLabel = window.i18n ? window.i18n.t('files.file_types.document') : 'Document';
}
iconClass = item.icon_class || (window.ui && window.ui.getIconClass
? window.ui.getIconClass(item.name)
: 'fas fa-file');
const cat = item.category || '';
typeLabel = cat
? (window.i18n ? window.i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : cat)
: (window.i18n ? window.i18n.t('files.file_types.document') : 'Document');
}
// Grid view element
@@ -1135,6 +1117,55 @@ window.loadTrashItems = loadTrashItems;
window.formatFileSize = formatFileSize;
window.performSearch = performSearch;
/**
* Centralised date+time formatter — use this everywhere instead of inline
* toLocaleDateString/toLocaleTimeString calls.
* @param {Date|number|string} value Date object, unix-seconds number, or ISO string
* @returns {string} e.g. "16/02/2026 14:35"
*/
window.formatDateTime = function formatDateTime(value) {
if (!value) return '';
let d;
if (value instanceof Date) {
d = value;
} else if (typeof value === 'number') {
// Heuristic: values < 1e12 are unix seconds, otherwise ms
d = new Date(value < 1e12 ? value * 1000 : value);
} else {
d = new Date(value);
}
if (isNaN(d.getTime())) return String(value);
return d.toLocaleDateString() + ' ' +
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
/**
* Short date formatter (no time) for shares / expiration dates.
* @param {Date|number|string} value
* @returns {string} e.g. "Feb 16, 2026"
*/
window.formatDateShort = function formatDateShort(value) {
if (!value) return 'N/A';
const d = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
if (isNaN(d.getTime())) return String(value);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
/**
* Centralised "is this MIME type text-viewable?" check.
* Used by fileViewer, inlineViewer, and ui.isViewableFile.
*/
window.isTextViewable = function isTextViewable(mimeType) {
if (!mimeType) return false;
if (mimeType.startsWith('text/')) return true;
const textTypes = [
'application/json', 'application/xml', 'application/javascript',
'application/x-sh', 'application/x-yaml', 'application/toml',
'application/x-toml', 'application/sql',
];
return textTypes.includes(mimeType);
};
// Set up global selectFolder function for navigation
window.selectFolder = (id, name) => {
app.currentPath = id;
@@ -1424,10 +1455,10 @@ function switchToRecentFilesView() {
elements.actionsBar.style.display = 'flex';
// Add event listener for clear button
document.getElementById('clear-recent-btn').addEventListener('click', async () => {
document.getElementById('clear-recent-btn').addEventListener('click', () => {
if (window.recent) {
await window.recent.clearRecentFiles();
await window.recent.displayRecentFiles();
window.recent.clearRecentFiles();
window.recent.displayRecentFiles();
window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
}
});
@@ -1542,7 +1573,7 @@ function showUserProfileModal() {
const role = userData.role || 'user';
const initials = username.substring(0, 2).toUpperCase();
const usedBytes = userData.storage_used_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || (10 * 1024 * 1024 * 1024); // 10 GB default
const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0;
const barColor = percentage > 90 ? '#ef4444' : percentage > 70 ? '#f59e0b' : '#22c55e';
@@ -1829,14 +1860,15 @@ function logout() {
*/
function updateStorageUsageDisplay(userData) {
// Default values
const DEFAULT_QUOTA = 10 * 1024 * 1024 * 1024; // 10 GB
let usedBytes = 0;
let quotaBytes = 10737418240; // Default 10GB
let quotaBytes = DEFAULT_QUOTA;
let usagePercentage = 0;
// Get values from user data if available
if (userData) {
usedBytes = userData.storage_used_bytes || 0;
quotaBytes = userData.storage_quota_bytes || 10737418240;
quotaBytes = userData.storage_quota_bytes || DEFAULT_QUOTA;
// Calculate percentage (avoid division by zero)
if (quotaBytes > 0) {
+1 -3
View File
@@ -551,9 +551,7 @@ const sharedView = {
},
formatDate(value) {
if (!value) return 'N/A';
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
return window.formatDateShort ? window.formatDateShort(value) : String(value);
},
translate(key, defaultText) {
+16 -10
View File
@@ -26,6 +26,20 @@ const favorites = {
return `${type}:${id}`;
},
/**
* Replace the entire in-memory cache from an array of FavoriteItemDto
* objects (as returned by the batch endpoint). Avoids an extra
* GET /api/favorites round-trip.
*/
_replaceCacheFromResponse(items) {
this._cache.clear();
for (const item of items) {
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
}
this._ready = true;
console.log(`Favorites cache replaced from response: ${this._cache.size} items`);
},
// ───────────────────── lifecycle ─────────────────────
/**
@@ -218,11 +232,7 @@ const favorites = {
const folderId = item.item_id;
const parentId = item.parent_id || '';
const modifiedAt = item.modified_at
? new Date(item.modified_at)
: new Date(item.created_at);
const formattedDate = modifiedAt.toLocaleDateString() + ' ' +
modifiedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const formattedDate = window.formatDateTime(item.modified_at || item.created_at);
// --- grid element ---
const gridEl = document.createElement('div');
@@ -306,11 +316,7 @@ const favorites = {
: (window.i18n ? window.i18n.t('files.file_types.document') : 'Document');
const fileSize = item.size_formatted || (window.formatFileSize ? window.formatFileSize(item.item_size || 0) : '0 B');
const modifiedAt = item.modified_at
? new Date(item.modified_at)
: new Date(item.created_at);
const formattedDate = modifiedAt.toLocaleDateString() + ' ' +
modifiedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const formattedDate = window.formatDateTime(item.modified_at || item.created_at);
// --- grid element ---
const gridEl = document.createElement('div');
+4 -16
View File
@@ -319,10 +319,7 @@ class FileRenderer {
elem.dataset.folderName = item.name;
elem.dataset.parentId = item.parent_id || "";
// Format date
const modifiedDate = new Date(item.modified_at * 1000);
const formattedDate = modifiedDate.toLocaleDateString() + ' ' +
modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const formattedDate = window.formatDateTime(item.modified_at);
elem.innerHTML = `
<div class="name-cell">
@@ -375,10 +372,7 @@ class FileRenderer {
// Format file size and date
const fileSize = item.size_formatted || this.formatFileSize(item.size);
// Format date
const modifiedDate = new Date(item.modified_at * 1000);
const formattedDate = modifiedDate.toLocaleDateString() + ' ' +
modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const formattedDate = window.formatDateTime(item.modified_at);
elem.innerHTML = `
<div class="name-cell">
@@ -431,16 +425,10 @@ class FileRenderer {
}
/**
* Format file size in human-readable format
* Format file size — delegates to the single global definition in app.js
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
return window.formatFileSize ? window.formatFileSize(bytes) : `${bytes} Bytes`;
}
/**
+1 -2
View File
@@ -157,8 +157,7 @@ const fileSharing = {
*/
formatExpirationDate(value) {
if (!value) return 'No expiration';
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return window.formatDateTime(value);
},
/**
+3 -9
View File
@@ -294,17 +294,11 @@ class FileViewer {
}
/**
* Check if a MIME type is text-viewable
* Check if a MIME type is text-viewable — delegates to the single global
* definition exposed by app.js (window.isTextViewable).
*/
isTextViewable(mimeType) {
if (!mimeType) return false;
if (mimeType.startsWith('text/')) return true;
const textTypes = [
'application/json', 'application/xml', 'application/javascript',
'application/x-sh', 'application/x-yaml', 'application/toml',
'application/x-toml', 'application/sql',
];
return textTypes.includes(mimeType);
return window.isTextViewable ? window.isTextViewable(mimeType) : false;
}
/**
+2 -14
View File
@@ -167,21 +167,9 @@ class InlineViewer {
modal.classList.add('active');
}
// Check if a MIME type is text-viewable
// Check if a MIME type is text-viewable — delegates to window.isTextViewable
isTextViewable(mimeType) {
if (!mimeType) return false;
if (mimeType.startsWith('text/')) return true;
const textTypes = [
'application/json',
'application/xml',
'application/javascript',
'application/x-sh',
'application/x-yaml',
'application/toml',
'application/x-toml',
'application/sql',
];
return textTypes.includes(mimeType);
return window.isTextViewable ? window.isTextViewable(mimeType) : false;
}
// Creates a text viewer using authenticated fetch
+46 -18
View File
@@ -442,32 +442,60 @@ const multiSelect = {
}
},
/** Batch add to favorites */
/** Batch add to favorites — single API call */
async batchFavorites() {
const items = this.items;
if (items.length === 0 || !window.favorites) return;
let added = 0;
for (const item of items) {
const alreadyFav = window.favorites.isFavorite(item.id, item.type);
if (!alreadyFav) {
await window.favorites.addToFavorites(item.id, item.name, item.type, item.parentId);
added++;
}
}
this.clear();
if (typeof window.loadFiles === 'function') window.loadFiles();
if (added > 0) {
window.ui.showNotification(
this._t('favorites.add') || 'Added to favorites',
`${added} item${added !== 1 ? 's' : ''} added to favorites`
);
} else {
// Filter out items already in favourites
const toAdd = items.filter(i => !window.favorites.isFavorite(i.id, i.type));
if (toAdd.length === 0) {
this.clear();
window.ui.showNotification(
this._t('favorites.add') || 'Favorites',
'All selected items are already favorites'
);
return;
}
try {
const response = await fetch('/api/favorites/batch', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
items: toAdd.map(i => ({ item_id: i.id, item_type: i.type }))
})
});
if (!response.ok) throw new Error(`Server returned ${response.status}`);
const data = await response.json();
const inserted = data.stats?.inserted || 0;
// Replace cache directly from response (no extra GET)
if (data.favorites && window.favorites._replaceCacheFromResponse) {
window.favorites._replaceCacheFromResponse(data.favorites);
} else {
await window.favorites._fetchFromServer();
}
this.clear();
if (typeof window.loadFiles === 'function') window.loadFiles();
if (inserted > 0) {
window.ui.showNotification(
this._t('favorites.add') || 'Added to favorites',
`${inserted} item${inserted !== 1 ? 's' : ''} added to favorites`
);
} else {
window.ui.showNotification(
this._t('favorites.add') || 'Favorites',
'All selected items are already favorites'
);
}
} catch (e) {
console.error('Batch favorites error:', e);
window.ui.showNotification('Error', 'Could not add items to favorites');
}
},
+1 -3
View File
@@ -158,9 +158,7 @@ const recent = {
: (window.i18n ? window.i18n.t('files.file_types.document') : 'Document'));
const fileSize = isFolder ? '--' : (item.size_formatted || (window.formatFileSize ? window.formatFileSize(item.item_size || 0) : '0 B'));
const accessedDate = new Date(item.accessed_at);
const formattedDate = accessedDate.toLocaleDateString() + ' ' +
accessedDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const formattedDate = window.formatDateTime(item.accessed_at);
// Click handler
const onClick = () => {
+1 -3
View File
@@ -400,8 +400,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
function formatDate(value) {
if (!value) return 'N/A';
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
return window.formatDateShort ? window.formatDateShort(value) : String(value);
}
});
+41 -18
View File
@@ -443,13 +443,40 @@ const ui = {
if (!file || !file.mime_type) return false;
if (file.mime_type.startsWith('image/')) return true;
if (file.mime_type === 'application/pdf') return true;
if (file.mime_type.startsWith('text/')) return true;
const textTypes = [
'application/json', 'application/xml', 'application/javascript',
'application/x-sh', 'application/x-yaml', 'application/toml',
'application/x-toml', 'application/sql',
];
return textTypes.includes(file.mime_type);
// Delegate text-viewability to the single global definition
return window.isTextViewable ? window.isTextViewable(file.mime_type) : false;
},
/**
* Get FontAwesome icon class for a filename based on its extension.
* Used as fallback when the backend DTO doesn't include icon_class
* (e.g. trash items). Kept intentionally small — the comprehensive
* visual icon map lives in updateFileIcons().
*/
getIconClass(fileName) {
if (!fileName) return 'fas fa-file';
const ext = (fileName.split('.').pop() || '').toLowerCase();
const map = {
pdf:'fas fa-file-pdf', doc:'fas fa-file-word', docx:'fas fa-file-word',
txt:'fas fa-file-alt', rtf:'fas fa-file-alt', odt:'fas fa-file-alt',
xls:'fas fa-file-excel', xlsx:'fas fa-file-excel', csv:'fas fa-file-excel', ods:'fas fa-file-excel',
ppt:'fas fa-file-powerpoint', pptx:'fas fa-file-powerpoint', odp:'fas fa-file-powerpoint',
jpg:'fas fa-file-image', jpeg:'fas fa-file-image', png:'fas fa-file-image',
gif:'fas fa-file-image', svg:'fas fa-file-image', webp:'fas fa-file-image',
bmp:'fas fa-file-image', ico:'fas fa-file-image',
mp4:'fas fa-file-video', avi:'fas fa-file-video', mov:'fas fa-file-video',
mkv:'fas fa-file-video', webm:'fas fa-file-video', flv:'fas fa-file-video',
mp3:'fas fa-file-audio', wav:'fas fa-file-audio', ogg:'fas fa-file-audio',
flac:'fas fa-file-audio', aac:'fas fa-file-audio', m4a:'fas fa-file-audio',
zip:'fas fa-file-archive', rar:'fas fa-file-archive', '7z':'fas fa-file-archive',
tar:'fas fa-file-archive', gz:'fas fa-file-archive',
js:'fas fa-file-code', ts:'fas fa-file-code', py:'fas fa-file-code',
rs:'fas fa-file-code', java:'fas fa-file-code', html:'fas fa-file-code',
css:'fas fa-file-code', json:'fas fa-file-code', xml:'fas fa-file-code',
sh:'fas fa-terminal', bash:'fas fa-terminal', bat:'fas fa-terminal',
md:'fas fa-file-alt',
};
return map[ext] || 'fas fa-file';
},
/**
@@ -788,10 +815,7 @@ const ui = {
folderListElement.dataset.folderName = folder.name;
folderListElement.dataset.parentId = folder.parent_id || "";
// Format date
const modifiedDate = new Date(folder.modified_at * 1000);
const formattedDate = modifiedDate.toLocaleDateString() + ' ' +
modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const formattedDate = window.formatDateTime(folder.modified_at);
// Make draggable if not in root
if (window.app.currentPath !== "") {
@@ -903,18 +927,17 @@ const ui = {
console.log(`Adding file to the view: ${file.name} (${file.id})`);
// Use pre-computed display fields from the API response
// Use pre-computed display fields from the DTO when available
const iconClass = file.icon_class || 'fas fa-file';
const iconSpecialClass = file.icon_special_class || '';
const typeLabel = file.category
? (window.i18n ? window.i18n.t(`files.file_types.${file.category.toLowerCase()}`) || file.category : file.category)
const cat = file.category || '';
const typeLabel = cat
? (window.i18n ? window.i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : cat)
: (window.i18n ? window.i18n.t('files.file_types.document') : 'Document');
// Format size and date
// Format size and date — prefer DTO fields, fall back to computation
const fileSize = file.size_formatted || window.formatFileSize(file.size);
const modifiedDate = new Date(file.modified_at * 1000);
const formattedDate = modifiedDate.toLocaleDateString() + ' ' +
modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const formattedDate = window.formatDateTime(file.modified_at);
// Grid view element
const fileGridElement = document.createElement('div');