Better search (#2262)

* Revert "Revert "remove fixed filters" (#2261)"

This reverts commit 5b40cefe37.

* search sources

* only set default filters if no filters

* key saved search page on id and use raw search as searchbar default

* fix crate versions

* put media view filters in a dedicated hook

* remove ts-reset

* add comment about <Inner>

* generics!

* cleanup

* search paths/objects switch (#2278)

* feature flag target switcher

* use useZodParams in saved search route
This commit is contained in:
Brendan Allan 2024-04-05 23:40:46 +08:00 committed by GitHub
parent 6bb94e3507
commit f168b5e45d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 817 additions and 801 deletions

View file

@ -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"

View file

@ -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]

View file

@ -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")
}

View file

@ -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<Self, Self::Err> {
match s {
"paths" => Ok(SearchTarget::Paths),
"objects" => Ok(SearchTarget::Objects),
_ => Err(format!("invalid search target: {s}")),
}
}
}
pub(crate) fn mount() -> AlphaRouter<Ctx> {
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<String>,
#[specta(optional)]
@ -40,6 +73,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
[
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!(

View file

@ -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<TOrder extends Ordering>({
return {
useSettingsSnapshot: () => useSnapshot(store),
useLayoutSearchFilters: () => {
const explorerSettingsSnapshot = useSnapshot(store);
return explorerSettingsSnapshot.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [];
},
settingsStore: store,
orderingKeys
};

View file

@ -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<SearchFilterArgs[]>(
() => [
// { 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() {
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
center={<SearchBar defaultFilters={[defaultFilter]} />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Favorites</span>

View file

@ -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<SearchFilterArgs[]>(
// () => [
// ...(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,

View file

@ -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 }) =
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
center={<SearchBar defaultFilters={defaultFilters} />}
left={
<div className="flex items-center gap-2">
<Folder size={22} className="-mt-px" />
@ -267,62 +277,3 @@ function useLocationExplorerSettings(location: Location) {
preferences
};
}
function useLocationSearch(
explorerSettings: UseExplorerSettings<FilePathOrder>,
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;
}

View file

@ -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']);

View file

@ -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<SearchFilterArgs[]>(
() => [
// { 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() {
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
center={<SearchBar defaultFilters={[defaultFilters]} />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Recents</span>

View file

@ -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 <Inner key={id} id={id} />;
};
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 = () => {
>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions>
{(search.dynamicFilters !== dynamicFilters ||
{(search.filters !== filters ||
search.search !== savedSearch.data?.search) && (
<SaveButton searchId={id} />
)}
@ -107,7 +125,7 @@ export const Component = () => {
/>
</ExplorerContextProvider>
);
};
}
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
}
]);

View file

@ -29,7 +29,7 @@ export const CloseTab = forwardRef<HTMLDivElement, { onClick: () => 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 }
<RenderIcon className="size-4" icon={MagnifyingGlass} />
<FilterText>{search.search}</FilterText>
</StaticSection>
{allowRemove && <CloseTab onClick={() => search.setSearch('')} />}
{search.setSearch && <CloseTab onClick={() => search.setSearch?.('')} />}
</FilterContainer>
)}
<div className="group w-full">
<HorizontalScroll className="!mb-0 !pl-0">
{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 }
<FilterArg
arg={arg}
onDelete={
removalIndex !== null && allowRemove
removalIndex !== null && search.setFilters
? () => {
search.updateDynamicFilters(
(dyanmicFilters) => {
dyanmicFilters.splice(removalIndex, 1);
search.setFilters?.((filters) => {
filters?.splice(removalIndex, 1);
return dyanmicFilters;
}
);
return filters;
});
}
: undefined
}

View file

@ -55,13 +55,13 @@ export interface RenderSearchFilter<
Render: (props: {
filter: SearchFilterCRUD<TConditions>;
options: (FilterOption & { type: string })[];
search: UseSearch;
search: UseSearch<any>;
}) => 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<any> }) {
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<any>;
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<any>;
}) => {
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<any>;
}) => {
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;
});
}}
>

View file

