diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7f0d50529..d926c1c3d 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -30,17 +30,17 @@ thiserror.workspace = true opener = { version = "0.6.1", features = ["reveal"] } tauri = { version = "=1.5.3", features = [ - "macos-private-api", - "path-all", - "protocol-all", - "os-all", - "shell-all", - "dialog-all", - "linux-protocol-headers", - "updater", - "window-all", - "native-tls-vendored", - "tracing", + "macos-private-api", + "path-all", + "protocol-all", + "os-all", + "shell-all", + "dialog-all", + "linux-protocol-headers", + "updater", + "window-all", + "native-tls-vendored", + "tracing", ] } directories = "5.0.1" diff --git a/core/Cargo.toml b/core/Cargo.toml index 88763f862..06b99f942 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -25,15 +25,15 @@ sd-core-sync = { path = "./crates/sync" } # sd-cloud-api = { path = "../crates/cloud-api" } sd-file-path-helper = { path = "../crates/file-path-helper" } sd-crypto = { path = "../crates/crypto", features = [ - "sys", - "tokio", + "sys", + "tokio", ], optional = true } sd-ffmpeg = { path = "../crates/ffmpeg", optional = true } sd-file-ext = { path = "../crates/file-ext" } sd-images = { path = "../crates/images", features = [ - "rspc", - "serde", - "specta", + "rspc", + "serde", + "specta", ] } sd-media-metadata = { path = "../crates/media-metadata" } sd-p2p = { path = "../crates/p2p", features = ["specta"] } @@ -64,12 +64,12 @@ regex = { workspace = true } reqwest = { workspace = true, features = ["json", "native-tls-vendored"] } rmp-serde = { workspace = true } rspc = { workspace = true, features = [ - "axum", - "uuid", - "chrono", - "tracing", - "alpha", - "unstable", + "axum", + "uuid", + "chrono", + "tracing", + "alpha", + "unstable", ] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -79,12 +79,12 @@ strum_macros = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = [ - "sync", - "rt-multi-thread", - "io-util", - "macros", - "time", - "process", + "sync", + "rt-multi-thread", + "io-util", + "macros", + "time", + "process", ] } tokio-stream = { workspace = true, features = ["fs"] } tokio-util = { workspace = true, features = ["io"] } @@ -111,7 +111,7 @@ itertools = "0.12.0" libc = "0.2.153" mini-moka = "0.10.2" notify = { git = "https://github.com/notify-rs/notify.git", rev = "c3929ed114fbb0bc7457a9a498260461596b00ca", default-features = false, features = [ - "macos_fsevent", + "macos_fsevent", ] } rmpv = { workspace = true } serde-hashkey = "0.4.5" @@ -144,10 +144,10 @@ plist = "1" [target.'cfg(target_os = "ios")'.dependencies] icrate = { version = "0.1.0", features = [ - "Foundation", - "Foundation_NSFileManager", - "Foundation_NSString", - "Foundation_NSNumber", + "Foundation", + "Foundation_NSFileManager", + "Foundation_NSString", + "Foundation_NSNumber", ] } [dev-dependencies] diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 06c6b76cf..de058f8a2 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -1,224 +1,224 @@ datasource db { - provider = "sqlite" - url = "file:dev.db" + provider = "sqlite" + url = "file:dev.db" } generator client { - provider = "cargo prisma" - output = "../../crates/prisma/src/prisma" - module_path = "prisma" - client_format = "folder" + provider = "cargo prisma" + output = "../../crates/prisma/src/prisma" + module_path = "prisma" + client_format = "folder" } generator sync { - provider = "cargo prisma-sync" - output = "../../crates/prisma/src/prisma_sync" - client_format = "folder" + provider = "cargo prisma-sync" + output = "../../crates/prisma/src/prisma_sync" + client_format = "folder" } model CRDTOperation { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) - timestamp BigInt - model String + timestamp BigInt + model String - record_id Bytes - // Enum: ?? - kind String - data Bytes + record_id Bytes + // Enum: ?? + kind String + data Bytes - instance_id Int - instance Instance @relation(fields: [instance_id], references: [id]) + instance_id Int + instance Instance @relation(fields: [instance_id], references: [id]) - @@map("crdt_operation") + @@map("crdt_operation") } /// @deprecated: This model has to exist solely for backwards compatibility. model Node { - id Int @id @default(autoincrement()) - pub_id Bytes @unique - name String - // Enum: sd_core::node::Platform - platform Int - date_created DateTime - identity Bytes? // TODO: Change to required field in future + id Int @id @default(autoincrement()) + pub_id Bytes @unique + name String + // Enum: sd_core::node::Platform + platform Int + date_created DateTime + identity Bytes? // TODO: Change to required field in future - @@map("node") + @@map("node") } /// @local(id: pub_id) // represents a single `.db` file (SQLite DB) that is paired to the current library. // A `LibraryInstance` is always owned by a single `Node` but it's possible for that node to change (or two to be owned by a single node). model Instance { - id Int @id @default(autoincrement()) // This is is NOT globally unique - pub_id Bytes @unique // This UUID is meaningless and exists soley cause the `uhlc::ID` must be 16-bit. Really this should be derived from the `identity` field. - // Enum: sd_p2p::Identity (or sd_core::p2p::IdentityOrRemoteIdentity in early versions) - identity Bytes? - // Enum: sd_core::node::RemoteIdentity - remote_identity Bytes + id Int @id @default(autoincrement()) // This is is NOT globally unique + pub_id Bytes @unique // This UUID is meaningless and exists soley cause the `uhlc::ID` must be 16-bit. Really this should be derived from the `identity` field. + // Enum: sd_p2p::Identity (or sd_core::p2p::IdentityOrRemoteIdentity in early versions) + identity Bytes? + // Enum: sd_core::node::RemoteIdentity + remote_identity Bytes - node_id Bytes - metadata Bytes? // TODO: This should not be optional + node_id Bytes + metadata Bytes? // TODO: This should not be optional - last_seen DateTime // Time core started for owner, last P2P message for P2P node - date_created DateTime + last_seen DateTime // Time core started for owner, last P2P message for P2P node + date_created DateTime - // clock timestamp for sync - timestamp BigInt? + // clock timestamp for sync + timestamp BigInt? - locations Location[] - CRDTOperation CRDTOperation[] - CloudCRDTOperation CloudCRDTOperation[] + locations Location[] + CRDTOperation CRDTOperation[] + CloudCRDTOperation CloudCRDTOperation[] - @@map("instance") + @@map("instance") } model Statistics { - id Int @id @default(autoincrement()) - date_captured DateTime @default(now()) - total_object_count Int @default(0) - library_db_size String @default("0") - total_bytes_used String @default("0") - total_bytes_capacity String @default("0") - total_unique_bytes String @default("0") - total_bytes_free String @default("0") - preview_media_bytes String @default("0") + id Int @id @default(autoincrement()) + date_captured DateTime @default(now()) + total_object_count Int @default(0) + library_db_size String @default("0") + total_bytes_used String @default("0") + total_bytes_capacity String @default("0") + total_unique_bytes String @default("0") + total_bytes_free String @default("0") + preview_media_bytes String @default("0") - @@map("statistics") + @@map("statistics") } /// @local model Volume { - id Int @id @default(autoincrement()) - name String - mount_point String - total_bytes_capacity String @default("0") - total_bytes_available String @default("0") - disk_type String? - filesystem String? - is_system Boolean @default(false) - date_modified DateTime @default(now()) + id Int @id @default(autoincrement()) + name String + mount_point String + total_bytes_capacity String @default("0") + total_bytes_available String @default("0") + disk_type String? + filesystem String? + is_system Boolean @default(false) + date_modified DateTime @default(now()) - @@unique([mount_point, name]) - @@map("volume") + @@unique([mount_point, name]) + @@map("volume") } /// @shared(id: pub_id) model Location { - id Int @id @default(autoincrement()) - pub_id Bytes @unique + id Int @id @default(autoincrement()) + pub_id Bytes @unique - name String? - path String? - total_capacity Int? - available_capacity Int? - size_in_bytes Bytes? - is_archived Boolean? - generate_preview_media Boolean? - sync_preview_media Boolean? - hidden Boolean? - date_created DateTime? + name String? + path String? + total_capacity Int? + available_capacity Int? + size_in_bytes Bytes? + is_archived Boolean? + generate_preview_media Boolean? + sync_preview_media Boolean? + hidden Boolean? + date_created DateTime? - /// @local - // this is just a client side cache which is annoying but oh well (@brendan) - instance_id Int? - instance Instance? @relation(fields: [instance_id], references: [id], onDelete: SetNull) + /// @local + // this is just a client side cache which is annoying but oh well (@brendan) + instance_id Int? + instance Instance? @relation(fields: [instance_id], references: [id], onDelete: SetNull) - file_paths FilePath[] - indexer_rules IndexerRulesInLocation[] + file_paths FilePath[] + indexer_rules IndexerRulesInLocation[] - @@map("location") + @@map("location") } /// @shared(id: pub_id) model FilePath { - id Int @id @default(autoincrement()) - pub_id Bytes @unique + id Int @id @default(autoincrement()) + pub_id Bytes @unique - is_dir Boolean? + is_dir Boolean? - // content addressable storage id - blake3 sampled checksum - cas_id String? - // full byte contents digested into blake3 checksum - integrity_checksum String? + // content addressable storage id - blake3 sampled checksum + cas_id String? + // full byte contents digested into blake3 checksum + integrity_checksum String? - // location that owns this path - location_id Int? - location Location? @relation(fields: [location_id], references: [id], onDelete: SetNull) + // location that owns this path + location_id Int? + location Location? @relation(fields: [location_id], references: [id], onDelete: SetNull) - // the path of the file relative to its location - materialized_path String? + // the path of the file relative to its location + materialized_path String? - // the name and extension, MUST have 'COLLATE NOCASE' in migration - name String? - extension String? - hidden Boolean? + // the name and extension, MUST have 'COLLATE NOCASE' in migration + name String? + extension String? + hidden Boolean? - size_in_bytes String? // deprecated - size_in_bytes_bytes Bytes? + size_in_bytes String? // deprecated + size_in_bytes_bytes Bytes? - inode Bytes? // This is actually an unsigned 64 bit integer, but we don't have this type in SQLite + inode Bytes? // This is actually an unsigned 64 bit integer, but we don't have this type in SQLite - // the unique Object for this file path - object_id Int? - object Object? @relation(fields: [object_id], references: [id], onDelete: SetNull) + // the unique Object for this file path + object_id Int? + object Object? @relation(fields: [object_id], references: [id], onDelete: SetNull) - key_id Int? // replacement for encryption - // permissions String? + key_id Int? // replacement for encryption + // permissions String? - date_created DateTime? - date_modified DateTime? - date_indexed DateTime? + date_created DateTime? + date_modified DateTime? + date_indexed DateTime? - // key Key? @relation(fields: [key_id], references: [id]) + // key Key? @relation(fields: [key_id], references: [id]) - @@unique([location_id, materialized_path, name, extension]) - @@unique([location_id, inode]) - @@index([location_id]) - @@index([location_id, materialized_path]) - @@map("file_path") + @@unique([location_id, materialized_path, name, extension]) + @@unique([location_id, inode]) + @@index([location_id]) + @@index([location_id, materialized_path]) + @@map("file_path") } /// @shared(id: pub_id) model Object { - id Int @id @default(autoincrement()) - pub_id Bytes @unique - // Enum: sd_file_ext::kind::ObjectKind - kind Int? + id Int @id @default(autoincrement()) + pub_id Bytes @unique + // Enum: sd_file_ext::kind::ObjectKind + kind Int? - key_id Int? - // handy ways to mark an object - hidden Boolean? - favorite Boolean? - important Boolean? - // if we have generated preview media for this object on at least one Node - // commented out for now by @brendonovich since they they're irrelevant to the sync system - // has_thumbnail Boolean? - // has_thumbstrip Boolean? - // has_video_preview Boolean? - // TODO: change above to: - // has_generated_thumbnail Boolean @default(false) - // has_generated_thumbstrip Boolean @default(false) - // has_generated_video_preview Boolean @default(false) - // integration with ipfs - // ipfs_id String? - // plain text note - note String? - // the original known creation date of this object - date_created DateTime? - date_accessed DateTime? + key_id Int? + // handy ways to mark an object + hidden Boolean? + favorite Boolean? + important Boolean? + // if we have generated preview media for this object on at least one Node + // commented out for now by @brendonovich since they they're irrelevant to the sync system + // has_thumbnail Boolean? + // has_thumbstrip Boolean? + // has_video_preview Boolean? + // TODO: change above to: + // has_generated_thumbnail Boolean @default(false) + // has_generated_thumbstrip Boolean @default(false) + // has_generated_video_preview Boolean @default(false) + // integration with ipfs + // ipfs_id String? + // plain text note + note String? + // the original known creation date of this object + date_created DateTime? + date_accessed DateTime? - tags TagOnObject[] - labels LabelOnObject[] - albums ObjectInAlbum[] - spaces ObjectInSpace[] - file_paths FilePath[] - // comments Comment[] - media_data MediaData? + tags TagOnObject[] + labels LabelOnObject[] + albums ObjectInAlbum[] + spaces ObjectInSpace[] + file_paths FilePath[] + // comments Comment[] + media_data MediaData? - // key Key? @relation(fields: [key_id], references: [id]) + // key Key? @relation(fields: [key_id], references: [id]) - @@map("object") + @@map("object") } // if there is a conflicting cas_id, the conficting file should be updated to have a larger cas_id as @@ -276,181 +276,181 @@ model Object { /// @shared(id: object) model MediaData { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) - resolution Bytes? - media_date Bytes? - media_location Bytes? - camera_data Bytes? - artist String? - description String? - copyright String? - exif_version String? + resolution Bytes? + media_date Bytes? + media_location Bytes? + camera_data Bytes? + artist String? + description String? + copyright String? + exif_version String? - // purely for sorting/ordering, never sent to the frontend as they'd be useless - // these are also usually one-way, and not reversible - // (e.g. we can't get `MediaDate::Utc(2023-09-26T22:04:37+01:00)` from `1695758677` as we don't store the TZ) - epoch_time BigInt? // time since unix epoch + // purely for sorting/ordering, never sent to the frontend as they'd be useless + // these are also usually one-way, and not reversible + // (e.g. we can't get `MediaDate::Utc(2023-09-26T22:04:37+01:00)` from `1695758677` as we don't store the TZ) + epoch_time BigInt? // time since unix epoch - // video-specific - // duration Int? - // fps Int? - // streams Int? - // video_codec String? // eg: "h264, h265, av1" - // audio_codec String? // eg: "opus" + // video-specific + // duration Int? + // fps Int? + // streams Int? + // video_codec String? // eg: "h264, h265, av1" + // audio_codec String? // eg: "opus" - object_id Int @unique - object Object @relation(fields: [object_id], references: [id], onDelete: Cascade) + object_id Int @unique + object Object @relation(fields: [object_id], references: [id], onDelete: Cascade) - @@map("media_data") + @@map("media_data") } //// Tag //// /// @shared(id: pub_id) model Tag { - id Int @id @default(autoincrement()) - pub_id Bytes @unique - name String? - color String? + id Int @id @default(autoincrement()) + pub_id Bytes @unique + name String? + color String? - is_hidden Boolean? // user hidden entire tag + is_hidden Boolean? // user hidden entire tag - date_created DateTime? - date_modified DateTime? + date_created DateTime? + date_modified DateTime? - tag_objects TagOnObject[] + tag_objects TagOnObject[] - @@map("tag") + @@map("tag") } /// @relation(item: object, group: tag) model TagOnObject { - object_id Int - object Object @relation(fields: [object_id], references: [id], onDelete: Restrict) + object_id Int + object Object @relation(fields: [object_id], references: [id], onDelete: Restrict) - tag_id Int - tag Tag @relation(fields: [tag_id], references: [id], onDelete: Restrict) + tag_id Int + tag Tag @relation(fields: [tag_id], references: [id], onDelete: Restrict) - date_created DateTime? + date_created DateTime? - @@id([tag_id, object_id]) - @@map("tag_on_object") + @@id([tag_id, object_id]) + @@map("tag_on_object") } //// Label //// /// @shared(id: name) model Label { - id Int @id @default(autoincrement()) - name String @unique - date_created DateTime? - date_modified DateTime? + id Int @id @default(autoincrement()) + name String @unique + date_created DateTime? + date_modified DateTime? - label_objects LabelOnObject[] + label_objects LabelOnObject[] - @@map("label") + @@map("label") } /// @relation(item: object, group: label) model LabelOnObject { - date_created DateTime @default(now()) + date_created DateTime @default(now()) - object_id Int - object Object @relation(fields: [object_id], references: [id], onDelete: Restrict) + object_id Int + object Object @relation(fields: [object_id], references: [id], onDelete: Restrict) - label_id Int - label Label @relation(fields: [label_id], references: [id], onDelete: Restrict) + label_id Int + label Label @relation(fields: [label_id], references: [id], onDelete: Restrict) - @@id([label_id, object_id]) - @@map("label_on_object") + @@id([label_id, object_id]) + @@map("label_on_object") } //// Space //// model Space { - id Int @id @default(autoincrement()) - pub_id Bytes @unique - name String? - description String? - date_created DateTime? - date_modified DateTime? + id Int @id @default(autoincrement()) + pub_id Bytes @unique + name String? + description String? + date_created DateTime? + date_modified DateTime? - objects ObjectInSpace[] + objects ObjectInSpace[] - @@map("space") + @@map("space") } model ObjectInSpace { - space_id Int - space Space @relation(fields: [space_id], references: [id], onDelete: Restrict) + space_id Int + space Space @relation(fields: [space_id], references: [id], onDelete: Restrict) - object_id Int - object Object @relation(fields: [object_id], references: [id], onDelete: Restrict) + object_id Int + object Object @relation(fields: [object_id], references: [id], onDelete: Restrict) - @@id([space_id, object_id]) - @@map("object_in_space") + @@id([space_id, object_id]) + @@map("object_in_space") } //// Job //// model Job { - id Bytes @id + id Bytes @id - name String? - action String? // Will be composed of "{action_description}(-{children_order})*" + name String? + action String? // Will be composed of "{action_description}(-{children_order})*" - // Enum: sd_core::job::job_manager:JobStatus - status Int? // 0 = Queued + // Enum: sd_core::job::job_manager:JobStatus + status Int? // 0 = Queued - // List of errors, separated by "\n\n" in case of failed jobs or completed with errors - errors_text String? + // List of errors, separated by "\n\n" in case of failed jobs or completed with errors + errors_text String? - data Bytes? // Serialized data to be used on pause/resume - metadata Bytes? // Serialized metadata field with info about the job after completion + data Bytes? // Serialized data to be used on pause/resume + metadata Bytes? // Serialized metadata field with info about the job after completion - parent_id Bytes? + parent_id Bytes? - task_count Int? - completed_task_count Int? - date_estimated_completion DateTime? // Estimated timestamp that the job will be complete at + task_count Int? + completed_task_count Int? + date_estimated_completion DateTime? // Estimated timestamp that the job will be complete at - date_created DateTime? - date_started DateTime? // Started execution - date_completed DateTime? // Finished execution + date_created DateTime? + date_started DateTime? // Started execution + date_completed DateTime? // Finished execution - parent Job? @relation("jobs_dependency", fields: [parent_id], references: [id], onDelete: SetNull) - children Job[] @relation("jobs_dependency") + parent Job? @relation("jobs_dependency", fields: [parent_id], references: [id], onDelete: SetNull) + children Job[] @relation("jobs_dependency") - @@map("job") + @@map("job") } //// Album //// model Album { - id Int @id - pub_id Bytes @unique - name String? - is_hidden Boolean? + id Int @id + pub_id Bytes @unique + name String? + is_hidden Boolean? - date_created DateTime? - date_modified DateTime? + date_created DateTime? + date_modified DateTime? - objects ObjectInAlbum[] + objects ObjectInAlbum[] - @@map("album") + @@map("album") } model ObjectInAlbum { - date_created DateTime? - album_id Int - album Album @relation(fields: [album_id], references: [id], onDelete: NoAction) + date_created DateTime? + album_id Int + album Album @relation(fields: [album_id], references: [id], onDelete: NoAction) - object_id Int - object Object @relation(fields: [object_id], references: [id], onDelete: NoAction) + object_id Int + object Object @relation(fields: [object_id], references: [id], onDelete: NoAction) - @@id([album_id, object_id]) - @@map("object_in_album") + @@id([album_id, object_id]) + @@map("object_in_album") } //// Comment //// @@ -470,82 +470,84 @@ model ObjectInAlbum { //// Indexer Rules //// model IndexerRule { - id Int @id @default(autoincrement()) - pub_id Bytes @unique + id Int @id @default(autoincrement()) + pub_id Bytes @unique - name String? - default Boolean? - rules_per_kind Bytes? - date_created DateTime? - date_modified DateTime? + name String? + default Boolean? + rules_per_kind Bytes? + date_created DateTime? + date_modified DateTime? - locations IndexerRulesInLocation[] + locations IndexerRulesInLocation[] - @@map("indexer_rule") + @@map("indexer_rule") } model IndexerRulesInLocation { - location_id Int - location Location @relation(fields: [location_id], references: [id], onDelete: Restrict) + location_id Int + location Location @relation(fields: [location_id], references: [id], onDelete: Restrict) - indexer_rule_id Int - indexer_rule IndexerRule @relation(fields: [indexer_rule_id], references: [id], onDelete: Restrict) + indexer_rule_id Int + indexer_rule IndexerRule @relation(fields: [indexer_rule_id], references: [id], onDelete: Restrict) - @@id([location_id, indexer_rule_id]) - @@map("indexer_rule_in_location") + @@id([location_id, indexer_rule_id]) + @@map("indexer_rule_in_location") } /// @shared(id: key) model Preference { - key String @id - value Bytes? + key String @id + value Bytes? - @@map("preference") + @@map("preference") } model Notification { - id Int @id @default(autoincrement()) - read Boolean @default(false) - // Enum: crate::api::notifications::NotificationData - data Bytes - expires_at DateTime? + id Int @id @default(autoincrement()) + read Boolean @default(false) + // Enum: crate::api::notifications::NotificationData + data Bytes + expires_at DateTime? - @@map("notification") + @@map("notification") } /// @shared(id: pub_id) model SavedSearch { - id Int @id @default(autoincrement()) - pub_id Bytes @unique + id Int @id @default(autoincrement()) + pub_id Bytes @unique - search String? - filters String? + // enum: crate::api::search::saved::SearchTarget + target String? + search String? + filters String? - name String? - icon String? - description String? - // order Int? // Add this line to include ordering + name String? + icon String? + description String? + // order Int? // Add this line to include ordering - date_created DateTime? - date_modified DateTime? + date_created DateTime? + date_modified DateTime? - @@map("saved_search") + @@map("saved_search") } /// @local(id: id) model CloudCRDTOperation { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) - timestamp BigInt - model String + timestamp BigInt + model String - record_id Bytes - // Enum: ?? - kind String - data Bytes + record_id Bytes + // Enum: ?? + kind String + data Bytes - instance_id Int - instance Instance @relation(fields: [instance_id], references: [id]) + instance_id Int + instance Instance @relation(fields: [instance_id], references: [id]) - @@map("cloud_crdt_operation") + @@map("cloud_crdt_operation") } diff --git a/core/src/api/search/saved.rs b/core/src/api/search/saved.rs index 58e2b37d8..266455a64 100644 --- a/core/src/api/search/saved.rs +++ b/core/src/api/search/saved.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use crate::{api::utils::library, invalidate_query, library::Library}; use sd_prisma::{prisma::saved_search, prisma_sync}; @@ -6,21 +8,52 @@ use sd_utils::chain_optional_iter; use chrono::{DateTime, FixedOffset, Utc}; use rspc::alpha::AlphaRouter; -use serde::{de::IgnoredAny, Deserialize, Serialize}; +use serde::{de::IgnoredAny, Deserialize}; use specta::Type; use tracing::error; use uuid::Uuid; use super::{Ctx, R}; +#[derive(Type, Deserialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +enum SearchTarget { + #[default] + Paths, + Objects, +} + +impl std::fmt::Display for SearchTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SearchTarget::Paths => write!(f, "paths"), + SearchTarget::Objects => write!(f, "objects"), + } + } +} + +impl FromStr for SearchTarget { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "paths" => Ok(SearchTarget::Paths), + "objects" => Ok(SearchTarget::Objects), + _ => Err(format!("invalid search target: {s}")), + } + } +} + pub(crate) fn mount() -> AlphaRouter { R.router() .procedure("create", { R.with2(library()).mutation({ - #[derive(Serialize, Type, Deserialize, Clone, Debug)] + #[derive(Type, Deserialize, Clone, Debug)] #[specta(inline)] pub struct Args { pub name: String, + #[serde(default)] + pub target: SearchTarget, #[specta(optional)] pub search: Option, #[specta(optional)] @@ -40,6 +73,7 @@ pub(crate) fn mount() -> AlphaRouter { [ sync_db_entry!(date_created, saved_search::date_created), sync_db_entry!(args.name, saved_search::name), + sync_db_entry!(args.target.to_string(), saved_search::target), ], [ option_sync_db_entry!( diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index 26d51f22f..4192a4e43 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -11,7 +11,7 @@ import type { NodeState, Tag } from '@sd/client'; -import { type Ordering, type OrderingKeys } from '@sd/client'; +import { ObjectKindEnum, type Ordering, type OrderingKeys } from '@sd/client'; import { createDefaultExplorerSettings } from './store'; import { uniqueId } from './util'; @@ -128,6 +128,12 @@ export function useExplorerSettings({ return { useSettingsSnapshot: () => useSnapshot(store), + useLayoutSearchFilters: () => { + const explorerSettingsSnapshot = useSnapshot(store); + return explorerSettingsSnapshot.layoutMode === 'media' + ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] + : []; + }, settingsStore: store, orderingKeys }; diff --git a/interface/app/$libraryId/favorites.tsx b/interface/app/$libraryId/favorites.tsx index f3f53da44..984c7c6f7 100644 --- a/interface/app/$libraryId/favorites.tsx +++ b/interface/app/$libraryId/favorites.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { ObjectKindEnum, ObjectOrder, SearchFilterArgs, useObjectsExplorerQuery } from '@sd/client'; +import { ObjectOrder } from '@sd/client'; import { Icon } from '~/components'; import { useRouteTitle } from '~/hooks'; @@ -9,8 +9,9 @@ import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explo import { DefaultTopBarOptions } from './Explorer/TopBarOptions'; import { useExplorer, useExplorerSettings } from './Explorer/useExplorer'; import { EmptyNotice } from './Explorer/View/EmptyNotice'; -import { SearchContextProvider, SearchOptions, useSearch } from './search'; +import { SearchContextProvider, SearchOptions, useSearchFromSearchParams } from './search'; import SearchBar from './search/SearchBar'; +import { useSearchExplorerQuery } from './search/useSearchExplorerQuery'; import { TopBarPortal } from './TopBar/Portal'; export function Component() { @@ -23,37 +24,25 @@ export function Component() { orderingKeys: objectOrderingKeysSchema }); - const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + const search = useSearchFromSearchParams(); - const fixedFilters = useMemo( - () => [ - // { object: { favorite: true } }, - ...(explorerSettingsSnapshot.layoutMode === 'media' - ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] - : []) + const defaultFilter = { object: { favorite: true } }; + + const items = useSearchExplorerQuery({ + search, + explorerSettings, + filters: [ + ...search.allFilters, + // TODO: Add filter to search options + defaultFilter ], - [explorerSettingsSnapshot.layoutMode] - ); - - const search = useSearch({ - fixedFilters - }); - - const objects = useObjectsExplorerQuery({ - arg: { - take: 100, - filters: [ - ...search.allFilters, - // TODO: Add filter to search options - { object: { favorite: true } } - ] - }, - order: explorerSettings.useSettingsSnapshot().order + take: 100, + objects: { order: explorerSettings.useSettingsSnapshot().order } }); const explorer = useExplorer({ - ...objects, - isFetchingNextPage: objects.query.isFetchingNextPage, + ...items, + isFetchingNextPage: items.query.isFetchingNextPage, settings: explorerSettings }); @@ -61,7 +50,7 @@ export function Component() { } + center={} left={
Favorites diff --git a/interface/app/$libraryId/labels.tsx b/interface/app/$libraryId/labels.tsx index 24beaae25..1d8ea9790 100644 --- a/interface/app/$libraryId/labels.tsx +++ b/interface/app/$libraryId/labels.tsx @@ -9,7 +9,12 @@ import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explo import { DefaultTopBarOptions } from './Explorer/TopBarOptions'; import { useExplorer, useExplorerSettings } from './Explorer/useExplorer'; import { EmptyNotice } from './Explorer/View/EmptyNotice'; -import { SearchContextProvider, SearchOptions, useSearch } from './search'; +import { + SearchContextProvider, + SearchOptions, + useSearch, + useSearchFromSearchParams +} from './search'; import SearchBar from './search/SearchBar'; import { TopBarPortal } from './TopBar/Portal'; @@ -25,26 +30,7 @@ export function Component() { orderingKeys: objectOrderingKeysSchema }); - // const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); - - // const fixedFilters = useMemo( - // () => [ - // ...(explorerSettingsSnapshot.layoutMode === 'media' - // ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] - // : []) - // ], - // [explorerSettingsSnapshot.layoutMode] - // ); - - const search = useSearch({}); - - // const objects = useObjectsExplorerQuery({ - // arg: { - // take: 100, - // filters: [...search.allFilters, { object: { tags: { in: [3] } } }] - // }, - // explorerSettings - // }); + const search = useSearchFromSearchParams(); const explorer = useExplorer({ items: labels.data || null, diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx index 03720f20a..925d72b2f 100644 --- a/interface/app/$libraryId/location/$id.tsx +++ b/interface/app/$libraryId/location/$id.tsx @@ -1,20 +1,17 @@ import { ArrowClockwise, Info } from '@phosphor-icons/react'; import { useEffect, useMemo } from 'react'; -import { useSearchParams as useRawSearchParams } from 'react-router-dom'; import { stringify } from 'uuid'; import { arraysEqual, ExplorerSettings, FilePathOrder, Location, - ObjectKindEnum, useCache, useLibraryMutation, useLibraryQuery, useLibrarySubscription, useNodes, useOnlineLocations, - usePathsExplorerQuery, useRspcLibraryContext } from '@sd/client'; import { Loader, Tooltip } from '@sd/ui'; @@ -38,11 +35,12 @@ import { filePathOrderingKeysSchema } from '../Explorer/store'; import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; -import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; +import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { useExplorerSearchParams } from '../Explorer/util'; import { EmptyNotice } from '../Explorer/View/EmptyNotice'; -import { SearchContextProvider, SearchOptions, useSearch } from '../search'; +import { SearchContextProvider, SearchOptions, useSearchFromSearchParams } from '../search'; import SearchBar from '../search/SearchBar'; +import { useSearchExplorerQuery } from '../search/useSearchExplorerQuery'; import { TopBarPortal } from '../TopBar/Portal'; import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions'; import LocationOptions from './LocationOptions'; @@ -71,35 +69,47 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) = const { layoutMode, mediaViewWithDescendants, showHiddenFiles } = explorerSettings.useSettingsSnapshot(); - const search = useLocationSearch(explorerSettings, location); + const defaultFilters = useMemo( + () => [{ filePath: { locations: { in: [location.id] } } }], + [location.id] + ); - const paths = usePathsExplorerQuery({ - arg: { - filters: [ - ...search.allFilters, - { - filePath: { - path: { - location_id: location.id, - path: path ?? '', - include_descendants: - search.search !== '' || - search.dynamicFilters.length > 0 || - (layoutMode === 'media' && mediaViewWithDescendants) - } + const search = useSearchFromSearchParams(); + + const searchFiltersAreDefault = useMemo( + () => JSON.stringify(defaultFilters) !== JSON.stringify(search.filters), + [defaultFilters, search.filters] + ); + + const items = useSearchExplorerQuery({ + search, + explorerSettings, + filters: [ + ...(search.allFilters.length > 0 ? search.allFilters : defaultFilters), + { + filePath: { + path: { + location_id: location.id, + path: path ?? '', + include_descendants: + search.search !== '' || + (search.filters && + search.filters.length > 0 && + searchFiltersAreDefault) || + (layoutMode === 'media' && mediaViewWithDescendants) } - }, - !showHiddenFiles && { filePath: { hidden: false } } - ].filter(Boolean) as any, - take - }, - order: explorerSettings.useSettingsSnapshot().order, + } + }, + ...(!showHiddenFiles ? [{ filePath: { hidden: false } }] : []) + ], + take, + paths: { order: explorerSettings.useSettingsSnapshot().order }, onSuccess: () => explorerStore.resetNewThumbnails() }); const explorer = useExplorer({ - ...paths, - isFetchingNextPage: paths.query.isFetchingNextPage, + ...items, + isFetchingNextPage: items.query.isFetchingNextPage, isLoadingPreferences: preferences.isLoading, settings: explorerSettings, parent: { type: 'Location', location } @@ -134,7 +144,7 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) = } + center={} left={
@@ -267,62 +277,3 @@ function useLocationExplorerSettings(location: Location) { preferences }; } - -function useLocationSearch( - explorerSettings: UseExplorerSettings, - location: Location -) { - const [searchParams, setSearchParams] = useRawSearchParams(); - const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); - - const fixedFilters = useMemo( - () => [ - { filePath: { locations: { in: [location.id] } } }, - ...(explorerSettingsSnapshot.layoutMode === 'media' - ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] - : []) - ], - [location.id, explorerSettingsSnapshot.layoutMode] - ); - - const filtersParam = searchParams.get('filters'); - const dynamicFilters = useMemo(() => JSON.parse(filtersParam ?? '[]'), [filtersParam]); - - const searchQueryParam = searchParams.get('search'); - - const search = useSearch({ - open: !!searchQueryParam || dynamicFilters.length > 0 || undefined, - search: searchParams.get('search') ?? undefined, - fixedFilters, - dynamicFilters - }); - - useEffect(() => { - setSearchParams( - (p) => { - if (search.dynamicFilters.length > 0) - p.set('filters', JSON.stringify(search.dynamicFilters)); - else p.delete('filters'); - - return p; - }, - { replace: true } - ); - }, [search.dynamicFilters, setSearchParams]); - - const searchQuery = search.search; - - useEffect(() => { - setSearchParams( - (p) => { - if (searchQuery !== '') p.set('search', searchQuery); - else p.delete('search'); - - return p; - }, - { replace: true } - ); - }, [searchQuery, setSearchParams]); - - return search; -} diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index 631189843..ee7c6c048 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -4,7 +4,7 @@ import { useLocale } from '~/hooks'; import { useRouteTitle } from '~/hooks/useRouteTitle'; import { hardwareModelToIcon } from '~/util/hardware'; -import { SearchContextProvider, useSearch } from '../search'; +import { SearchContextProvider, useSearch, useSearchFromSearchParams } from '../search'; import SearchBar from '../search/SearchBar'; import { AddLocationButton } from '../settings/library/locations/AddLocationButton'; import { TopBarPortal } from '../TopBar/Portal'; @@ -25,7 +25,7 @@ export const Component = () => { const { data: node } = useBridgeQuery(['nodeState']); - const search = useSearch(); + const search = useSearchFromSearchParams(); const stats = useLibraryQuery(['library.statistics']); diff --git a/interface/app/$libraryId/recents.tsx b/interface/app/$libraryId/recents.tsx index f415a0307..62233341e 100644 --- a/interface/app/$libraryId/recents.tsx +++ b/interface/app/$libraryId/recents.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { ObjectKindEnum, ObjectOrder, SearchFilterArgs, useObjectsExplorerQuery } from '@sd/client'; +import { ObjectOrder } from '@sd/client'; import { Icon } from '~/components'; import { useRouteTitle } from '~/hooks'; @@ -9,8 +9,9 @@ import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explo import { DefaultTopBarOptions } from './Explorer/TopBarOptions'; import { useExplorer, useExplorerSettings } from './Explorer/useExplorer'; import { EmptyNotice } from './Explorer/View/EmptyNotice'; -import { SearchContextProvider, SearchOptions, useSearch } from './search'; +import { SearchContextProvider, SearchOptions, useSearchFromSearchParams } from './search'; import SearchBar from './search/SearchBar'; +import { useSearchExplorerQuery } from './search/useSearchExplorerQuery'; import { TopBarPortal } from './TopBar/Portal'; export function Component() { @@ -23,37 +24,25 @@ export function Component() { orderingKeys: objectOrderingKeysSchema }); - const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + const search = useSearchFromSearchParams(); - const fixedFilters = useMemo( - () => [ - // { object: { dateAccessed: { from: new Date(0).toISOString() } } }, - ...(explorerSettingsSnapshot.layoutMode === 'media' - ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] - : []) + const defaultFilters = { object: { dateAccessed: { from: new Date(0).toISOString() } } }; + + const items = useSearchExplorerQuery({ + search, + explorerSettings, + filters: [ + ...search.allFilters, + // TODO: Add fil ter to search options + defaultFilters ], - [explorerSettingsSnapshot.layoutMode] - ); - - const search = useSearch({ - fixedFilters - }); - - const objects = useObjectsExplorerQuery({ - arg: { - take: 100, - filters: [ - ...search.allFilters, - // TODO: Add fil ter to search options - { object: { dateAccessed: { from: new Date(0).toISOString() } } } - ] - }, - order: explorerSettings.useSettingsSnapshot().order + take: 100, + objects: { order: explorerSettings.useSettingsSnapshot().order } }); const explorer = useExplorer({ - ...objects, - isFetchingNextPage: objects.query.isFetchingNextPage, + ...items, + isFetchingNextPage: items.query.isFetchingNextPage, settings: explorerSettings }); @@ -61,7 +50,7 @@ export function Component() { } + center={} left={
Recents diff --git a/interface/app/$libraryId/saved-search/$id.tsx b/interface/app/$libraryId/saved-search/$id.tsx index 1ce371d9e..dc0a53e5b 100644 --- a/interface/app/$libraryId/saved-search/$id.tsx +++ b/interface/app/$libraryId/saved-search/$id.tsx @@ -1,16 +1,17 @@ import { MagnifyingGlass } from '@phosphor-icons/react'; import { getIcon, iconNames } from '@sd/assets/util'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; +import { useParams } from 'react-router'; import { FilePathOrder, SearchFilterArgs, + SearchTarget, useLibraryMutation, - useLibraryQuery, - usePathsExplorerQuery + useLibraryQuery } from '@sd/client'; import { Button } from '@sd/ui'; import { SearchIdParamsSchema } from '~/app/route-schemas'; -import { useRouteTitle, useZodRouteParams } from '~/hooks'; +import { useRouteTitle, useZodParams } from '~/hooks'; import Explorer from '../Explorer'; import { ExplorerContextProvider } from '../Explorer/Context'; @@ -22,13 +23,25 @@ import { import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { EmptyNotice } from '../Explorer/View/EmptyNotice'; -import { SearchContextProvider, SearchOptions, useSearch, useSearchContext } from '../search'; +import { + SearchContextProvider, + SearchOptions, + useMemorySource, + useSearch, + useSearchContext +} from '../search'; import SearchBar from '../search/SearchBar'; +import { useSearchExplorerQuery } from '../search/useSearchExplorerQuery'; import { TopBarPortal } from '../TopBar/Portal'; export const Component = () => { - const { id } = useZodRouteParams(SearchIdParamsSchema); + const { id } = useZodParams(SearchIdParamsSchema); + // This forces the search to throw away all data + modified search state when id changes + return ; +}; + +function Inner({ id }: { id: number }) { const savedSearch = useLibraryQuery(['search.saved.get', id], { suspense: true }); @@ -46,25 +59,30 @@ export const Component = () => { const rawFilters = savedSearch.data?.filters; - const dynamicFilters = useMemo(() => { + const filters = useMemo(() => { if (rawFilters) return JSON.parse(rawFilters) as SearchFilterArgs[]; }, [rawFilters]); const search = useSearch({ - open: true, - search: savedSearch.data?.search ?? undefined, - dynamicFilters + source: useMemorySource({ + initialFilters: filters ?? [], + initialSearch: savedSearch.data?.search ?? '', + initialTarget: (savedSearch.data?.target as SearchTarget) ?? undefined + }) }); - const paths = usePathsExplorerQuery({ - arg: { filters: search.allFilters, take: 50 }, - order: explorerSettings.useSettingsSnapshot().order, + const items = useSearchExplorerQuery({ + search, + explorerSettings, + filters: search.allFilters, + take: 50, + paths: { order: explorerSettings.useSettingsSnapshot().order }, onSuccess: () => explorerStore.resetNewThumbnails() }); const explorer = useExplorer({ - ...paths, - isFetchingNextPage: paths.query.isFetchingNextPage, + ...items, + isFetchingNextPage: items.query.isFetchingNextPage, settings: explorerSettings }); @@ -85,7 +103,7 @@ export const Component = () => { >
- {(search.dynamicFilters !== dynamicFilters || + {(search.filters !== filters || search.search !== savedSearch.data?.search) && ( )} @@ -107,7 +125,7 @@ export const Component = () => { /> ); -}; +} function SaveButton({ searchId }: { searchId: number }) { const updateSavedSearch = useLibraryMutation(['search.saved.update']); @@ -123,7 +141,7 @@ function SaveButton({ searchId }: { searchId: number }) { updateSavedSearch.mutate([ searchId, { - filters: JSON.stringify(search.dynamicFilters), + filters: JSON.stringify(search.filters), search: search.search } ]); diff --git a/interface/app/$libraryId/search/AppliedFilters.tsx b/interface/app/$libraryId/search/AppliedFilters.tsx index b0f965aa0..447da1516 100644 --- a/interface/app/$libraryId/search/AppliedFilters.tsx +++ b/interface/app/$libraryId/search/AppliedFilters.tsx @@ -29,7 +29,7 @@ export const CloseTab = forwardRef void }>(({ o ); }); -export const AppliedFilters = ({ allowRemove = true }: { allowRemove?: boolean }) => { +export const AppliedFilters = () => { const search = useSearchContext(); return ( @@ -40,12 +40,12 @@ export const AppliedFilters = ({ allowRemove = true }: { allowRemove?: boolean } {search.search} - {allowRemove && search.setSearch('')} />} + {search.setSearch && search.setSearch?.('')} />} )}
- {search.mergedFilters.map(({ arg, removalIndex }, index) => { + {search.mergedFilters?.map(({ arg, removalIndex }, index) => { const filter = filterRegistry.find((f) => f.extract(arg)); if (!filter) return; return ( @@ -53,15 +53,13 @@ export const AppliedFilters = ({ allowRemove = true }: { allowRemove?: boolean } { - search.updateDynamicFilters( - (dyanmicFilters) => { - dyanmicFilters.splice(removalIndex, 1); + search.setFilters?.((filters) => { + filters?.splice(removalIndex, 1); - return dyanmicFilters; - } - ); + return filters; + }); } : undefined } diff --git a/interface/app/$libraryId/search/Filters.tsx b/interface/app/$libraryId/search/Filters.tsx index 7b204b5bc..dee39f9ad 100644 --- a/interface/app/$libraryId/search/Filters.tsx +++ b/interface/app/$libraryId/search/Filters.tsx @@ -55,13 +55,13 @@ export interface RenderSearchFilter< Render: (props: { filter: SearchFilterCRUD; options: (FilterOption & { type: string })[]; - search: UseSearch; + search: UseSearch; }) => JSX.Element; // Apply is responsible for applying the filter to the search args useOptions: (props: { search: string }) => FilterOption[]; } -export function useToggleOptionSelected({ search }: { search: UseSearch }) { +export function useToggleOptionSelected({ search }: { search: UseSearch }) { return ({ filter, option, @@ -71,28 +71,25 @@ export function useToggleOptionSelected({ search }: { search: UseSearch }) { option: FilterOption; select: boolean; }) => { - search.updateDynamicFilters((dynamicFilters) => { - const key = getKey({ ...option, type: filter.name }); - if (search.fixedFiltersKeys?.has(key)) return dynamicFilters; - - const rawArg = dynamicFilters.find((arg) => filter.extract(arg)); + search.setFilters?.((filters = []) => { + const rawArg = filters.find((arg) => filter.extract(arg)); if (!rawArg) { const arg = filter.create(option.value); - dynamicFilters.push(arg); + filters.push(arg); } else { - const rawArgIndex = dynamicFilters.findIndex((arg) => filter.extract(arg))!; + const rawArgIndex = filters.findIndex((arg) => filter.extract(arg))!; const arg = filter.extract(rawArg)!; if (select) { if (rawArg) filter.applyAdd(arg, option); } else { - if (!filter.applyRemove(arg, option)) dynamicFilters.splice(rawArgIndex, 1); + if (!filter.applyRemove(arg, option)) filters.splice(rawArgIndex, 1); } } - return dynamicFilters; + return filters; }); }; } @@ -105,7 +102,7 @@ const FilterOptionList = ({ }: { filter: SearchFilterCRUD; options: FilterOption[]; - search: UseSearch; + search: UseSearch; empty?: () => JSX.Element; }) => { const { allFiltersKeys } = search; @@ -143,7 +140,13 @@ const FilterOptionList = ({ ); }; -const FilterOptionText = ({ filter, search }: { filter: SearchFilterCRUD; search: UseSearch }) => { +const FilterOptionText = ({ + filter, + search +}: { + filter: SearchFilterCRUD; + search: UseSearch; +}) => { const [value, setValue] = useState(''); const { allFiltersKeys } = search; @@ -159,14 +162,14 @@ const FilterOptionText = ({ filter, search }: { filter: SearchFilterCRUD; search className="flex gap-1.5" onSubmit={(e) => { e.preventDefault(); - search.updateDynamicFilters((dynamicFilters) => { - if (allFiltersKeys.has(key)) return dynamicFilters; + search.setFilters?.((filters) => { + if (allFiltersKeys.has(key)) return filters; const arg = filter.create(value); - dynamicFilters.push(arg); + filters?.push(arg); setValue(''); - return dynamicFilters; + return filters; }); }} > @@ -189,9 +192,9 @@ const FilterOptionBoolean = ({ search }: { filter: SearchFilterCRUD; - search: UseSearch; + search: UseSearch; }) => { - const { fixedFiltersKeys, allFiltersKeys } = search; + const { allFiltersKeys } = search; const key = getKey({ type: filter.name, @@ -204,19 +207,17 @@ const FilterOptionBoolean = ({ icon={filter.icon} selected={allFiltersKeys?.has(key)} setSelected={() => { - search.updateDynamicFilters((dynamicFilters) => { - if (fixedFiltersKeys?.has(key)) return dynamicFilters; - - const index = dynamicFilters.findIndex((f) => filter.extract(f) !== undefined); + search.setFilters?.((filters = []) => { + const index = filters.findIndex((f) => filter.extract(f) !== undefined); if (index !== -1) { - dynamicFilters.splice(index, 1); + filters.splice(index, 1); } else { const arg = filter.create(true); - dynamicFilters.push(arg); + filters.push(arg); } - return dynamicFilters; + return filters; }); }} > diff --git a/interface/app/$libraryId/search/SearchBar.tsx b/interface/app/$libraryId/search/SearchBar.tsx index e49c1441d..ebe284304 100644 --- a/interface/app/$libraryId/search/SearchBar.tsx +++ b/interface/app/$libraryId/search/SearchBar.tsx @@ -2,18 +2,22 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { createSearchParams } from 'react-router-dom'; import { useDebouncedCallback } from 'use-debounce'; +import { SearchFilterArgs } from '@sd/client'; import { Input, ModifierKeys, Shortcut } from '@sd/ui'; import { useOperatingSystem } from '~/hooks'; import { keybindForOs } from '~/util/keybinds'; import { useSearchContext } from './context'; import { useSearchStore } from './store'; +import { SearchTarget } from './useSearch'; interface Props { redirectToSearch?: boolean; + defaultFilters?: SearchFilterArgs[]; + defaultTarget?: SearchTarget; } -export default ({ redirectToSearch }: Props) => { +export default ({ redirectToSearch, defaultFilters, defaultTarget }: Props) => { const search = useSearchContext(); const searchRef = useRef(null); const navigate = useNavigate(); @@ -56,14 +60,14 @@ export default ({ redirectToSearch }: Props) => { }; }, [blurHandler, focusHandler]); - const [value, setValue] = useState(''); + const [value, setValue] = useState(search.rawSearch); useEffect(() => { - setValue(search.rawSearch); + if (search.rawSearch !== undefined) setValue(search.rawSearch); }, [search.rawSearch]); const updateDebounce = useDebouncedCallback((value: string) => { - search.setSearch(value); + search.setSearch?.(value); if (redirectToSearch) { navigate({ pathname: '../search', @@ -80,7 +84,9 @@ export default ({ redirectToSearch }: Props) => { } function clearValue() { - search.setSearch(''); + search.setSearch?.(undefined); + search.setFilters?.(undefined); + search.setTarget?.(undefined); } return ( @@ -99,7 +105,14 @@ export default ({ redirectToSearch }: Props) => { search.setSearchBarFocused(false); } }} - onFocus={() => search.setSearchBarFocused(true)} + onFocus={() => { + search.setSearchBarFocused(true); + search.setFilters?.((f) => { + if (!f) return defaultFilters ?? []; + else return f; + }); + search.setTarget?.(search.target ?? defaultTarget); + }} right={
{ diff --git a/interface/app/$libraryId/search/SearchOptions.tsx b/interface/app/$libraryId/search/SearchOptions.tsx index aa77f0677..f863a0f37 100644 --- a/interface/app/$libraryId/search/SearchOptions.tsx +++ b/interface/app/$libraryId/search/SearchOptions.tsx @@ -2,7 +2,7 @@ import { FunnelSimple, Icon, Plus } from '@phosphor-icons/react'; import { IconTypes } from '@sd/assets/util'; import clsx from 'clsx'; import { memo, PropsWithChildren, useDeferredValue, useMemo, useState } from 'react'; -import { useLibraryMutation } from '@sd/client'; +import { useFeatureFlag, useLibraryMutation } from '@sd/client'; import { Button, ContextMenuDivItem, @@ -15,7 +15,7 @@ import { } from '@sd/ui'; import { useIsDark, useKeybind } from '~/hooks'; -import { AppliedFilters } from './AppliedFilters'; +import { AppliedFilters, FilterContainer, InteractiveSection } from './AppliedFilters'; import { useSearchContext } from './context'; import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters'; import { @@ -93,6 +93,9 @@ export const SearchOptions = ({ }: { allowExit?: boolean } & PropsWithChildren) => { const search = useSearchContext(); const isDark = useIsDark(); + + const showSearchTargets = useFeatureFlag('searchTargetSwitcher'); + return (
{ @@ -107,11 +110,26 @@ export const SearchOptions = ({ isDark ? 'bg-black/10' : 'bg-black/5' )} > - {/* - - Paths - - */} + {showSearchTargets && ( + + search.setTarget?.('paths')} + className={clsx( + search.target === 'paths' ? 'bg-app-box' : 'hover:bg-app-box/50' + )} + > + Paths + + search.setTarget?.('objects')} + className={clsx( + search.target === 'objects' ? 'bg-app-box' : 'hover:bg-app-box/50' + )} + > + Objects + + + )} @@ -124,7 +142,7 @@ export const SearchOptions = ({ {children ?? ( <> - {(search.dynamicFilters.length > 0 || search.search !== '') && ( + {((search.filters && search.filters.length > 0) || search.search !== '') && ( )} @@ -136,7 +154,7 @@ export const SearchOptions = ({ }; const SearchResults = memo( - ({ searchQuery, search }: { searchQuery: string; search: UseSearch }) => { + ({ searchQuery, search }: { searchQuery: string; search: UseSearch }) => { const { allFiltersKeys } = search; const searchResults = useSearchRegisteredFilters(searchQuery); @@ -275,8 +293,11 @@ function SaveSearchButton() { saveSearch.mutate({ name, + target: search.target, search: search.search, - filters: JSON.stringify(search.mergedFilters.map((f) => f.arg)), + filters: search.mergedFilters + ? JSON.stringify(search.mergedFilters.map((f) => f.arg)) + : undefined, description: null, icon: null }); @@ -308,17 +329,17 @@ function SaveSearchButton() { function EscapeButton() { const search = useSearchContext(); - useKeybind(['Escape'], () => { - search.setSearch(''); + function escape() { + search.setSearch?.(undefined); + search.setFilters?.(undefined); search.setSearchBarFocused(false); - }); + } + + useKeybind(['Escape'], escape); return ( { - search.setSearch(''); - search.setSearchBarFocused(false); - }} + onClick={escape} className="ml-2 rounded-lg border border-app-line bg-app-box px-2 py-1 text-[10.5px] tracking-widest shadow" > ESC diff --git a/interface/app/$libraryId/search/context.tsx b/interface/app/$libraryId/search/context.tsx index 0184dccf4..e7ff96201 100644 --- a/interface/app/$libraryId/search/context.tsx +++ b/interface/app/$libraryId/search/context.tsx @@ -2,7 +2,7 @@ import { createContext, PropsWithChildren, useContext } from 'react'; import { UseSearch } from './useSearch'; -const SearchContext = createContext(null); +const SearchContext = createContext | null>(null); export function useSearchContext() { const ctx = useContext(SearchContext); @@ -17,6 +17,6 @@ export function useSearchContext() { export function SearchContextProvider({ children, search -}: { search: UseSearch } & PropsWithChildren) { +}: { search: UseSearch } & PropsWithChildren) { return {children}; } diff --git a/interface/app/$libraryId/search/index.tsx b/interface/app/$libraryId/search/index.tsx index 3f7ec1e84..186869755 100644 --- a/interface/app/$libraryId/search/index.tsx +++ b/interface/app/$libraryId/search/index.tsx @@ -1,6 +1,5 @@ -import { useEffect, useMemo } from 'react'; -import { useSearchParams as useRawSearchParams } from 'react-router-dom'; -import { ObjectKindEnum, ObjectOrder, useObjectsExplorerQuery } from '@sd/client'; +import { useMemo } from 'react'; +import { ObjectOrder } from '@sd/client'; import { Icon } from '~/components'; import { useRouteTitle } from '~/hooks'; @@ -9,10 +8,12 @@ import Explorer from '../Explorer'; import { ExplorerContextProvider } from '../Explorer/Context'; import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store'; import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; -import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; +import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { EmptyNotice } from '../Explorer/View/EmptyNotice'; import { TopBarPortal } from '../TopBar/Portal'; import SearchBar from './SearchBar'; +import { useSearchFromSearchParams } from './useSearch'; +import { useSearchExplorerQuery } from './useSearchExplorerQuery'; export * from './context'; export * from './SearchOptions'; @@ -28,19 +29,19 @@ export function Component() { orderingKeys: objectOrderingKeysSchema }); - const search = useSearchWithFilters(explorerSettings); + const search = useSearchFromSearchParams(); - const objects = useObjectsExplorerQuery({ - arg: { - take: 100, - filters: search.allFilters - }, - order: explorerSettings.useSettingsSnapshot().order + const items = useSearchExplorerQuery({ + search, + explorerSettings, + filters: search.allFilters, + take: 100, + objects: { order: explorerSettings.useSettingsSnapshot().order } }); const explorer = useExplorer({ - ...objects, - isFetchingNextPage: objects.query.isFetchingNextPage, + ...items, + isFetchingNextPage: items.query.isFetchingNextPage, settings: explorerSettings }); @@ -76,60 +77,3 @@ export function Component() { ); } - -function useSearchWithFilters(explorerSettings: UseExplorerSettings) { - const [searchParams, setSearchParams] = useRawSearchParams(); - const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); - - const fixedFilters = useMemo( - () => [ - ...(explorerSettingsSnapshot.layoutMode === 'media' - ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] - : []) - ], - [explorerSettingsSnapshot.layoutMode] - ); - - const filtersParam = searchParams.get('filters'); - const dynamicFilters = useMemo(() => JSON.parse(filtersParam ?? '[]'), [filtersParam]); - - const searchQueryParam = searchParams.get('search'); - - const search = useSearch({ - open: !!searchQueryParam || dynamicFilters.length > 0 || undefined, - search: searchParams.get('search') ?? undefined, - fixedFilters, - dynamicFilters - }); - - useEffect(() => { - setSearchParams( - (p) => { - if (search.dynamicFilters.length > 0) - p.set('filters', JSON.stringify(search.dynamicFilters)); - else p.delete('filters'); - - return p; - }, - { replace: true } - ); - }, [search.dynamicFilters, setSearchParams]); - - const searchQuery = search.search; - - useEffect(() => { - setSearchParams( - (p) => { - if (searchQuery !== '') p.set('search', searchQuery); - else p.delete('search'); - - return p; - }, - { replace: true } - ); - // Do not add setSearchParams to the dependencies array, it will cause CMDK to not navigate to search page (multiple times) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchQuery]); - - return search; -} diff --git a/interface/app/$libraryId/search/useSearch.ts b/interface/app/$libraryId/search/useSearch.ts index f81207ecb..a4b2de45c 100644 --- a/interface/app/$libraryId/search/useSearch.ts +++ b/interface/app/$libraryId/search/useSearch.ts @@ -1,42 +1,135 @@ import { produce } from 'immer'; import { useCallback, useMemo, useState } from 'react'; +import { useSearchParams as useRawSearchParams } from 'react-router-dom'; import { useDebouncedValue } from 'rooks'; import { SearchFilterArgs } from '@sd/client'; -import { filterRegistry } from './Filters'; import { argsToOptions, getKey, useSearchStore } from './store'; -export interface UseSearchProps { - open?: boolean; +export type SearchTarget = 'paths' | 'objects'; + +export interface UseSearchSource { + target: SearchTarget; + setTarget?: (target?: SearchTarget) => void; + filters?: SearchFilterArgs[]; + setFilters?: (cb?: (filters?: SearchFilterArgs[]) => SearchFilterArgs[] | undefined) => void; search?: string; - /** - * Filters that cannot be removed - */ - fixedFilters?: SearchFilterArgs[]; - /** - * Filters that can be removed. - * When this value changes dynamic filters stored internally will reset. - */ - dynamicFilters?: SearchFilterArgs[]; + setSearch?: (search?: string) => void; + open?: boolean; } -export function useSearch(props?: UseSearchProps) { +export interface UseSearchProps { + source: TSource; +} + +export function useSearchParamsSource() { + const [searchParams, setSearchParams] = useRawSearchParams(); + + const filtersSearchParam = searchParams.get('filters'); + const filters = useMemo( + () => (filtersSearchParam ? JSON.parse(filtersSearchParam) : undefined), + [filtersSearchParam] + ); + + const searchSearchParam = searchParams.get('search'); + + const setFilters = useCallback( + (cb?: (args?: SearchFilterArgs[]) => SearchFilterArgs[] | undefined) => { + setSearchParams( + (p) => { + if (cb === undefined) p.delete('filters'); + else p.set('filters', JSON.stringify(produce(filters, cb))); + + return p; + }, + { replace: true } + ); + }, + [filters, setSearchParams] + ); + + function setSearch(search?: string) { + setSearchParams( + (p) => { + if (search && search !== '') p.set('search', search); + else p.delete('search'); + + return p; + }, + { replace: true } + ); + } + + const target = (searchParams.get('target') ?? 'paths') as SearchTarget; + + return { + filters, + setFilters, + search: searchSearchParam ?? '', + setSearch, + open: searchSearchParam !== null || filtersSearchParam !== null, + target, + setTarget: (target) => + setSearchParams( + (p) => { + if (target) p.set('target', target); + else p.delete('target'); + return p; + }, + { replace: true } + ) + } satisfies UseSearchSource; +} + +export function useStaticSource(props: Pick) { + return props satisfies UseSearchSource; +} + +export function useMemorySource(props: { + initialFilters?: SearchFilterArgs[]; + initialSearch?: string; + initialTarget?: SearchTarget; +}) { + const [filters, setFilters] = useState(props.initialFilters); + const [search, setSearch] = useState(props.initialSearch); + const [target, setTarget] = useState(props.initialTarget ?? 'paths'); + + return { + filters, + setFilters: (s) => { + if (s === undefined) setFilters(undefined); + else setFilters((f) => produce(f, s)); + }, + search, + setSearch, + target, + setTarget: (t) => setTarget(t ?? 'paths') + } satisfies UseSearchSource; +} + +export function useSearch(props: UseSearchProps) { + const { + filters, + setFilters, + search: rawSearch, + setSearch, + open, + target, + setTarget + } = props.source; + const [searchBarFocused, setSearchBarFocused] = useState(false); const searchState = useSearchStore(); - // Filters that can't be removed - - const fixedFilters = useMemo(() => props?.fixedFilters ?? [], [props?.fixedFilters]); - - const fixedFiltersAsOptions = useMemo( - () => argsToOptions(fixedFilters, searchState.filterOptions), - [fixedFilters, searchState.filterOptions] + const filtersAsOptions = useMemo( + () => argsToOptions(filters ?? [], searchState.filterOptions), + [filters, searchState.filterOptions] ); - const fixedFiltersKeys: Set = useMemo(() => { + const filtersKeys: Set = useMemo(() => { return new Set( - fixedFiltersAsOptions.map(({ arg, filter }) => + filtersAsOptions.map(({ arg, filter }) => getKey({ type: filter.name, name: arg.name, @@ -44,95 +137,19 @@ export function useSearch(props?: UseSearchProps) { }) ) ); - }, [fixedFiltersAsOptions]); - - // Filters that can be removed - - const [dynamicFilters, setDynamicFilters] = useState(props?.dynamicFilters ?? []); - const [dynamicFiltersFromProps, setDynamicFiltersFromProps] = useState(props?.dynamicFilters); - - if (dynamicFiltersFromProps !== props?.dynamicFilters) { - setDynamicFiltersFromProps(props?.dynamicFilters); - setDynamicFilters(props?.dynamicFilters ?? []); - } - - const dynamicFiltersAsOptions = useMemo( - () => argsToOptions(dynamicFilters, searchState.filterOptions), - [dynamicFilters, searchState.filterOptions] - ); - - const dynamicFiltersKeys: Set = useMemo(() => { - return new Set( - dynamicFiltersAsOptions.map(({ arg, filter }) => - getKey({ - type: filter.name, - name: arg.name, - value: arg.value - }) - ) - ); - }, [dynamicFiltersAsOptions]); - - const updateDynamicFilters = useCallback( - (cb: (args: SearchFilterArgs[]) => SearchFilterArgs[]) => - setDynamicFilters((filters) => produce(filters, cb)), - [] - ); + }, [filtersAsOptions]); // Merging of filters that should be ORed - const mergedFilters = useMemo(() => { - const value: { arg: SearchFilterArgs; removalIndex: number | null }[] = fixedFilters.map( - (arg) => ({ - arg, - removalIndex: null - }) - ); - - for (const [index, arg] of dynamicFilters.entries()) { - const filter = filterRegistry.find((f) => f.extract(arg)); - if (!filter) continue; - - const fixedEquivalentIndex = fixedFilters.findIndex( - (a) => filter.extract(a) !== undefined - ); - - if (fixedEquivalentIndex !== -1) { - const merged = filter.merge( - filter.extract(fixedFilters[fixedEquivalentIndex]!)! as any, - filter.extract(arg)! as any - ); - - value[fixedEquivalentIndex] = { - arg: filter.create(merged), - removalIndex: fixedEquivalentIndex - }; - } else { - value.push({ - arg, - removalIndex: index - }); - } - } - - return value; - }, [fixedFilters, dynamicFilters]); - - // Filters generated from the search query - - // rawSearch should only ever be read by the search input - const [rawSearch, setRawSearch] = useState(props?.search ?? ''); - const [searchFromProps, setSearchFromProps] = useState(props?.search); - - if (searchFromProps !== props?.search) { - setSearchFromProps(props?.search); - setRawSearch(props?.search ?? ''); - } + const mergedFilters = useMemo( + () => filters?.map((arg, removalIndex) => ({ arg, removalIndex })), + [filters] + ); const [search] = useDebouncedValue(rawSearch, 300); const searchFilters = useMemo(() => { - const [name, ext] = search.split('.') ?? []; + const [name, ext] = search?.split('.') ?? []; const filters: SearchFilterArgs[] = []; @@ -144,8 +161,8 @@ export function useSearch(props?: UseSearchProps) { // All filters combined together const allFilters = useMemo( - () => [...mergedFilters.map((v) => v.arg), ...searchFilters], - [mergedFilters, searchFilters] + () => [...(filters ?? []), ...searchFilters], + [filters, searchFilters] ); const allFiltersAsOptions = useMemo( @@ -166,22 +183,28 @@ export function useSearch(props?: UseSearchProps) { }, [allFiltersAsOptions]); return { - open: props?.open || searchBarFocused, - fixedFilters, - fixedFiltersKeys, + open: open || searchBarFocused, search, + // rawSearch should only ever be read by the search input rawSearch, - setSearch: setRawSearch, + setSearch, searchBarFocused, setSearchBarFocused, - dynamicFilters, - setDynamicFilters, - updateDynamicFilters, - dynamicFiltersKeys, + filters, + setFilters, + filtersKeys, mergedFilters, allFilters, - allFiltersKeys + allFiltersKeys, + target, + setTarget }; } -export type UseSearch = ReturnType; +export function useSearchFromSearchParams() { + return useSearch({ + source: useSearchParamsSource() + }); +} + +export type UseSearch = ReturnType>; diff --git a/interface/app/$libraryId/search/useSearchExplorerQuery.ts b/interface/app/$libraryId/search/useSearchExplorerQuery.ts new file mode 100644 index 000000000..deec03aea --- /dev/null +++ b/interface/app/$libraryId/search/useSearchExplorerQuery.ts @@ -0,0 +1,36 @@ +import { + FilePathOrder, + FilePathSearchArgs, + ObjectOrder, + ObjectSearchArgs, + SearchFilterArgs, + useObjectsExplorerQuery, + usePathsExplorerQuery +} from '@sd/client'; + +import { UseExplorerSettings } from '../Explorer/useExplorer'; +import { UseSearch, UseSearchSource } from './useSearch'; + +export function useSearchExplorerQuery(props: { + search: UseSearch; + explorerSettings: UseExplorerSettings; + filters: SearchFilterArgs[]; + take: number; + paths?: { arg?: Omit; order?: FilePathOrder | null }; + objects?: { arg?: Omit; order?: ObjectOrder | null }; + onSuccess?: () => void; +}) { + const filters = [...props.filters, ...props.explorerSettings.useLayoutSearchFilters()]; + + if (props.search.target === 'paths') { + return usePathsExplorerQuery({ + arg: { ...props.paths?.arg, filters, take: props.take }, + order: null + }); + } else { + return useObjectsExplorerQuery({ + arg: { ...props.objects?.arg, filters, take: props.take }, + order: null + }); + } +} diff --git a/interface/app/$libraryId/settings/library/saved-searches/index.tsx b/interface/app/$libraryId/settings/library/saved-searches/index.tsx index 8d5eee88f..0ebdca052 100644 --- a/interface/app/$libraryId/settings/library/saved-searches/index.tsx +++ b/interface/app/$libraryId/settings/library/saved-searches/index.tsx @@ -9,7 +9,7 @@ import { useZodForm } from '@sd/client'; import { Button, Card, Form, InputField, Label, Tooltip, z } from '@sd/ui'; -import { SearchContextProvider, useSearch } from '~/app/$libraryId/search'; +import { SearchContextProvider, useSearch, useStaticSource } from '~/app/$libraryId/search'; import { AppliedFilters } from '~/app/$libraryId/search/AppliedFilters'; import { Heading } from '~/app/$libraryId/settings/Layout'; import { useDebouncedFormWatch, useLocale } from '~/hooks'; @@ -82,13 +82,19 @@ function EditForm({ savedSearch, onDelete }: { savedSearch: SavedSearch; onDelet updateSavedSearch.mutate([savedSearch.id, { name: data.name ?? '' }]); }); - const fixedFilters = useMemo(() => { + const filters = useMemo(() => { if (savedSearch.filters === null) return []; return JSON.parse(savedSearch.filters) as SearchFilterArgs[]; }, [savedSearch.filters]); - const search = useSearch({ search: savedSearch.search ?? undefined, fixedFilters }); + const search = useSearch({ + source: useStaticSource({ + search: savedSearch.search ?? '', + filters, + target: 'paths' + }) + }); return (
@@ -113,7 +119,7 @@ function EditForm({ savedSearch, onDelete }: { savedSearch: SavedSearch; onDelet
- +
diff --git a/interface/app/$libraryId/tag/$id.tsx b/interface/app/$libraryId/tag/$id.tsx index fb7b4f269..4c4e316e8 100644 --- a/interface/app/$libraryId/tag/$id.tsx +++ b/interface/app/$libraryId/tag/$id.tsx @@ -1,12 +1,5 @@ import { useMemo } from 'react'; -import { - ObjectKindEnum, - ObjectOrder, - useCache, - useLibraryQuery, - useNodes, - useObjectsExplorerQuery -} from '@sd/client'; +import { ObjectOrder, useCache, useLibraryQuery, useNodes } from '@sd/client'; import { LocationIdParamsSchema } from '~/app/route-schemas'; import { Icon } from '~/components'; import { useRouteTitle, useZodRouteParams } from '~/hooks'; @@ -17,8 +10,9 @@ import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Expl import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { EmptyNotice } from '../Explorer/View/EmptyNotice'; -import { SearchContextProvider, SearchOptions, useSearch } from '../search'; +import { SearchContextProvider, SearchOptions, useSearchFromSearchParams } from '../search'; import SearchBar from '../search/SearchBar'; +import { useSearchExplorerQuery } from '../search/useSearchExplorerQuery'; import { TopBarPortal } from '../TopBar/Portal'; export function Component() { @@ -36,30 +30,21 @@ export function Component() { orderingKeys: objectOrderingKeysSchema }); - const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + const search = useSearchFromSearchParams(); - const fixedFilters = useMemo( - () => [ - { object: { tags: { in: [tag!.id] } } }, - ...(explorerSettingsSnapshot.layoutMode === 'media' - ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] - : []) - ], - [tag, explorerSettingsSnapshot.layoutMode] - ); + const defaultFilters = useMemo(() => [{ object: { tags: { in: [tag.id] } } }], [tag.id]); - const search = useSearch({ - fixedFilters - }); - - const objects = useObjectsExplorerQuery({ - arg: { take: 100, filters: search.allFilters }, - order: explorerSettings.useSettingsSnapshot().order + const items = useSearchExplorerQuery({ + search, + explorerSettings, + filters: search.allFilters.length > 0 ? search.allFilters : defaultFilters, + take: 100, + objects: { order: explorerSettings.useSettingsSnapshot().order } }); const explorer = useExplorer({ - ...objects, - isFetchingNextPage: objects.query.isFetchingNextPage, + ...items, + isFetchingNextPage: items.query.isFetchingNextPage, settings: explorerSettings, parent: { type: 'Tag', tag: tag! } }); @@ -68,7 +53,7 @@ export function Component() { } + center={} left={
(schema: Z): z.infer { + // eslint-disable-next-line + const params = useParams(); + + return useMemo(() => schema.parse(params), [schema, params]); +} diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 8011d872b..3815110fe 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -46,7 +46,7 @@ export type Procedures = { { key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } | { key: "search.paths", input: LibraryArgs, result: SearchData } | { key: "search.pathsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } | - { key: "search.saved.get", input: LibraryArgs, result: { id: number; pub_id: number[]; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null } | null } | + { key: "search.saved.get", input: LibraryArgs, result: { id: number; pub_id: number[]; target: string | null; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null } | null } | { key: "search.saved.list", input: LibraryArgs, result: SavedSearch[] } | { key: "sync.enabled", input: LibraryArgs, result: boolean } | { key: "sync.messages", input: LibraryArgs, result: CRDTOperation[] } | @@ -118,7 +118,7 @@ export type Procedures = { { key: "p2p.debugConnect", input: RemoteIdentity, result: string } | { key: "p2p.spacedrop", input: SpacedropArgs, result: string } | { key: "preferences.update", input: LibraryArgs, result: null } | - { key: "search.saved.create", input: LibraryArgs<{ name: string; search?: string | null; filters?: string | null; description?: string | null; icon?: string | null }>, result: null } | + { key: "search.saved.create", input: LibraryArgs<{ name: string; target?: SearchTarget; search?: string | null; filters?: string | null; description?: string | null; icon?: string | null }>, result: null } | { key: "search.saved.delete", input: LibraryArgs, result: null } | { key: "search.saved.update", input: LibraryArgs<[number, Args]>, result: null } | { key: "sync.enable", input: LibraryArgs, result: null } | @@ -572,12 +572,14 @@ export type Response = { Start: { user_code: string; verification_url: string; v export type RuleKind = "AcceptFilesByGlob" | "RejectFilesByGlob" | "AcceptIfChildrenDirectoriesArePresent" | "RejectIfChildrenDirectoriesArePresent" -export type SavedSearch = { id: number; pub_id: number[]; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null } +export type SavedSearch = { id: number; pub_id: number[]; target: string | null; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null } export type SearchData = { cursor: number[] | null; items: Reference[]; nodes: CacheNode[] } export type SearchFilterArgs = { filePath: FilePathFilterArgs } | { object: ObjectFilterArgs } +export type SearchTarget = "paths" | "objects" + export type SetFavoriteArgs = { id: number; favorite: boolean } export type SetNoteArgs = { id: number; note: string | null } diff --git a/packages/client/src/stores/featureFlags.tsx b/packages/client/src/stores/featureFlags.tsx index e35952725..f5cf1393e 100644 --- a/packages/client/src/stores/featureFlags.tsx +++ b/packages/client/src/stores/featureFlags.tsx @@ -10,7 +10,8 @@ export const features = [ 'debugRoutes', 'solidJsDemo', 'hostedLocations', - 'debugDragAndDrop' + 'debugDragAndDrop', + 'searchTargetSwitcher' ] as const; // This defines which backend feature flags show up in the UI.