fix(ui): fix list view column spacing, rubber-band selection, and clippy warnings
Add column-gap to list header/items, prevent rubber-band selection from swallowing clicks on list headers and file items, preserve rubber-band selection after mouseup, and extract type aliases to resolve clippy type_complexity warnings.
This commit is contained in:
@@ -7,6 +7,20 @@
|
||||
//! File paths are resolved by JOINing with `storage.folders.path` (the
|
||||
//! materialized path column), so no recursive CTEs or N+1 queries are needed.
|
||||
|
||||
/// Row shape returned by media-file queries (avoids `clippy::type_complexity`).
|
||||
type MediaFileRow = (
|
||||
String, // id
|
||||
String, // name
|
||||
Option<String>, // folder_id
|
||||
Option<String>, // folder path
|
||||
i64, // size
|
||||
String, // mime_type
|
||||
i64, // created_at
|
||||
i64, // updated_at
|
||||
Option<String>, // user_id
|
||||
i64, // sort_date
|
||||
);
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::{Stream, TryStreamExt};
|
||||
use moka::sync::Cache;
|
||||
@@ -156,18 +170,7 @@ impl FileBlobReadRepository {
|
||||
before: Option<i64>,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<File>, Vec<i64>), DomainError> {
|
||||
let rows: Vec<(
|
||||
String, // id
|
||||
String, // name
|
||||
Option<String>, // folder_id
|
||||
Option<String>, // folder path
|
||||
i64, // size
|
||||
String, // mime_type
|
||||
i64, // created_at
|
||||
i64, // updated_at
|
||||
Option<String>, // user_id
|
||||
i64, // sort_date
|
||||
)> = sqlx::query_as(
|
||||
let rows: Vec<MediaFileRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
|
||||
@@ -10,6 +10,19 @@ use tracing::error;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::infrastructure::services::exif_service::ExifMetadata;
|
||||
|
||||
/// Row shape returned by metadata queries (avoids `clippy::type_complexity`).
|
||||
type MetadataRow = (
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i16>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
);
|
||||
|
||||
/// Metadata as stored/retrieved from the database.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StoredMetadata {
|
||||
@@ -73,17 +86,7 @@ impl FileMetadataRepository {
|
||||
|
||||
/// Get metadata for a single file.
|
||||
pub async fn get(&self, file_id: &str) -> Result<Option<StoredMetadata>, DomainError> {
|
||||
let row: Option<(
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i16>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
)> = sqlx::query_as(
|
||||
let row: Option<MetadataRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT file_id::text, captured_at, latitude, longitude,
|
||||
camera_make, camera_model, orientation, width, height
|
||||
@@ -135,17 +138,7 @@ impl FileMetadataRepository {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows: Vec<(
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i16>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
)> = sqlx::query_as(
|
||||
let rows: Vec<MetadataRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT file_id::text, captured_at, latitude, longitude,
|
||||
camera_make, camera_model, orientation, width, height
|
||||
|
||||
@@ -39,7 +39,11 @@ pub async fn handle_preview(
|
||||
) -> impl IntoResponse {
|
||||
// Parse the Nextcloud file ID — the NC app may append an instance suffix
|
||||
// (e.g. "00000326ocnca"), so strip non-digit characters first.
|
||||
let numeric_part: String = params.file_id.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
let numeric_part: String = params
|
||||
.file_id
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
let nc_file_id: i64 = match numeric_part.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
.list-header {
|
||||
display: grid;
|
||||
grid-template-columns: var(--files-list-columns);
|
||||
column-gap: 12px;
|
||||
padding: 15px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
@@ -24,6 +25,7 @@
|
||||
.file-item {
|
||||
display: grid;
|
||||
grid-template-columns: var(--files-list-columns);
|
||||
column-gap: 12px;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
align-items: center;
|
||||
@@ -332,6 +334,7 @@
|
||||
border: 2px dashed #ffc107;
|
||||
}
|
||||
|
||||
|
||||
[data-theme="dark"] .files-list-view {
|
||||
background-color: #1e293b;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
@@ -380,3 +383,4 @@
|
||||
[data-theme="dark"] .file-item.drop-target {
|
||||
background-color: rgba(255, 193, 7, 0.05);
|
||||
}
|
||||
|
||||
|
||||
@@ -517,6 +517,8 @@ function setupEventListeners() {
|
||||
// Deselect all cards when clicking empty area (not on a card, menu, or modal)
|
||||
// Note: multiSelect._hookGlobalDeselect() handles clearing the internal
|
||||
// selection state; this handler only covers the legacy CSS class removal.
|
||||
// Skip if a rubber-band selection just finished — the click is a side-effect.
|
||||
if (window.__rubberBandJustFinished) return;
|
||||
if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar') && !e.target.closest('.list-header.selection-mode')) {
|
||||
document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected'));
|
||||
document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected'));
|
||||
|
||||
+11
-2
@@ -1350,9 +1350,11 @@ function initRubberBandSelection() {
|
||||
container.addEventListener('mousedown', (e) => {
|
||||
// Only start if clicking empty area (not on a card, button, menu, input…)
|
||||
if (e.button !== 0) return; // left click only
|
||||
if (e.target.closest('.file-card') || e.target.closest('.context-menu') ||
|
||||
if (e.target.closest('.file-card') || e.target.closest('.file-item') ||
|
||||
e.target.closest('.context-menu') ||
|
||||
e.target.closest('.upload-dropdown') || e.target.closest('button') ||
|
||||
e.target.closest('input') || e.target.closest('.breadcrumb')) return;
|
||||
e.target.closest('input') || e.target.closest('.breadcrumb') ||
|
||||
e.target.closest('.list-header')) return;
|
||||
|
||||
active = true;
|
||||
startX = e.clientX;
|
||||
@@ -1420,9 +1422,16 @@ function initRubberBandSelection() {
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
const hadSelection = selRect.style.display === 'block';
|
||||
selRect.style.display = 'none';
|
||||
// Update the batch bar after rubber band selection completes
|
||||
if (window.multiSelect) window.multiSelect._syncUI();
|
||||
// Suppress the click event that follows mouseup so the global
|
||||
// deselect handler doesn't immediately clear the selection.
|
||||
if (hadSelection) {
|
||||
window.__rubberBandJustFinished = true;
|
||||
requestAnimationFrame(() => { window.__rubberBandJustFinished = false; });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -532,6 +532,7 @@ const multiSelect = {
|
||||
|
||||
_hookGlobalDeselect() {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (window.__rubberBandJustFinished) return;
|
||||
if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .list-header.selection-mode, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return;
|
||||
if (this.hasSelection) this.clear();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user