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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user