Merge pull request #177 from jaredwolff/fix/nc-android-discovery
This commit is contained in:
@@ -7,6 +7,20 @@
|
|||||||
//! File paths are resolved by JOINing with `storage.folders.path` (the
|
//! File paths are resolved by JOINing with `storage.folders.path` (the
|
||||||
//! materialized path column), so no recursive CTEs or N+1 queries are needed.
|
//! 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 bytes::Bytes;
|
||||||
use futures::{Stream, TryStreamExt};
|
use futures::{Stream, TryStreamExt};
|
||||||
use moka::sync::Cache;
|
use moka::sync::Cache;
|
||||||
@@ -156,18 +170,7 @@ impl FileBlobReadRepository {
|
|||||||
before: Option<i64>,
|
before: Option<i64>,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<(Vec<File>, Vec<i64>), DomainError> {
|
) -> Result<(Vec<File>, Vec<i64>), DomainError> {
|
||||||
let rows: Vec<(
|
let rows: Vec<MediaFileRow> = sqlx::query_as(
|
||||||
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(
|
|
||||||
r#"
|
r#"
|
||||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
fi.size, fi.mime_type,
|
fi.size, fi.mime_type,
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ use tracing::error;
|
|||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::infrastructure::services::exif_service::ExifMetadata;
|
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.
|
/// Metadata as stored/retrieved from the database.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct StoredMetadata {
|
pub struct StoredMetadata {
|
||||||
@@ -73,17 +86,7 @@ impl FileMetadataRepository {
|
|||||||
|
|
||||||
/// Get metadata for a single file.
|
/// Get metadata for a single file.
|
||||||
pub async fn get(&self, file_id: &str) -> Result<Option<StoredMetadata>, DomainError> {
|
pub async fn get(&self, file_id: &str) -> Result<Option<StoredMetadata>, DomainError> {
|
||||||
let row: Option<(
|
let row: Option<MetadataRow> = sqlx::query_as(
|
||||||
String,
|
|
||||||
Option<DateTime<Utc>>,
|
|
||||||
Option<f64>,
|
|
||||||
Option<f64>,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
Option<i16>,
|
|
||||||
Option<i32>,
|
|
||||||
Option<i32>,
|
|
||||||
)> = sqlx::query_as(
|
|
||||||
r#"
|
r#"
|
||||||
SELECT file_id::text, captured_at, latitude, longitude,
|
SELECT file_id::text, captured_at, latitude, longitude,
|
||||||
camera_make, camera_model, orientation, width, height
|
camera_make, camera_model, orientation, width, height
|
||||||
@@ -135,17 +138,7 @@ impl FileMetadataRepository {
|
|||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows: Vec<(
|
let rows: Vec<MetadataRow> = sqlx::query_as(
|
||||||
String,
|
|
||||||
Option<DateTime<Utc>>,
|
|
||||||
Option<f64>,
|
|
||||||
Option<f64>,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
Option<i16>,
|
|
||||||
Option<i32>,
|
|
||||||
Option<i32>,
|
|
||||||
)> = sqlx::query_as(
|
|
||||||
r#"
|
r#"
|
||||||
SELECT file_id::text, captured_at, latitude, longitude,
|
SELECT file_id::text, captured_at, latitude, longitude,
|
||||||
camera_make, camera_model, orientation, width, height
|
camera_make, camera_model, orientation, width, height
|
||||||
|
|||||||
@@ -621,7 +621,11 @@ async fn oidc_callback(
|
|||||||
user = %username,
|
user = %username,
|
||||||
"OIDC login completed Nextcloud Login Flow v2 successfully"
|
"OIDC login completed Nextcloud Login Flow v2 successfully"
|
||||||
);
|
);
|
||||||
Ok(Redirect::temporary("/nextcloud-success.html"))
|
let nc_url = format!(
|
||||||
|
"nc://login/server:{}&user:{}&password:{}",
|
||||||
|
base_url, username, app_password
|
||||||
|
);
|
||||||
|
Ok(Redirect::temporary(&nc_url))
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
user = %username,
|
user = %username,
|
||||||
|
|||||||
@@ -196,16 +196,21 @@ pub async fn handle_login_submit(
|
|||||||
base_url = %base_url,
|
base_url = %base_url,
|
||||||
"Login Flow v2: flow completed successfully"
|
"Login Flow v2: flow completed successfully"
|
||||||
);
|
);
|
||||||
|
// Redirect to nc:// deep link so the Nextcloud mobile app receives
|
||||||
|
// the credentials via Android/iOS intent. Desktop clients use polling
|
||||||
|
// instead, so they will pick up the result from the poll endpoint.
|
||||||
|
let nc_url = format!(
|
||||||
|
"nc://login/server:{}&user:{}&password:{}",
|
||||||
|
base_url, current_user.username, app_password
|
||||||
|
);
|
||||||
|
axum::response::Redirect::to(&nc_url).into_response()
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
user = %current_user.username,
|
user = %current_user.username,
|
||||||
"Login Flow v2: complete() returned false — flow token not found"
|
"Login Flow v2: complete() returned false — flow token not found"
|
||||||
);
|
);
|
||||||
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
|
axum::response::Redirect::to("/nextcloud-error.html?type=session-expired").into_response()
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html_with_csp(include_str!("../../../static/nextcloud-success.html"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is
|
/// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is
|
||||||
|
|||||||
@@ -229,6 +229,19 @@ pub async fn handle_notifications_push() -> Response {
|
|||||||
Json(ocs_ok(200, json!({}))).into_response()
|
Json(ocs_ok(200, json!({}))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /ocs/v2.php/apps/recommendations/api/v1/recommendations
|
||||||
|
///
|
||||||
|
/// Returns recommended files. Stub that returns an empty list.
|
||||||
|
pub async fn handle_recommendations() -> Response {
|
||||||
|
Json(json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||||
|
"data": []
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?search={query}&itemType={type}
|
/// GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?search={query}&itemType={type}
|
||||||
///
|
///
|
||||||
/// Returns matching users for the sharing autocomplete UI.
|
/// Returns matching users for the sharing autocomplete UI.
|
||||||
|
|||||||
@@ -37,8 +37,14 @@ pub async fn handle_preview(
|
|||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
Query(params): Query<PreviewParams>,
|
Query(params): Query<PreviewParams>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Parse the Nextcloud file ID (numeric) to get the OxiCloud UUID
|
// Parse the Nextcloud file ID — the NC app may append an instance suffix
|
||||||
let nc_file_id: i64 = match params.file_id.parse() {
|
// (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 nc_file_id: i64 = match numeric_part.parse() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
|
|||||||
// Public routes — no auth required.
|
// Public routes — no auth required.
|
||||||
let public = Router::new()
|
let public = Router::new()
|
||||||
.route("/status.php", get(status_handler::handle_status))
|
.route("/status.php", get(status_handler::handle_status))
|
||||||
|
// NC connectivity check — app expects 204 to confirm server is reachable.
|
||||||
|
.route("/index.php/204", get(handle_connectivity_check))
|
||||||
|
// Bare /remote.php/dav — NC clients probe this to confirm WebDAV is available.
|
||||||
|
.route("/remote.php/dav", any(handle_dav_discovery))
|
||||||
|
.route("/remote.php/dav/", any(handle_dav_discovery))
|
||||||
.route(
|
.route(
|
||||||
"/index.php/login/v2",
|
"/index.php/login/v2",
|
||||||
post(login_v2_handler::handle_login_initiate),
|
post(login_v2_handler::handle_login_initiate),
|
||||||
@@ -96,6 +101,10 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
|
|||||||
"/ocs/v2.php/apps/notifications/api/v2/push",
|
"/ocs/v2.php/apps/notifications/api/v2/push",
|
||||||
post(ocs_handler::handle_notifications_push),
|
post(ocs_handler::handle_notifications_push),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/ocs/v2.php/apps/recommendations/api/v1/recommendations",
|
||||||
|
get(ocs_handler::handle_recommendations),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/ocs/v2.php/apps/files_sharing/api/v1/sharees",
|
"/ocs/v2.php/apps/files_sharing/api/v1/sharees",
|
||||||
get(ocs_handler::handle_sharees_search),
|
get(ocs_handler::handle_sharees_search),
|
||||||
@@ -254,3 +263,22 @@ async fn handle_dav_trashbin_root(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.into_response())
|
.map_err(|e| e.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /index.php/204` — NC app connectivity check. Returns 204 No Content.
|
||||||
|
async fn handle_connectivity_check() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::NO_CONTENT)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bare `/remote.php/dav` — NC clients (especially Android) probe this endpoint
|
||||||
|
/// during server discovery to confirm WebDAV is available.
|
||||||
|
async fn handle_dav_discovery() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("DAV", "1, 3")
|
||||||
|
.header("Allow", "OPTIONS, GET, HEAD, PROPFIND")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|||||||
@@ -277,8 +277,31 @@ async fn handle_get(
|
|||||||
user: &CurrentUser,
|
user: &CurrentUser,
|
||||||
subpath: &str,
|
subpath: &str,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
// GET on root folder — NC clients use this as an existence check
|
||||||
|
if subpath.is_empty() || subpath == "/" {
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("DAV", "1, 3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
let internal_path = nc_to_internal_path(&user.username, subpath)?;
|
let internal_path = nc_to_internal_path(&user.username, subpath)?;
|
||||||
let file_service = &state.applications.file_retrieval_service;
|
let file_service = &state.applications.file_retrieval_service;
|
||||||
|
let folder_service = &state.applications.folder_service;
|
||||||
|
|
||||||
|
// Check if path is a folder first (NC clients use GET as existence check)
|
||||||
|
if folder_service
|
||||||
|
.get_folder_by_path(&internal_path)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("DAV", "1, 3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
let file = file_service
|
let file = file_service
|
||||||
.get_file_by_path(&internal_path)
|
.get_file_by_path(&internal_path)
|
||||||
@@ -311,8 +334,31 @@ async fn handle_head(
|
|||||||
user: &CurrentUser,
|
user: &CurrentUser,
|
||||||
subpath: &str,
|
subpath: &str,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
// HEAD on root folder — NC clients use this as an existence check
|
||||||
|
if subpath.is_empty() || subpath == "/" {
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("DAV", "1, 3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
let internal_path = nc_to_internal_path(&user.username, subpath)?;
|
let internal_path = nc_to_internal_path(&user.username, subpath)?;
|
||||||
let file_service = &state.applications.file_retrieval_service;
|
let file_service = &state.applications.file_retrieval_service;
|
||||||
|
let folder_service = &state.applications.folder_service;
|
||||||
|
|
||||||
|
// Check if path is a folder (NC clients use HEAD as existence check)
|
||||||
|
if folder_service
|
||||||
|
.get_folder_by_path(&internal_path)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("DAV", "1, 3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
let file = file_service
|
let file = file_service
|
||||||
.get_file_by_path(&internal_path)
|
.get_file_by_path(&internal_path)
|
||||||
@@ -552,43 +598,75 @@ async fn handle_mkcol(
|
|||||||
use crate::application::dtos::folder_dto::CreateFolderDto;
|
use crate::application::dtos::folder_dto::CreateFolderDto;
|
||||||
|
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
|
let internal_path = nc_to_internal_path(&user.username, subpath)?;
|
||||||
|
|
||||||
// Split into parent + new folder name.
|
// If the folder already exists, return 405 per RFC 4918 §9.3.1
|
||||||
let (parent_subpath, folder_name) = match subpath.rsplit_once('/') {
|
if folder_service
|
||||||
Some((parent, name)) => (parent, name),
|
.get_folder_by_path(&internal_path)
|
||||||
None => ("", subpath),
|
|
||||||
};
|
|
||||||
|
|
||||||
let parent_internal = nc_to_internal_path(&user.username, parent_subpath)?;
|
|
||||||
|
|
||||||
// Resolve parent folder ID.
|
|
||||||
let parent_folder = folder_service
|
|
||||||
.get_folder_by_path(&parent_internal)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::not_found("Parent folder not found"))?;
|
.is_ok()
|
||||||
|
{
|
||||||
let dto = CreateFolderDto {
|
return Ok(Response::builder()
|
||||||
name: folder_name.to_string(),
|
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||||
parent_id: Some(parent_folder.id.clone()),
|
|
||||||
};
|
|
||||||
|
|
||||||
match folder_service.create_folder(dto).await {
|
|
||||||
Ok(_) => Ok(Response::builder()
|
|
||||||
.status(StatusCode::CREATED)
|
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap()),
|
.unwrap());
|
||||||
Err(e) if e.message.contains("already exists") || e.message.contains("Already Exists") => {
|
|
||||||
// RFC 4918 §9.3.1: MKCOL on existing resource → 405
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Err(e) => Err(AppError::internal_error(format!(
|
|
||||||
"Failed to create folder: {}",
|
|
||||||
e
|
|
||||||
))),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect path segments that need to be created (walk from root to leaf)
|
||||||
|
let segments: Vec<&str> = subpath.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
|
||||||
|
let user_root = nc_to_internal_path(&user.username, "")?;
|
||||||
|
let mut current_path = user_root.clone();
|
||||||
|
let mut parent_id = folder_service
|
||||||
|
.get_folder_by_path(&user_root)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::not_found("User root folder not found"))?
|
||||||
|
.id
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
for segment in &segments {
|
||||||
|
current_path = format!("{}/{}", current_path, segment);
|
||||||
|
match folder_service.get_folder_by_path(¤t_path).await {
|
||||||
|
Ok(existing) => {
|
||||||
|
parent_id = existing.id.clone();
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let dto = CreateFolderDto {
|
||||||
|
name: segment.to_string(),
|
||||||
|
parent_id: Some(parent_id.clone()),
|
||||||
|
};
|
||||||
|
match folder_service.create_folder(dto).await {
|
||||||
|
Ok(created) => {
|
||||||
|
parent_id = created.id.clone();
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
if e.message.contains("already exists")
|
||||||
|
|| e.message.contains("Already Exists") =>
|
||||||
|
{
|
||||||
|
// Race condition — folder created concurrently
|
||||||
|
let folder = folder_service
|
||||||
|
.get_folder_by_path(¤t_path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::internal_error("Folder exists but cannot be found")
|
||||||
|
})?;
|
||||||
|
parent_id = folder.id.clone();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(AppError::internal_error(format!(
|
||||||
|
"Failed to create folder: {}",
|
||||||
|
e
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::CREATED)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── DELETE ────────────────────
|
// ──────────────────── DELETE ────────────────────
|
||||||
@@ -874,10 +952,18 @@ async fn write_nc_multistatus<W: std::io::Write>(
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if depth != "0" {
|
// When folder is None, files are the target resource itself (single-file
|
||||||
|
// PROPFIND) and must always be emitted. When folder is Some, files/subfolders
|
||||||
|
// are children and should only be listed when depth > 0.
|
||||||
|
let emit_children = folder.is_none() || depth != "0";
|
||||||
|
|
||||||
|
if emit_children {
|
||||||
// Files.
|
// Files.
|
||||||
for file in files {
|
for file in files {
|
||||||
let child_sub = if subpath.is_empty() {
|
let child_sub = if folder.is_none() {
|
||||||
|
// Single-file PROPFIND — subpath already points to the file.
|
||||||
|
subpath.to_string()
|
||||||
|
} else if subpath.is_empty() {
|
||||||
file.name.clone()
|
file.name.clone()
|
||||||
} else {
|
} else {
|
||||||
format!("{}/{}", subpath.trim_end_matches('/'), file.name)
|
format!("{}/{}", subpath.trim_end_matches('/'), file.name)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
.list-header {
|
.list-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--files-list-columns);
|
grid-template-columns: var(--files-list-columns);
|
||||||
|
column-gap: 12px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2d3748;
|
color: #2d3748;
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
.file-item {
|
.file-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--files-list-columns);
|
grid-template-columns: var(--files-list-columns);
|
||||||
|
column-gap: 12px;
|
||||||
padding: 12px 15px;
|
padding: 12px 15px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -332,6 +334,7 @@
|
|||||||
border: 2px dashed #ffc107;
|
border: 2px dashed #ffc107;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[data-theme="dark"] .files-list-view {
|
[data-theme="dark"] .files-list-view {
|
||||||
background-color: #1e293b;
|
background-color: #1e293b;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
@@ -380,3 +383,4 @@
|
|||||||
[data-theme="dark"] .file-item.drop-target {
|
[data-theme="dark"] .file-item.drop-target {
|
||||||
background-color: rgba(255, 193, 7, 0.05);
|
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)
|
// Deselect all cards when clicking empty area (not on a card, menu, or modal)
|
||||||
// Note: multiSelect._hookGlobalDeselect() handles clearing the internal
|
// Note: multiSelect._hookGlobalDeselect() handles clearing the internal
|
||||||
// selection state; this handler only covers the legacy CSS class removal.
|
// 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')) {
|
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-card.selected').forEach(c => c.classList.remove('selected'));
|
||||||
document.querySelectorAll('.file-item.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) => {
|
container.addEventListener('mousedown', (e) => {
|
||||||
// Only start if clicking empty area (not on a card, button, menu, input…)
|
// Only start if clicking empty area (not on a card, button, menu, input…)
|
||||||
if (e.button !== 0) return; // left click only
|
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('.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;
|
active = true;
|
||||||
startX = e.clientX;
|
startX = e.clientX;
|
||||||
@@ -1420,9 +1422,16 @@ function initRubberBandSelection() {
|
|||||||
document.addEventListener('mouseup', () => {
|
document.addEventListener('mouseup', () => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
active = false;
|
active = false;
|
||||||
|
const hadSelection = selRect.style.display === 'block';
|
||||||
selRect.style.display = 'none';
|
selRect.style.display = 'none';
|
||||||
// Update the batch bar after rubber band selection completes
|
// Update the batch bar after rubber band selection completes
|
||||||
if (window.multiSelect) window.multiSelect._syncUI();
|
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() {
|
_hookGlobalDeselect() {
|
||||||
document.addEventListener('click', (e) => {
|
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 (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();
|
if (this.hasSelection) this.clear();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user