@ -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<HTMLInputElement>(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={
<div className="pointer-events-none flex h-7 items-center space-x-1 opacity-70 group-focus-within:hidden">
{

View file

@ -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 (
<div
onMouseEnter={() => {
@ -107,11 +110,26 @@ export const SearchOptions = ({
isDark ? 'bg-black/10' : 'bg-black/5'
)}
>
{/* <OptionContainer className="flex flex-row items-center">
<FilterContainer>
<InteractiveSection>Paths</InteractiveSection>
</FilterContainer>
</OptionContainer> */}
{showSearchTargets && (
<OptionContainer className="flex flex-row items-center overflow-hidden rounded">
<InteractiveSection
onClick={() => search.setTarget?.('paths')}
className={clsx(
search.target === 'paths' ? 'bg-app-box' : 'hover:bg-app-box/50'
)}
>
Paths
</InteractiveSection>
<InteractiveSection
onClick={() => search.setTarget?.('objects')}
className={clsx(
search.target === 'objects' ? 'bg-app-box' : 'hover:bg-app-box/50'
)}
>
Objects
</InteractiveSection>
</OptionContainer>
)}
<AddFilterButton />
@ -124,7 +142,7 @@ export const SearchOptions = ({
{children ?? (
<>
{(search.dynamicFilters.length > 0 || search.search !== '') && (
{((search.filters && search.filters.length > 0) || search.search !== '') && (
<SaveSearchButton />
)}
@ -136,7 +154,7 @@ export const SearchOptions = ({
};
const SearchResults = memo(
({ searchQuery, search }: { searchQuery: string; search: UseSearch }) => {
({ searchQuery, search }: { searchQuery: string; search: UseSearch<any> }) => {
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 (
<kbd
onClick={() => {
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

View file

@ -2,7 +2,7 @@ import { createContext, PropsWithChildren, useContext } from 'react';
import { UseSearch } from './useSearch';
const SearchContext = createContext<UseSearch | null>(null);
const SearchContext = createContext<UseSearch<any> | 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<any> } & PropsWithChildren) {
return <SearchContext.Provider value={search}>{children}</SearchContext.Provider>;
}

View file

@ -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() {
</ExplorerContextProvider>
);
}
function useSearchWithFilters(explorerSettings: UseExplorerSettings<ObjectOrder>) {
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;
}

View file

@ -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<TSource extends UseSearchSource> {
source: TSource;
}
export function useSearchParamsSource() {
const [searchParams, setSearchParams] = useRawSearchParams();
const filtersSearchParam = searchParams.get('filters');
const filters = useMemo<SearchFilterArgs[] | undefined>(
() => (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<UseSearchSource, 'filters' | 'search' | 'target'>) {
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<TSource extends UseSearchSource>(props: UseSearchProps<TSource>) {
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<string> = useMemo(() => {
const filtersKeys: Set<string> = 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<string> = 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<typeof useSearch>;
export function useSearchFromSearchParams() {
return useSearch({
source: useSearchParamsSource()
});
}
export type UseSearch<TSource extends UseSearchSource> = ReturnType<typeof useSearch<TSource>>;

View file

@ -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<TSource extends UseSearchSource>(props: {
search: UseSearch<TSource>;
explorerSettings: UseExplorerSettings<any>;
filters: SearchFilterArgs[];
take: number;
paths?: { arg?: Omit<FilePathSearchArgs, 'filters' | 'take'>; order?: FilePathOrder | null };
objects?: { arg?: Omit<ObjectSearchArgs, 'filters' | 'take'>; 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
});
}
}

View file

@ -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 (
<Form form={form}>
@ -113,7 +119,7 @@ function EditForm({ savedSearch, onDelete }: { savedSearch: SavedSearch; onDelet
<Label className="font-medium">{t('filters')}</Label>
<div className="flex flex-col items-start gap-2">
<SearchContextProvider search={search}>
<AppliedFilters allowRemove={false} />
<AppliedFilters />
</SearchContextProvider>
</div>
</div>

View file

@ -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() {
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
center={<SearchBar defaultFilters={defaultFilters} defaultTarget="objects" />}
left={
<div className="flex flex-row items-center gap-2">
<div

View file

@ -29,3 +29,4 @@ export * from './useZodRouteParams';
export * from './useZodSearchParams';
export * from './usePrefersReducedMotion';
export * from './useRandomInterval';
export * from './useZodParams';

View file

@ -0,0 +1,10 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import { z } from 'zod';
export function useZodParams<Z extends z.AnyZodObject>(schema: Z): z.infer<Z> {
// eslint-disable-next-line
const params = useParams();
return useMemo(() => schema.parse(params), [schema, params]);
}

View file

@ -46,7 +46,7 @@ export type Procedures = {
{ key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
{ key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "search.pathsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
{ key: "search.saved.get", input: LibraryArgs<number>, 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<number>, 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<null>, result: SavedSearch[] } |
{ key: "sync.enabled", input: LibraryArgs<null>, result: boolean } |
{ key: "sync.messages", input: LibraryArgs<null>, 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<LibraryPreferences>, 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<number>, result: null } |
{ key: "search.saved.update", input: LibraryArgs<[number, Args]>, result: null } |
{ key: "sync.enable", input: LibraryArgs<null>, 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<T> = { cursor: number[] | null; items: Reference<T>[]; 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 }

View file

@ -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.