[ENG-1269] Search options (#1561)
* search options start * small progress * more * bunch of stuff * semi functioning filters * cleanup setup api * progress * remove filters * hooked up to query epic moment * fix * move db stuff to specific modules * in/notIn for some fields * generate ts * big gains * working filter options for locations, tags and kind * working search query * perfect fixed filters * saved searches lol * merge error * saved searches via api * better routing * [ENG-1338] Fix fresh Spacedrive install failing to start due to attempting to query a nonexistent Library (#1649) Fix Spacedrive failing to start due to attempting to query a nonexistent Library - Rename useShoudRedirect to useRedirectToNewLocations - Improve behaviour for the immedite redirection after adding a new location * Show hidden files false by default (#1652) bool * fix remove filter in list * tweaks * fix nav buttons * unify MediaData search handling * cleanup saved search writing * Add left top bar portals for tags and search + fixed media view on tags * added search to filter dropdown * render cycle improvements * hotfix * wip * Refactor with Brendan, but this is a WIP and the search query no longer works Co-authored-by: Brendan Allan <Brendonovich@users.noreply.github.com> * progress * fix location/$id page * fix tags too Co-authored-by: Brendan Allan <Brendonovich@users.noreply.github.com> * 3rd refactor lol epic style * half-done with enum-ification of SearchFilterArgs * broken fixed filters but working inNotIn filters * search name + extension kinda working * hidden filter * fixed filters working?? * deferred search value * extensions works * filtered search items mostly working * tweaks * stacked approach working for non-search filters * move to Explorer/Search * actually use filterArgs in queries things actually work properly now * added new icons from Mint * goof * cleanup types, filters and mutation logic * actually use search value * remove overview from sidebar * don't shrink LibrariesDropdown ga * remove overview from sidebar and default to /network --------- Co-authored-by: Brendan Allan <brendonovich@outlook.com> Co-authored-by: Vítor Vasconcellos <vasconcellos.dev@gmail.com> Co-authored-by: Brendan Allan <Brendonovich@users.noreply.github.com>
926
Cargo.lock
generated
|
@ -12,10 +12,14 @@ export default function LocationScreen({ navigation, route }: SharedScreenProps<
|
|||
const { data } = useLibraryQuery([
|
||||
'search.paths',
|
||||
{
|
||||
filter: {
|
||||
locationId: id,
|
||||
path: path ?? ''
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
filePath: {
|
||||
locations: { in: [id] },
|
||||
path: { path: path ?? '', location_id: id, include_descendants: false }
|
||||
}
|
||||
}
|
||||
],
|
||||
take: 100
|
||||
}
|
||||
]);
|
||||
|
|
|
@ -2,7 +2,7 @@ import { MagnifyingGlass } from 'phosphor-react-native';
|
|||
import { Suspense, useDeferredValue, useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { getExplorerItemData, useLibraryQuery } from '@sd/client';
|
||||
import { getExplorerItemData, SearchFilterArgs, useLibraryQuery } from '@sd/client';
|
||||
import Explorer from '~/components/explorer/Explorer';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { RootStackScreenProps } from '~/navigation';
|
||||
|
@ -18,14 +18,23 @@ const SearchScreen = ({ navigation }: RootStackScreenProps<'Search'>) => {
|
|||
const [search, setSearch] = useState('');
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const [name, ext] = deferredSearch.split('.');
|
||||
|
||||
const filters: SearchFilterArgs[] = [];
|
||||
|
||||
if (name) filters.push({ filePath: { name: { contains: name } } });
|
||||
if (ext) filters.push({ filePath: { extension: { in: [ext] } } });
|
||||
|
||||
return filters;
|
||||
}, [deferredSearch]);
|
||||
|
||||
const query = useLibraryQuery(
|
||||
[
|
||||
'search.paths',
|
||||
{
|
||||
// ...args,
|
||||
filter: {
|
||||
search: deferredSearch
|
||||
},
|
||||
filters,
|
||||
take: 100
|
||||
}
|
||||
],
|
||||
|
|
|
@ -9,9 +9,7 @@ export default function TagScreen({ navigation, route }: SharedScreenProps<'Tag'
|
|||
const search = useLibraryQuery([
|
||||
'search.objects',
|
||||
{
|
||||
filter: {
|
||||
tags: [id]
|
||||
},
|
||||
filters: [{ object: { tags: { in: [id] } } }],
|
||||
take: 100
|
||||
}
|
||||
]);
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "saved_search" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"pub_id" BLOB NOT NULL,
|
||||
"filters" BLOB,
|
||||
"name" TEXT,
|
||||
"icon" TEXT,
|
||||
"description" TEXT,
|
||||
"order" INTEGER,
|
||||
"date_created" DATETIME,
|
||||
"date_modified" DATETIME
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "saved_search_pub_id_key" ON "saved_search"("pub_id");
|
|
@ -532,3 +532,17 @@ model Notification {
|
|||
|
||||
@@map("notification")
|
||||
}
|
||||
|
||||
model SavedSearch {
|
||||
id Int @id @default(autoincrement())
|
||||
pub_id Bytes @unique
|
||||
filters Bytes?
|
||||
name String?
|
||||
icon String?
|
||||
description String?
|
||||
order Int? // Add this line to include ordering
|
||||
date_created DateTime?
|
||||
date_modified DateTime?
|
||||
|
||||
@@map("saved_search")
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ impl BackendFeature {
|
|||
|
||||
mod auth;
|
||||
mod backups;
|
||||
mod categories;
|
||||
// mod categories;
|
||||
mod ephemeral_files;
|
||||
mod files;
|
||||
mod jobs;
|
||||
|
@ -173,7 +173,7 @@ pub(crate) fn mount() -> Arc<Router> {
|
|||
.merge("library.", libraries::mount())
|
||||
.merge("volumes.", volumes::mount())
|
||||
.merge("tags.", tags::mount())
|
||||
.merge("categories.", categories::mount())
|
||||
// .merge("categories.", categories::mount())
|
||||
// .merge("keys.", keys::mount())
|
||||
.merge("locations.", locations::mount())
|
||||
.merge("ephemeralFiles.", ephemeral_files::mount())
|
||||
|
|
|
@ -1,769 +0,0 @@
|
|||
use crate::{
|
||||
api::{
|
||||
locations::{file_path_with_object, object_with_file_paths, ExplorerItem},
|
||||
utils::library,
|
||||
},
|
||||
library::{Category, Library},
|
||||
location::{
|
||||
file_path_helper::{check_file_path_exists, IsolatedFilePathData},
|
||||
non_indexed, LocationError,
|
||||
},
|
||||
object::media::thumbnail::get_indexed_thumb_key,
|
||||
prisma::{self, file_path, location, object, tag, tag_on_object, PrismaClient},
|
||||
};
|
||||
|
||||
use std::{collections::BTreeSet, path::PathBuf};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use prisma_client_rust::{operator, or, WhereQuery};
|
||||
use rspc::{alpha::AlphaRouter, ErrorCode};
|
||||
use sd_prisma::prisma::media_data;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use super::{Ctx, R};
|
||||
|
||||
const MAX_TAKE: u8 = 100;
|
||||
|
||||
#[derive(Serialize, Type, Debug)]
|
||||
struct SearchData<T> {
|
||||
cursor: Option<Vec<u8>>,
|
||||
items: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OptionalRange<T> {
|
||||
from: Option<T>,
|
||||
to: Option<T>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SortOrder {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
impl From<SortOrder> for prisma::SortOrder {
|
||||
fn from(value: SortOrder) -> prisma::SortOrder {
|
||||
match value {
|
||||
SortOrder::Asc => prisma::SortOrder::Asc,
|
||||
SortOrder::Desc => prisma::SortOrder::Desc,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
|
||||
pub enum FilePathOrder {
|
||||
Name(SortOrder),
|
||||
SizeInBytes(SortOrder),
|
||||
DateCreated(SortOrder),
|
||||
DateModified(SortOrder),
|
||||
DateIndexed(SortOrder),
|
||||
Object(Box<ObjectOrder>),
|
||||
DateImageTaken(Box<ObjectOrder>),
|
||||
}
|
||||
|
||||
impl FilePathOrder {
|
||||
fn get_sort_order(&self) -> prisma::SortOrder {
|
||||
(*match self {
|
||||
Self::Name(v) => v,
|
||||
Self::SizeInBytes(v) => v,
|
||||
Self::DateCreated(v) => v,
|
||||
Self::DateModified(v) => v,
|
||||
Self::DateIndexed(v) => v,
|
||||
Self::Object(v) => return v.get_sort_order(),
|
||||
Self::DateImageTaken(v) => return v.get_sort_order(),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn into_param(self) -> file_path::OrderByWithRelationParam {
|
||||
let dir = self.get_sort_order();
|
||||
use file_path::*;
|
||||
match self {
|
||||
Self::Name(_) => name::order(dir),
|
||||
Self::SizeInBytes(_) => size_in_bytes_bytes::order(dir),
|
||||
Self::DateCreated(_) => date_created::order(dir),
|
||||
Self::DateModified(_) => date_modified::order(dir),
|
||||
Self::DateIndexed(_) => date_indexed::order(dir),
|
||||
Self::Object(v) => object::order(vec![v.into_param()]),
|
||||
Self::DateImageTaken(v) => object::order(vec![v.into_param()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(untagged)]
|
||||
enum MaybeNot<T> {
|
||||
None(T),
|
||||
Not { not: T },
|
||||
}
|
||||
|
||||
impl<T> MaybeNot<T> {
|
||||
fn into_prisma<R: From<prisma_client_rust::Operator<R>>>(self, param: fn(T) -> R) -> R {
|
||||
match self {
|
||||
Self::None(v) => param(v),
|
||||
Self::Not { not } => prisma_client_rust::not![param(not)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Default, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FilePathFilterArgs {
|
||||
#[specta(optional)]
|
||||
location_id: Option<location::id::Type>,
|
||||
#[specta(optional)]
|
||||
search: Option<String>,
|
||||
#[specta(optional)]
|
||||
extension: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: OptionalRange<DateTime<Utc>>,
|
||||
#[specta(optional)]
|
||||
path: Option<String>,
|
||||
#[specta(optional)]
|
||||
with_descendants: Option<bool>,
|
||||
#[specta(optional)]
|
||||
object: Option<ObjectFilterArgs>,
|
||||
#[specta(optional)]
|
||||
hidden: Option<bool>,
|
||||
}
|
||||
|
||||
impl FilePathFilterArgs {
|
||||
async fn into_params(
|
||||
self,
|
||||
db: &PrismaClient,
|
||||
) -> Result<Vec<file_path::WhereParam>, rspc::Error> {
|
||||
let location = if let Some(location_id) = self.location_id {
|
||||
Some(
|
||||
db.location()
|
||||
.find_unique(location::id::equals(location_id))
|
||||
.exec()
|
||||
.await?
|
||||
.ok_or(LocationError::IdNotFound(location_id))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let directory_materialized_path_str = match (self.path, location) {
|
||||
(Some(path), Some(location)) if !path.is_empty() && path != "/" => {
|
||||
let parent_iso_file_path =
|
||||
IsolatedFilePathData::from_relative_str(location.id, &path);
|
||||
if !check_file_path_exists::<LocationError>(&parent_iso_file_path, db).await? {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::NotFound,
|
||||
"Directory not found".into(),
|
||||
));
|
||||
}
|
||||
|
||||
parent_iso_file_path.materialized_path_for_children()
|
||||
}
|
||||
(Some(_empty), _) => Some("/".into()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
{
|
||||
use file_path::*;
|
||||
|
||||
Ok(sd_utils::chain_optional_iter(
|
||||
self.search
|
||||
.unwrap_or_default()
|
||||
.split(' ')
|
||||
.map(str::to_string)
|
||||
.map(name::contains),
|
||||
[
|
||||
self.location_id.map(Some).map(location_id::equals),
|
||||
self.extension.map(Some).map(extension::equals),
|
||||
self.created_at.from.map(|v| date_created::gte(v.into())),
|
||||
self.created_at.to.map(|v| date_created::lte(v.into())),
|
||||
self.hidden.map(Some).map(hidden::equals),
|
||||
directory_materialized_path_str
|
||||
.map(Some)
|
||||
.map(|materialized_path| {
|
||||
if let Some(true) = self.with_descendants {
|
||||
materialized_path::starts_with(
|
||||
materialized_path.unwrap_or_else(|| "/".into()),
|
||||
)
|
||||
} else {
|
||||
materialized_path::equals(materialized_path)
|
||||
}
|
||||
}),
|
||||
self.object.and_then(|obj| {
|
||||
let params = obj.into_params();
|
||||
|
||||
(!params.is_empty()).then(|| object::is(params))
|
||||
}),
|
||||
],
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CursorOrderItem<T> {
|
||||
order: SortOrder,
|
||||
data: T,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FilePathObjectCursor {
|
||||
DateAccessed(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
Kind(CursorOrderItem<i32>),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FilePathCursorVariant {
|
||||
None,
|
||||
Name(CursorOrderItem<String>),
|
||||
SizeInBytes(SortOrder),
|
||||
DateCreated(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
DateModified(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
DateIndexed(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
Object(FilePathObjectCursor),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FilePathCursor {
|
||||
is_dir: bool,
|
||||
variant: FilePathCursorVariant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ObjectCursor {
|
||||
None,
|
||||
DateAccessed(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
Kind(CursorOrderItem<i32>),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
|
||||
pub enum ObjectOrder {
|
||||
DateAccessed(SortOrder),
|
||||
Kind(SortOrder),
|
||||
DateImageTaken(SortOrder),
|
||||
}
|
||||
|
||||
enum MediaDataSortParameter {
|
||||
DateImageTaken,
|
||||
}
|
||||
|
||||
impl ObjectOrder {
|
||||
fn get_sort_order(&self) -> prisma::SortOrder {
|
||||
(*match self {
|
||||
Self::DateAccessed(v) => v,
|
||||
Self::Kind(v) => v,
|
||||
Self::DateImageTaken(v) => v,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn media_data(
|
||||
&self,
|
||||
param: MediaDataSortParameter,
|
||||
dir: prisma::SortOrder,
|
||||
) -> object::OrderByWithRelationParam {
|
||||
let order = match param {
|
||||
MediaDataSortParameter::DateImageTaken => media_data::epoch_time::order(dir),
|
||||
};
|
||||
|
||||
object::media_data::order(vec![order])
|
||||
}
|
||||
|
||||
fn into_param(self) -> object::OrderByWithRelationParam {
|
||||
let dir = self.get_sort_order();
|
||||
use object::*;
|
||||
|
||||
match self {
|
||||
Self::DateAccessed(_) => date_accessed::order(dir),
|
||||
Self::Kind(_) => kind::order(dir),
|
||||
Self::DateImageTaken(_) => self.media_data(MediaDataSortParameter::DateImageTaken, dir),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OrderAndPagination<TId, TOrder, TCursor> {
|
||||
OrderOnly(TOrder),
|
||||
Offset { offset: i32, order: Option<TOrder> },
|
||||
Cursor { id: TId, cursor: TCursor },
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Default, Clone, Copy)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum ObjectHiddenFilter {
|
||||
#[default]
|
||||
Exclude,
|
||||
Include,
|
||||
}
|
||||
|
||||
impl ObjectHiddenFilter {
|
||||
fn to_param(self) -> Option<object::WhereParam> {
|
||||
match self {
|
||||
ObjectHiddenFilter::Exclude => Some(or![
|
||||
object::hidden::equals(None),
|
||||
object::hidden::not(Some(true))
|
||||
]),
|
||||
ObjectHiddenFilter::Include => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ObjectFilterArgs {
|
||||
#[specta(optional)]
|
||||
favorite: Option<bool>,
|
||||
#[serde(default)]
|
||||
hidden: ObjectHiddenFilter,
|
||||
#[specta(optional)]
|
||||
date_accessed: Option<MaybeNot<Option<chrono::DateTime<FixedOffset>>>>,
|
||||
#[serde(default)]
|
||||
kind: BTreeSet<i32>,
|
||||
#[serde(default)]
|
||||
tags: Vec<i32>,
|
||||
#[specta(optional)]
|
||||
category: Option<Category>,
|
||||
}
|
||||
|
||||
impl ObjectFilterArgs {
|
||||
fn into_params(self) -> Vec<object::WhereParam> {
|
||||
use object::*;
|
||||
|
||||
sd_utils::chain_optional_iter(
|
||||
[],
|
||||
[
|
||||
self.hidden.to_param(),
|
||||
self.favorite.map(Some).map(favorite::equals),
|
||||
self.date_accessed
|
||||
.map(|date| date.into_prisma(date_accessed::equals)),
|
||||
(!self.kind.is_empty()).then(|| kind::in_vec(self.kind.into_iter().collect())),
|
||||
(!self.tags.is_empty()).then(|| {
|
||||
let tags = self.tags.into_iter().map(tag::id::equals).collect();
|
||||
let tags_on_object = tag_on_object::tag::is(vec![operator::or(tags)]);
|
||||
|
||||
tags::some(vec![tags_on_object])
|
||||
}),
|
||||
self.category.map(Category::to_where_param),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mount() -> AlphaRouter<Ctx> {
|
||||
R.router()
|
||||
.procedure("ephemeralPaths", {
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
|
||||
enum EphemeralPathOrder {
|
||||
Name(SortOrder),
|
||||
SizeInBytes(SortOrder),
|
||||
DateCreated(SortOrder),
|
||||
DateModified(SortOrder),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EphemeralPathSearchArgs {
|
||||
path: PathBuf,
|
||||
with_hidden_files: bool,
|
||||
#[specta(optional)]
|
||||
order: Option<EphemeralPathOrder>,
|
||||
}
|
||||
|
||||
R.with2(library()).query(
|
||||
|(node, library),
|
||||
EphemeralPathSearchArgs {
|
||||
path,
|
||||
with_hidden_files,
|
||||
order,
|
||||
}| async move {
|
||||
let mut paths =
|
||||
non_indexed::walk(path, with_hidden_files, node, library).await?;
|
||||
|
||||
macro_rules! order_match {
|
||||
($order:ident, [$(($variant:ident, |$i:ident| $func:expr)),+]) => {{
|
||||
match $order {
|
||||
$(EphemeralPathOrder::$variant(order) => {
|
||||
paths.entries.sort_unstable_by(|path1, path2| {
|
||||
let func = |$i: &ExplorerItem| $func;
|
||||
|
||||
let one = func(path1);
|
||||
let two = func(path2);
|
||||
|
||||
match order {
|
||||
SortOrder::Desc => two.cmp(&one),
|
||||
SortOrder::Asc => one.cmp(&two),
|
||||
}
|
||||
});
|
||||
})+
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
if let Some(order) = order {
|
||||
order_match!(
|
||||
order,
|
||||
[
|
||||
(Name, |p| p.name().to_lowercase()),
|
||||
(SizeInBytes, |p| p.size_in_bytes()),
|
||||
(DateCreated, |p| p.date_created()),
|
||||
(DateModified, |p| p.date_modified())
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Ok(paths)
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("paths", {
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FilePathSearchArgs {
|
||||
#[specta(optional)]
|
||||
take: Option<u8>,
|
||||
#[specta(optional)]
|
||||
order_and_pagination:
|
||||
Option<OrderAndPagination<file_path::id::Type, FilePathOrder, FilePathCursor>>,
|
||||
#[serde(default)]
|
||||
filter: FilePathFilterArgs,
|
||||
#[serde(default = "default_group_directories")]
|
||||
group_directories: bool,
|
||||
}
|
||||
|
||||
fn default_group_directories() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
R.with2(library()).query(
|
||||
|(node, library),
|
||||
FilePathSearchArgs {
|
||||
take,
|
||||
order_and_pagination,
|
||||
filter,
|
||||
group_directories,
|
||||
}| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
let mut query = db.file_path().find_many(filter.into_params(db).await?);
|
||||
|
||||
if let Some(take) = take {
|
||||
query = query.take(take as i64);
|
||||
}
|
||||
|
||||
// WARN: this order_by for grouping directories MUST always come before the other order_by
|
||||
if group_directories {
|
||||
query = query.order_by(file_path::is_dir::order(prisma::SortOrder::Desc));
|
||||
}
|
||||
|
||||
// WARN: this order_by for sorting data MUST always come after the other order_by
|
||||
if let Some(order_and_pagination) = order_and_pagination {
|
||||
match order_and_pagination {
|
||||
OrderAndPagination::OrderOnly(order) => {
|
||||
query = query.order_by(order.into_param());
|
||||
}
|
||||
OrderAndPagination::Offset { offset, order } => {
|
||||
query = query.skip(offset as i64);
|
||||
|
||||
if let Some(order) = order {
|
||||
query = query.order_by(order.into_param())
|
||||
}
|
||||
}
|
||||
OrderAndPagination::Cursor { id, cursor } => {
|
||||
// This may seem dumb but it's vital!
|
||||
// If we're grouping by directories + all directories have been fetched,
|
||||
// we don't want to include them in the results.
|
||||
// It's important to keep in mind that since the `order_by` for
|
||||
// `group_directories` comes before all other orderings,
|
||||
// all other orderings will be applied independently to directories and paths.
|
||||
if group_directories && !cursor.is_dir {
|
||||
query.add_where(file_path::is_dir::not(Some(true)))
|
||||
}
|
||||
|
||||
macro_rules! arm {
|
||||
($field:ident, $item:ident) => {{
|
||||
let item = $item;
|
||||
|
||||
let data = item.data.clone();
|
||||
|
||||
query.add_where(or![
|
||||
match item.order {
|
||||
SortOrder::Asc => file_path::$field::gt(data),
|
||||
SortOrder::Desc => file_path::$field::lt(data),
|
||||
},
|
||||
prisma_client_rust::and![
|
||||
file_path::$field::equals(Some(item.data)),
|
||||
match item.order {
|
||||
SortOrder::Asc => file_path::id::gt(id),
|
||||
SortOrder::Desc => file_path::id::lt(id),
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
query = query
|
||||
.order_by(file_path::$field::order(item.order.into()));
|
||||
}};
|
||||
}
|
||||
|
||||
match cursor.variant {
|
||||
FilePathCursorVariant::None => {
|
||||
query.add_where(file_path::id::gt(id));
|
||||
}
|
||||
FilePathCursorVariant::SizeInBytes(order) => {
|
||||
query = query.order_by(
|
||||
file_path::size_in_bytes_bytes::order(order.into()),
|
||||
);
|
||||
}
|
||||
FilePathCursorVariant::Name(item) => arm!(name, item),
|
||||
FilePathCursorVariant::DateCreated(item) => {
|
||||
arm!(date_created, item)
|
||||
}
|
||||
FilePathCursorVariant::DateModified(item) => {
|
||||
arm!(date_modified, item)
|
||||
}
|
||||
FilePathCursorVariant::DateIndexed(item) => {
|
||||
arm!(date_indexed, item)
|
||||
}
|
||||
FilePathCursorVariant::Object(obj) => {
|
||||
macro_rules! arm {
|
||||
($field:ident, $item:ident) => {{
|
||||
let item = $item;
|
||||
|
||||
query.add_where(match item.order {
|
||||
SortOrder::Asc => file_path::object::is(vec![
|
||||
object::$field::gt(item.data),
|
||||
]),
|
||||
SortOrder::Desc => file_path::object::is(vec![
|
||||
object::$field::lt(item.data),
|
||||
]),
|
||||
});
|
||||
|
||||
query =
|
||||
query.order_by(file_path::object::order(vec![
|
||||
object::$field::order(item.order.into()),
|
||||
]));
|
||||
}};
|
||||
}
|
||||
|
||||
match obj {
|
||||
FilePathObjectCursor::Kind(item) => arm!(kind, item),
|
||||
FilePathObjectCursor::DateAccessed(item) => {
|
||||
arm!(date_accessed, item)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
query =
|
||||
query.order_by(file_path::id::order(prisma::SortOrder::Asc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let file_paths = query
|
||||
.include(file_path_with_object::include())
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
let mut items = Vec::with_capacity(file_paths.len());
|
||||
|
||||
for file_path in file_paths {
|
||||
let thumbnail_exists_locally = if let Some(cas_id) = &file_path.cas_id {
|
||||
library
|
||||
.thumbnail_exists(&node, cas_id)
|
||||
.await
|
||||
.map_err(LocationError::from)?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
items.push(ExplorerItem::Path {
|
||||
has_local_thumbnail: thumbnail_exists_locally,
|
||||
thumbnail_key: file_path
|
||||
.cas_id
|
||||
.as_ref()
|
||||
.map(|i| get_indexed_thumb_key(i, library.id)),
|
||||
item: file_path,
|
||||
})
|
||||
}
|
||||
|
||||
Ok(SearchData {
|
||||
items,
|
||||
cursor: None,
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("pathsCount", {
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[specta(inline)]
|
||||
struct Args {
|
||||
#[serde(default)]
|
||||
filter: FilePathFilterArgs,
|
||||
}
|
||||
|
||||
R.with2(library())
|
||||
.query(|(_, library), Args { filter }| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
Ok(db
|
||||
.file_path()
|
||||
.count(filter.into_params(db).await?)
|
||||
.exec()
|
||||
.await? as u32)
|
||||
})
|
||||
})
|
||||
.procedure("objects", {
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ObjectSearchArgs {
|
||||
take: u8,
|
||||
#[specta(optional)]
|
||||
order_and_pagination:
|
||||
Option<OrderAndPagination<object::id::Type, ObjectOrder, ObjectCursor>>,
|
||||
#[serde(default)]
|
||||
filter: ObjectFilterArgs,
|
||||
}
|
||||
|
||||
R.with2(library()).query(
|
||||
|(node, library),
|
||||
ObjectSearchArgs {
|
||||
take,
|
||||
order_and_pagination,
|
||||
filter,
|
||||
}| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
let take = take.max(MAX_TAKE);
|
||||
|
||||
let mut query = db
|
||||
.object()
|
||||
.find_many(filter.into_params())
|
||||
.take(take as i64);
|
||||
|
||||
if let Some(order_and_pagination) = order_and_pagination {
|
||||
match order_and_pagination {
|
||||
OrderAndPagination::OrderOnly(order) => {
|
||||
query = query.order_by(order.into_param());
|
||||
}
|
||||
OrderAndPagination::Offset { offset, order } => {
|
||||
query = query.skip(offset as i64);
|
||||
|
||||
if let Some(order) = order {
|
||||
query = query.order_by(order.into_param())
|
||||
}
|
||||
}
|
||||
OrderAndPagination::Cursor { id, cursor } => {
|
||||
macro_rules! arm {
|
||||
($field:ident, $item:ident) => {{
|
||||
let item = $item;
|
||||
|
||||
let data = item.data.clone();
|
||||
|
||||
query.add_where(or![
|
||||
match item.order {
|
||||
SortOrder::Asc => object::$field::gt(data),
|
||||
SortOrder::Desc => object::$field::lt(data),
|
||||
},
|
||||
prisma_client_rust::and![
|
||||
object::$field::equals(Some(item.data)),
|
||||
match item.order {
|
||||
SortOrder::Asc => object::id::gt(id),
|
||||
SortOrder::Desc => object::id::lt(id),
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
query = query
|
||||
.order_by(object::$field::order(item.order.into()));
|
||||
}};
|
||||
}
|
||||
|
||||
match cursor {
|
||||
ObjectCursor::None => {
|
||||
query.add_where(object::id::gt(id));
|
||||
}
|
||||
ObjectCursor::Kind(item) => arm!(kind, item),
|
||||
ObjectCursor::DateAccessed(item) => arm!(date_accessed, item),
|
||||
}
|
||||
|
||||
query =
|
||||
query.order_by(object::pub_id::order(prisma::SortOrder::Asc))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (objects, cursor) = {
|
||||
let mut objects = query
|
||||
.include(object_with_file_paths::include())
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
let cursor = (objects.len() as u8 > take)
|
||||
.then(|| objects.pop())
|
||||
.flatten()
|
||||
.map(|r| r.pub_id);
|
||||
|
||||
(objects, cursor)
|
||||
};
|
||||
|
||||
let mut items = Vec::with_capacity(objects.len());
|
||||
|
||||
for object in objects {
|
||||
let cas_id = object
|
||||
.file_paths
|
||||
.iter()
|
||||
.map(|fp| fp.cas_id.as_ref())
|
||||
.find_map(|c| c);
|
||||
|
||||
let thumbnail_exists_locally = if let Some(cas_id) = cas_id {
|
||||
library.thumbnail_exists(&node, cas_id).await.map_err(|e| {
|
||||
rspc::Error::with_cause(
|
||||
ErrorCode::InternalServerError,
|
||||
"Failed to check that thumbnail exists".to_string(),
|
||||
e,
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
items.push(ExplorerItem::Object {
|
||||
has_local_thumbnail: thumbnail_exists_locally,
|
||||
thumbnail_key: cas_id.map(|i| get_indexed_thumb_key(i, library.id)),
|
||||
item: object,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SearchData { items, cursor })
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("objectsCount", {
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[specta(inline)]
|
||||
struct Args {
|
||||
#[serde(default)]
|
||||
filter: ObjectFilterArgs,
|
||||
}
|
||||
|
||||
R.with2(library())
|
||||
.query(|(_, library), Args { filter }| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
Ok(db.object().count(filter.into_params()).exec().await? as u32)
|
||||
})
|
||||
})
|
||||
}
|
294
core/src/api/search/file_path.rs
Normal file
|
@ -0,0 +1,294 @@
|
|||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use prisma_client_rust::{OrderByQuery, PaginatedQuery, WhereQuery};
|
||||
use rspc::ErrorCode;
|
||||
use sd_prisma::prisma::{self, file_path};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use crate::location::{
|
||||
file_path_helper::{check_file_path_exists, IsolatedFilePathData},
|
||||
LocationError,
|
||||
};
|
||||
|
||||
use super::object::*;
|
||||
use super::utils::{self, *};
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
|
||||
pub enum FilePathOrder {
|
||||
Name(SortOrder),
|
||||
SizeInBytes(SortOrder),
|
||||
DateCreated(SortOrder),
|
||||
DateModified(SortOrder),
|
||||
DateIndexed(SortOrder),
|
||||
Object(Box<ObjectOrder>),
|
||||
}
|
||||
|
||||
impl FilePathOrder {
|
||||
pub fn get_sort_order(&self) -> prisma::SortOrder {
|
||||
(*match self {
|
||||
Self::Name(v) => v,
|
||||
Self::SizeInBytes(v) => v,
|
||||
Self::DateCreated(v) => v,
|
||||
Self::DateModified(v) => v,
|
||||
Self::DateIndexed(v) => v,
|
||||
Self::Object(v) => return v.get_sort_order(),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn into_param(self) -> file_path::OrderByWithRelationParam {
|
||||
let dir = self.get_sort_order();
|
||||
use file_path::*;
|
||||
match self {
|
||||
Self::Name(_) => name::order(dir),
|
||||
Self::SizeInBytes(_) => size_in_bytes_bytes::order(dir),
|
||||
Self::DateCreated(_) => date_created::order(dir),
|
||||
Self::DateModified(_) => date_modified::order(dir),
|
||||
Self::DateIndexed(_) => date_indexed::order(dir),
|
||||
Self::Object(v) => object::order(vec![v.into_param()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FilePathFilterArgs {
|
||||
Locations(InOrNotIn<file_path::id::Type>),
|
||||
Path {
|
||||
location_id: prisma::location::id::Type,
|
||||
path: String,
|
||||
include_descendants: bool,
|
||||
},
|
||||
// #[deprecated]
|
||||
// Search(String),
|
||||
Name(TextMatch),
|
||||
Extension(InOrNotIn<String>),
|
||||
CreatedAt(Range<DateTime<Utc>>),
|
||||
ModifiedAt(Range<DateTime<Utc>>),
|
||||
IndexedAt(Range<DateTime<Utc>>),
|
||||
Hidden(bool),
|
||||
}
|
||||
|
||||
impl FilePathFilterArgs {
|
||||
pub async fn into_params(
|
||||
self,
|
||||
db: &prisma::PrismaClient,
|
||||
) -> Result<Vec<file_path::WhereParam>, rspc::Error> {
|
||||
use file_path::*;
|
||||
|
||||
Ok(match self {
|
||||
Self::Locations(v) => v
|
||||
.to_param(
|
||||
file_path::location_id::in_vec,
|
||||
file_path::location_id::not_in_vec,
|
||||
)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default(),
|
||||
Self::Path {
|
||||
location_id,
|
||||
path,
|
||||
include_descendants,
|
||||
} => {
|
||||
let directory_materialized_path_str = if !path.is_empty() && path != "/" {
|
||||
let parent_iso_file_path =
|
||||
IsolatedFilePathData::from_relative_str(location_id, &path);
|
||||
|
||||
if !check_file_path_exists::<LocationError>(&parent_iso_file_path, db).await? {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::NotFound,
|
||||
"Directory not found".into(),
|
||||
));
|
||||
}
|
||||
|
||||
parent_iso_file_path.materialized_path_for_children()
|
||||
} else {
|
||||
Some("/".into())
|
||||
};
|
||||
|
||||
directory_materialized_path_str
|
||||
.map(Some)
|
||||
.map(|materialized_path| {
|
||||
vec![if include_descendants {
|
||||
materialized_path::starts_with(
|
||||
materialized_path.unwrap_or_else(|| "/".into()),
|
||||
)
|
||||
} else {
|
||||
materialized_path::equals(materialized_path)
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
Self::Name(v) => v
|
||||
.to_param(name::contains, name::starts_with, name::ends_with, |s| {
|
||||
name::equals(Some(s))
|
||||
})
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default(),
|
||||
Self::Extension(v) => v
|
||||
.to_param(extension::in_vec, extension::not_in_vec)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default(),
|
||||
Self::CreatedAt(v) => {
|
||||
vec![match v {
|
||||
Range::From(v) => date_created::gte(v.into()),
|
||||
Range::To(v) => date_created::lte(v.into()),
|
||||
}]
|
||||
}
|
||||
Self::ModifiedAt(v) => {
|
||||
vec![match v {
|
||||
Range::From(v) => date_modified::gte(v.into()),
|
||||
Range::To(v) => date_modified::lte(v.into()),
|
||||
}]
|
||||
}
|
||||
Self::IndexedAt(v) => {
|
||||
vec![match v {
|
||||
Range::From(v) => date_indexed::gte(v.into()),
|
||||
Range::To(v) => date_indexed::lte(v.into()),
|
||||
}]
|
||||
}
|
||||
Self::Hidden(v) => {
|
||||
vec![hidden::equals(Some(v))]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FilePathObjectCursor {
|
||||
DateAccessed(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
Kind(CursorOrderItem<i32>),
|
||||
}
|
||||
|
||||
impl FilePathObjectCursor {
|
||||
fn apply(self, query: &mut file_path::FindManyQuery) {
|
||||
macro_rules! arm {
|
||||
($field:ident, $item:ident) => {{
|
||||
let item = $item;
|
||||
|
||||
query.add_where(match item.order {
|
||||
SortOrder::Asc => {
|
||||
prisma::file_path::object::is(vec![prisma::object::$field::gt(item.data)])
|
||||
}
|
||||
SortOrder::Desc => {
|
||||
prisma::file_path::object::is(vec![prisma::object::$field::lt(item.data)])
|
||||
}
|
||||
});
|
||||
|
||||
query.add_order_by(prisma::file_path::object::order(vec![
|
||||
prisma::object::$field::order(item.order.into()),
|
||||
]));
|
||||
}};
|
||||
}
|
||||
|
||||
match self {
|
||||
FilePathObjectCursor::Kind(item) => arm!(kind, item),
|
||||
FilePathObjectCursor::DateAccessed(item) => {
|
||||
arm!(date_accessed, item)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FilePathCursorVariant {
|
||||
None,
|
||||
Name(CursorOrderItem<String>),
|
||||
SizeInBytes(SortOrder),
|
||||
DateCreated(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
DateModified(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
DateIndexed(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
Object(FilePathObjectCursor),
|
||||
}
|
||||
|
||||
impl FilePathCursorVariant {
|
||||
pub fn apply(self, query: &mut file_path::FindManyQuery, id: i32) {
|
||||
macro_rules! arm {
|
||||
($field:ident, $item:ident) => {{
|
||||
let item = $item;
|
||||
|
||||
let data = item.data.clone();
|
||||
|
||||
query.add_where(prisma_client_rust::or![
|
||||
match item.order {
|
||||
SortOrder::Asc => prisma::file_path::$field::gt(data),
|
||||
SortOrder::Desc => prisma::file_path::$field::lt(data),
|
||||
},
|
||||
prisma_client_rust::and![
|
||||
prisma::file_path::$field::equals(Some(item.data)),
|
||||
match item.order {
|
||||
SortOrder::Asc => prisma::file_path::id::gt(id),
|
||||
SortOrder::Desc => prisma::file_path::id::lt(id),
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
query.add_order_by(prisma::file_path::$field::order(item.order.into()));
|
||||
}};
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::None => {
|
||||
query.add_where(prisma::file_path::id::gt(id));
|
||||
}
|
||||
Self::SizeInBytes(order) => {
|
||||
query.add_order_by(prisma::file_path::size_in_bytes_bytes::order(order.into()));
|
||||
}
|
||||
Self::Name(item) => arm!(name, item),
|
||||
Self::DateCreated(item) => {
|
||||
arm!(date_created, item)
|
||||
}
|
||||
Self::DateModified(item) => {
|
||||
arm!(date_modified, item)
|
||||
}
|
||||
Self::DateIndexed(item) => {
|
||||
arm!(date_indexed, item)
|
||||
}
|
||||
Self::Object(obj) => obj.apply(query),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FilePathCursor {
|
||||
pub is_dir: bool,
|
||||
pub variant: FilePathCursorVariant,
|
||||
}
|
||||
|
||||
pub type OrderAndPagination =
|
||||
utils::OrderAndPagination<prisma::file_path::id::Type, FilePathOrder, FilePathCursor>;
|
||||
|
||||
impl OrderAndPagination {
|
||||
pub fn apply(self, query: &mut file_path::FindManyQuery, group_directories: bool) {
|
||||
match self {
|
||||
Self::OrderOnly(order) => {
|
||||
query.add_order_by(order.into_param());
|
||||
}
|
||||
Self::Offset { offset, order } => {
|
||||
query.set_skip(offset as i64);
|
||||
|
||||
if let Some(order) = order {
|
||||
query.add_order_by(order.into_param())
|
||||
}
|
||||
}
|
||||
Self::Cursor { id, cursor } => {
|
||||
// This may seem dumb but it's vital!
|
||||
// If we're grouping by directories + all directories have been fetched,
|
||||
// we don't want to include them in the results.
|
||||
// It's important to keep in mind that since the `order_by` for
|
||||
// `group_directories` comes before all other orderings,
|
||||
// all other orderings will be applied independently to directories and paths.
|
||||
if group_directories && !cursor.is_dir {
|
||||
query.add_where(prisma::file_path::is_dir::not(Some(true)))
|
||||
}
|
||||
|
||||
cursor.variant.apply(query, id);
|
||||
|
||||
query.add_order_by(prisma::file_path::id::order(prisma::SortOrder::Asc));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
28
core/src/api/search/media_data.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use sd_prisma::prisma::{self, media_data};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use super::utils::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
|
||||
pub enum MediaDataOrder {
|
||||
EpochTime(SortOrder),
|
||||
}
|
||||
|
||||
impl MediaDataOrder {
|
||||
pub fn get_sort_order(&self) -> prisma::SortOrder {
|
||||
(*match self {
|
||||
Self::EpochTime(v) => v,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn into_param(self) -> media_data::OrderByWithRelationParam {
|
||||
let dir = self.get_sort_order();
|
||||
use media_data::*;
|
||||
match self {
|
||||
Self::EpochTime(_) => epoch_time::order(dir),
|
||||
}
|
||||
}
|
||||
}
|
369
core/src/api/search/mod.rs
Normal file
|
@ -0,0 +1,369 @@
|
|||
pub mod file_path;
|
||||
pub mod media_data;
|
||||
pub mod object;
|
||||
pub mod saved;
|
||||
mod utils;
|
||||
|
||||
pub use self::{file_path::*, object::*, utils::*};
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
locations::{file_path_with_object, object_with_file_paths, ExplorerItem},
|
||||
utils::library,
|
||||
},
|
||||
library::Library,
|
||||
location::{non_indexed, LocationError},
|
||||
object::media::thumbnail::get_indexed_thumb_key,
|
||||
};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rspc::{alpha::AlphaRouter, ErrorCode};
|
||||
use sd_prisma::prisma::{self, PrismaClient};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use super::{Ctx, R};
|
||||
|
||||
const MAX_TAKE: u8 = 100;
|
||||
|
||||
#[derive(Serialize, Type, Debug)]
|
||||
struct SearchData<T> {
|
||||
cursor: Option<Vec<u8>>,
|
||||
items: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchFilterArgs {
|
||||
FilePath(FilePathFilterArgs),
|
||||
Object(ObjectFilterArgs),
|
||||
}
|
||||
|
||||
impl SearchFilterArgs {
|
||||
async fn into_params<T>(
|
||||
self,
|
||||
db: &PrismaClient,
|
||||
file_path: fn(Vec<prisma::file_path::WhereParam>) -> Vec<T>,
|
||||
object: fn(Vec<prisma::object::WhereParam>) -> Vec<T>,
|
||||
) -> Result<Vec<T>, rspc::Error> {
|
||||
Ok(match self {
|
||||
Self::FilePath(v) => file_path(v.into_params(db).await?),
|
||||
Self::Object(v) => object(v.into_params()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn into_file_path_params(
|
||||
self,
|
||||
db: &PrismaClient,
|
||||
) -> Result<Vec<prisma::file_path::WhereParam>, rspc::Error> {
|
||||
self.into_params(db, |v| v, |v| vec![prisma::file_path::object::is(v)])
|
||||
.await
|
||||
}
|
||||
|
||||
async fn into_object_params(
|
||||
self,
|
||||
db: &PrismaClient,
|
||||
) -> Result<Vec<prisma::object::WhereParam>, rspc::Error> {
|
||||
self.into_params(db, |v| vec![prisma::object::file_paths::some(v)], |v| v)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mount() -> AlphaRouter<Ctx> {
|
||||
R.router()
|
||||
.procedure("ephemeralPaths", {
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
|
||||
enum EphemeralPathOrder {
|
||||
Name(SortOrder),
|
||||
SizeInBytes(SortOrder),
|
||||
DateCreated(SortOrder),
|
||||
DateModified(SortOrder),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EphemeralPathSearchArgs {
|
||||
path: PathBuf,
|
||||
with_hidden_files: bool,
|
||||
#[specta(optional)]
|
||||
order: Option<EphemeralPathOrder>,
|
||||
}
|
||||
|
||||
R.with2(library()).query(
|
||||
|(node, library),
|
||||
EphemeralPathSearchArgs {
|
||||
path,
|
||||
with_hidden_files,
|
||||
order,
|
||||
}| async move {
|
||||
let mut paths =
|
||||
non_indexed::walk(path, with_hidden_files, node, library).await?;
|
||||
|
||||
macro_rules! order_match {
|
||||
($order:ident, [$(($variant:ident, |$i:ident| $func:expr)),+]) => {{
|
||||
match $order {
|
||||
$(EphemeralPathOrder::$variant(order) => {
|
||||
paths.entries.sort_unstable_by(|path1, path2| {
|
||||
let func = |$i: &ExplorerItem| $func;
|
||||
|
||||
let one = func(path1);
|
||||
let two = func(path2);
|
||||
|
||||
match order {
|
||||
SortOrder::Desc => two.cmp(&one),
|
||||
SortOrder::Asc => one.cmp(&two),
|
||||
}
|
||||
});
|
||||
})+
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
if let Some(order) = order {
|
||||
order_match!(
|
||||
order,
|
||||
[
|
||||
(Name, |p| p.name().to_lowercase()),
|
||||
(SizeInBytes, |p| p.size_in_bytes()),
|
||||
(DateCreated, |p| p.date_created()),
|
||||
(DateModified, |p| p.date_modified())
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Ok(paths)
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("paths", {
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FilePathSearchArgs {
|
||||
#[specta(optional)]
|
||||
take: Option<u8>,
|
||||
#[specta(optional)]
|
||||
order_and_pagination: Option<file_path::OrderAndPagination>,
|
||||
#[serde(default)]
|
||||
filters: Vec<SearchFilterArgs>,
|
||||
#[serde(default = "default_group_directories")]
|
||||
group_directories: bool,
|
||||
}
|
||||
|
||||
fn default_group_directories() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
R.with2(library()).query(
|
||||
|(node, library),
|
||||
FilePathSearchArgs {
|
||||
take,
|
||||
order_and_pagination,
|
||||
filters,
|
||||
group_directories,
|
||||
}| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
let mut query = db.file_path().find_many({
|
||||
let mut params = Vec::new();
|
||||
|
||||
for filter in filters {
|
||||
params.extend(filter.into_file_path_params(db).await?);
|
||||
}
|
||||
|
||||
params
|
||||
});
|
||||
|
||||
if let Some(take) = take {
|
||||
query = query.take(take as i64);
|
||||
}
|
||||
|
||||
// WARN: this order_by for grouping directories MUST always come before the other order_by
|
||||
if group_directories {
|
||||
query = query
|
||||
.order_by(prisma::file_path::is_dir::order(prisma::SortOrder::Desc));
|
||||
}
|
||||
|
||||
// WARN: this order_by for sorting data MUST always come after the other order_by
|
||||
if let Some(order_and_pagination) = order_and_pagination {
|
||||
order_and_pagination.apply(&mut query, group_directories)
|
||||
}
|
||||
|
||||
let file_paths = query
|
||||
.include(file_path_with_object::include())
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
let mut items = Vec::with_capacity(file_paths.len());
|
||||
|
||||
for file_path in file_paths {
|
||||
let thumbnail_exists_locally = if let Some(cas_id) = &file_path.cas_id {
|
||||
library
|
||||
.thumbnail_exists(&node, cas_id)
|
||||
.await
|
||||
.map_err(LocationError::from)?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
items.push(ExplorerItem::Path {
|
||||
has_local_thumbnail: thumbnail_exists_locally,
|
||||
thumbnail_key: file_path
|
||||
.cas_id
|
||||
.as_ref()
|
||||
.map(|i| get_indexed_thumb_key(i, library.id)),
|
||||
item: file_path,
|
||||
})
|
||||
}
|
||||
|
||||
Ok(SearchData {
|
||||
items,
|
||||
cursor: None,
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("pathsCount", {
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[specta(inline)]
|
||||
struct Args {
|
||||
#[specta(default)]
|
||||
filters: Vec<SearchFilterArgs>,
|
||||
}
|
||||
|
||||
R.with2(library())
|
||||
.query(|(_, library), Args { filters }| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
Ok(db
|
||||
.file_path()
|
||||
.count({
|
||||
let mut params = Vec::new();
|
||||
|
||||
for filter in filters {
|
||||
params.extend(filter.into_file_path_params(db).await?);
|
||||
}
|
||||
|
||||
params
|
||||
})
|
||||
.exec()
|
||||
.await? as u32)
|
||||
})
|
||||
})
|
||||
.procedure("objects", {
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ObjectSearchArgs {
|
||||
take: u8,
|
||||
#[specta(optional)]
|
||||
order_and_pagination: Option<object::OrderAndPagination>,
|
||||
#[serde(default)]
|
||||
filters: Vec<SearchFilterArgs>,
|
||||
}
|
||||
|
||||
R.with2(library()).query(
|
||||
|(node, library),
|
||||
ObjectSearchArgs {
|
||||
take,
|
||||
order_and_pagination,
|
||||
filters,
|
||||
}| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
let take = take.max(MAX_TAKE);
|
||||
|
||||
let mut query = db
|
||||
.object()
|
||||
.find_many({
|
||||
let mut params = Vec::new();
|
||||
|
||||
for filter in filters {
|
||||
params.extend(filter.into_object_params(db).await?);
|
||||
}
|
||||
|
||||
params
|
||||
})
|
||||
.take(take as i64);
|
||||
|
||||
if let Some(order_and_pagination) = order_and_pagination {
|
||||
order_and_pagination.apply(&mut query);
|
||||
}
|
||||
|
||||
let (objects, cursor) = {
|
||||
let mut objects = query
|
||||
.include(object_with_file_paths::include())
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
let cursor = (objects.len() as u8 > take)
|
||||
.then(|| objects.pop())
|
||||
.flatten()
|
||||
.map(|r| r.pub_id);
|
||||
|
||||
(objects, cursor)
|
||||
};
|
||||
|
||||
let mut items = Vec::with_capacity(objects.len());
|
||||
|
||||
for object in objects {
|
||||
let cas_id = object
|
||||
.file_paths
|
||||
.iter()
|
||||
.map(|fp| fp.cas_id.as_ref())
|
||||
.find_map(|c| c);
|
||||
|
||||
let thumbnail_exists_locally = if let Some(cas_id) = cas_id {
|
||||
library.thumbnail_exists(&node, cas_id).await.map_err(|e| {
|
||||
rspc::Error::with_cause(
|
||||
ErrorCode::InternalServerError,
|
||||
"Failed to check that thumbnail exists".to_string(),
|
||||
e,
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
items.push(ExplorerItem::Object {
|
||||
has_local_thumbnail: thumbnail_exists_locally,
|
||||
thumbnail_key: cas_id.map(|i| get_indexed_thumb_key(i, library.id)),
|
||||
item: object,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SearchData { items, cursor })
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("objectsCount", {
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[specta(inline)]
|
||||
struct Args {
|
||||
#[serde(default)]
|
||||
filters: Vec<SearchFilterArgs>,
|
||||
}
|
||||
|
||||
R.with2(library())
|
||||
.query(|(_, library), Args { filters }| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
Ok(db
|
||||
.object()
|
||||
.count({
|
||||
let mut params = Vec::new();
|
||||
|
||||
for filter in filters {
|
||||
params.extend(filter.into_object_params(db).await?);
|
||||
}
|
||||
|
||||
params
|
||||
})
|
||||
.exec()
|
||||
.await? as u32)
|
||||
})
|
||||
})
|
||||
// .merge("saved.", saved::mount())
|
||||
}
|
171
core/src/api/search/object.rs
Normal file
|
@ -0,0 +1,171 @@
|
|||
use chrono::{DateTime, FixedOffset};
|
||||
use prisma_client_rust::not;
|
||||
use prisma_client_rust::{or, OrderByQuery, PaginatedQuery, WhereQuery};
|
||||
use sd_prisma::prisma::{self, object, tag_on_object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
// use crate::library::Category;
|
||||
|
||||
use super::media_data::*;
|
||||
use super::utils::{self, *};
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ObjectCursor {
|
||||
None,
|
||||
DateAccessed(CursorOrderItem<DateTime<FixedOffset>>),
|
||||
Kind(CursorOrderItem<i32>),
|
||||
}
|
||||
|
||||
impl ObjectCursor {
|
||||
fn apply(self, query: &mut object::FindManyQuery, id: i32) {
|
||||
macro_rules! arm {
|
||||
($field:ident, $item:ident) => {{
|
||||
let item = $item;
|
||||
|
||||
let data = item.data.clone();
|
||||
|
||||
query.add_where(or![
|
||||
match item.order {
|
||||
SortOrder::Asc => prisma::object::$field::gt(data),
|
||||
SortOrder::Desc => prisma::object::$field::lt(data),
|
||||
},
|
||||
prisma_client_rust::and![
|
||||
prisma::object::$field::equals(Some(item.data)),
|
||||
match item.order {
|
||||
SortOrder::Asc => prisma::object::id::gt(id),
|
||||
SortOrder::Desc => prisma::object::id::lt(id),
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
query.add_order_by(prisma::object::$field::order(item.order.into()));
|
||||
}};
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::None => {
|
||||
query.add_where(prisma::object::id::gt(id));
|
||||
}
|
||||
Self::Kind(item) => arm!(kind, item),
|
||||
Self::DateAccessed(item) => arm!(date_accessed, item),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
|
||||
pub enum ObjectOrder {
|
||||
DateAccessed(SortOrder),
|
||||
Kind(SortOrder),
|
||||
MediaData(Box<MediaDataOrder>),
|
||||
}
|
||||
|
||||
impl ObjectOrder {
|
||||
pub fn get_sort_order(&self) -> prisma::SortOrder {
|
||||
(*match self {
|
||||
Self::DateAccessed(v) => v,
|
||||
Self::Kind(v) => v,
|
||||
Self::MediaData(v) => return v.get_sort_order(),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn into_param(self) -> object::OrderByWithRelationParam {
|
||||
let dir = self.get_sort_order();
|
||||
use object::*;
|
||||
|
||||
match self {
|
||||
Self::DateAccessed(_) => date_accessed::order(dir),
|
||||
Self::Kind(_) => kind::order(dir),
|
||||
Self::MediaData(v) => media_data::order(vec![v.into_param()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Default, Clone, Copy)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ObjectHiddenFilter {
|
||||
#[default]
|
||||
Exclude,
|
||||
Include,
|
||||
}
|
||||
|
||||
impl ObjectHiddenFilter {
|
||||
pub fn to_param(self) -> Option<object::WhereParam> {
|
||||
match self {
|
||||
ObjectHiddenFilter::Exclude => Some(or![
|
||||
object::hidden::equals(None),
|
||||
object::hidden::not(Some(true))
|
||||
]),
|
||||
ObjectHiddenFilter::Include => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ObjectFilterArgs {
|
||||
Favorite(bool),
|
||||
Hidden(ObjectHiddenFilter),
|
||||
Kind(InOrNotIn<i32>),
|
||||
Tags(InOrNotIn<i32>),
|
||||
DateAccessed(Range<chrono::DateTime<FixedOffset>>),
|
||||
}
|
||||
|
||||
impl ObjectFilterArgs {
|
||||
pub fn into_params(self) -> Vec<object::WhereParam> {
|
||||
use object::*;
|
||||
|
||||
match self {
|
||||
Self::Favorite(v) => vec![favorite::equals(Some(v))],
|
||||
Self::Hidden(v) => v.to_param().map(|v| vec![v]).unwrap_or_default(),
|
||||
Self::Tags(v) => v
|
||||
.to_param(
|
||||
|v| tags::some(vec![tag_on_object::tag_id::in_vec(v)]),
|
||||
|v| tags::none(vec![tag_on_object::tag_id::in_vec(v)]),
|
||||
)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default(),
|
||||
Self::Kind(v) => v
|
||||
.to_param(kind::in_vec, kind::not_in_vec)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default(),
|
||||
Self::DateAccessed(v) => {
|
||||
vec![
|
||||
not![date_accessed::equals(None)],
|
||||
match v {
|
||||
Range::From(v) => date_accessed::gte(v.into()),
|
||||
Range::To(v) => date_accessed::lte(v.into()),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type OrderAndPagination =
|
||||
utils::OrderAndPagination<prisma::object::id::Type, ObjectOrder, ObjectCursor>;
|
||||
|
||||
impl OrderAndPagination {
|
||||
pub fn apply(self, query: &mut object::FindManyQuery) {
|
||||
match self {
|
||||
Self::OrderOnly(order) => {
|
||||
query.add_order_by(order.into_param());
|
||||
}
|
||||
Self::Offset { offset, order } => {
|
||||
query.set_skip(offset as i64);
|
||||
|
||||
if let Some(order) = order {
|
||||
query.add_order_by(order.into_param())
|
||||
}
|
||||
}
|
||||
Self::Cursor { id, cursor } => {
|
||||
cursor.apply(query, id);
|
||||
|
||||
query.add_order_by(prisma::object::pub_id::order(prisma::SortOrder::Asc))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
186
core/src/api/search/saved.rs
Normal file
|
@ -0,0 +1,186 @@
|
|||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use rspc::alpha::AlphaRouter;
|
||||
use sd_utils::chain_optional_iter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{api::utils::library, invalidate_query, library::Library, prisma::saved_search};
|
||||
|
||||
use super::{Ctx, R};
|
||||
|
||||
#[derive(Serialize, Type, Deserialize, Clone, Debug)]
|
||||
pub struct Filter {
|
||||
pub value: String,
|
||||
pub name: String,
|
||||
pub icon: Option<String>,
|
||||
pub filter_type: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Type, Deserialize, Clone, Debug)]
|
||||
pub struct SavedSearchCreateArgs {
|
||||
pub name: Option<String>,
|
||||
pub filters: Option<Vec<Filter>>,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Type, Deserialize, Clone, Debug)]
|
||||
pub struct SavedSearchUpdateArgs {
|
||||
pub id: i32,
|
||||
pub name: Option<String>,
|
||||
pub filters: Option<Vec<Filter>>,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl SavedSearchCreateArgs {
|
||||
pub async fn exec(
|
||||
self,
|
||||
Library { db, .. }: &Library,
|
||||
) -> prisma_client_rust::Result<saved_search::Data> {
|
||||
print!("SavedSearchCreateArgs {:?}", self);
|
||||
let pub_id = Uuid::new_v4().as_bytes().to_vec();
|
||||
let date_created: DateTime<FixedOffset> = Utc::now().into();
|
||||
|
||||
db.saved_search()
|
||||
.create(
|
||||
pub_id,
|
||||
chain_optional_iter(
|
||||
[saved_search::date_created::set(Some(date_created))],
|
||||
[
|
||||
self.name.map(Some).map(saved_search::name::set),
|
||||
self.filters
|
||||
.map(|f| serde_json::to_string(&f).unwrap().into_bytes())
|
||||
.map(Some)
|
||||
.map(saved_search::filters::set),
|
||||
self.description
|
||||
.map(Some)
|
||||
.map(saved_search::description::set),
|
||||
self.icon.map(Some).map(saved_search::icon::set),
|
||||
],
|
||||
),
|
||||
)
|
||||
.exec()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
||||
R.router()
|
||||
.procedure("create", {
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), args: SavedSearchCreateArgs| async move {
|
||||
args.exec(&library).await?;
|
||||
// invalidate_query!(library, "search.saved.list");
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.procedure("get", {
|
||||
R.with2(library())
|
||||
.query(|(_, library), search_id: i32| async move {
|
||||
Ok(library
|
||||
.db
|
||||
.saved_search()
|
||||
.find_unique(saved_search::id::equals(search_id))
|
||||
.exec()
|
||||
.await?)
|
||||
})
|
||||
})
|
||||
.procedure("list", {
|
||||
#[derive(Serialize, Type, Deserialize, Clone)]
|
||||
pub struct SavedSearchResponse {
|
||||
pub id: i32,
|
||||
pub pub_id: Vec<u8>,
|
||||
pub name: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub order: Option<i32>,
|
||||
pub date_created: Option<DateTime<FixedOffset>>,
|
||||
pub date_modified: Option<DateTime<FixedOffset>>,
|
||||
pub filters: Option<Vec<Filter>>,
|
||||
}
|
||||
R.with2(library()).query(|(_, library), _: ()| async move {
|
||||
let searches: Vec<saved_search::Data> = library
|
||||
.db
|
||||
.saved_search()
|
||||
.find_many(vec![])
|
||||
// .order_by(saved_search::order::order(prisma::SortOrder::Desc))
|
||||
.exec()
|
||||
.await?;
|
||||
let result: Result<Vec<SavedSearchResponse>, _> = searches
|
||||
.into_iter()
|
||||
.map(|search| {
|
||||
let filters_bytes = search.filters.unwrap_or_else(Vec::new);
|
||||
|
||||
let filters_string = String::from_utf8(filters_bytes).unwrap();
|
||||
let filters: Vec<Filter> = serde_json::from_str(&filters_string).unwrap();
|
||||
|
||||
Ok(SavedSearchResponse {
|
||||
id: search.id,
|
||||
pub_id: search.pub_id,
|
||||
name: search.name,
|
||||
icon: search.icon,
|
||||
description: search.description,
|
||||
order: search.order,
|
||||
date_created: search.date_created,
|
||||
date_modified: search.date_modified,
|
||||
filters: Some(filters),
|
||||
})
|
||||
})
|
||||
.collect(); // Collects the Result, if there is any Err it will be propagated.
|
||||
|
||||
result
|
||||
})
|
||||
})
|
||||
.procedure("update", {
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), args: SavedSearchUpdateArgs| async move {
|
||||
let mut params = vec![];
|
||||
|
||||
if let Some(name) = args.name {
|
||||
params.push(saved_search::name::set(Some(name)));
|
||||
}
|
||||
|
||||
if let Some(filters) = &args.filters {
|
||||
let filters_as_string = serde_json::to_string(filters).unwrap();
|
||||
let filters_as_bytes = filters_as_string.into_bytes();
|
||||
params.push(saved_search::filters::set(Some(filters_as_bytes)));
|
||||
}
|
||||
|
||||
if let Some(description) = args.description {
|
||||
params.push(saved_search::description::set(Some(description)));
|
||||
}
|
||||
|
||||
if let Some(icon) = args.icon {
|
||||
params.push(saved_search::icon::set(Some(icon)));
|
||||
}
|
||||
|
||||
params.push(saved_search::date_modified::set(Some(Utc::now().into())));
|
||||
|
||||
library
|
||||
.db
|
||||
.saved_search()
|
||||
.update(saved_search::id::equals(args.id), params)
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
// invalidate_query!(library, "search.saved.list");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.procedure("delete", {
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), search_id: i32| async move {
|
||||
library
|
||||
.db
|
||||
.saved_search()
|
||||
.delete(saved_search::id::equals(search_id))
|
||||
.exec()
|
||||
.await?;
|
||||
// invalidate_query!(library, "search.saved.list");
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
124
core/src/api/search/utils.rs
Normal file
|
@ -0,0 +1,124 @@
|
|||
use sd_prisma::prisma;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Range<T> {
|
||||
From(T),
|
||||
To(T),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SortOrder {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
impl From<SortOrder> for prisma::SortOrder {
|
||||
fn from(value: SortOrder) -> prisma::SortOrder {
|
||||
match value {
|
||||
SortOrder::Asc => prisma::SortOrder::Asc,
|
||||
SortOrder::Desc => prisma::SortOrder::Desc,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum MaybeNot<T> {
|
||||
None(T),
|
||||
Not { not: T },
|
||||
}
|
||||
|
||||
impl<T> MaybeNot<T> {
|
||||
pub fn into_prisma<R: From<prisma_client_rust::Operator<R>>>(self, param: fn(T) -> R) -> R {
|
||||
match self {
|
||||
Self::None(v) => param(v),
|
||||
Self::Not { not } => prisma_client_rust::not![param(not)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CursorOrderItem<T> {
|
||||
pub order: SortOrder,
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OrderAndPagination<TId, TOrder, TCursor> {
|
||||
OrderOnly(TOrder),
|
||||
Offset { offset: i32, order: Option<TOrder> },
|
||||
Cursor { id: TId, cursor: TCursor },
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InOrNotIn<T> {
|
||||
In(Vec<T>),
|
||||
NotIn(Vec<T>),
|
||||
}
|
||||
|
||||
impl<T> InOrNotIn<T> {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::In(v) => v.is_empty(),
|
||||
Self::NotIn(v) => v.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_param<TParam>(
|
||||
self,
|
||||
in_fn: fn(Vec<T>) -> TParam,
|
||||
not_in_fn: fn(Vec<T>) -> TParam,
|
||||
) -> Option<TParam> {
|
||||
self.is_empty()
|
||||
.then_some(None)
|
||||
.unwrap_or_else(|| match self {
|
||||
Self::In(v) => Some(in_fn(v)),
|
||||
Self::NotIn(v) => Some(not_in_fn(v)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Type, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextMatch {
|
||||
Contains(String),
|
||||
StartsWith(String),
|
||||
EndsWith(String),
|
||||
Equals(String),
|
||||
}
|
||||
|
||||
impl TextMatch {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::Contains(v) => v.is_empty(),
|
||||
Self::StartsWith(v) => v.is_empty(),
|
||||
Self::EndsWith(v) => v.is_empty(),
|
||||
Self::Equals(v) => v.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update the to_param method of TextMatch
|
||||
pub fn to_param<TParam>(
|
||||
self,
|
||||
contains_fn: fn(String) -> TParam,
|
||||
starts_with_fn: fn(String) -> TParam,
|
||||
ends_with_fn: fn(String) -> TParam,
|
||||
equals_fn: fn(String) -> TParam,
|
||||
) -> Option<TParam> {
|
||||
self.is_empty()
|
||||
.then_some(None)
|
||||
.unwrap_or_else(|| match self {
|
||||
Self::Contains(v) => Some(contains_fn(v)),
|
||||
Self::StartsWith(v) => Some(starts_with_fn(v)),
|
||||
Self::EndsWith(v) => Some(ends_with_fn(v)),
|
||||
Self::Equals(v) => Some(equals_fn(v)),
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
pub(crate) mod cat;
|
||||
// pub(crate) mod cat;
|
||||
mod config;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod library;
|
||||
mod manager;
|
||||
mod name;
|
||||
|
||||
pub use cat::*;
|
||||
// pub use cat::*;
|
||||
pub use config::*;
|
||||
pub use library::*;
|
||||
pub use manager::*;
|
||||
|
|
|
@ -52,7 +52,7 @@ impl LibraryPreferences {
|
|||
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocationSettings {
|
||||
explorer: ExplorerSettings<search::FilePathOrder>,
|
||||
explorer: ExplorerSettings<search::file_path::FilePathOrder>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
|
||||
|
|
|
@ -10,7 +10,7 @@ Spacedrive is a decentralized cross platform app that allows you to manage files
|
|||
|
||||
## Can I download it yet?
|
||||
|
||||
Only if you compile it yourself, but it is not stable yet. Follow [@spacedriveapp](https://x.com/spacedriveapp) for updates!
|
||||
Yes, we are currently in alpha. Downloads are available for [macOS Intel](https://spacedrive.com/api/releases/desktop/stable/darwin/x86_64), [macOS](https://spacedrive.com/api/releases/desktop/stable/darwin/aarch64), [Windows](https://spacedrive.com/api/releases/desktop/stable/windows/x86_64), and [Linux](https://spacedrive.com/api/releases/desktop/stable/linux/x86_64). There are missing features and a few annoying bugs, but we're working rapidly to fix them.
|
||||
|
||||
## How does it work?
|
||||
|
||||
|
|
195
interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx
Normal file
|
@ -0,0 +1,195 @@
|
|||
import { MagnifyingGlass, X } from '@phosphor-icons/react';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
import { tw } from '@sd/ui';
|
||||
|
||||
import { useSearchContext } from './Context';
|
||||
import { filterRegistry } from './Filters';
|
||||
import { getSearchStore, updateFilterArgs, useSearchStore } from './store';
|
||||
import { RenderIcon } from './util';
|
||||
|
||||
export const FilterContainer = tw.div`flex flex-row items-center rounded bg-app-box overflow-hidden`;
|
||||
|
||||
export const InteractiveSection = tw.div`flex group flex-row items-center border-app-darkerBox/70 px-2 py-0.5 text-sm text-ink-dull hover:bg-app-lightBox/20`;
|
||||
|
||||
export const StaticSection = tw.div`flex flex-row items-center pl-2 pr-1 text-sm`;
|
||||
|
||||
export const FilterText = tw.span`mx-1 py-0.5 text-sm text-ink-dull`;
|
||||
|
||||
export const CloseTab = forwardRef<HTMLDivElement, { onClick: () => void }>(({ onClick }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex h-full items-center rounded-r border-l border-app-darkerBox/70 px-1.5 py-0.5 text-sm hover:bg-app-lightBox/30"
|
||||
onClick={onClick}
|
||||
>
|
||||
<RenderIcon className="h-3 w-3" icon={X} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const AppliedOptions = () => {
|
||||
const searchState = useSearchStore();
|
||||
|
||||
const { allFilterArgs } = useSearchContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-2">
|
||||
{searchState.searchQuery && (
|
||||
<FilterContainer>
|
||||
<StaticSection>
|
||||
<RenderIcon className="h-4 w-4" icon={MagnifyingGlass} />
|
||||
<FilterText>{searchState.searchQuery}</FilterText>
|
||||
</StaticSection>
|
||||
<CloseTab onClick={() => (getSearchStore().searchQuery = null)} />
|
||||
</FilterContainer>
|
||||
)}
|
||||
{allFilterArgs.map(({ arg, removalIndex }, index) => {
|
||||
const filter = filterRegistry.find((f) => f.extract(arg));
|
||||
if (!filter) return;
|
||||
|
||||
const activeOptions = filter.argsToOptions(
|
||||
filter.extract(arg)! as any,
|
||||
searchState.filterOptions
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterContainer key={`${filter.name}-${index}`}>
|
||||
<StaticSection>
|
||||
<RenderIcon className="h-4 w-4" icon={filter.icon} />
|
||||
<FilterText>{filter.name}</FilterText>
|
||||
</StaticSection>
|
||||
<InteractiveSection className="border-l">
|
||||
{/* {Object.entries(filter.conditions).map(([value, displayName]) => (
|
||||
<div key={value}>{displayName}</div>
|
||||
))} */}
|
||||
{
|
||||
(filter.conditions as any)[
|
||||
filter.getCondition(filter.extract(arg) as any) as any
|
||||
]
|
||||
}
|
||||
</InteractiveSection>
|
||||
|
||||
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm">
|
||||
{activeOptions && (
|
||||
<>
|
||||
{activeOptions.length === 1 ? (
|
||||
<RenderIcon
|
||||
className="h-4 w-4"
|
||||
icon={activeOptions[0]!.icon}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: `${activeOptions.length * 12}px` }}
|
||||
>
|
||||
{activeOptions.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute -top-2 left-0"
|
||||
style={{
|
||||
zIndex: activeOptions.length - index,
|
||||
left: `${index * 10}px`
|
||||
}}
|
||||
>
|
||||
<RenderIcon
|
||||
className="h-4 w-4"
|
||||
icon={option.icon}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeOptions.length > 1
|
||||
? `${activeOptions.length} ${pluralize(filter.name)}`
|
||||
: activeOptions[0]?.name}
|
||||
</>
|
||||
)}
|
||||
</InteractiveSection>
|
||||
|
||||
{removalIndex !== null && (
|
||||
<CloseTab
|
||||
onClick={() => {
|
||||
updateFilterArgs((args) => {
|
||||
args.splice(removalIndex, 1);
|
||||
|
||||
return args;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FilterContainer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function pluralize(word?: string) {
|
||||
if (word?.endsWith('s')) return word;
|
||||
return `${word}s`;
|
||||
}
|
||||
|
||||
// {
|
||||
// groupedFilters?.map((group) => {
|
||||
// const showRemoveButton = group.filters.some((filter) => filter.canBeRemoved);
|
||||
// const meta = filterRegistry.find((f) => f.name === group.type);
|
||||
|
||||
// return (
|
||||
// <FilterContainer key={group.type}>
|
||||
// <StaticSection>
|
||||
// <RenderIcon className="h-4 w-4" icon={meta?.icon} />
|
||||
// <FilterText>{meta?.name}</FilterText>
|
||||
// </StaticSection>
|
||||
// {meta?.conditions && (
|
||||
// <InteractiveSection className="border-l">
|
||||
// {/* {Object.values(meta.conditions).map((condition) => (
|
||||
// <div key={condition}>{condition}</div>
|
||||
// ))} */}
|
||||
// is
|
||||
// </InteractiveSection>
|
||||
// )}
|
||||
|
||||
// <InteractiveSection className="border-app-darkerBox/70 gap-1 border-l py-0.5 pl-1.5 pr-2 text-sm">
|
||||
// {group.filters.length > 1 && (
|
||||
// <div
|
||||
// className="relative"
|
||||
// style={{ width: `${group.filters.length * 12}px` }}
|
||||
// >
|
||||
// {group.filters.map((filter, index) => (
|
||||
// <div
|
||||
// key={index}
|
||||
// className="absolute -top-2 left-0"
|
||||
// style={{
|
||||
// zIndex: group.filters.length - index,
|
||||
// left: `${index * 10}px`
|
||||
// }}
|
||||
// >
|
||||
// <RenderIcon className="h-4 w-4" icon={filter.icon} />
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
// {group.filters.length === 1 && (
|
||||
// <RenderIcon className="h-4 w-4" icon={group.filters[0]?.icon} />
|
||||
// )}
|
||||
// {group.filters.length > 1
|
||||
// ? `${group.filters.length} ${pluralize(meta?.name)}`
|
||||
// : group.filters[0]?.name}
|
||||
// </InteractiveSection>
|
||||
|
||||
// {showRemoveButton && (
|
||||
// <CloseTab
|
||||
// onClick={() =>
|
||||
// group.filters.forEach((filter) => {
|
||||
// if (filter.canBeRemoved) {
|
||||
// deselectFilterOption(filter);
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// />
|
||||
// )}
|
||||
// </FilterContainer>
|
||||
// );
|
||||
// });
|
||||
// }
|
85
interface/app/$libraryId/Explorer/Search/Context.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { createContext, PropsWithChildren, useContext, useMemo } from 'react';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
|
||||
import { useTopBarContext } from '../../TopBar/Layout';
|
||||
import { filterRegistry } from './Filters';
|
||||
import { argsToOptions, getKey, useSearchStore } from './store';
|
||||
|
||||
const Context = createContext<ReturnType<typeof useContextValue> | null>(null);
|
||||
|
||||
function useContextValue() {
|
||||
const searchState = useSearchStore();
|
||||
|
||||
const { fixedArgs, setFixedArgs } = useTopBarContext();
|
||||
|
||||
const fixedArgsAsOptions = useMemo(() => {
|
||||
return fixedArgs ? argsToOptions(fixedArgs, searchState.filterOptions) : null;
|
||||
}, [fixedArgs, searchState.filterOptions]);
|
||||
|
||||
const fixedArgsKeys = useMemo(() => {
|
||||
const keys = fixedArgsAsOptions
|
||||
? new Set(
|
||||
fixedArgsAsOptions?.map(({ arg, filter }) => {
|
||||
return getKey({
|
||||
type: filter.name,
|
||||
name: arg.name,
|
||||
value: arg.value
|
||||
});
|
||||
})
|
||||
)
|
||||
: null;
|
||||
return keys;
|
||||
}, [fixedArgsAsOptions]);
|
||||
|
||||
const allFilterArgs = useMemo(() => {
|
||||
if (!fixedArgs) return [];
|
||||
|
||||
const value: { arg: SearchFilterArgs; removalIndex: number | null }[] = fixedArgs.map(
|
||||
(arg) => ({
|
||||
arg,
|
||||
removalIndex: null
|
||||
})
|
||||
);
|
||||
|
||||
for (const [index, arg] of searchState.filterArgs.entries()) {
|
||||
const filter = filterRegistry.find((f) => f.extract(arg));
|
||||
if (!filter) continue;
|
||||
|
||||
const fixedEquivalentIndex = fixedArgs.findIndex(
|
||||
(a) => filter.extract(a) !== undefined
|
||||
);
|
||||
if (fixedEquivalentIndex !== -1) {
|
||||
const merged = filter.merge(
|
||||
filter.extract(fixedArgs[fixedEquivalentIndex]!)! as any,
|
||||
filter.extract(arg)! as any
|
||||
);
|
||||
|
||||
value[fixedEquivalentIndex] = {
|
||||
arg: filter.create(merged),
|
||||
removalIndex: fixedEquivalentIndex
|
||||
};
|
||||
} else {
|
||||
value.push({
|
||||
arg,
|
||||
removalIndex: index
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}, [fixedArgs, searchState.filterArgs]);
|
||||
|
||||
return { setFixedArgs, fixedArgs, fixedArgsKeys, allFilterArgs };
|
||||
}
|
||||
|
||||
export const SearchContextProvider = ({ children }: PropsWithChildren) => {
|
||||
return <Context.Provider value={useContextValue()}>{children}</Context.Provider>;
|
||||
};
|
||||
|
||||
export function useSearchContext() {
|
||||
const ctx = useContext(Context);
|
||||
|
||||
if (!ctx) throw new Error('SearchContextProvider not found!');
|
||||
|
||||
return ctx;
|
||||
}
|
564
interface/app/$libraryId/Explorer/Search/Filters.tsx
Normal file
|
@ -0,0 +1,564 @@
|
|||
import { CircleDashed, Cube, Folder, Icon, SelectionSlash, Textbox } from '@phosphor-icons/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client';
|
||||
import { Button, Input } from '@sd/ui';
|
||||
|
||||
import { SearchOptionItem, SearchOptionSubMenu } from '.';
|
||||
import { useSearchContext } from './Context';
|
||||
import { AllKeys, FilterOption, getKey, updateFilterArgs, useSearchStore } from './store';
|
||||
import { FilterTypeCondition, filterTypeCondition } from './util';
|
||||
|
||||
export interface SearchFilter<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any
|
||||
> {
|
||||
name: string;
|
||||
icon: Icon;
|
||||
conditions: TConditions;
|
||||
}
|
||||
|
||||
export interface SearchFilterCRUD<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any,
|
||||
T = any
|
||||
> extends SearchFilter<TConditions> {
|
||||
getCondition: (args: T) => AllKeys<TConditions>;
|
||||
setCondition: (args: T, condition: keyof TConditions) => void;
|
||||
applyAdd: (args: T, option: FilterOption) => void;
|
||||
applyRemove: (args: T, option: FilterOption) => T | undefined;
|
||||
argsToOptions: (args: T, options: Map<string, FilterOption[]>) => FilterOption[];
|
||||
extract: (arg: SearchFilterArgs) => T | undefined;
|
||||
create: (data: any) => SearchFilterArgs;
|
||||
merge: (left: T, right: T) => T;
|
||||
}
|
||||
|
||||
export interface RenderSearchFilter<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any,
|
||||
T = any
|
||||
> extends SearchFilterCRUD<TConditions, T> {
|
||||
// Render is responsible for fetching the filter options and rendering them
|
||||
Render: (props: {
|
||||
filter: SearchFilterCRUD<TConditions>;
|
||||
options: (FilterOption & { type: string })[];
|
||||
}) => JSX.Element;
|
||||
// Apply is responsible for applying the filter to the search args
|
||||
useOptions: (props: { search: string }) => FilterOption[];
|
||||
}
|
||||
|
||||
export function useToggleOptionSelected() {
|
||||
const { fixedArgsKeys } = useSearchContext();
|
||||
|
||||
return useCallback(
|
||||
({
|
||||
filter,
|
||||
option,
|
||||
select
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
option: FilterOption;
|
||||
select: boolean;
|
||||
}) =>
|
||||
updateFilterArgs((args) => {
|
||||
const key = getKey({ ...option, type: filter.name });
|
||||
|
||||
if (fixedArgsKeys?.has(key)) return args;
|
||||
|
||||
const rawArg = args.find((arg) => filter.extract(arg));
|
||||
|
||||
if (!rawArg) {
|
||||
const arg = filter.create(option.value);
|
||||
args.push(arg);
|
||||
} else {
|
||||
const rawArgIndex = args.findIndex((arg) => filter.extract(arg))!;
|
||||
|
||||
const arg = filter.extract(rawArg)!;
|
||||
|
||||
if (select) {
|
||||
if (rawArg) filter.applyAdd(arg, option);
|
||||
} else {
|
||||
if (!filter.applyRemove(arg, option)) args.splice(rawArgIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}),
|
||||
[fixedArgsKeys]
|
||||
);
|
||||
}
|
||||
|
||||
const FilterOptionList = ({
|
||||
filter,
|
||||
options
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
options: FilterOption[];
|
||||
}) => {
|
||||
const store = useSearchStore();
|
||||
const { fixedArgsKeys } = useSearchContext();
|
||||
|
||||
const toggleOptionSelected = useToggleOptionSelected();
|
||||
|
||||
return (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
{options?.map((option) => {
|
||||
const optionKey = getKey({
|
||||
...option,
|
||||
type: filter.name
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchOptionItem
|
||||
selected={
|
||||
store.filterArgsKeys.has(optionKey) || fixedArgsKeys?.has(optionKey)
|
||||
}
|
||||
setSelected={(value) =>
|
||||
toggleOptionSelected({
|
||||
filter,
|
||||
option,
|
||||
select: value
|
||||
})
|
||||
}
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
>
|
||||
{option.name}
|
||||
</SearchOptionItem>
|
||||
);
|
||||
})}
|
||||
</SearchOptionSubMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterOptionText = ({ filter }: { filter: SearchFilterCRUD }) => {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const { fixedArgsKeys } = useSearchContext();
|
||||
|
||||
return (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<Input value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
<Button
|
||||
variant="accent"
|
||||
onClick={() => {
|
||||
updateFilterArgs((args) => {
|
||||
const key = getKey({
|
||||
type: filter.name,
|
||||
name: value,
|
||||
value
|
||||
});
|
||||
|
||||
if (fixedArgsKeys?.has(key)) return args;
|
||||
|
||||
const arg = filter.create(value);
|
||||
args.push(arg);
|
||||
|
||||
return args;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</SearchOptionSubMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterOptionBoolean = ({ filter }: { filter: SearchFilterCRUD }) => {
|
||||
const { filterArgsKeys } = useSearchStore();
|
||||
|
||||
const { fixedArgsKeys } = useSearchContext();
|
||||
|
||||
const key = getKey({
|
||||
type: filter.name,
|
||||
name: filter.name,
|
||||
value: true
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchOptionItem
|
||||
icon={filter.icon}
|
||||
selected={fixedArgsKeys?.has(key) || filterArgsKeys.has(key)}
|
||||
setSelected={() => {
|
||||
updateFilterArgs((args) => {
|
||||
if (fixedArgsKeys?.has(key)) return args;
|
||||
|
||||
const index = args.findIndex((f) => filter.extract(f) !== undefined);
|
||||
|
||||
if (index !== -1) {
|
||||
args.splice(index, 1);
|
||||
} else {
|
||||
const arg = filter.create(true);
|
||||
args.push(arg);
|
||||
}
|
||||
|
||||
return args;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</SearchOptionItem>
|
||||
);
|
||||
};
|
||||
|
||||
function createFilter<TConditions extends FilterTypeCondition[keyof FilterTypeCondition], T>(
|
||||
filter: RenderSearchFilter<TConditions, T>
|
||||
) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
function createInOrNotInFilter<T extends string | number>(
|
||||
filter: Omit<
|
||||
ReturnType<typeof createFilter<any, InOrNotIn<T>>>,
|
||||
| 'conditions'
|
||||
| 'getCondition'
|
||||
| 'argsToOptions'
|
||||
| 'setCondition'
|
||||
| 'applyAdd'
|
||||
| 'applyRemove'
|
||||
| 'create'
|
||||
| 'merge'
|
||||
> & {
|
||||
create(value: InOrNotIn<T>): SearchFilterArgs;
|
||||
argsToOptions(values: T[], options: Map<string, FilterOption[]>): FilterOption[];
|
||||
}
|
||||
): ReturnType<typeof createFilter<(typeof filterTypeCondition)['inOrNotIn'], InOrNotIn<T>>> {
|
||||
return {
|
||||
...filter,
|
||||
create: (data) => {
|
||||
if (typeof data === 'number' || typeof data === 'string')
|
||||
return filter.create({
|
||||
in: [data as any]
|
||||
});
|
||||
else if (data) return filter.create(data);
|
||||
else return filter.create({ in: [] });
|
||||
},
|
||||
conditions: filterTypeCondition.inOrNotIn,
|
||||
getCondition: (data) => {
|
||||
if ('in' in data) return 'in';
|
||||
else return 'notIn';
|
||||
},
|
||||
setCondition: (data, condition) => {
|
||||
const contents = 'in' in data ? data.in : data.notIn;
|
||||
|
||||
return condition === 'in' ? { in: contents } : { notIn: contents };
|
||||
},
|
||||
argsToOptions: (data, options) => {
|
||||
let values: T[];
|
||||
|
||||
if ('in' in data) values = data.in;
|
||||
else values = data.notIn;
|
||||
|
||||
return filter.argsToOptions(values, options);
|
||||
},
|
||||
applyAdd: (data, option) => {
|
||||
if ('in' in data) data.in.push(option.value);
|
||||
else data.notIn.push(option.value);
|
||||
|
||||
return data;
|
||||
},
|
||||
applyRemove: (data, option) => {
|
||||
if ('in' in data) {
|
||||
data.in = data.in.filter((id) => id !== option.value);
|
||||
|
||||
if (data.in.length === 0) return;
|
||||
} else {
|
||||
data.notIn = data.notIn.filter((id) => id !== option.value);
|
||||
|
||||
if (data.notIn.length === 0) return;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
merge: (left, right) => {
|
||||
if ('in' in left && 'in' in right) {
|
||||
return {
|
||||
in: [...new Set([...left.in, ...right.in])]
|
||||
};
|
||||
} else if ('notIn' in left && 'notIn' in right) {
|
||||
return {
|
||||
notIn: [...new Set([...left.notIn, ...right.notIn])]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Cannot merge InOrNotIns with different conditions');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createTextMatchFilter(
|
||||
filter: Omit<
|
||||
ReturnType<typeof createFilter<any, TextMatch>>,
|
||||
| 'conditions'
|
||||
| 'getCondition'
|
||||
| 'argsToOptions'
|
||||
| 'setCondition'
|
||||
| 'applyAdd'
|
||||
| 'applyRemove'
|
||||
| 'create'
|
||||
| 'merge'
|
||||
> & {
|
||||
create(value: TextMatch): SearchFilterArgs;
|
||||
}
|
||||
): ReturnType<typeof createFilter<(typeof filterTypeCondition)['textMatch'], TextMatch>> {
|
||||
return {
|
||||
...filter,
|
||||
conditions: filterTypeCondition.textMatch,
|
||||
create: (contains) => filter.create({ contains }),
|
||||
getCondition: (data) => {
|
||||
if ('contains' in data) return 'contains';
|
||||
else if ('startsWith' in data) return 'startsWith';
|
||||
else if ('endsWith' in data) return 'endsWith';
|
||||
else return 'equals';
|
||||
},
|
||||
setCondition: (data, condition) => {
|
||||
let value: string;
|
||||
|
||||
if ('contains' in data) value = data.contains;
|
||||
else if ('startsWith' in data) value = data.startsWith;
|
||||
else if ('endsWith' in data) value = data.endsWith;
|
||||
else value = data.equals;
|
||||
|
||||
return {
|
||||
[condition]: value
|
||||
};
|
||||
},
|
||||
argsToOptions: (data) => {
|
||||
let value: string;
|
||||
|
||||
if ('contains' in data) value = data.contains;
|
||||
else if ('startsWith' in data) value = data.startsWith;
|
||||
else if ('endsWith' in data) value = data.endsWith;
|
||||
else value = data.equals;
|
||||
|
||||
return [
|
||||
{
|
||||
type: filter.name,
|
||||
name: value,
|
||||
value
|
||||
}
|
||||
];
|
||||
},
|
||||
applyAdd: (data, { value }) => {
|
||||
if ('contains' in data) return { contains: value };
|
||||
else if ('startsWith' in data) return { startsWith: value };
|
||||
else if ('endsWith' in data) return { endsWith: value };
|
||||
else if ('equals' in data) return { equals: value };
|
||||
},
|
||||
applyRemove: () => undefined,
|
||||
merge: (left) => left
|
||||
};
|
||||
}
|
||||
|
||||
function createBooleanFilter(
|
||||
filter: Omit<
|
||||
ReturnType<typeof createFilter<any, boolean>>,
|
||||
| 'conditions'
|
||||
| 'getCondition'
|
||||
| 'argsToOptions'
|
||||
| 'setCondition'
|
||||
| 'applyAdd'
|
||||
| 'applyRemove'
|
||||
| 'create'
|
||||
| 'merge'
|
||||
> & {
|
||||
create(value: boolean): SearchFilterArgs;
|
||||
}
|
||||
): ReturnType<typeof createFilter<(typeof filterTypeCondition)['trueOrFalse'], boolean>> {
|
||||
return {
|
||||
...filter,
|
||||
conditions: filterTypeCondition.trueOrFalse,
|
||||
create: () => filter.create(true),
|
||||
getCondition: (data) => (data ? 'true' : 'false'),
|
||||
setCondition: (_, condition) => condition === 'true',
|
||||
argsToOptions: (value) => {
|
||||
if (!value) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
type: filter.name,
|
||||
name: filter.name,
|
||||
value
|
||||
}
|
||||
];
|
||||
},
|
||||
applyAdd: (_, { value }) => value,
|
||||
applyRemove: () => undefined,
|
||||
merge: (left) => left
|
||||
};
|
||||
}
|
||||
|
||||
export const filterRegistry = [
|
||||
createInOrNotInFilter({
|
||||
name: 'Location',
|
||||
icon: Folder, // Phosphor folder icon
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations;
|
||||
},
|
||||
create: (locations) => ({ filePath: { locations } }),
|
||||
argsToOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
if (!option) return;
|
||||
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () => {
|
||||
const query = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
|
||||
return (query.data ?? []).map((location) => ({
|
||||
name: location.name!,
|
||||
value: location.id,
|
||||
icon: 'Folder' // Spacedrive folder icon
|
||||
}));
|
||||
},
|
||||
Render: ({ filter, options }) => <FilterOptionList filter={filter} options={options} />
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: 'Tags',
|
||||
icon: CircleDashed,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'tags' in arg.object) return arg.object.tags;
|
||||
},
|
||||
create: (tags) => ({ object: { tags } }),
|
||||
argsToOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
if (!option) return;
|
||||
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () => {
|
||||
const query = useLibraryQuery(['tags.list'], { keepPreviousData: true });
|
||||
|
||||
return (query.data ?? []).map((tag) => ({
|
||||
name: tag.name!,
|
||||
value: tag.id,
|
||||
icon: tag.color || 'CircleDashed'
|
||||
}));
|
||||
},
|
||||
Render: ({ filter, options }) => <FilterOptionList filter={filter} options={options} />
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: 'Kind',
|
||||
icon: Cube,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'kind' in arg.object) return arg.object.kind;
|
||||
},
|
||||
create: (kind) => ({ object: { kind } }),
|
||||
argsToOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
if (!option) return;
|
||||
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () =>
|
||||
Object.keys(ObjectKind)
|
||||
.filter((key) => !isNaN(Number(key)) && ObjectKind[Number(key)] !== undefined)
|
||||
.map((key) => {
|
||||
const kind = ObjectKind[Number(key)];
|
||||
return {
|
||||
name: kind as string,
|
||||
value: Number(key),
|
||||
icon: kind + '20'
|
||||
};
|
||||
}),
|
||||
Render: ({ filter, options }) => <FilterOptionList filter={filter} options={options} />
|
||||
}),
|
||||
createTextMatchFilter({
|
||||
name: 'Name',
|
||||
icon: Textbox,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name;
|
||||
},
|
||||
create: (name) => ({ filePath: { name } }),
|
||||
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
|
||||
Render: ({ filter }) => <FilterOptionText filter={filter} />
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: 'Extension',
|
||||
icon: Textbox,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension;
|
||||
},
|
||||
create: (extension) => ({ filePath: { extension } }),
|
||||
argsToOptions(values) {
|
||||
return values.map((value) => ({
|
||||
type: this.name,
|
||||
name: value,
|
||||
value
|
||||
}));
|
||||
},
|
||||
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
|
||||
Render: ({ filter }) => <FilterOptionText filter={filter} />
|
||||
}),
|
||||
createBooleanFilter({
|
||||
name: 'Hidden',
|
||||
icon: SelectionSlash,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden;
|
||||
},
|
||||
create: (hidden) => ({ filePath: { hidden } }),
|
||||
useOptions: () => {
|
||||
return [
|
||||
{
|
||||
name: 'Hidden',
|
||||
value: true,
|
||||
icon: 'SelectionSlash' // Spacedrive folder icon
|
||||
}
|
||||
];
|
||||
},
|
||||
Render: ({ filter }) => <FilterOptionBoolean filter={filter} />
|
||||
})
|
||||
// idk how to handle this rn since include_descendants is part of 'path' now
|
||||
//
|
||||
// createFilter({
|
||||
// name: 'WithDescendants',
|
||||
// icon: SelectionSlash,
|
||||
// conditions: filterTypeCondition.trueOrFalse,
|
||||
// setCondition: (args, condition: 'true' | 'false') => {
|
||||
// const filePath = (args.filePath ??= {});
|
||||
|
||||
// filePath.withDescendants = condition === 'true';
|
||||
// },
|
||||
// applyAdd: () => {},
|
||||
// applyRemove: (args) => {
|
||||
// delete args.filePath?.withDescendants;
|
||||
// },
|
||||
// useOptions: () => {
|
||||
// return [
|
||||
// {
|
||||
// name: 'With Descendants',
|
||||
// value: true,
|
||||
// icon: 'SelectionSlash' // Spacedrive folder icon
|
||||
// }
|
||||
// ];
|
||||
// },
|
||||
// Render: ({ filter }) => {
|
||||
// return <FilterOptionBoolean filter={filter} />;
|
||||
// },
|
||||
// apply(filter, args) {
|
||||
// (args.filePath ??= {}).withDescendants = filter.condition;
|
||||
// }
|
||||
// })
|
||||
] as const satisfies ReadonlyArray<RenderSearchFilter<any>>;
|
||||
|
||||
export type FilterType = (typeof filterRegistry)[number]['name'];
|
50
interface/app/$libraryId/Explorer/Search/SavedSearches.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
// import { Filter, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
|
||||
import { getKey, useSearchStore } from './store';
|
||||
|
||||
export const useSavedSearches = () => {
|
||||
const searchStore = useSearchStore();
|
||||
// const savedSearches = useLibraryQuery(['search.saved.list']);
|
||||
// const createSavedSearch = useLibraryMutation(['search.saved.create']);
|
||||
// const removeSavedSearch = useLibraryMutation(['search.saved.delete']);
|
||||
// const searches = savedSearches.data || [];
|
||||
|
||||
// const [selectedSavedSearch, setSelectedSavedSearch] = useState<number | null>(null);
|
||||
|
||||
return {
|
||||
// searches,
|
||||
loadSearch: (id: number) => {
|
||||
// const search = searches?.find((search) => search.id === id);
|
||||
// if (search) {
|
||||
// TODO
|
||||
// search.filters?.forEach(({ filter_type, name, value, icon }) => {
|
||||
// const filter: Filter = {
|
||||
// type: filter_type,
|
||||
// name,
|
||||
// value,
|
||||
// icon: icon || ''
|
||||
// };
|
||||
// const key = getKey(filter);
|
||||
// searchStore.registeredFilters.set(key, filter);
|
||||
// selectFilter(filter, true);
|
||||
// });
|
||||
// }
|
||||
},
|
||||
removeSearch: (id: number) => {
|
||||
// removeSavedSearch.mutate(id);
|
||||
},
|
||||
saveSearch: (name: string) => {
|
||||
// createSavedSearch.mutate({
|
||||
// name,
|
||||
// description: '',
|
||||
// icon: '',
|
||||
// filters: filters.map((filter) => ({
|
||||
// filter_type: filter.type,
|
||||
// name: filter.name,
|
||||
// value: filter.value,
|
||||
// icon: filter.icon || 'Folder'
|
||||
// }))
|
||||
// });
|
||||
}
|
||||
};
|
||||
};
|
235
interface/app/$libraryId/Explorer/Search/index.tsx
Normal file
|
@ -0,0 +1,235 @@
|
|||
import { CaretRight, FunnelSimple, Icon, Plus } from '@phosphor-icons/react';
|
||||
import { IconTypes } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { memo, PropsWithChildren, useDeferredValue, useState } from 'react';
|
||||
import { Button, ContextMenuDivItem, DropdownMenu, Input, RadixCheckbox, tw } from '@sd/ui';
|
||||
import { useKeybind } from '~/hooks';
|
||||
|
||||
import { AppliedOptions } from './AppliedFilters';
|
||||
import { useSearchContext } from './Context';
|
||||
import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters';
|
||||
import {
|
||||
getSearchStore,
|
||||
useRegisterSearchFilterOptions,
|
||||
useSearchRegisteredFilters,
|
||||
useSearchStore
|
||||
} from './store';
|
||||
import { RenderIcon } from './util';
|
||||
|
||||
// const Label = tw.span`text-ink-dull mr-2 text-xs`;
|
||||
const OptionContainer = tw.div`flex flex-row items-center`;
|
||||
|
||||
interface SearchOptionItemProps extends PropsWithChildren {
|
||||
selected?: boolean;
|
||||
setSelected?: (selected: boolean) => void;
|
||||
icon?: Icon | IconTypes | string;
|
||||
}
|
||||
const MENU_STYLES = `!rounded-md border !border-app-line !bg-app-box`;
|
||||
|
||||
// One component so all items have the same styling, including the submenu
|
||||
const SearchOptionItemInternals = (props: SearchOptionItemProps) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-2">
|
||||
{props.selected !== undefined && (
|
||||
<RadixCheckbox checked={props.selected} onCheckedChange={props.setSelected} />
|
||||
)}
|
||||
<RenderIcon icon={props.icon} />
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// for individual items in a submenu, defined in Options
|
||||
export const SearchOptionItem = (props: SearchOptionItemProps) => {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
props.setSelected?.(!props.selected);
|
||||
}}
|
||||
variant="dull"
|
||||
>
|
||||
<SearchOptionItemInternals {...props} />
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchOptionSubMenu = (props: SearchOptionItemProps & { name?: string }) => {
|
||||
return (
|
||||
<DropdownMenu.SubMenu
|
||||
trigger={
|
||||
<ContextMenuDivItem rightArrow variant="dull">
|
||||
<SearchOptionItemInternals {...props}>{props.name}</SearchOptionItemInternals>
|
||||
</ContextMenuDivItem>
|
||||
}
|
||||
className={clsx(MENU_STYLES, '-mt-1.5')}
|
||||
>
|
||||
{props.children}
|
||||
</DropdownMenu.SubMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const Separator = () => <DropdownMenu.Separator className="!border-app-line" />;
|
||||
|
||||
const SearchOptions = () => {
|
||||
const searchState = useSearchStore();
|
||||
|
||||
const [newFilterName, setNewFilterName] = useState('');
|
||||
const [_search, setSearch] = useState('');
|
||||
|
||||
const search = useDeferredValue(_search);
|
||||
|
||||
useKeybind(['Escape'], () => {
|
||||
getSearchStore().isSearching = false;
|
||||
});
|
||||
|
||||
// const savedSearches = useSavedSearches();
|
||||
|
||||
for (const filter of filterRegistry) {
|
||||
const options = filter.useOptions({ search }).map((o) => ({ ...o, type: filter.name }));
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useRegisterSearchFilterOptions(filter, options);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => {
|
||||
getSearchStore().interactingWithSearchOptions = true;
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
getSearchStore().interactingWithSearchOptions = false;
|
||||
}}
|
||||
className="flex h-[45px] w-full flex-row items-center gap-4 border-b border-app-line/50 bg-app-darkerBox/90 px-4 backdrop-blur"
|
||||
>
|
||||
{/* <OptionContainer className="flex flex-row items-center">
|
||||
<FilterContainer>
|
||||
<InteractiveSection>Paths</InteractiveSection>
|
||||
</FilterContainer>
|
||||
</OptionContainer> */}
|
||||
|
||||
<OptionContainer>
|
||||
<DropdownMenu.Root
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={MENU_STYLES}
|
||||
trigger={
|
||||
<Button className="flex flex-row gap-1" size="xs" variant="dotted">
|
||||
<FunnelSimple />
|
||||
Add Filter
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={_search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
variant="transparent"
|
||||
placeholder="Filter..."
|
||||
/>
|
||||
<Separator />
|
||||
{_search === '' ? (
|
||||
filterRegistry.map((filter) => (
|
||||
<filter.Render
|
||||
key={filter.name}
|
||||
filter={filter as any}
|
||||
options={searchState.filterOptions.get(filter.name)!}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<SearchResults search={search} />
|
||||
)}
|
||||
</DropdownMenu.Root>
|
||||
</OptionContainer>
|
||||
{/* We're keeping AppliedOptions to the right of the "Add Filter" button because its not worth rebuilding the dropdown with custom logic to lock the position as the trigger will move if to the right of the applied options and that is bad UX. */}
|
||||
<AppliedOptions />
|
||||
<div className="grow" />
|
||||
|
||||
{searchState.filterArgs.length > 0 && (
|
||||
<DropdownMenu.Root
|
||||
className={clsx(MENU_STYLES)}
|
||||
trigger={
|
||||
<Button className="flex flex-row" size="xs" variant="dotted">
|
||||
<Plus weight="bold" className="mr-1" />
|
||||
Save Search
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="mx-1.5 my-1 flex flex-row items-center overflow-hidden">
|
||||
<Input
|
||||
value={newFilterName}
|
||||
onChange={(e) => setNewFilterName(e.target.value)}
|
||||
autoFocus
|
||||
variant="default"
|
||||
placeholder="Name"
|
||||
className="w-[130px]"
|
||||
/>
|
||||
{/* <Button
|
||||
onClick={() => {
|
||||
if (!newFilterName) return;
|
||||
savedSearches.saveSearch(newFilterName);
|
||||
setNewFilterName('');
|
||||
}}
|
||||
className="ml-2"
|
||||
variant="accent"
|
||||
>
|
||||
Save
|
||||
</Button> */}
|
||||
</div>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
|
||||
<kbd
|
||||
onClick={() => (getSearchStore().isSearching = false)}
|
||||
className="ml-2 rounded-lg border border-app-line bg-app-box px-2 py-1 text-[10.5px] tracking-widest shadow"
|
||||
>
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchOptions;
|
||||
|
||||
const SearchResults = memo(({ search }: { search: string }) => {
|
||||
const { fixedArgsKeys } = useSearchContext();
|
||||
const searchState = useSearchStore();
|
||||
const searchResults = useSearchRegisteredFilters(search);
|
||||
|
||||
const toggleOptionSelected = useToggleOptionSelected();
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchResults.map((option) => {
|
||||
const filter = filterRegistry.find((f) => f.name === option.type);
|
||||
if (!filter) return;
|
||||
|
||||
return (
|
||||
<SearchOptionItem
|
||||
selected={
|
||||
searchState.filterArgsKeys.has(option.key) ||
|
||||
fixedArgsKeys?.has(option.key)
|
||||
}
|
||||
setSelected={(select) =>
|
||||
toggleOptionSelected({
|
||||
filter: filter as SearchFilterCRUD,
|
||||
option,
|
||||
select
|
||||
})
|
||||
}
|
||||
key={option.key}
|
||||
>
|
||||
<div className="mr-4 flex flex-row items-center gap-1.5">
|
||||
<RenderIcon icon={filter.icon} />
|
||||
<span className="text-ink-dull">{filter.name}</span>
|
||||
<CaretRight weight="bold" className="text-ink-dull/70" />
|
||||
<RenderIcon icon={option.icon} />
|
||||
{option.name}
|
||||
</div>
|
||||
</SearchOptionItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
155
interface/app/$libraryId/Explorer/Search/store.tsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { Icon } from '@phosphor-icons/react';
|
||||
import { produce } from 'immer';
|
||||
import { useEffect, useLayoutEffect, useMemo } from 'react';
|
||||
import { proxy, ref, useSnapshot } from 'valtio';
|
||||
import { proxyMap } from 'valtio/utils';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
|
||||
import { useSearchContext } from './Context';
|
||||
import { filterRegistry, FilterType, RenderSearchFilter } from './Filters';
|
||||
|
||||
export type SearchType = 'paths' | 'objects';
|
||||
|
||||
export type SearchScope = 'directory' | 'location' | 'device' | 'library';
|
||||
|
||||
export interface FilterOption {
|
||||
value: string | any;
|
||||
name: string;
|
||||
icon?: string | Icon; // "Folder" or "#efefef"
|
||||
}
|
||||
|
||||
export interface FilterOptionWithType extends FilterOption {
|
||||
type: FilterType;
|
||||
}
|
||||
|
||||
export type AllKeys<T> = T extends any ? keyof T : never;
|
||||
|
||||
const searchStore = proxy({
|
||||
isSearching: false,
|
||||
interactingWithSearchOptions: false,
|
||||
searchType: 'paths' as SearchType,
|
||||
searchQuery: null as string | null,
|
||||
filterArgs: ref([] as SearchFilterArgs[]),
|
||||
filterArgsKeys: ref(new Set<string>()),
|
||||
filterOptions: ref(new Map<string, FilterOptionWithType[]>()),
|
||||
// we register filters so we can search them
|
||||
registeredFilters: proxyMap() as Map<string, FilterOptionWithType>
|
||||
});
|
||||
|
||||
export function useSearchFilters<T extends SearchType>(
|
||||
_searchType: T,
|
||||
fixedArgs: SearchFilterArgs[]
|
||||
) {
|
||||
const { setFixedArgs, allFilterArgs } = useSearchContext();
|
||||
const searchState = useSearchStore();
|
||||
|
||||
// don't want the search bar to pop in after the top bar has loaded!
|
||||
useLayoutEffect(() => {
|
||||
resetSearchStore();
|
||||
setFixedArgs(fixedArgs);
|
||||
}, [fixedArgs]);
|
||||
|
||||
const searchQueryFilters = useMemo(() => {
|
||||
const [name, ext] = searchState.searchQuery?.split('.') ?? [];
|
||||
|
||||
const filters: SearchFilterArgs[] = [];
|
||||
|
||||
if (name) filters.push({ filePath: { name: { contains: name } } });
|
||||
if (ext) filters.push({ filePath: { extension: { in: [ext] } } });
|
||||
|
||||
return filters;
|
||||
}, [searchState.searchQuery]);
|
||||
|
||||
return useMemo(
|
||||
() => [...searchQueryFilters, ...allFilterArgs.map(({ arg }) => arg)],
|
||||
[searchQueryFilters, allFilterArgs]
|
||||
);
|
||||
}
|
||||
|
||||
// this makes the filter unique and easily searchable using .includes
|
||||
export const getKey = (filter: FilterOptionWithType) =>
|
||||
`${filter.type}-${filter.name}-${filter.value}`;
|
||||
|
||||
// this hook allows us to register filters to the search store
|
||||
// and returns the filters with the correct type
|
||||
export const useRegisterSearchFilterOptions = (
|
||||
filter: RenderSearchFilter,
|
||||
options: (FilterOption & { type: FilterType })[]
|
||||
) => {
|
||||
useEffect(
|
||||
() => {
|
||||
if (options) {
|
||||
searchStore.filterOptions.set(filter.name, options);
|
||||
searchStore.filterOptions = ref(new Map(searchStore.filterOptions));
|
||||
}
|
||||
},
|
||||
options?.map(getKey) ?? []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const keys = options.map((filter) => {
|
||||
const key = getKey(filter);
|
||||
|
||||
if (!searchStore.registeredFilters.has(key)) {
|
||||
searchStore.registeredFilters.set(key, filter);
|
||||
|
||||
return key;
|
||||
}
|
||||
});
|
||||
|
||||
return () =>
|
||||
keys.forEach((key) => {
|
||||
if (key) searchStore.registeredFilters.delete(key);
|
||||
});
|
||||
}, options.map(getKey));
|
||||
};
|
||||
|
||||
export function argsToOptions(args: SearchFilterArgs[], options: Map<string, FilterOption[]>) {
|
||||
return args.flatMap((fixedArg) => {
|
||||
const filter = filterRegistry.find((f) => f.extract(fixedArg))!;
|
||||
|
||||
return filter
|
||||
.argsToOptions(filter.extract(fixedArg) as any, options)
|
||||
.map((arg) => ({ arg, filter }));
|
||||
});
|
||||
}
|
||||
|
||||
export function updateFilterArgs(fn: (args: SearchFilterArgs[]) => SearchFilterArgs[]) {
|
||||
searchStore.filterArgs = ref(produce(searchStore.filterArgs, fn));
|
||||
searchStore.filterArgsKeys = ref(
|
||||
new Set(
|
||||
argsToOptions(searchStore.filterArgs, searchStore.filterOptions).map(
|
||||
({ arg, filter }) =>
|
||||
getKey({
|
||||
type: filter.name,
|
||||
name: arg.name,
|
||||
value: arg.value
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const useSearchRegisteredFilters = (query: string) => {
|
||||
const { registeredFilters } = useSearchStore();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
!query
|
||||
? []
|
||||
: [...registeredFilters.entries()]
|
||||
.filter(([key, _]) => key.toLowerCase().includes(query.toLowerCase()))
|
||||
.map(([key, filter]) => ({ ...filter, key })),
|
||||
[registeredFilters, query]
|
||||
);
|
||||
};
|
||||
|
||||
export const resetSearchStore = () => {
|
||||
searchStore.searchQuery = null;
|
||||
searchStore.filterArgs = ref([]);
|
||||
};
|
||||
|
||||
export const useSearchStore = () => useSnapshot(searchStore);
|
||||
|
||||
export const getSearchStore = () => searchStore;
|
120
interface/app/$libraryId/Explorer/Search/util.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { CircleDashed, Folder, Icon, Tag } from '@phosphor-icons/react';
|
||||
import { IconTypes } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { InOrNotIn, Range, TextMatch } from '@sd/client';
|
||||
import { Icon as SDIcon } from '~/components';
|
||||
|
||||
function isIn<T>(kind: InOrNotIn<T>): kind is { in: T[] } {
|
||||
return 'in' in kind;
|
||||
}
|
||||
|
||||
export function inOrNotIn<T>(
|
||||
kind: InOrNotIn<T> | null | undefined,
|
||||
value: T,
|
||||
condition: boolean
|
||||
): InOrNotIn<T> {
|
||||
if (condition) {
|
||||
if (kind && isIn(kind)) {
|
||||
kind.in.push(value);
|
||||
return kind;
|
||||
} else {
|
||||
return { in: [value] };
|
||||
}
|
||||
} else {
|
||||
if (kind && !isIn(kind)) {
|
||||
kind.notIn.push(value);
|
||||
return kind;
|
||||
} else {
|
||||
return { notIn: [value] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function textMatch(type: 'contains' | 'startsWith' | 'endsWith' | 'equals') {
|
||||
return (value: string): TextMatch => {
|
||||
switch (type) {
|
||||
case 'contains':
|
||||
return { contains: value };
|
||||
case 'startsWith':
|
||||
return { startsWith: value };
|
||||
case 'endsWith':
|
||||
return { endsWith: value };
|
||||
case 'equals':
|
||||
return { equals: value };
|
||||
default:
|
||||
throw new Error('Invalid TextMatch type.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const filterTypeCondition = {
|
||||
inOrNotIn: {
|
||||
in: 'is',
|
||||
notIn: 'is not'
|
||||
},
|
||||
textMatch: {
|
||||
contains: 'contains',
|
||||
startsWith: 'starts with',
|
||||
endsWith: 'ends with',
|
||||
equals: 'is'
|
||||
},
|
||||
optionalRange: {
|
||||
from: 'from',
|
||||
to: 'to'
|
||||
},
|
||||
trueOrFalse: {
|
||||
true: 'is',
|
||||
false: 'is not'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type FilterTypeCondition = typeof filterTypeCondition;
|
||||
|
||||
export const RenderIcon = ({
|
||||
className,
|
||||
icon
|
||||
}: {
|
||||
icon?: Icon | IconTypes | string;
|
||||
className?: string;
|
||||
}) => {
|
||||
if (typeof icon === 'string' && icon.startsWith('#')) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('mr-0.5 h-[15px] w-[15px] shrink-0 rounded-full border', className)}
|
||||
style={{
|
||||
backgroundColor: icon ? icon : 'transparent',
|
||||
borderColor: icon || '#efefef'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (typeof icon === 'string') {
|
||||
return (
|
||||
<SDIcon
|
||||
name={icon as any}
|
||||
size={18}
|
||||
className={clsx('shrink-0 text-ink-dull', className)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const IconComponent = icon;
|
||||
return (
|
||||
IconComponent && (
|
||||
<IconComponent
|
||||
size={15}
|
||||
weight="bold"
|
||||
className={clsx('shrink-0 text-ink-dull group-hover:text-white', className)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const getIconComponent = (iconName: string): Icon => {
|
||||
const icons: Record<string, React.ComponentType> = {
|
||||
Folder,
|
||||
CircleDashed,
|
||||
Tag
|
||||
};
|
||||
|
||||
return icons[iconName] as Icon;
|
||||
};
|
|
@ -10,14 +10,20 @@ import DismissibleNotice from './DismissibleNotice';
|
|||
import { Inspector, INSPECTOR_WIDTH } from './Inspector';
|
||||
import ExplorerContextMenu from './ParentContextMenu';
|
||||
import { getQuickPreviewStore } from './QuickPreview/store';
|
||||
import SearchOptions from './Search';
|
||||
import { getExplorerStore, useExplorerStore } from './store';
|
||||
import { useKeyRevealFinder } from './useKeyRevealFinder';
|
||||
import View, { EmptyNotice, ExplorerViewProps } from './View';
|
||||
import { ExplorerPath, PATH_BAR_HEIGHT } from './View/ExplorerPath';
|
||||
|
||||
import 'react-slidedown/lib/slidedown.css';
|
||||
|
||||
import { useSearchStore } from './Search/store';
|
||||
|
||||
interface Props {
|
||||
emptyNotice?: ExplorerViewProps['emptyNotice'];
|
||||
contextMenu?: () => ReactNode;
|
||||
showFilterBar?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -28,6 +34,7 @@ export default function Explorer(props: PropsWithChildren<Props>) {
|
|||
const explorerStore = useExplorerStore();
|
||||
const explorer = useExplorerContext();
|
||||
const layoutStore = useExplorerLayoutStore();
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
const showPathBar = explorer.showPathBar && layoutStore.showPathBar;
|
||||
|
||||
|
@ -84,6 +91,10 @@ export default function Explorer(props: PropsWithChildren<Props>) {
|
|||
>
|
||||
{explorer.items && explorer.items.length > 0 && <DismissibleNotice />}
|
||||
|
||||
<div className="search-options-slide sticky top-0 z-10 ">
|
||||
{searchStore.isSearching && props.showFilterBar && <SearchOptions />}
|
||||
</div>
|
||||
|
||||
<View
|
||||
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
|
||||
emptyNotice={
|
||||
|
@ -100,7 +111,6 @@ export default function Explorer(props: PropsWithChildren<Props>) {
|
|||
</div>
|
||||
</div>
|
||||
</ExplorerContextMenu>
|
||||
|
||||
{showPathBar && <ExplorerPath />}
|
||||
|
||||
{explorerStore.showInspector && (
|
||||
|
|
|
@ -37,18 +37,16 @@ export function useObjectsInfiniteQuery({
|
|||
|
||||
if (!order) cursor = 'none';
|
||||
else if (cItem) {
|
||||
const direction = order.value;
|
||||
|
||||
switch (order.field) {
|
||||
case 'kind': {
|
||||
const data = cItem.item.kind;
|
||||
if (data !== null) cursor = { kind: { order: direction, data } };
|
||||
if (data !== null) cursor = { kind: { order: order.value, data } };
|
||||
break;
|
||||
}
|
||||
case 'dateAccessed': {
|
||||
const data = cItem.item.date_accessed;
|
||||
if (data !== null)
|
||||
cursor = { dateAccessed: { order: direction, data } };
|
||||
cursor = { dateAccessed: { order: order.value, data } };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -188,13 +188,13 @@ export const filePathOrderingKeysSchema = z.union([
|
|||
z.literal('dateIndexed').describe('Date Indexed'),
|
||||
z.literal('dateCreated').describe('Date Created'),
|
||||
z.literal('object.dateAccessed').describe('Date Accessed'),
|
||||
z.literal('object.dateImageTaken').describe('Date Taken')
|
||||
z.literal('object.mediaData.epochTime').describe('Date Taken')
|
||||
]);
|
||||
|
||||
export const objectOrderingKeysSchema = z.union([
|
||||
z.literal('dateAccessed').describe('Date Accessed'),
|
||||
z.literal('kind').describe('Kind'),
|
||||
z.literal('dateImageTaken').describe('Date Taken')
|
||||
z.literal('mediaData.epochTime').describe('Date Taken')
|
||||
]);
|
||||
|
||||
export const nonIndexedPathOrderingSchema = z.union([
|
||||
|
|
|
@ -20,34 +20,24 @@ export default () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar mask-fade-out flex grow flex-col overflow-x-hidden overflow-y-scroll pb-10">
|
||||
<div className="space-y-0.5">
|
||||
<Tooltip
|
||||
position="right"
|
||||
label="Overview"
|
||||
keybinds={[symbols.Shift.icon, symbols.Meta.icon, 'O']}
|
||||
>
|
||||
<SidebarLink to="overview">
|
||||
<Icon component={Planet} />
|
||||
Overview
|
||||
</SidebarLink>
|
||||
</Tooltip>
|
||||
{/* <SidebarLink to="spacedrop">
|
||||
<div className="no-scrollbar mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
|
||||
{/* <div className="space-y-0.5"> */}
|
||||
{/* <SidebarLink to="spacedrop">
|
||||
<Icon component={Broadcast} />
|
||||
Spacedrop
|
||||
</SidebarLink> */}
|
||||
{/*
|
||||
{/*
|
||||
{/* <SidebarLink to="imports">
|
||||
<Icon component={ArchiveBox} />
|
||||
Imports
|
||||
</SidebarLink> */}
|
||||
{useFeatureFlag('syncRoute') && (
|
||||
<SidebarLink to="sync">
|
||||
<Icon component={ArrowsClockwise} />
|
||||
Sync
|
||||
</SidebarLink>
|
||||
)}
|
||||
</div>
|
||||
{useFeatureFlag('syncRoute') && (
|
||||
<SidebarLink to="sync">
|
||||
<Icon component={ArrowsClockwise} />
|
||||
Sync
|
||||
</SidebarLink>
|
||||
)}
|
||||
{/* </div> */}
|
||||
<EphemeralSection />
|
||||
{library && (
|
||||
<LibraryContextProvider library={library}>
|
||||
|
|
|
@ -95,7 +95,7 @@ export const EphemeralSection = () => {
|
|||
return (
|
||||
<SidebarLink
|
||||
className="group relative w-full"
|
||||
to={`network/34`}
|
||||
to="./network"
|
||||
key={index}
|
||||
>
|
||||
<SidebarIcon name="Globe" />
|
||||
|
|
|
@ -32,7 +32,7 @@ export default () => {
|
|||
>
|
||||
{libraries.data?.map((lib) => (
|
||||
<DropdownMenu.Item
|
||||
to={`/${lib.uuid}/overview`}
|
||||
to={`/${lib.uuid}`}
|
||||
key={lib.uuid}
|
||||
selected={lib.uuid === currentLibraryId}
|
||||
>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { X } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
|
@ -10,8 +11,9 @@ import {
|
|||
} from '@sd/client';
|
||||
import { Button, Tooltip } from '@sd/ui';
|
||||
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
|
||||
import { Icon, SubtleButton } from '~/components';
|
||||
import { Folder, Icon, SubtleButton } from '~/components';
|
||||
|
||||
import { useSavedSearches } from '../../Explorer/Search/SavedSearches';
|
||||
import SidebarLink from './Link';
|
||||
import LocationsContextMenu from './LocationsContextMenu';
|
||||
import Section from './Section';
|
||||
|
@ -51,6 +53,8 @@ export const LibrarySection = () => {
|
|||
null
|
||||
);
|
||||
|
||||
// const savedSearches = useSavedSearches();
|
||||
|
||||
useEffect(() => {
|
||||
const outsideClick = () => {
|
||||
document.addEventListener('click', () => {
|
||||
|
@ -65,6 +69,41 @@ export const LibrarySection = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* {savedSearches.searches.length > 0 && (
|
||||
<Section
|
||||
name="Saved"
|
||||
// actionArea={
|
||||
// <Link to="settings/library/saved-searches">
|
||||
// <SubtleButton />
|
||||
// </Link>
|
||||
// }
|
||||
>
|
||||
<SeeMore
|
||||
items={savedSearches.searches}
|
||||
renderItem={(search) => (
|
||||
<SidebarLink
|
||||
className="group/button relative w-full"
|
||||
to={`search/${search.id}`}
|
||||
key={search.id}
|
||||
>
|
||||
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
|
||||
<Folder size={18} />
|
||||
</div>
|
||||
|
||||
<span className="truncate">{search.name}</span>
|
||||
<Button
|
||||
className="absolute right-[2px] top-[2px] hidden rounded-full shadow group-hover/button:block"
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={() => savedSearches.removeSearch(search.id)}
|
||||
>
|
||||
<X weight="bold" className="text-ink-dull/50" />
|
||||
</Button>
|
||||
</SidebarLink>
|
||||
)}
|
||||
/>
|
||||
</Section>
|
||||
)} */}
|
||||
<Section
|
||||
name="Devices"
|
||||
actionArea={
|
||||
|
|
|
@ -8,7 +8,7 @@ export default (
|
|||
onDoubleClick?: () => void;
|
||||
}>
|
||||
) => (
|
||||
<div onDoubleClick={props.onDoubleClick} className="group mt-5">
|
||||
<div onDoubleClick={props.onDoubleClick} className="group">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<CategoryHeading className="ml-1">{props.name}</CategoryHeading>
|
||||
{props.actionArea && (
|
||||
|
|
|
@ -44,7 +44,7 @@ export default () => {
|
|||
<div
|
||||
data-tauri-drag-region
|
||||
className={clsx(
|
||||
'w-full transition-[height] ease-linear motion-reduce:transition-none',
|
||||
'w-full shrink-0 transition-[height] ease-linear motion-reduce:transition-none',
|
||||
windowState.isFullScreen ? 'h-0 duration-100' : 'h-5 duration-75'
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx';
|
||||
import { Suspense, useEffect, useMemo, useRef } from 'react';
|
||||
import { Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ClientContextProvider,
|
||||
initPlausible,
|
||||
|
@ -47,8 +47,8 @@ const Layout = () => {
|
|||
if (library === null && libraries.data) {
|
||||
const firstLibrary = libraries.data[0];
|
||||
|
||||
if (firstLibrary) return <Navigate to={`/${firstLibrary.uuid}/overview`} replace />;
|
||||
else return <Navigate to="/" replace />;
|
||||
if (firstLibrary) return <Navigate to={`/${firstLibrary.uuid}`} replace />;
|
||||
else return <Navigate to="./network" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,28 +1,35 @@
|
|||
import { createContext, Dispatch, SetStateAction, useContext, useState } from 'react';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
|
||||
import TopBar from '.';
|
||||
|
||||
interface TopBarContext {
|
||||
left: HTMLDivElement | null;
|
||||
right: HTMLDivElement | null;
|
||||
setNoSearch: (value: boolean) => void;
|
||||
topBarHeight: number;
|
||||
setTopBarHeight: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
const TopBarContext = createContext<ReturnType<typeof useContextValue> | null>(null);
|
||||
|
||||
const TopBarContext = createContext<TopBarContext | null>(null);
|
||||
|
||||
export const Component = () => {
|
||||
function useContextValue() {
|
||||
const [left, setLeft] = useState<HTMLDivElement | null>(null);
|
||||
const [right, setRight] = useState<HTMLDivElement | null>(null);
|
||||
const [noSearch, setNoSearch] = useState(false);
|
||||
|
||||
const [fixedArgs, setFixedArgs] = useState<SearchFilterArgs[] | null>(null);
|
||||
const [topBarHeight, setTopBarHeight] = useState(0);
|
||||
|
||||
return {
|
||||
left,
|
||||
setLeft,
|
||||
right,
|
||||
setRight,
|
||||
fixedArgs,
|
||||
setFixedArgs,
|
||||
topBarHeight,
|
||||
setTopBarHeight
|
||||
};
|
||||
}
|
||||
|
||||
export const Component = () => {
|
||||
const value = useContextValue();
|
||||
|
||||
return (
|
||||
<TopBarContext.Provider value={{ left, right, setNoSearch, topBarHeight, setTopBarHeight }}>
|
||||
<TopBar leftRef={setLeft} rightRef={setRight} noSearch={noSearch} />
|
||||
<TopBarContext.Provider value={value}>
|
||||
<TopBar />
|
||||
<Outlet />
|
||||
</TopBarContext.Provider>
|
||||
);
|
||||
|
|
|
@ -2,7 +2,7 @@ import { ArrowLeft, ArrowRight } from '@phosphor-icons/react';
|
|||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import { useKeyMatcher, useOperatingSystem, useSearchStore, useShortcut } from '~/hooks';
|
||||
import { useKeyMatcher, useOperatingSystem, useShortcut } from '~/hooks';
|
||||
import { useRoutingContext } from '~/RoutingContext';
|
||||
|
||||
import TopBarButton from './TopBarButton';
|
||||
|
@ -11,14 +11,11 @@ export const NavigationButtons = () => {
|
|||
const { currentIndex, maxIndex } = useRoutingContext();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { isFocused } = useSearchStore();
|
||||
const os = useOperatingSystem();
|
||||
const { icon } = useKeyMatcher('Meta');
|
||||
|
||||
// console.log(history, location);
|
||||
|
||||
const canGoBack = currentIndex !== 0 && !isFocused;
|
||||
const canGoForward = currentIndex !== maxIndex && !isFocused;
|
||||
const canGoBack = currentIndex !== 0;
|
||||
const canGoForward = currentIndex !== maxIndex;
|
||||
|
||||
useShortcut('navBackwardHistory', () => {
|
||||
if (!canGoBack) return;
|
||||
|
@ -45,7 +42,7 @@ export const NavigationButtons = () => {
|
|||
};
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
return () => document.removeEventListener('mousedown', onMouseDown);
|
||||
}, [navigate, isFocused, os, canGoBack, canGoForward]);
|
||||
}, [navigate, os, canGoBack, canGoForward]);
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region={os === 'macOS'} className="flex">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useTopBarContext } from './Layout';
|
||||
|
@ -6,15 +6,10 @@ import { useTopBarContext } from './Layout';
|
|||
interface Props {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
noSearch?: boolean;
|
||||
}
|
||||
export const TopBarPortal = ({ left, right, noSearch }: Props) => {
|
||||
export const TopBarPortal = ({ left, right }: Props) => {
|
||||
const ctx = useTopBarContext();
|
||||
|
||||
useEffect(() => {
|
||||
ctx.setNoSearch(noSearch ?? false);
|
||||
}, [ctx, noSearch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{left && ctx.left && createPortal(left, ctx.left)}
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from 'react';
|
||||
import { useLocation, useNavigate, useResolvedPath } from 'react-router';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import { useLocation, useResolvedPath } from 'react-router';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Input, ModifierKeys, Shortcut } from '@sd/ui';
|
||||
import { SearchParamsSchema } from '~/app/route-schemas';
|
||||
import { getSearchStore, useOperatingSystem, useZodSearchParams } from '~/hooks';
|
||||
import { useOperatingSystem, useZodSearchParams } from '~/hooks';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
|
||||
import { getSearchStore, useSearchStore } from '../Explorer/Search/store';
|
||||
|
||||
export default () => {
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [searchParams, setSearchParams] = useZodSearchParams(SearchParamsSchema);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
const os = useOperatingSystem(true);
|
||||
const keybind = keybindForOs(os);
|
||||
|
||||
|
@ -28,6 +30,7 @@ export default () => {
|
|||
const [value, setValue] = useState(searchParams.search ?? '');
|
||||
|
||||
const updateParams = useDebouncedCallback((value: string) => {
|
||||
getSearchStore().searchQuery = value;
|
||||
startTransition(() =>
|
||||
setSearchParams((p) => ({ ...p, search: value }), {
|
||||
replace: true
|
||||
|
@ -38,7 +41,9 @@ export default () => {
|
|||
const updateValue = useCallback(
|
||||
(value: string) => {
|
||||
setValue(value);
|
||||
if (searchPath.pathname === location.pathname) updateParams(value);
|
||||
// TODO: idk that looked important but uncommenting it fixed my bug
|
||||
// if (searchPath.pathname === location.pathname)
|
||||
updateParams(value);
|
||||
},
|
||||
[searchPath.pathname, location.pathname, updateParams]
|
||||
);
|
||||
|
@ -82,20 +87,20 @@ export default () => {
|
|||
size="sm"
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
getSearchStore().isFocused = false;
|
||||
if (value === '') {
|
||||
setSearchParams({}, { replace: true });
|
||||
navigate(-1);
|
||||
if (value === '' && !searchStore.interactingWithSearchOptions) {
|
||||
getSearchStore().isSearching = false;
|
||||
// setSearchParams({}, { replace: true });
|
||||
// navigate(-1);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
getSearchStore().isFocused = true;
|
||||
if (searchPath.pathname !== location.pathname) {
|
||||
navigate({
|
||||
pathname: 'search',
|
||||
search: createSearchParams({ search: value }).toString()
|
||||
});
|
||||
}
|
||||
getSearchStore().isSearching = true;
|
||||
// if (searchPath.pathname !== location.pathname) {
|
||||
// navigate({
|
||||
// pathname: 'search',
|
||||
// search: createSearchParams({ search: value }).toString()
|
||||
// });
|
||||
// }
|
||||
}}
|
||||
value={value}
|
||||
right={
|
||||
|
|
|
@ -12,16 +12,9 @@ import { useTopBarContext } from './Layout';
|
|||
import { NavigationButtons } from './NavigationButtons';
|
||||
import SearchBar from './SearchBar';
|
||||
|
||||
interface Props {
|
||||
leftRef?: Ref<HTMLDivElement>;
|
||||
rightRef?: Ref<HTMLDivElement>;
|
||||
noSearch?: boolean;
|
||||
}
|
||||
|
||||
const TopBar = (props: Props) => {
|
||||
const TopBar = () => {
|
||||
const transparentBg = useShowControls().transparentBg;
|
||||
const { isDragging } = useExplorerStore();
|
||||
const os = useOperatingSystem();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const topBar = useTopBarContext();
|
||||
|
@ -44,6 +37,8 @@ const TopBar = (props: Props) => {
|
|||
|
||||
const tabs = useTabsContext();
|
||||
|
||||
const ctx = useTopBarContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
@ -67,12 +62,12 @@ const TopBar = (props: Props) => {
|
|||
className="flex flex-1 items-center gap-3.5 overflow-hidden"
|
||||
>
|
||||
<NavigationButtons />
|
||||
<div ref={props.leftRef} className="overflow-hidden" />
|
||||
<div ref={ctx.setLeft} className="overflow-hidden" />
|
||||
</div>
|
||||
|
||||
{!props.noSearch && <SearchBar />}
|
||||
{ctx.fixedArgs && <SearchBar />}
|
||||
|
||||
<div ref={props.rightRef} className={clsx(!props.noSearch && 'flex-1')} />
|
||||
<div ref={ctx.setRight} className={clsx(ctx.fixedArgs && 'flex-1')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -228,7 +228,6 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
|
|||
</Tooltip>
|
||||
}
|
||||
right={<DefaultTopBarOptions />}
|
||||
noSearch={true}
|
||||
/>
|
||||
<Explorer
|
||||
emptyNotice={
|
||||
|
|
|
@ -6,10 +6,6 @@ import settingsRoutes from './settings';
|
|||
const pageRoutes: RouteObject = {
|
||||
lazy: () => import('./PageLayout'),
|
||||
children: [
|
||||
{
|
||||
path: 'overview',
|
||||
lazy: () => import('./overview')
|
||||
},
|
||||
{ path: 'people', lazy: () => import('./people') },
|
||||
{ path: 'media', lazy: () => import('./media') },
|
||||
{ path: 'spaces', lazy: () => import('./spaces') },
|
||||
|
@ -21,19 +17,19 @@ const pageRoutes: RouteObject = {
|
|||
// Routes that render the explorer and don't need padding and stuff
|
||||
// provided by PageLayout
|
||||
const explorerRoutes: RouteObject[] = [
|
||||
{ path: 'ephemeral/:id', lazy: () => import('./ephemeral') },
|
||||
{ path: 'location/:id', lazy: () => import('./location/$id') },
|
||||
{ path: 'node/:id', lazy: () => import('./node/$id') },
|
||||
{ path: 'tag/:id', lazy: () => import('./tag/$id') },
|
||||
{ path: 'ephemeral/:id', lazy: () => import('./ephemeral') },
|
||||
{ path: 'network/:id', lazy: () => import('./network') },
|
||||
{ path: 'search', lazy: () => import('./search') }
|
||||
{ path: 'network', lazy: () => import('./network') }
|
||||
// { path: 'search/:id', lazy: () => import('./search') }
|
||||
];
|
||||
|
||||
// Routes that should render with the top bar - pretty much everything except
|
||||
// 404 and settings
|
||||
const topBarRoutes: RouteObject = {
|
||||
lazy: () => import('./TopBar/Layout'),
|
||||
children: [pageRoutes, ...explorerRoutes]
|
||||
children: [...explorerRoutes, pageRoutes]
|
||||
};
|
||||
|
||||
export default [
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { ArrowClockwise, Info } from '@phosphor-icons/react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { stringify } from 'uuid';
|
||||
import {
|
||||
arraysEqual,
|
||||
ExplorerSettings,
|
||||
FilePathFilterArgs,
|
||||
FilePathOrder,
|
||||
Location,
|
||||
ObjectKindEnum,
|
||||
|
@ -30,6 +30,8 @@ import { useQuickRescan } from '~/hooks/useQuickRescan';
|
|||
import Explorer from '../Explorer';
|
||||
import { ExplorerContextProvider } from '../Explorer/Context';
|
||||
import { usePathsInfiniteQuery } from '../Explorer/queries';
|
||||
import { SearchContextProvider } from '../Explorer/Search/Context';
|
||||
import { useSearchFilters } from '../Explorer/Search/store';
|
||||
import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
|
@ -40,35 +42,46 @@ import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
|
|||
import LocationOptions from './LocationOptions';
|
||||
|
||||
export const Component = () => {
|
||||
const rspc = useRspcLibraryContext();
|
||||
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
const location = useLibraryQuery(['locations.get', locationId], {
|
||||
keepPreviousData: true,
|
||||
suspense: true
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchContextProvider>
|
||||
<LocationExplorer path={path} location={location.data!} />)
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const LocationExplorer = ({ location, path }: { location: Location; path?: string }) => {
|
||||
const rspc = useRspcLibraryContext();
|
||||
|
||||
const location = useLibraryQuery(['locations.get', locationId]);
|
||||
const onlineLocations = useOnlineLocations();
|
||||
|
||||
const rescan = useQuickRescan();
|
||||
|
||||
const locationOnline = useMemo(() => {
|
||||
const pub_id = location.data?.pub_id;
|
||||
const pub_id = location?.pub_id;
|
||||
if (!pub_id) return false;
|
||||
return onlineLocations.some((l) => arraysEqual(pub_id, l));
|
||||
}, [location.data?.pub_id, onlineLocations]);
|
||||
}, [location?.pub_id, onlineLocations]);
|
||||
|
||||
const preferences = useLibraryQuery(['preferences.get']);
|
||||
const updatePreferences = useLibraryMutation('preferences.update');
|
||||
|
||||
const isLocationIndexing = useIsLocationIndexing(locationId);
|
||||
const isLocationIndexing = useIsLocationIndexing(location.id);
|
||||
|
||||
const settings = useMemo(() => {
|
||||
const defaults = createDefaultExplorerSettings<FilePathOrder>({
|
||||
order: { field: 'name', value: 'Asc' }
|
||||
});
|
||||
|
||||
if (!location.data) return defaults;
|
||||
if (!location) return defaults;
|
||||
|
||||
const pubId = stringify(location.data.pub_id);
|
||||
const pubId = stringify(location.pub_id);
|
||||
|
||||
const settings = preferences.data?.location?.[pubId]?.explorer;
|
||||
|
||||
|
@ -79,34 +92,37 @@ export const Component = () => {
|
|||
}
|
||||
|
||||
return defaults;
|
||||
}, [location.data, preferences.data?.location]);
|
||||
}, [location, preferences.data?.location]);
|
||||
|
||||
const onSettingsChanged = async (
|
||||
settings: ExplorerSettings<FilePathOrder>,
|
||||
location: Location
|
||||
) => {
|
||||
if (location.id === locationId && preferences.isLoading) return;
|
||||
const onSettingsChanged = useDebouncedCallback(
|
||||
async (settings: ExplorerSettings<FilePathOrder>) => {
|
||||
if (preferences.isLoading) return;
|
||||
|
||||
const pubId = stringify(location.pub_id);
|
||||
const pubId = stringify(location.pub_id);
|
||||
|
||||
try {
|
||||
await updatePreferences.mutateAsync({
|
||||
location: { [pubId]: { explorer: settings } }
|
||||
});
|
||||
rspc.queryClient.invalidateQueries(['preferences.get']);
|
||||
} catch (e) {
|
||||
alert('An error has occurred while updating your preferences.');
|
||||
}
|
||||
};
|
||||
try {
|
||||
await updatePreferences.mutateAsync({
|
||||
location: { [pubId]: { explorer: settings } }
|
||||
});
|
||||
rspc.queryClient.invalidateQueries(['preferences.get']);
|
||||
} catch (e) {
|
||||
alert('An error has occurred while updating your preferences.');
|
||||
}
|
||||
},
|
||||
500
|
||||
);
|
||||
|
||||
const explorerSettings = useExplorerSettings({
|
||||
settings,
|
||||
onSettingsChanged,
|
||||
orderingKeys: filePathOrderingKeysSchema,
|
||||
location: location.data
|
||||
location
|
||||
});
|
||||
|
||||
const { items, count, loadMore, query } = useItems({ locationId, settings: explorerSettings });
|
||||
const { items, count, loadMore, query } = useItems({
|
||||
location,
|
||||
settings: explorerSettings
|
||||
});
|
||||
|
||||
const explorer = useExplorer({
|
||||
items,
|
||||
|
@ -115,13 +131,13 @@ export const Component = () => {
|
|||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isLoadingPreferences: preferences.isLoading,
|
||||
settings: explorerSettings,
|
||||
...(location.data && {
|
||||
parent: { type: 'Location', location: location.data }
|
||||
...(location && {
|
||||
parent: { type: 'Location', location }
|
||||
})
|
||||
});
|
||||
|
||||
useLibrarySubscription(
|
||||
['locations.quickRescan', { sub_path: path ?? '', location_id: locationId }],
|
||||
['locations.quickRescan', { sub_path: path ?? '', location_id: location.id }],
|
||||
{ onData() {} }
|
||||
);
|
||||
|
||||
|
@ -133,12 +149,12 @@ export const Component = () => {
|
|||
|
||||
useEffect(() => explorer.scrollRef.current?.scrollTo({ top: 0 }), [explorer.scrollRef, path]);
|
||||
|
||||
useKeyDeleteFile(explorer.selectedItems, location.data?.id);
|
||||
useKeyDeleteFile(explorer.selectedItems, location.id);
|
||||
|
||||
useShortcut('rescan', () => rescan(locationId));
|
||||
useShortcut('rescan', () => rescan(location.id));
|
||||
|
||||
const title = useRouteTitle(
|
||||
(path && path?.length > 1 ? getLastSectionOfPath(path) : location.data?.name) ?? ''
|
||||
(path && path?.length > 1 ? getLastSectionOfPath(path) : location.name) ?? ''
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -153,9 +169,7 @@ export const Component = () => {
|
|||
<Info className="text-ink-faint" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{location.data && (
|
||||
<LocationOptions location={location.data} path={path || ''} />
|
||||
)}
|
||||
<LocationOptions location={location} path={path || ''} />
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
|
@ -163,7 +177,7 @@ export const Component = () => {
|
|||
options={[
|
||||
{
|
||||
toolTipLabel: 'Reload',
|
||||
onClick: () => rescan(locationId),
|
||||
onClick: () => rescan(location.id),
|
||||
icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
|
||||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
|
@ -172,16 +186,15 @@ export const Component = () => {
|
|||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLocationIndexing ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader />
|
||||
</div>
|
||||
) : !preferences.isLoading ? (
|
||||
<Explorer
|
||||
showFilterBar
|
||||
emptyNotice={
|
||||
<EmptyNotice
|
||||
loading={location.isFetching}
|
||||
icon={<Icon name="FolderNoSpace" size={128} />}
|
||||
message="No files found here"
|
||||
/>
|
||||
|
@ -193,10 +206,10 @@ export const Component = () => {
|
|||
};
|
||||
|
||||
const useItems = ({
|
||||
locationId,
|
||||
location,
|
||||
settings
|
||||
}: {
|
||||
locationId: number;
|
||||
location: Location;
|
||||
settings: UseExplorerSettings<FilePathOrder>;
|
||||
}) => {
|
||||
const [{ path, take }] = useExplorerSearchParams();
|
||||
|
@ -205,24 +218,43 @@ const useItems = ({
|
|||
|
||||
const explorerSettings = settings.useSettingsSnapshot();
|
||||
|
||||
const filter: FilePathFilterArgs = { locationId, path: path ?? '' };
|
||||
// useMemo lets us embrace immutability and use fixedFilters in useEffects!
|
||||
const fixedFilters = useMemo(
|
||||
() => [
|
||||
{ filePath: { locations: { in: [location.id] } } },
|
||||
...(explorerSettings.layoutMode === 'media'
|
||||
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
|
||||
: [])
|
||||
],
|
||||
[location.id, explorerSettings.layoutMode]
|
||||
);
|
||||
|
||||
if (explorerSettings.layoutMode === 'media') {
|
||||
filter.object = { kind: [ObjectKindEnum.Image, ObjectKindEnum.Video] };
|
||||
const baseFilters = useSearchFilters('paths', fixedFilters);
|
||||
|
||||
if (explorerSettings.mediaViewWithDescendants) filter.withDescendants = true;
|
||||
}
|
||||
const filters = [...baseFilters];
|
||||
|
||||
if (!explorerSettings.showHiddenFiles) filter.hidden = false;
|
||||
filters.push({
|
||||
filePath: {
|
||||
path: {
|
||||
location_id: location.id,
|
||||
path: path ?? '',
|
||||
include_descendants:
|
||||
explorerSettings.layoutMode === 'media' &&
|
||||
explorerSettings.mediaViewWithDescendants
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const count = useLibraryQuery(['search.pathsCount', { filter }]);
|
||||
if (!explorerSettings.showHiddenFiles) filters.push({ filePath: { hidden: false } });
|
||||
|
||||
const query = usePathsInfiniteQuery({
|
||||
arg: { filter, take },
|
||||
arg: { filters, take },
|
||||
library,
|
||||
settings
|
||||
});
|
||||
|
||||
const count = useLibraryQuery(['search.pathsCount', { filters }], { enabled: query.isSuccess });
|
||||
|
||||
const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) ?? null, [query.data]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { memo, Suspense, useDeferredValue, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useDiscoveredPeers } from '@sd/client';
|
||||
import { PathParamsSchema, type PathParams } from '~/app/route-schemas';
|
||||
import { Icon } from '~/components';
|
||||
import { useZodSearchParams } from '~/hooks';
|
||||
import { useRouteTitle } from '~/hooks/useRouteTitle';
|
||||
|
||||
import Explorer from './Explorer';
|
||||
|
@ -12,7 +10,7 @@ import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
|
|||
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
|
||||
import { TopBarPortal } from './TopBar/Portal';
|
||||
|
||||
const Network = memo((props: { args: PathParams }) => {
|
||||
export const Component = () => {
|
||||
const title = useRouteTitle('Network');
|
||||
|
||||
const discoveredPeers = useDiscoveredPeers();
|
||||
|
@ -56,7 +54,6 @@ const Network = memo((props: { args: PathParams }) => {
|
|||
</div>
|
||||
}
|
||||
right={<DefaultTopBarOptions />}
|
||||
noSearch={true}
|
||||
/>
|
||||
<Explorer
|
||||
emptyNotice={
|
||||
|
@ -72,15 +69,4 @@ const Network = memo((props: { args: PathParams }) => {
|
|||
/>
|
||||
</ExplorerContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export const Component = () => {
|
||||
const [pathParams] = useZodSearchParams(PathParamsSchema);
|
||||
const path = useDeferredValue(pathParams);
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Network args={path} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,201 +0,0 @@
|
|||
import { ArrowLeft, ArrowRight } from '@phosphor-icons/react';
|
||||
import { getIcon } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import Sticky from 'react-sticky-el';
|
||||
import { useDraggable } from 'react-use-draggable-scroll';
|
||||
import { Category, useLibraryQuery } from '@sd/client';
|
||||
import { tw } from '@sd/ui';
|
||||
import { useIsDark, useShowControls } from '~/hooks';
|
||||
|
||||
import { useLayoutContext } from '../Layout/Context';
|
||||
import { usePageLayoutContext } from '../PageLayout/Context';
|
||||
import CategoryButton from './CategoryButton';
|
||||
import { IconForCategory } from './data';
|
||||
|
||||
export const CategoryList = [
|
||||
'Recents',
|
||||
'Favorites',
|
||||
'Albums',
|
||||
'Photos',
|
||||
'Screenshots',
|
||||
'Videos',
|
||||
'Movies',
|
||||
'Music',
|
||||
'Downloads',
|
||||
'Encrypted',
|
||||
'Documents',
|
||||
'Projects',
|
||||
'Applications',
|
||||
// 'Archives',
|
||||
'Databases',
|
||||
'Games',
|
||||
'Books',
|
||||
// 'Contacts',
|
||||
'Trash'
|
||||
] as Category[];
|
||||
|
||||
const ArrowButton = tw.div`absolute top-1/2 z-40 flex h-8 w-8 shrink-0 -translate-y-1/2 items-center p-2 cursor-pointer justify-center rounded-full border border-app-line bg-app/50 hover:opacity-95 backdrop-blur-md transition-all duration-200`;
|
||||
|
||||
export const Categories = (props: { selected: Category; onSelectedChanged(c: Category): void }) => {
|
||||
const isDark = useIsDark();
|
||||
|
||||
const { ref: pageRef } = usePageLayoutContext();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { events } = useDraggable(ref as React.MutableRefObject<HTMLDivElement>);
|
||||
const [lastCategoryVisible, setLastCategoryVisible] = useState(false);
|
||||
const transparentBg = useShowControls().transparentBg;
|
||||
|
||||
const { scroll, mouseState } = useMouseHandlers({ ref });
|
||||
|
||||
const categories = useLibraryQuery(['categories.list']);
|
||||
|
||||
const handleArrowOnClick = (direction: 'right' | 'left') => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
element.scrollTo({
|
||||
left: direction === 'left' ? element.scrollLeft + 200 : element.scrollLeft - 200,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
const lastCategoryVisibleHandler = (index: number) => {
|
||||
index === CategoryList.length - 1 && setLastCategoryVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
const maskImage = `linear-gradient(90deg, transparent 0.1%, rgba(0, 0, 0, 1) ${
|
||||
scroll > 0 ? '10%' : '0%'
|
||||
}, rgba(0, 0, 0, 1) ${lastCategoryVisible ? '95%' : '85%'}, transparent 99%)`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Sticky
|
||||
scrollElement={pageRef.current || undefined}
|
||||
stickyClassName="z-20 !top-[46px]"
|
||||
topOffset={-46}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex px-3 py-1.5 backdrop-blur',
|
||||
!transparentBg && 'bg-app/90'
|
||||
)}
|
||||
>
|
||||
<ArrowButton
|
||||
onClick={() => handleArrowOnClick('right')}
|
||||
className={clsx('left-3', scroll === 0 && 'pointer-events-none opacity-0')}
|
||||
>
|
||||
<ArrowLeft weight="bold" className="h-4 w-4 text-ink" />
|
||||
</ArrowButton>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
{...events}
|
||||
className="no-scrollbar flex space-x-px overflow-x-scroll pr-[60px]"
|
||||
style={{
|
||||
WebkitMaskImage: maskImage, // Required for Chromium based browsers
|
||||
maskImage
|
||||
}}
|
||||
>
|
||||
{categories.data &&
|
||||
CategoryList.map((category, index) => {
|
||||
const iconString = IconForCategory[category] || 'Document';
|
||||
return (
|
||||
<motion.div
|
||||
onViewportEnter={() => lastCategoryVisibleHandler(index)}
|
||||
onViewportLeave={() => lastCategoryVisibleHandler(index)}
|
||||
viewport={{
|
||||
root: ref,
|
||||
// WARNING: Edge breaks if the values are not postfixed with px or %
|
||||
margin: '0% -120px 0% 0%'
|
||||
}}
|
||||
className={clsx(
|
||||
'min-w-fit',
|
||||
mouseState !== 'dragging' && '!cursor-default'
|
||||
)}
|
||||
key={category}
|
||||
>
|
||||
<CategoryButton
|
||||
category={category}
|
||||
icon={getIcon(iconString, isDark)}
|
||||
items={categories.data[category]}
|
||||
selected={props.selected === category}
|
||||
onClick={() => props.onSelectedChanged(category)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ArrowButton
|
||||
onClick={() => handleArrowOnClick('left')}
|
||||
className={clsx(
|
||||
'right-3',
|
||||
lastCategoryVisible && 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
<ArrowRight weight="bold" className="h-4 w-4 text-ink" />
|
||||
</ArrowButton>
|
||||
</div>
|
||||
</Sticky>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useMouseHandlers = ({ ref }: { ref: RefObject<HTMLDivElement> }) => {
|
||||
const layout = useLayoutContext();
|
||||
|
||||
const [scroll, setScroll] = useState(0);
|
||||
|
||||
type MouseState = 'idle' | 'mousedown' | 'dragging';
|
||||
const [mouseState, setMouseState] = useState<MouseState>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const onScroll = () => {
|
||||
setScroll(element.scrollLeft);
|
||||
|
||||
setMouseState((s) => {
|
||||
if (s !== 'mousedown') return s;
|
||||
|
||||
if (layout.ref.current) layout.ref.current.style.cursor = 'grabbing';
|
||||
|
||||
return 'dragging';
|
||||
});
|
||||
};
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const { deltaX, deltaY } = event;
|
||||
const scrollAmount = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
|
||||
element.scrollTo({ left: element.scrollLeft + scrollAmount });
|
||||
};
|
||||
const onMouseDown = () => setMouseState('mousedown');
|
||||
|
||||
const onMouseUp = () => {
|
||||
setMouseState('idle');
|
||||
if (layout.ref.current) {
|
||||
layout.ref.current.style.cursor = '';
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('scroll', onScroll);
|
||||
element.addEventListener('wheel', onWheel);
|
||||
element.addEventListener('mousedown', onMouseDown);
|
||||
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('scroll', onScroll);
|
||||
element.removeEventListener('wheel', onWheel);
|
||||
element.removeEventListener('mousedown', onMouseDown);
|
||||
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, [ref, layout.ref]);
|
||||
|
||||
return { scroll, mouseState };
|
||||
};
|
|
@ -1,34 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import { formatNumber } from '@sd/client';
|
||||
|
||||
interface CategoryButtonProps {
|
||||
category: string;
|
||||
items: number;
|
||||
icon: string;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default ({ category, icon, items, selected, onClick, disabled }: CategoryButtonProps) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'flex shrink-0 items-center rounded-lg px-1.5 py-1 text-sm outline-none focus:bg-app-selectedItem/50',
|
||||
selected && 'bg-app-selectedItem',
|
||||
disabled && 'cursor-not-allowed opacity-30'
|
||||
)}
|
||||
>
|
||||
<img src={icon} className="mr-3 h-12 w-12" />
|
||||
<div className="pr-5">
|
||||
<h2 className="text-sm font-medium">{category}</h2>
|
||||
{items !== undefined && (
|
||||
<p className="text-xs text-ink-faint">
|
||||
{formatNumber(items)} Item{(items > 1 || items === 0) && 's'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,58 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSnapshot } from 'valtio';
|
||||
import { useCallbackToWatchResize } from '~/hooks';
|
||||
|
||||
import { useExplorerContext } from '../Explorer/Context';
|
||||
import { Inspector } from '../Explorer/Inspector';
|
||||
import { useExplorerStore } from '../Explorer/store';
|
||||
import { usePageLayoutContext } from '../PageLayout/Context';
|
||||
|
||||
export default () => {
|
||||
const page = usePageLayoutContext();
|
||||
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const settings = useSnapshot(explorer.settingsStore);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
const updateHeight = useCallback(() => {
|
||||
if (!ref.current || !page.ref.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = page.ref.current;
|
||||
|
||||
if (scrollTop < 0 || scrollTop + clientHeight > scrollHeight) return;
|
||||
|
||||
const { height } = page.ref.current.getBoundingClientRect();
|
||||
|
||||
const offset = ref.current.offsetTop - scrollTop;
|
||||
|
||||
setHeight(height - offset);
|
||||
}, [page.ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = page.ref.current;
|
||||
if (!element) return;
|
||||
|
||||
updateHeight();
|
||||
|
||||
element.addEventListener('scroll', updateHeight);
|
||||
return () => element.removeEventListener('scroll', updateHeight);
|
||||
}, [page.ref, updateHeight, explorerStore.showInspector]);
|
||||
|
||||
useCallbackToWatchResize(updateHeight, [updateHeight], page.ref);
|
||||
|
||||
if (!explorerStore.showInspector) return null;
|
||||
|
||||
return (
|
||||
<Inspector
|
||||
ref={ref}
|
||||
showThumbnail={settings.layoutMode !== 'media'}
|
||||
className="no-scrollbar sticky top-[68px] shrink-0 overscroll-y-none p-3.5 pr-1.5"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,138 +0,0 @@
|
|||
import { Info } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import 'react-loading-skeleton/dist/skeleton.css';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { byteSize, Statistics, useLibraryContext, useLibraryQuery } from '@sd/client';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import { useCounter } from '~/hooks';
|
||||
|
||||
interface StatItemProps {
|
||||
title: string;
|
||||
bytes: bigint;
|
||||
isLoading: boolean;
|
||||
info?: string;
|
||||
}
|
||||
|
||||
const StatItemNames: Partial<Record<keyof Statistics, string>> = {
|
||||
total_bytes_capacity: 'Total capacity',
|
||||
preview_media_bytes: 'Preview media',
|
||||
library_db_size: 'Index size',
|
||||
total_bytes_free: 'Free space'
|
||||
};
|
||||
const StatDescriptions: Partial<Record<keyof Statistics, string>> = {
|
||||
total_bytes_capacity:
|
||||
'The total capacity of all nodes connected to the library. May show incorrect values during alpha.',
|
||||
preview_media_bytes: 'The total size of all preview media files, such as thumbnails.',
|
||||
library_db_size: 'The size of the library database.',
|
||||
total_bytes_free: 'Free space available on all nodes connected to the library.'
|
||||
};
|
||||
|
||||
const EMPTY_STATISTICS = {
|
||||
id: 0,
|
||||
date_captured: '',
|
||||
total_bytes_capacity: '0',
|
||||
preview_media_bytes: '0',
|
||||
library_db_size: '0',
|
||||
total_object_count: 0,
|
||||
total_bytes_free: '0',
|
||||
total_bytes_used: '0',
|
||||
total_unique_bytes: '0'
|
||||
};
|
||||
|
||||
const displayableStatItems = Object.keys(StatItemNames) as unknown as keyof typeof StatItemNames;
|
||||
|
||||
let mounted = false;
|
||||
|
||||
const StatItem = (props: StatItemProps) => {
|
||||
const { title, bytes, isLoading } = props;
|
||||
|
||||
// This is important to the counter working.
|
||||
// On first render of the counter this will potentially be `false` which means the counter should the count up.
|
||||
// but in a `useEffect` `mounted` will be set to `true` so that subsequent renders of the counter will not run the count up.
|
||||
// The acts as a cache of the value of `mounted` on the first render of this `StateItem`.
|
||||
const [isMounted] = useState(mounted);
|
||||
|
||||
const size = byteSize(bytes);
|
||||
const count = useCounter({
|
||||
name: title,
|
||||
end: size.value,
|
||||
duration: isMounted ? 0 : 1,
|
||||
saveState: false
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex w-32 shrink-0 flex-col rounded-md px-4 py-3 duration-75',
|
||||
!bytes && 'hidden'
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-nowrap text-sm text-gray-400 ">
|
||||
{title}
|
||||
{props.info && (
|
||||
<Tooltip label={props.info}>
|
||||
<Info
|
||||
weight="fill"
|
||||
className="-mt-0.5 ml-1 inline h-3 w-3 text-ink-faint opacity-0 transition-opacity group-hover:opacity-70"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="text-2xl">
|
||||
{isLoading && (
|
||||
<div>
|
||||
<Skeleton
|
||||
enableAnimation={true}
|
||||
baseColor={'#21212e'}
|
||||
highlightColor={'#13131a'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx({
|
||||
hidden: isLoading
|
||||
})}
|
||||
>
|
||||
<span className="font-black tabular-nums">{count}</span>
|
||||
<span className="ml-1 text-[16px] text-gray-400">{size.unit}</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default () => {
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
const stats = useLibraryQuery(['library.statistics'], {
|
||||
initialData: { ...EMPTY_STATISTICS }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!stats.isLoading) mounted = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full px-5 pb-2 pt-4">
|
||||
{/* STAT CONTAINER */}
|
||||
<div className="-mb-1 flex h-20 overflow-hidden">
|
||||
{Object.entries(stats?.data || []).map(([key, value]) => {
|
||||
if (!displayableStatItems.includes(key)) return null;
|
||||
return (
|
||||
<StatItem
|
||||
key={`${library.uuid} ${key}`}
|
||||
title={StatItemNames[key as keyof Statistics]!}
|
||||
bytes={BigInt(value)}
|
||||
isLoading={stats.isLoading}
|
||||
info={StatDescriptions[key as keyof Statistics]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,153 +0,0 @@
|
|||
import { iconNames } from '@sd/assets/util';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Category,
|
||||
FilePathFilterArgs,
|
||||
FilePathOrder,
|
||||
ObjectFilterArgs,
|
||||
ObjectKindEnum,
|
||||
ObjectOrder,
|
||||
useLibraryContext,
|
||||
useLibraryQuery
|
||||
} from '@sd/client';
|
||||
|
||||
import { useObjectsInfiniteQuery, usePathsInfiniteQuery } from '../Explorer/queries';
|
||||
import {
|
||||
createDefaultExplorerSettings,
|
||||
filePathOrderingKeysSchema,
|
||||
objectOrderingKeysSchema
|
||||
} from '../Explorer/store';
|
||||
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
import { usePageLayoutContext } from '../PageLayout/Context';
|
||||
|
||||
export const IconForCategory: Partial<Record<Category, string>> = {
|
||||
Recents: iconNames.Collection,
|
||||
Favorites: iconNames.Heart,
|
||||
Albums: iconNames.Album,
|
||||
Photos: iconNames.Image,
|
||||
Videos: iconNames.Video,
|
||||
Movies: iconNames.Movie,
|
||||
Music: iconNames.Audio,
|
||||
Documents: iconNames.Document,
|
||||
Downloads: iconNames.Package,
|
||||
Applications: iconNames.Application,
|
||||
Games: iconNames.Game,
|
||||
Books: iconNames.Book,
|
||||
Encrypted: iconNames.Lock,
|
||||
Databases: iconNames.Database,
|
||||
Projects: iconNames.Folder,
|
||||
Trash: iconNames.Trash,
|
||||
Screenshots: iconNames.Screenshot
|
||||
};
|
||||
|
||||
export const IconToDescription = {
|
||||
Recents: "See files you've recently opened or created",
|
||||
Favorites: 'See files you have marked as favorites',
|
||||
Albums: 'Organize your photos and videos into albums',
|
||||
Photos: 'View all photos in your library',
|
||||
Videos: 'View all videos in your library',
|
||||
Movies: 'View all movies in your library',
|
||||
Music: 'View all music in your library',
|
||||
Documents: 'View all documents in your library',
|
||||
Downloads: 'View all downloads in your library',
|
||||
Encrypted: 'View all encrypted files in your library',
|
||||
Projects: 'View all projects in your library',
|
||||
Applications: 'View all applications in your library',
|
||||
Archives: 'View all archives in your library',
|
||||
Databases: 'View all databases in your library',
|
||||
Games: 'View all games in your library',
|
||||
Books: 'View all books in your library',
|
||||
Contacts: 'View all contacts in your library',
|
||||
Trash: 'View all files in your trash',
|
||||
Screenshots: 'View all screenshots in your library'
|
||||
};
|
||||
|
||||
export const OBJECT_CATEGORIES: Category[] = ['Recents', 'Favorites'];
|
||||
|
||||
// this is a gross function so it's in a separate hook :)
|
||||
export function useCategoryExplorer(category: Category) {
|
||||
const { library } = useLibraryContext();
|
||||
const page = usePageLayoutContext();
|
||||
|
||||
const isObjectQuery = OBJECT_CATEGORIES.includes(category);
|
||||
|
||||
const pathsExplorerSettings = useExplorerSettings({
|
||||
settings: useMemo(() => createDefaultExplorerSettings<FilePathOrder>(), []),
|
||||
orderingKeys: filePathOrderingKeysSchema
|
||||
});
|
||||
|
||||
const objectsExplorerSettings = useExplorerSettings({
|
||||
settings: useMemo(() => createDefaultExplorerSettings<ObjectOrder>(), []),
|
||||
orderingKeys: objectOrderingKeysSchema
|
||||
});
|
||||
|
||||
const explorerSettings = isObjectQuery ? objectsExplorerSettings : pathsExplorerSettings;
|
||||
const settings = explorerSettings.useSettingsSnapshot();
|
||||
|
||||
const take = 100;
|
||||
|
||||
const objectFilter: ObjectFilterArgs = {
|
||||
category,
|
||||
...(settings.layoutMode === 'media' && {
|
||||
kind: [ObjectKindEnum.Image, ObjectKindEnum.Video]
|
||||
})
|
||||
};
|
||||
|
||||
const objectsCount = useLibraryQuery(['search.objectsCount', { filter: objectFilter }]);
|
||||
|
||||
const objectsQuery = useObjectsInfiniteQuery({
|
||||
enabled: isObjectQuery,
|
||||
library,
|
||||
arg: { take, filter: objectFilter },
|
||||
settings: objectsExplorerSettings
|
||||
});
|
||||
|
||||
const objectsItems = useMemo(
|
||||
() => objectsQuery.data?.pages?.flatMap((d) => d.items) ?? [],
|
||||
[objectsQuery.data]
|
||||
);
|
||||
|
||||
const pathsFilter: FilePathFilterArgs = { object: objectFilter };
|
||||
|
||||
const pathsCount = useLibraryQuery(['search.pathsCount', { filter: pathsFilter }]);
|
||||
|
||||
// TODO: Make a custom double click handler for directories to take users to the location explorer.
|
||||
// For now it's not needed because folders shouldn't show.
|
||||
const pathsQuery = usePathsInfiniteQuery({
|
||||
enabled: !isObjectQuery,
|
||||
library,
|
||||
arg: { take, filter: pathsFilter },
|
||||
settings: pathsExplorerSettings
|
||||
});
|
||||
|
||||
const pathsItems = useMemo(
|
||||
() => pathsQuery.data?.pages?.flatMap((d) => d.items) ?? [],
|
||||
[pathsQuery.data]
|
||||
);
|
||||
|
||||
const loadMore = () => {
|
||||
const query = isObjectQuery ? objectsQuery : pathsQuery;
|
||||
if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
|
||||
};
|
||||
|
||||
const shared = {
|
||||
loadMore,
|
||||
scrollRef: page.ref
|
||||
};
|
||||
|
||||
return isObjectQuery
|
||||
? // eslint-disable-next-line
|
||||
useExplorer({
|
||||
items: objectsItems ?? null,
|
||||
count: objectsCount.data,
|
||||
settings: objectsExplorerSettings,
|
||||
...shared
|
||||
})
|
||||
: // eslint-disable-next-line
|
||||
useExplorer({
|
||||
items: pathsItems ?? null,
|
||||
count: pathsCount.data,
|
||||
settings: pathsExplorerSettings,
|
||||
...shared
|
||||
});
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
import { getIcon } from '@sd/assets/util';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSnapshot } from 'valtio';
|
||||
import { Category } from '@sd/client';
|
||||
|
||||
import 'react-loading-skeleton/dist/skeleton.css';
|
||||
|
||||
import { useRouteTitle } from '~/hooks/useRouteTitle';
|
||||
|
||||
import { useIsDark } from '../../../hooks';
|
||||
import { ExplorerContextProvider } from '../Explorer/Context';
|
||||
import ContextMenu, { ObjectItems } from '../Explorer/ContextMenu';
|
||||
import { Conditional } from '../Explorer/ContextMenu/ConditionalItem';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import View from '../Explorer/View';
|
||||
import Statistics from '../overview/Statistics';
|
||||
import { usePageLayoutContext } from '../PageLayout/Context';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
import { Categories } from './Categories';
|
||||
import { IconForCategory, IconToDescription, useCategoryExplorer } from './data';
|
||||
import Inspector from './Inspector';
|
||||
|
||||
export const Component = () => {
|
||||
useRouteTitle('Overview');
|
||||
|
||||
const isDark = useIsDark();
|
||||
const page = usePageLayoutContext();
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category>('Recents');
|
||||
|
||||
const explorer = useCategoryExplorer(selectedCategory);
|
||||
|
||||
useEffect(() => {
|
||||
if (!page.ref.current) return;
|
||||
|
||||
const { scrollTop } = page.ref.current;
|
||||
if (scrollTop > 100) page.ref.current.scrollTo({ top: 100 });
|
||||
}, [selectedCategory, page.ref]);
|
||||
|
||||
const settings = useSnapshot(explorer.settingsStore);
|
||||
|
||||
return (
|
||||
<ExplorerContextProvider explorer={explorer}>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
|
||||
<Statistics />
|
||||
{/* <div className="mt-2 w-full" /> */}
|
||||
<Categories selected={selectedCategory} onSelectedChanged={setSelectedCategory} />
|
||||
|
||||
<div className="flex flex-1">
|
||||
<View
|
||||
top={114}
|
||||
className={settings.layoutMode === 'list' ? 'min-w-0' : undefined}
|
||||
contextMenu={
|
||||
<ContextMenu>
|
||||
<Conditional items={[ObjectItems.RemoveFromRecents]} />
|
||||
</ContextMenu>
|
||||
}
|
||||
emptyNotice={
|
||||
<div className="flex h-full flex-col items-center justify-center text-white">
|
||||
<img
|
||||
src={getIcon(
|
||||
IconForCategory[selectedCategory] || 'Document',
|
||||
isDark
|
||||
)}
|
||||
className="h-32 w-32"
|
||||
/>
|
||||
<h1 className="mt-4 text-lg font-bold">{selectedCategory}</h1>
|
||||
<p className="mt-1 text-sm text-ink-dull">
|
||||
{IconToDescription[selectedCategory]}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Inspector />
|
||||
</div>
|
||||
</ExplorerContextProvider>
|
||||
);
|
||||
};
|
|
@ -1,95 +1,114 @@
|
|||
import { MagnifyingGlass } from '@phosphor-icons/react';
|
||||
import { memo, Suspense, useDeferredValue, useMemo } from 'react';
|
||||
import { FilePathOrder, getExplorerItemData, useLibraryQuery } from '@sd/client';
|
||||
import { SearchParamsSchema, type SearchParams } from '~/app/route-schemas';
|
||||
import { useZodSearchParams } from '~/hooks';
|
||||
// import { MagnifyingGlass } from '@phosphor-icons/react';
|
||||
// import { getIcon, iconNames } from '@sd/assets/util';
|
||||
// import { Suspense, useDeferredValue, useEffect, useMemo } from 'react';
|
||||
// import { FilePathFilterArgs, useLibraryContext } from '@sd/client';
|
||||
// import { SearchIdParamsSchema, SearchParams, SearchParamsSchema } from '~/app/route-schemas';
|
||||
// import { useZodRouteParams, useZodSearchParams } from '~/hooks';
|
||||
|
||||
import Explorer from './Explorer';
|
||||
import { ExplorerContextProvider } from './Explorer/Context';
|
||||
import {
|
||||
createDefaultExplorerSettings,
|
||||
filePathOrderingKeysSchema,
|
||||
getExplorerStore
|
||||
} from './Explorer/store';
|
||||
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
|
||||
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
|
||||
import { EmptyNotice } from './Explorer/View';
|
||||
import { TopBarPortal } from './TopBar/Portal';
|
||||
// import Explorer from './Explorer';
|
||||
// import { ExplorerContextProvider } from './Explorer/Context';
|
||||
// import { usePathsInfiniteQuery } from './Explorer/queries';
|
||||
// import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from './Explorer/store';
|
||||
// import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
|
||||
// import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
|
||||
// import { EmptyNotice } from './Explorer/View';
|
||||
// import { useSavedSearches } from './Explorer/View/SearchOptions/SavedSearches';
|
||||
// import { getSearchStore, useSearchFilters } from './Explorer/View/SearchOptions/store';
|
||||
// import { TopBarPortal } from './TopBar/Portal';
|
||||
|
||||
const SearchExplorer = memo((props: { args: SearchParams }) => {
|
||||
const { search, ...args } = props.args;
|
||||
// const useItems = (searchParams: SearchParams, id: number) => {
|
||||
// const { library } = useLibraryContext();
|
||||
// const explorerSettings = useExplorerSettings({
|
||||
// settings: createDefaultExplorerSettings({
|
||||
// order: {
|
||||
// field: 'name',
|
||||
// value: 'Asc'
|
||||
// }
|
||||
// }),
|
||||
// orderingKeys: filePathOrderingKeysSchema
|
||||
// });
|
||||
|
||||
const query = useLibraryQuery(['search.paths', { ...args, filter: { search } }], {
|
||||
suspense: true,
|
||||
enabled: !!search,
|
||||
onSuccess: () => getExplorerStore().resetNewThumbnails()
|
||||
});
|
||||
// const searchFilters = useSearchFilters('paths', []);
|
||||
|
||||
const explorerSettings = useExplorerSettings({
|
||||
settings: useMemo(
|
||||
() =>
|
||||
createDefaultExplorerSettings<FilePathOrder>({
|
||||
order: {
|
||||
field: 'name',
|
||||
value: 'Asc'
|
||||
}
|
||||
}),
|
||||
[]
|
||||
),
|
||||
orderingKeys: filePathOrderingKeysSchema
|
||||
});
|
||||
// const savedSearches = useSavedSearches();
|
||||
|
||||
const settingsSnapshot = explorerSettings.useSettingsSnapshot();
|
||||
// useEffect(() => {
|
||||
// if (id) {
|
||||
// getSearchStore().isSearching = true;
|
||||
// savedSearches.loadSearch(id);
|
||||
// }
|
||||
// }, [id]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const items = query.data?.items ?? [];
|
||||
// const take = 50; // Specify the number of items to fetch per query
|
||||
|
||||
if (settingsSnapshot.layoutMode !== 'media') return items;
|
||||
// const query = usePathsInfiniteQuery({
|
||||
// arg: { filter: searchFilters, take },
|
||||
// library,
|
||||
// // @ts-ignore todo: fix
|
||||
// settings: explorerSettings,
|
||||
// suspense: true
|
||||
// });
|
||||
|
||||
return items?.filter((item) => {
|
||||
const { kind } = getExplorerItemData(item);
|
||||
return kind === 'Video' || kind === 'Image';
|
||||
});
|
||||
}, [query.data, settingsSnapshot.layoutMode]);
|
||||
// const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) ?? [], [query.data]);
|
||||
|
||||
const explorer = useExplorer({
|
||||
items,
|
||||
settings: explorerSettings
|
||||
});
|
||||
// return { items, query };
|
||||
// };
|
||||
|
||||
return (
|
||||
<ExplorerContextProvider explorer={explorer}>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
<Explorer
|
||||
emptyNotice={
|
||||
<EmptyNotice
|
||||
icon={
|
||||
!search ? (
|
||||
<MagnifyingGlass
|
||||
size={110}
|
||||
className="mb-5 text-ink-faint"
|
||||
opacity={0.3}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
message={
|
||||
search ? `No results found for "${search}"` : 'Search for files...'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ExplorerContextProvider>
|
||||
);
|
||||
});
|
||||
// const SearchExplorer = ({ id, searchParams }: { id: number; searchParams: SearchParams }) => {
|
||||
// const { items, query } = useItems(searchParams, id);
|
||||
|
||||
export const Component = () => {
|
||||
const [searchParams] = useZodSearchParams(SearchParamsSchema);
|
||||
// const explorerSettings = useExplorerSettings({
|
||||
// settings: createDefaultExplorerSettings({
|
||||
// order: {
|
||||
// field: 'name',
|
||||
// value: 'Asc'
|
||||
// }
|
||||
// }),
|
||||
// orderingKeys: filePathOrderingKeysSchema
|
||||
// });
|
||||
|
||||
const search = useDeferredValue(searchParams);
|
||||
// const explorer = useExplorer({
|
||||
// items,
|
||||
// settings: explorerSettings
|
||||
// });
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchExplorer args={search} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
// return (
|
||||
// <ExplorerContextProvider explorer={explorer}>
|
||||
// <TopBarPortal
|
||||
// left={
|
||||
// <div className="flex flex-row items-center gap-2">
|
||||
// <MagnifyingGlass className="text-ink-dull" weight="bold" size={18} />
|
||||
// <span className="truncate text-sm font-medium">Search</span>
|
||||
// </div>
|
||||
// }
|
||||
// right={<DefaultTopBarOptions />}
|
||||
// />
|
||||
// <Explorer
|
||||
// showFilterBar
|
||||
// emptyNotice={
|
||||
// <EmptyNotice
|
||||
// icon={<img className="h-32 w-32" src={getIcon(iconNames.FolderNoSpace)} />}
|
||||
// message={
|
||||
// searchParams.search
|
||||
// ? `No results found for "${searchParams.search}"`
|
||||
// : 'Search for files...'
|
||||
// }
|
||||
// />
|
||||
// }
|
||||
// />
|
||||
// </ExplorerContextProvider>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export const Component = () => {
|
||||
// const [searchParams] = useZodSearchParams(SearchParamsSchema);
|
||||
// const { id } = useZodRouteParams(SearchIdParamsSchema);
|
||||
|
||||
// const search = useDeferredValue(searchParams);
|
||||
|
||||
// return (
|
||||
// <Suspense>
|
||||
// <SearchExplorer id={id} searchParams={search} />
|
||||
// </Suspense>
|
||||
// );
|
||||
// };
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { ObjectFilterArgs, ObjectOrder, useLibraryContext, useLibraryQuery } from '@sd/client';
|
||||
import { ObjectKindEnum, ObjectOrder, Tag, useLibraryContext, useLibraryQuery } from '@sd/client';
|
||||
import { LocationIdParamsSchema } from '~/app/route-schemas';
|
||||
import { Icon } from '~/components';
|
||||
import { useRouteTitle, useZodRouteParams } from '~/hooks';
|
||||
|
@ -7,6 +7,8 @@ import { useRouteTitle, useZodRouteParams } from '~/hooks';
|
|||
import Explorer from '../Explorer';
|
||||
import { ExplorerContextProvider } from '../Explorer/Context';
|
||||
import { useObjectsInfiniteQuery } from '../Explorer/queries';
|
||||
import { SearchContextProvider } from '../Explorer/Search/Context';
|
||||
import { useSearchFilters } from '../Explorer/Search/store';
|
||||
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
|
@ -14,6 +16,14 @@ import { EmptyNotice } from '../Explorer/View';
|
|||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<SearchContextProvider>
|
||||
<Inner />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function Inner() {
|
||||
const { id: tagId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
|
||||
|
||||
|
@ -30,7 +40,10 @@ export const Component = () => {
|
|||
orderingKeys: objectOrderingKeysSchema
|
||||
});
|
||||
|
||||
const { items, count, loadMore, query } = useItems({ tagId, settings: explorerSettings });
|
||||
const { items, count, loadMore, query } = useItems({
|
||||
tag: tag.data!,
|
||||
settings: explorerSettings
|
||||
});
|
||||
|
||||
const explorer = useExplorer({
|
||||
items,
|
||||
|
@ -44,8 +57,20 @@ export const Component = () => {
|
|||
|
||||
return (
|
||||
<ExplorerContextProvider explorer={explorer}>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
<TopBarPortal
|
||||
left={
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div
|
||||
className="h-[14px] w-[14px] shrink-0 rounded-full"
|
||||
style={{ backgroundColor: tag?.data?.color || '#efefef' }}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">{tag?.data?.name}</span>
|
||||
</div>
|
||||
}
|
||||
right={<DefaultTopBarOptions />}
|
||||
/>
|
||||
<Explorer
|
||||
showFilterBar
|
||||
emptyNotice={
|
||||
<EmptyNotice
|
||||
loading={query.isFetching}
|
||||
|
@ -56,24 +81,30 @@ export const Component = () => {
|
|||
/>
|
||||
</ExplorerContextProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function useItems({
|
||||
tagId,
|
||||
settings
|
||||
}: {
|
||||
tagId: number;
|
||||
settings: UseExplorerSettings<ObjectOrder>;
|
||||
}) {
|
||||
function useItems({ tag, settings }: { tag: Tag; settings: UseExplorerSettings<ObjectOrder> }) {
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
const filter: ObjectFilterArgs = { tags: [tagId] };
|
||||
const explorerSettings = settings.useSettingsSnapshot();
|
||||
|
||||
const count = useLibraryQuery(['search.objectsCount', { filter }]);
|
||||
const fixedFilters = useMemo(
|
||||
() => [
|
||||
{ object: { tags: { in: [tag.id] } } },
|
||||
...(explorerSettings.layoutMode === 'media'
|
||||
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
|
||||
: [])
|
||||
],
|
||||
[tag.id, explorerSettings.layoutMode]
|
||||
);
|
||||
|
||||
const filters = useSearchFilters('objects', fixedFilters);
|
||||
|
||||
const count = useLibraryQuery(['search.objectsCount', { filters }]);
|
||||
|
||||
const query = useObjectsInfiniteQuery({
|
||||
library,
|
||||
arg: { take: 100, filter: { tags: [tagId] } },
|
||||
arg: { take: 100, filters },
|
||||
settings
|
||||
});
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ const Index = () => {
|
|||
|
||||
const libraryId = currentLibrary ? currentLibrary.uuid : libraries.data[0]?.uuid;
|
||||
|
||||
return <Navigate to={`${libraryId}/overview`} replace state={{ first: true }} />;
|
||||
return <Navigate to={`${libraryId}/network`} replace />;
|
||||
};
|
||||
|
||||
const Wrapper = () => {
|
||||
|
|
|
@ -20,6 +20,9 @@ export type TagsSettingsParams = z.infer<typeof TagsSettingsParamsSchema>;
|
|||
export const PathParamsSchema = z.object({ path: z.string().optional() });
|
||||
export type PathParams = z.infer<typeof PathParamsSchema>;
|
||||
|
||||
export const SearchIdParamsSchema = z.object({ id: z.coerce.number() });
|
||||
export type SearchIdParams = z.infer<typeof SearchIdParamsSchema>;
|
||||
|
||||
export const SearchParamsSchema = PathParamsSchema.extend({
|
||||
take: z.coerce.number().default(100),
|
||||
order: z
|
||||
|
|
|
@ -377,3 +377,8 @@ body {
|
|||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.react-slidedown.search-options-slide {
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1);
|
||||
}
|
||||
|
|
109
interface/components/MultiCheckbox.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { Check } from '@phosphor-icons/react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import { useRef } from 'react';
|
||||
import { CheckBox } from '@sd/ui';
|
||||
import { useScrolled } from '~/hooks/useScrolled';
|
||||
|
||||
import { Menu } from './Menu';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: React.ReactNode;
|
||||
id: number;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface SelectorProps {
|
||||
items?: Item[];
|
||||
headerArea?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default ({ items, headerArea }: SelectorProps) => {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: items?.length || 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 30,
|
||||
paddingStart: 2
|
||||
});
|
||||
|
||||
const { isScrolled } = useScrolled(parentRef, 10);
|
||||
|
||||
return (
|
||||
<>
|
||||
{headerArea && (
|
||||
<>
|
||||
{headerArea}
|
||||
<Menu.Separator
|
||||
className={clsx('mx-0 mb-0 transition', isScrolled && 'shadow')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{items && items.length > 0 ? (
|
||||
<div
|
||||
ref={parentRef}
|
||||
style={{
|
||||
maxHeight: `400px`,
|
||||
height: `100%`,
|
||||
width: `100%`,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const item = items[virtualRow.index];
|
||||
|
||||
if (!item) return null;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{item.color && (
|
||||
<div
|
||||
className="mr-0.5 h-[15px] w-[15px] shrink-0 rounded-full border"
|
||||
style={{
|
||||
backgroundColor: item.selected
|
||||
? item.color
|
||||
: 'transparent',
|
||||
borderColor: item.color || '#efefef'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{item.icon}
|
||||
{!item.color && !item.icon && (
|
||||
<CheckBox checked={item.selected} />
|
||||
)}
|
||||
<span className="truncate">{item.name}</span>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 text-center text-xs text-ink-faint">
|
||||
{items ? 'No item' : 'Failed to load items'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -13,7 +13,7 @@ export * from './useKeybindEventHandler';
|
|||
export * from './useKeybind';
|
||||
export * from './useOperatingSystem';
|
||||
export * from './useScrolled';
|
||||
export * from './useSearchStore';
|
||||
// export * from './useSearchStore';
|
||||
export * from './useShortcut';
|
||||
export * from './useShowControls';
|
||||
export * from './useSpacedropState';
|
||||
|
|
88
interface/hooks/useSearchFilter.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
// import { useEffect, useState } from 'react';
|
||||
// import { FilePathFilterArgs, ObjectKindEnum, useLibraryQuery } from '@sd/client';
|
||||
// import { getSearchStore, SetFilter, useSearchStore } from '~/hooks';
|
||||
|
||||
// export interface SearchFilterOptions {
|
||||
// locationId?: number;
|
||||
// tags?: number[];
|
||||
// objectKinds?: ObjectKindEnum[];
|
||||
// }
|
||||
|
||||
// // Converts selected filters into a FilePathFilterArgs object for querying file paths
|
||||
// const filtersToFilePathArgs = (filters: SetFilter[]): FilePathFilterArgs => {
|
||||
// const filePathArgs: FilePathFilterArgs = {};
|
||||
|
||||
// // Iterate through selected filters and add them to the FilePathFilterArgs object
|
||||
// filters.forEach((filter) => {
|
||||
// switch (filter.categoryName) {
|
||||
// case 'Location':
|
||||
// filePathArgs.locationId = Number(filter.id);
|
||||
// break;
|
||||
// case 'Tagged':
|
||||
// if (!filePathArgs.object) filePathArgs.object = {};
|
||||
// if (!filePathArgs.object.tags) filePathArgs.object.tags = [];
|
||||
// filePathArgs.object.tags.push(Number(filter.id));
|
||||
// break;
|
||||
// case 'Kind':
|
||||
// if (!filePathArgs.object) filePathArgs.object = { kind: [] };
|
||||
// filePathArgs.object.kind?.push(filter.id as unknown as ObjectKindEnum);
|
||||
// break;
|
||||
// }
|
||||
// });
|
||||
|
||||
// return filePathArgs;
|
||||
// };
|
||||
|
||||
// // Custom hook to manage search filters state and transform it to FilePathFilterArgs for further processing
|
||||
// export const useSearchFilters = (options: SearchFilterOptions): FilePathFilterArgs => {
|
||||
// const { locationId, tags, objectKinds } = options;
|
||||
// const searchStore = useSearchStore();
|
||||
// const [filePathArgs, setFilePathArgs] = useState<FilePathFilterArgs>({});
|
||||
|
||||
// useEffect(() => {
|
||||
// const searchStore = getSearchStore();
|
||||
|
||||
// // If no filters are selected, initialize filters based on the provided options
|
||||
// if (searchStore.selectedFilters.size === 0) {
|
||||
// // handle location filter
|
||||
// if (locationId) {
|
||||
// const filter = searchStore.registerFilter(
|
||||
// `${locationId}-${locationId}`,
|
||||
// { id: locationId, name: '', icon: 'Folder' },
|
||||
// 'Location'
|
||||
// );
|
||||
// searchStore.selectFilter(filter.key, true);
|
||||
// }
|
||||
// // handle tags filter
|
||||
// tags?.forEach((tag) => {
|
||||
// const tagFilter = searchStore.registerFilter(
|
||||
// `${tag}-${tag}`,
|
||||
// { id: tag, name: `${tag}`, icon: `${tag}` },
|
||||
// 'Tag'
|
||||
// );
|
||||
// if (tagFilter) {
|
||||
// searchStore.selectFilter(tagFilter.key, true);
|
||||
// }
|
||||
// });
|
||||
// // handle object kinds filter
|
||||
// objectKinds?.forEach((kind) => {
|
||||
// const kindFilter = Array.from(searchStore.filters.values()).find(
|
||||
// (filter) =>
|
||||
// filter.categoryName === 'Kind' && filter.name === ObjectKindEnum[kind]
|
||||
// );
|
||||
// if (kindFilter) {
|
||||
// searchStore.selectFilter(kindFilter.key, true);
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Convert selected filters to FilePathFilterArgs and update the state whenever selected filters change
|
||||
// const selectedFiltersArray = Array.from(searchStore.selectedFilters.values());
|
||||
// const updatedFilePathArgs = filtersToFilePathArgs(selectedFiltersArray);
|
||||
|
||||
// setFilePathArgs(updatedFilePathArgs);
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, [locationId, tags, objectKinds, searchStore.selectedFilters, searchStore.filters]);
|
||||
|
||||
// return filePathArgs;
|
||||
// };
|
|
@ -1,7 +1,151 @@
|
|||
import { proxy, useSnapshot } from 'valtio';
|
||||
// import { Icon } from '@phosphor-icons/react';
|
||||
// import { IconTypes } from '@sd/assets/util';
|
||||
// import { useEffect, useState } from 'react';
|
||||
// import { proxy, useSnapshot } from 'valtio';
|
||||
// import { proxyMap } from 'valtio/utils';
|
||||
|
||||
const searchStore = proxy({ isFocused: false });
|
||||
// // import { ObjectKind } from '@sd/client';
|
||||
|
||||
export const useSearchStore = () => useSnapshot(searchStore);
|
||||
// type SearchType = 'paths' | 'objects' | 'tags';
|
||||
|
||||
export const getSearchStore = () => searchStore;
|
||||
// type SearchScope = 'directory' | 'location' | 'device' | 'library';
|
||||
|
||||
// interface FilterCategory {
|
||||
// icon: string; // must be string
|
||||
// name: string;
|
||||
// }
|
||||
|
||||
// /// Filters are stored in a map, so they can be accessed by key
|
||||
// export interface Filter {
|
||||
// id: string | number;
|
||||
// icon: Icon | IconTypes | string;
|
||||
// name: string;
|
||||
// }
|
||||
|
||||
// // Once a filter is registered, it is given a key and a category name
|
||||
// export interface RegisteredFilter extends Filter {
|
||||
// categoryName: string; // used to link filters to category
|
||||
// key: string; // used to identify filters in the map
|
||||
// }
|
||||
|
||||
// // Once a filter is selected, condition state is tracked
|
||||
// export interface SetFilter extends RegisteredFilter {
|
||||
// condition: boolean;
|
||||
// category?: FilterCategory;
|
||||
// }
|
||||
|
||||
// interface Filters {
|
||||
// name: string;
|
||||
// icon: string;
|
||||
// filters: Filter[];
|
||||
// }
|
||||
|
||||
// export type GroupedFilters = {
|
||||
// [categoryName: string]: SetFilter[];
|
||||
// };
|
||||
|
||||
// export function useCreateSearchFilter({ filters, name, icon }: Filters) {
|
||||
// const [registeredFilters, setRegisteredFilters] = useState<RegisteredFilter[]>([]);
|
||||
|
||||
// useEffect(() => {
|
||||
// const newRegisteredFilters: RegisteredFilter[] = [];
|
||||
|
||||
// searchStore.filterCategories.set(name, { name, icon });
|
||||
|
||||
// filters.map((filter) => {
|
||||
// const registeredFilter = searchStore.registerFilter(
|
||||
// // id doesn't have to be a particular format, just needs to be unique
|
||||
// `${filter.id}-${filter.name}`,
|
||||
// filter,
|
||||
// name
|
||||
// );
|
||||
// newRegisteredFilters.push(registeredFilter);
|
||||
// });
|
||||
|
||||
// setRegisteredFilters(newRegisteredFilters);
|
||||
|
||||
// console.log(getSearchStore());
|
||||
|
||||
// return () => {
|
||||
// filters.forEach((filter) => {
|
||||
// searchStore.unregisterFilter(`${filter.id}-${filter.name}`);
|
||||
// });
|
||||
// setRegisteredFilters([]); // or filter out the unregistered filters
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
// return {
|
||||
// name,
|
||||
// icon,
|
||||
// filters: registeredFilters // returning the registered filters with their keys
|
||||
// };
|
||||
// }
|
||||
|
||||
// const searchStore = proxy({
|
||||
// isSearching: false,
|
||||
// interactingWithSearchOptions: false,
|
||||
// searchScope: 'directory',
|
||||
// //
|
||||
// // searchType: 'paths',
|
||||
// // objectKind: null as typeof ObjectKind | null,
|
||||
// // tagged: null as string[] | null,
|
||||
// // dateRange: null as [Date, Date] | null
|
||||
|
||||
// filters: proxyMap() as Map<string, RegisteredFilter>,
|
||||
// filterCategories: proxyMap() as Map<string, FilterCategory>,
|
||||
// selectedFilters: proxyMap() as Map<string, SetFilter>,
|
||||
|
||||
// registerFilter: (key: string, filter: Filter, categoryName: string) => {
|
||||
// searchStore.filters.set(key, { ...filter, key, categoryName });
|
||||
// return searchStore.filters.get(key)!;
|
||||
// },
|
||||
|
||||
// unregisterFilter: (key: string) => {
|
||||
// searchStore.filters.delete(key);
|
||||
// },
|
||||
|
||||
// selectFilter: (key: string, condition: boolean) => {
|
||||
// searchStore.selectedFilters.set(key, { ...searchStore.filters.get(key)!, condition });
|
||||
// },
|
||||
|
||||
// deselectFilter: (key: string) => {
|
||||
// searchStore.selectedFilters.delete(key);
|
||||
// },
|
||||
|
||||
// clearSelectedFilters: () => {
|
||||
// searchStore.selectedFilters.clear();
|
||||
// },
|
||||
|
||||
// getSelectedFilters: (): GroupedFilters => {
|
||||
// return Array.from(searchStore.selectedFilters.values())
|
||||
// .map((filter) => ({
|
||||
// ...filter,
|
||||
// category: searchStore.filterCategories.get(filter.categoryName)!
|
||||
// }))
|
||||
// .reduce((grouped, filter) => {
|
||||
// if (!grouped[filter.categoryName]) {
|
||||
// grouped[filter.categoryName] = [];
|
||||
// }
|
||||
// grouped[filter.categoryName]?.push(filter);
|
||||
// return grouped;
|
||||
// }, {} as GroupedFilters);
|
||||
// },
|
||||
|
||||
// searchFilters: (query: string) => {
|
||||
// if (!query) return searchStore.filters;
|
||||
// return Array.from(searchStore.filters.values()).filter((filter) =>
|
||||
// filter.name.toLowerCase().includes(query.toLowerCase())
|
||||
// );
|
||||
// },
|
||||
|
||||
// reset() {
|
||||
// searchStore.searchScope = 'directory';
|
||||
// searchStore.filters.clear();
|
||||
// searchStore.filterCategories.clear();
|
||||
// searchStore.selectedFilters.clear();
|
||||
// }
|
||||
// });
|
||||
|
||||
// export const useSearchStore = () => useSnapshot(searchStore);
|
||||
|
||||
// export const getSearchStore = () => searchStore;
|
||||
|
|
|
@ -11,9 +11,16 @@
|
|||
"dependencies": {
|
||||
"@fontsource/inter": "^4.5.15",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@icons-pack/react-simple-icons": "^9.1.0",
|
||||
"@phosphor-icons/react": "^2.0.13",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-progress": "^1.0.1",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.2",
|
||||
"@redux-devtools/extension": "^3.2.5",
|
||||
"@remix-run/router": "^1.4.0",
|
||||
"@sd/assets": "workspace:*",
|
||||
"@sd/client": "workspace:*",
|
||||
"@sd/ui": "workspace:*",
|
||||
|
@ -29,6 +36,7 @@
|
|||
"dayjs": "^1.11.10",
|
||||
"dragselect": "^2.7.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"immer": "^10.0.3",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
|
@ -42,6 +50,7 @@
|
|||
"react-router": "6.18.0",
|
||||
"react-router-dom": "6.18.0",
|
||||
"react-selecto": "^1.26.0",
|
||||
"react-slidedown": "^2.4.7",
|
||||
"react-sticky-el": "^2.1.0",
|
||||
"react-truncate-markup": "^5.1.2",
|
||||
"react-use-draggable-scroll": "^0.4.7",
|
||||
|
|
BIN
packages/assets/icons/Album-20.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/assets/icons/Alias-20.png
Normal file
After Width: | Height: | Size: 796 B |
BIN
packages/assets/icons/Archive-20.png
Normal file
After Width: | Height: | Size: 765 B |
BIN
packages/assets/icons/Audio-20.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
packages/assets/icons/Book-20.png
Normal file
After Width: | Height: | Size: 703 B |
BIN
packages/assets/icons/Code-20.png
Normal file
After Width: | Height: | Size: 846 B |
BIN
packages/assets/icons/Collection-20.png
Normal file
After Width: | Height: | Size: 819 B |
BIN
packages/assets/icons/Config-20.png
Normal file
After Width: | Height: | Size: 1,017 B |
BIN
packages/assets/icons/Database-20.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/assets/icons/Document-20.png
Normal file
After Width: | Height: | Size: 577 B |
BIN
packages/assets/icons/Dotfile-20.png
Normal file
After Width: | Height: | Size: 662 B |
BIN
packages/assets/icons/Encrypted-20.png
Normal file
After Width: | Height: | Size: 856 B |
BIN
packages/assets/icons/Executable-20.png
Normal file
After Width: | Height: | Size: 675 B |
BIN
packages/assets/icons/Folder-20.png
Normal file
After Width: | Height: | Size: 560 B |
BIN
packages/assets/icons/Font-20.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/assets/icons/Image-20.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/assets/icons/Key-20.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
packages/assets/icons/Link-20.png
Normal file
After Width: | Height: | Size: 771 B |
BIN
packages/assets/icons/Mesh-20.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/assets/icons/Package-20.png
Normal file
After Width: | Height: | Size: 576 B |
BIN
packages/assets/icons/Screenshot-20.png
Normal file
After Width: | Height: | Size: 928 B |
BIN
packages/assets/icons/Text-20.png
Normal file
After Width: | Height: | Size: 861 B |
BIN
packages/assets/icons/Unknown-20.png
Normal file
After Width: | Height: | Size: 900 B |
BIN
packages/assets/icons/Video-20.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
packages/assets/icons/WebPageArchive-20.png
Normal file
After Width: | Height: | Size: 920 B |
BIN
packages/assets/icons/Widget-20.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
|
@ -4,25 +4,34 @@
|
|||
*/
|
||||
|
||||
import Album_Light from './Album_Light.png';
|
||||
import Album20 from './Album-20.png';
|
||||
import Album from './Album.png';
|
||||
import Alias_Light from './Alias_Light.png';
|
||||
import Alias20 from './Alias-20.png';
|
||||
import Alias from './Alias.png';
|
||||
import AmazonS3 from './AmazonS3.png';
|
||||
import Application_Light from './Application_Light.png';
|
||||
import Application from './Application.png';
|
||||
import Archive_Light from './Archive_Light.png';
|
||||
import Archive20 from './Archive-20.png';
|
||||
import Archive from './Archive.png';
|
||||
import Audio_Light from './Audio_Light.png';
|
||||
import Audio20 from './Audio-20.png';
|
||||
import Audio from './Audio.png';
|
||||
import BackBlaze from './BackBlaze.png';
|
||||
import Ball from './Ball.png';
|
||||
import Book_Light from './Book_Light.png';
|
||||
import Book20 from './Book-20.png';
|
||||
import Book from './Book.png';
|
||||
import BookBlue from './BookBlue.png';
|
||||
import Box from './Box.png';
|
||||
import Code20 from './Code-20.png';
|
||||
import Collection_Light from './Collection_Light.png';
|
||||
import Collection20 from './Collection-20.png';
|
||||
import Collection from './Collection.png';
|
||||
import Config20 from './Config-20.png';
|
||||
import Database_Light from './Database_Light.png';
|
||||
import Database20 from './Database-20.png';
|
||||
import Database from './Database.png';
|
||||
import DAV from './DAV.png';
|
||||
import DeleteLocation from './DeleteLocation.png';
|
||||
|
@ -33,7 +42,9 @@ import Document_pdf_Light from './Document_pdf_Light.png';
|
|||
import Document_pdf from './Document_pdf.png';
|
||||
import Document_xls_Light from './Document_xls_Light.png';
|
||||
import Document_xls from './Document_xls.png';
|
||||
import Document20 from './Document-20.png';
|
||||
import Document from './Document.png';
|
||||
import Dotfile20 from './Dotfile-20.png';
|
||||
import Drive_Light from './Drive_Light.png';
|
||||
import DriveAmazonS3_Light from './Drive-AmazonS3_Light.png';
|
||||
import DriveAmazonS3 from './Drive-AmazonS3.png';
|
||||
|
@ -59,20 +70,24 @@ import DrivePCloud from './Drive-PCloud.png';
|
|||
import Drive from './Drive.png';
|
||||
import Dropbox from './Dropbox.png';
|
||||
import Encrypted_Light from './Encrypted_Light.png';
|
||||
import Encrypted20 from './Encrypted-20.png';
|
||||
import Encrypted from './Encrypted.png';
|
||||
import Entity_Light from './Entity_Light.png';
|
||||
import Entity from './Entity.png';
|
||||
import Executable_Light_old from './Executable_Light_old.png';
|
||||
import Executable_Light from './Executable_Light.png';
|
||||
import Executable_old from './Executable_old.png';
|
||||
import Executable20 from './Executable-20.png';
|
||||
import Executable from './Executable.png';
|
||||
import Face_Light from './Face_Light.png';
|
||||
import Folder_Light from './Folder_Light.png';
|
||||
import Folder20 from './Folder-20.png';
|
||||
import Folder from './Folder.png';
|
||||
import FolderGrey_Light from './FolderGrey_Light.png';
|
||||
import FolderGrey from './FolderGrey.png';
|
||||
import FolderNoSpace_Light from './FolderNoSpace_Light.png';
|
||||
import FolderNoSpace from './FolderNoSpace.png';
|
||||
import Font20 from './Font-20.png';
|
||||
import Game_Light from './Game_Light.png';
|
||||
import Game from './Game.png';
|
||||
import Globe_Light from './Globe_Light.png';
|
||||
|
@ -86,19 +101,23 @@ import Heart from './Heart.png';
|
|||
import Home_Light from './Home_Light.png';
|
||||
import Home from './Home.png';
|
||||
import Image_Light from './Image_Light.png';
|
||||
import Image20 from './Image-20.png';
|
||||
import Image from './Image.png';
|
||||
import Key_Light from './Key_Light.png';
|
||||
import Key20 from './Key-20.png';
|
||||
import Key from './Key.png';
|
||||
import Keys_Light from './Keys_Light.png';
|
||||
import Keys from './Keys.png';
|
||||
import Laptop_Light from './Laptop_Light.png';
|
||||
import Laptop from './Laptop.png';
|
||||
import Link_Light from './Link_Light.png';
|
||||
import Link20 from './Link-20.png';
|
||||
import Link from './Link.png';
|
||||
import Lock_Light from './Lock_Light.png';
|
||||
import Lock from './Lock.png';
|
||||
import Mega from './Mega.png';
|
||||
import Mesh_Light from './Mesh_Light.png';
|
||||
import Mesh20 from './Mesh-20.png';
|
||||
import Mesh from './Mesh.png';
|
||||
import Mobile_Light from './Mobile_Light.png';
|
||||
import Mobile from './Mobile.png';
|
||||
|
@ -110,11 +129,13 @@ import Node from './Node.png';
|
|||
import OneDrive from './OneDrive.png';
|
||||
import OpenStack from './OpenStack.png';
|
||||
import Package_Light from './Package_Light.png';
|
||||
import Package20 from './Package-20.png';
|
||||
import Package from './Package.png';
|
||||
import PCloud from './PCloud.png';
|
||||
import Scrapbook_Light from './Scrapbook_Light.png';
|
||||
import Scrapbook from './Scrapbook.png';
|
||||
import Screenshot_Light from './Screenshot_Light.png';
|
||||
import Screenshot20 from './Screenshot-20.png';
|
||||
import Screenshot from './Screenshot.png';
|
||||
import ScreenshotAlt from './ScreenshotAlt.png';
|
||||
import SD_Light from './SD_Light.png';
|
||||
|
@ -131,6 +152,7 @@ import Terminal_Light from './Terminal_Light.png';
|
|||
import Terminal from './Terminal.png';
|
||||
import Text_Light from './Text_Light.png';
|
||||
import Text_txt from './Text_txt.png';
|
||||
import Text20 from './Text-20.png';
|
||||
import Text from './Text.png';
|
||||
import TextAlt_Light from './TextAlt_Light.png';
|
||||
import TextAlt from './TextAlt.png';
|
||||
|
@ -140,35 +162,49 @@ import Trash_Light from './Trash_Light.png';
|
|||
import Trash from './Trash.png';
|
||||
import Undefined_Light from './Undefined_Light.png';
|
||||
import Undefined from './Undefined.png';
|
||||
import Unknown20 from './Unknown-20.png';
|
||||
import Video_Light from './Video_Light.png';
|
||||
import Video20 from './Video-20.png';
|
||||
import Video from './Video.png';
|
||||
import WebPageArchive20 from './WebPageArchive-20.png';
|
||||
import Widget_Light from './Widget_Light.png';
|
||||
import Widget20 from './Widget-20.png';
|
||||
import Widget from './Widget.png';
|
||||
|
||||
export {
|
||||
Album20,
|
||||
Album,
|
||||
Album_Light,
|
||||
Alias20,
|
||||
Alias,
|
||||
Alias_Light,
|
||||
AmazonS3,
|
||||
Application,
|
||||
Application_Light,
|
||||
Archive20,
|
||||
Archive,
|
||||
Archive_Light,
|
||||
Audio20,
|
||||
Audio,
|
||||
Audio_Light,
|
||||
BackBlaze,
|
||||
Ball,
|
||||
Book20,
|
||||
Book,
|
||||
BookBlue,
|
||||
Book_Light,
|
||||
Box,
|
||||
Code20,
|
||||
Collection20,
|
||||
Collection,
|
||||
Collection_Light,
|
||||
Config20,
|
||||
DAV,
|
||||
Database20,
|
||||
Database,
|
||||
Database_Light,
|
||||
DeleteLocation,
|
||||
Document20,
|
||||
Document,
|
||||
Document_Light,
|
||||
Document_doc,
|
||||
|
@ -177,6 +213,7 @@ export {
|
|||
Document_pdf_Light,
|
||||
Document_xls,
|
||||
Document_xls_Light,
|
||||
Dotfile20,
|
||||
DriveAmazonS3,
|
||||
DriveAmazonS3_Light,
|
||||
DriveBackBlaze,
|
||||
|
@ -201,21 +238,25 @@ export {
|
|||
Drive,
|
||||
Drive_Light,
|
||||
Dropbox,
|
||||
Encrypted20,
|
||||
Encrypted,
|
||||
Encrypted_Light,
|
||||
Entity,
|
||||
Entity_Light,
|
||||
Executable20,
|
||||
Executable,
|
||||
Executable_Light,
|
||||
Executable_Light_old,
|
||||
Executable_old,
|
||||
Face_Light,
|
||||
Folder20,
|
||||
Folder,
|
||||
FolderGrey,
|
||||
FolderGrey_Light,
|
||||
FolderNoSpace,
|
||||
FolderNoSpace_Light,
|
||||
Folder_Light,
|
||||
Font20,
|
||||
Game,
|
||||
Game_Light,
|
||||
Globe,
|
||||
|
@ -228,19 +269,23 @@ export {
|
|||
Heart_Light,
|
||||
Home,
|
||||
Home_Light,
|
||||
Image20,
|
||||
Image,
|
||||
Image_Light,
|
||||
Key20,
|
||||
Key,
|
||||
Key_Light,
|
||||
Keys,
|
||||
Keys_Light,
|
||||
Laptop,
|
||||
Laptop_Light,
|
||||
Link20,
|
||||
Link,
|
||||
Link_Light,
|
||||
Lock,
|
||||
Lock_Light,
|
||||
Mega,
|
||||
Mesh20,
|
||||
Mesh,
|
||||
Mesh_Light,
|
||||
Mobile,
|
||||
|
@ -253,12 +298,14 @@ export {
|
|||
OneDrive,
|
||||
OpenStack,
|
||||
PCloud,
|
||||
Package20,
|
||||
Package,
|
||||
Package_Light,
|
||||
SD,
|
||||
SD_Light,
|
||||
Scrapbook,
|
||||
Scrapbook_Light,
|
||||
Screenshot20,
|
||||
Screenshot,
|
||||
ScreenshotAlt,
|
||||
Screenshot_Light,
|
||||
|
@ -272,6 +319,7 @@ export {
|
|||
Tags_Light,
|
||||
Terminal,
|
||||
Terminal_Light,
|
||||
Text20,
|
||||
Text,
|
||||
TextAlt,
|
||||
TextAlt_Light,
|
||||
|
@ -283,8 +331,12 @@ export {
|
|||
Trash_Light,
|
||||
Undefined,
|
||||
Undefined_Light,
|
||||
Unknown20,
|
||||
Video20,
|
||||
Video,
|
||||
Video_Light,
|
||||
WebPageArchive20,
|
||||
Widget20,
|
||||
Widget,
|
||||
Widget_Light
|
||||
};
|
||||
|
|
|
@ -6,7 +6,6 @@ export type Procedures = {
|
|||
{ key: "auth.me", input: never, result: { id: string; email: string } } |
|
||||
{ key: "backups.getAll", input: never, result: GetAll } |
|
||||
{ key: "buildInfo", input: never, result: BuildInfo } |
|
||||
{ key: "categories.list", input: LibraryArgs<null>, result: { [key in Category]: number } } |
|
||||
{ key: "ephemeralFiles.getMediaData", input: string, result: MediaMetadata | null } |
|
||||
{ key: "files.get", input: LibraryArgs<GetArgs>, result: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: FilePath[] } | null } |
|
||||
{ key: "files.getConvertableImageExtensions", input: never, result: string[] } |
|
||||
|
@ -33,9 +32,9 @@ export type Procedures = {
|
|||
{ key: "preferences.get", input: LibraryArgs<null>, result: LibraryPreferences } |
|
||||
{ key: "search.ephemeralPaths", input: LibraryArgs<EphemeralPathSearchArgs>, result: NonIndexedFileSystemEntries } |
|
||||
{ key: "search.objects", input: LibraryArgs<ObjectSearchArgs>, result: SearchData<ExplorerItem> } |
|
||||
{ key: "search.objectsCount", input: LibraryArgs<{ filter?: ObjectFilterArgs }>, result: number } |
|
||||
{ key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
|
||||
{ key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
|
||||
{ key: "search.pathsCount", input: LibraryArgs<{ filter?: FilePathFilterArgs }>, result: number } |
|
||||
{ key: "search.pathsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
|
||||
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
|
||||
{ key: "tags.get", input: LibraryArgs<number>, result: Tag | null } |
|
||||
{ key: "tags.getForObject", input: LibraryArgs<number>, result: Tag[] } |
|
||||
|
@ -130,11 +129,6 @@ export type CRDTOperationType = SharedOperation | RelationOperation
|
|||
|
||||
export type CameraData = { device_make: string | null; device_model: string | null; color_space: string | null; color_profile: ColorProfile | null; focal_length: number | null; shutter_speed: number | null; flash: Flash | null; orientation: Orientation; lens_make: string | null; lens_model: string | null; bit_depth: number | null; red_eye: boolean | null; zoom: number | null; iso: number | null; software: string | null; serial_number: string | null; lens_serial_number: string | null; contrast: number | null; saturation: number | null; sharpness: number | null; composite: Composite | null }
|
||||
|
||||
/**
|
||||
* Meow
|
||||
*/
|
||||
export type Category = "Recents" | "Favorites" | "Albums" | "Photos" | "Videos" | "Movies" | "Music" | "Documents" | "Downloads" | "Encrypted" | "Projects" | "Applications" | "Archives" | "Databases" | "Games" | "Books" | "Contacts" | "Trash" | "Screenshots"
|
||||
|
||||
export type ChangeNodeNameArgs = { name: string | null; p2p_enabled: boolean | null; p2p_port: MaybeUndefined<number> }
|
||||
|
||||
export type ColorProfile = "Normal" | "Custom" | "HDRNoOriginal" | "HDRWithOriginal" | "OriginalForHDR" | "Panorama" | "PortraitHDR" | "Portrait"
|
||||
|
@ -204,13 +198,13 @@ export type FilePathCursor = { isDir: boolean; variant: FilePathCursorVariant }
|
|||
|
||||
export type FilePathCursorVariant = "none" | { name: CursorOrderItem<string> } | { sizeInBytes: SortOrder } | { dateCreated: CursorOrderItem<string> } | { dateModified: CursorOrderItem<string> } | { dateIndexed: CursorOrderItem<string> } | { object: FilePathObjectCursor }
|
||||
|
||||
export type FilePathFilterArgs = { locationId?: number | null; search?: string | null; extension?: string | null; createdAt?: OptionalRange<string>; path?: string | null; withDescendants?: boolean | null; object?: ObjectFilterArgs | null; hidden?: boolean | null }
|
||||
export type FilePathFilterArgs = { locations: InOrNotIn<number> } | { path: { location_id: number; path: string; include_descendants: boolean } } | { name: TextMatch } | { extension: InOrNotIn<string> } | { createdAt: Range<string> } | { modifiedAt: Range<string> } | { indexedAt: Range<string> } | { hidden: boolean }
|
||||
|
||||
export type FilePathObjectCursor = { dateAccessed: CursorOrderItem<string> } | { kind: CursorOrderItem<number> }
|
||||
|
||||
export type FilePathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } | { field: "dateIndexed"; value: SortOrder } | { field: "object"; value: ObjectOrder } | { field: "dateImageTaken"; value: ObjectOrder }
|
||||
export type FilePathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } | { field: "dateIndexed"; value: SortOrder } | { field: "object"; value: ObjectOrder }
|
||||
|
||||
export type FilePathSearchArgs = { take?: number | null; orderAndPagination?: OrderAndPagination<number, FilePathOrder, FilePathCursor> | null; filter?: FilePathFilterArgs; groupDirectories?: boolean }
|
||||
export type FilePathSearchArgs = { take?: number | null; orderAndPagination?: OrderAndPagination<number, FilePathOrder, FilePathCursor> | null; filters?: SearchFilterArgs[]; groupDirectories?: boolean }
|
||||
|
||||
export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: Object | null }
|
||||
|
||||
|
@ -234,6 +228,8 @@ export type IdentifyUniqueFilesArgs = { id: number; path: string }
|
|||
|
||||
export type ImageMetadata = { resolution: Resolution; date_taken: MediaDate | null; location: MediaLocation | null; camera_data: CameraData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null }
|
||||
|
||||
export type InOrNotIn<T> = { in: T[] } | { notIn: T[] }
|
||||
|
||||
export type IndexerRule = { id: number; pub_id: number[]; name: string | null; default: boolean | null; rules_per_kind: number[] | null; date_created: string | null; date_modified: string | null }
|
||||
|
||||
/**
|
||||
|
@ -308,10 +304,10 @@ export type LocationWithIndexerRules = { id: number; pub_id: number[]; name: str
|
|||
*/
|
||||
export type ManagerConfig = { enabled: boolean; port?: number | null }
|
||||
|
||||
export type MaybeNot<T> = T | { not: T }
|
||||
|
||||
export type MaybeUndefined<T> = null | null | T
|
||||
|
||||
export type MediaDataOrder = { field: "epochTime"; value: SortOrder }
|
||||
|
||||
/**
|
||||
* This can be either naive with no TZ (`YYYY-MM-DD HH-MM-SS`) or UTC (`YYYY-MM-DD HH-MM-SS ±HHMM`),
|
||||
* where `±HHMM` is the timezone data. It may be negative if West of the Prime Meridian, or positive if East.
|
||||
|
@ -345,13 +341,13 @@ export type Object = { id: number; pub_id: number[]; kind: number | null; key_id
|
|||
|
||||
export type ObjectCursor = "none" | { dateAccessed: CursorOrderItem<string> } | { kind: CursorOrderItem<number> }
|
||||
|
||||
export type ObjectFilterArgs = { favorite?: boolean | null; hidden?: ObjectHiddenFilter; dateAccessed?: MaybeNot<string | null> | null; kind?: number[]; tags?: number[]; category?: Category | null }
|
||||
export type ObjectFilterArgs = { favorite: boolean } | { hidden: ObjectHiddenFilter } | { kind: InOrNotIn<number> } | { tags: InOrNotIn<number> } | { dateAccessed: Range<string> }
|
||||
|
||||
export type ObjectHiddenFilter = "exclude" | "include"
|
||||
|
||||
export type ObjectOrder = { field: "dateAccessed"; value: SortOrder } | { field: "kind"; value: SortOrder } | { field: "dateImageTaken"; value: SortOrder }
|
||||
export type ObjectOrder = { field: "dateAccessed"; value: SortOrder } | { field: "kind"; value: SortOrder } | { field: "mediaData"; value: MediaDataOrder }
|
||||
|
||||
export type ObjectSearchArgs = { take: number; orderAndPagination?: OrderAndPagination<number, ObjectOrder, ObjectCursor> | null; filter?: ObjectFilterArgs }
|
||||
export type ObjectSearchArgs = { take: number; orderAndPagination?: OrderAndPagination<number, ObjectOrder, ObjectCursor> | null; filters?: SearchFilterArgs[] }
|
||||
|
||||
export type ObjectValidatorArgs = { id: number; path: string }
|
||||
|
||||
|
@ -363,8 +359,6 @@ export type ObjectWithFilePaths = { id: number; pub_id: number[]; kind: number |
|
|||
*/
|
||||
export type OperatingSystem = "Windows" | "Linux" | "MacOS" | "Ios" | "Android" | { Other: string }
|
||||
|
||||
export type OptionalRange<T> = { from: T | null; to: T | null }
|
||||
|
||||
export type OrderAndPagination<TId, TOrder, TCursor> = { orderOnly: TOrder } | { offset: { offset: number; order: TOrder | null } } | { cursor: { id: TId; cursor: TCursor } }
|
||||
|
||||
export type Orientation = "Normal" | "CW90" | "CW180" | "CW270" | "MirroredVertical" | "MirroredHorizontal" | "MirroredHorizontalAnd90CW" | "MirroredHorizontalAnd270CW"
|
||||
|
@ -390,6 +384,8 @@ export type PeerStatus = "Unavailable" | "Discovered" | "Connected"
|
|||
|
||||
export type PlusCode = string
|
||||
|
||||
export type Range<T> = { from: T } | { to: T }
|
||||
|
||||
export type RelationOperation = { relation_item: any; relation_group: any; relation: string; data: RelationOperationData }
|
||||
|
||||
export type RelationOperationData = "c" | { u: { field: string; value: any } } | "d"
|
||||
|
@ -414,6 +410,8 @@ export type SanitisedNodeConfig = { id: string; name: string; p2p_enabled: boole
|
|||
|
||||
export type SearchData<T> = { cursor: number[] | null; items: T[] }
|
||||
|
||||
export type SearchFilterArgs = { filePath: FilePathFilterArgs } | { object: ObjectFilterArgs }
|
||||
|
||||
export type SetFavoriteArgs = { id: number; favorite: boolean }
|
||||
|
||||
export type SetNoteArgs = { id: number; note: string | null }
|
||||
|
@ -440,6 +438,8 @@ export type TagUpdateArgs = { id: number; name: string | null; color: string | n
|
|||
|
||||
export type Target = { Object: number } | { FilePath: number }
|
||||
|
||||
export type TextMatch = { contains: string } | { startsWith: string } | { endsWith: string } | { equals: string }
|
||||
|
||||
export type VideoMetadata = { duration: number | null; video_codec: string | null; audio_codec: string | null }
|
||||
|
||||
export type Volume = { name: string; mount_points: string[]; total_capacity: string; available_capacity: string; disk_type: DiskType; file_system: string | null; is_root_filesystem: boolean }
|
||||
|
|
1
packages/test-files
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 146fbb543fc001bcd8fe5c0d4d59d2bc2948c5f8
|
|
@ -36,7 +36,8 @@ export const buttonStyles = cva(
|
|||
icon: '!p-1',
|
||||
lg: 'text-md px-3 py-1.5 font-medium',
|
||||
md: 'px-2.5 py-1.5 text-sm font-medium',
|
||||
sm: 'px-2 py-1 text-sm font-medium'
|
||||
sm: 'px-2 py-1 text-sm font-medium',
|
||||
xs: 'px-1.5 py-0.5 text-xs font-normal'
|
||||
},
|
||||
variant: {
|
||||
default: [
|
||||
|
@ -62,6 +63,12 @@ export const buttonStyles = cva(
|
|||
],
|
||||
colored: ['text-white shadow-sm hover:bg-opacity-90 active:bg-opacity-100'],
|
||||
bare: ''
|
||||
},
|
||||
rounding: {
|
||||
none: 'rounded-none',
|
||||
left: 'rounded-l-md rounded-r-none',
|
||||
right: 'rounded-l-none rounded-r-md',
|
||||
both: 'rounded-md'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
@ -98,6 +98,7 @@ const contextMenuItemStyles = cva(
|
|||
variants: {
|
||||
variant: {
|
||||
default: 'group-radix-highlighted:bg-accent',
|
||||
dull: 'group-radix-highlighted:bg-app-selected/50 group-radix-state-open:bg-app-selected/50',
|
||||
danger: [
|
||||
'text-red-600 dark:text-red-400',
|
||||
'group-radix-highlighted:text-white',
|
||||
|
|
|
@ -6,6 +6,7 @@ import React, {
|
|||
ContextType,
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useContext,
|
||||
|
@ -93,13 +94,21 @@ const Separator = (props: { className?: string }) => (
|
|||
const SubMenu = ({
|
||||
label,
|
||||
icon,
|
||||
iconProps,
|
||||
keybind,
|
||||
variant,
|
||||
className,
|
||||
...props
|
||||
}: RadixDM.MenuSubContentProps & ContextMenuItemProps) => {
|
||||
}: RadixDM.MenuSubContentProps & ContextMenuItemProps & { trigger?: ReactNode }) => {
|
||||
return (
|
||||
<RadixDM.Sub>
|
||||
<RadixDM.SubTrigger className={contextMenuItemClassNames}>
|
||||
<ContextMenuDivItem rightArrow {...{ label, icon }} />
|
||||
{props.trigger || (
|
||||
<ContextMenuDivItem
|
||||
rightArrow
|
||||
{...{ label, icon, iconProps, keybind, variant }}
|
||||
/>
|
||||
)}
|
||||
</RadixDM.SubTrigger>
|
||||
<RadixDM.Portal>
|
||||
<Suspense fallback={null}>
|
||||
|
@ -132,20 +141,22 @@ const Item = ({
|
|||
}: DropdownItemProps & RadixDM.MenuItemProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderInner = (
|
||||
// to style this, pass in variant
|
||||
<ContextMenuDivItem
|
||||
// className={clsx(selected && 'bg-accent text-white')}
|
||||
{...{ icon, iconProps, label, keybind, variant, children }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<RadixDM.Item ref={ref} className={clsx(contextMenuItemClassNames, className)} {...props}>
|
||||
{to ? (
|
||||
<Link to={to} onClick={() => ref.current?.click()}>
|
||||
<ContextMenuDivItem
|
||||
className={clsx(selected && 'bg-accent text-white')}
|
||||
{...{ icon, iconProps, label, keybind, variant, children }}
|
||||
/>
|
||||
{renderInner}
|
||||
</Link>
|
||||
) : (
|
||||
<ContextMenuDivItem
|
||||
className={clsx(selected && 'bg-accent text-white')}
|
||||
{...{ icon, iconProps, label, keybind, variant, children }}
|
||||
/>
|
||||
renderInner
|
||||
)}
|
||||
</RadixDM.Item>
|
||||
);
|
||||
|
|
0
packages/ui/src/Icon.tsx
Normal file
|
@ -10,6 +10,7 @@ import { Button } from './Button';
|
|||
export interface InputBaseProps extends VariantProps<typeof inputStyles> {
|
||||
icon?: Icon | React.ReactNode;
|
||||
iconPosition?: 'left' | 'right';
|
||||
inputElementClassName?: string;
|
||||
right?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
@ -18,6 +19,7 @@ export type InputProps = InputBaseProps & Omit<React.ComponentProps<'input'>, 's
|
|||
export type TextareaProps = InputBaseProps & React.ComponentProps<'textarea'>;
|
||||
|
||||
export const inputSizes = {
|
||||
xs: 'h-[25px]',
|
||||
sm: 'h-[30px]',
|
||||
md: 'h-[34px]',
|
||||
lg: 'h-[38px]'
|
||||
|
@ -35,6 +37,10 @@ export const inputStyles = cva(
|
|||
default: [
|
||||
'border-app-line bg-app-input placeholder-ink-faint focus-within:bg-app-focus',
|
||||
'focus-within:border-app-divider/80 focus-within:ring-app-selected/30'
|
||||
],
|
||||
transparent: [
|
||||
'border-transparent bg-transparent placeholder-ink-dull focus-within:bg-transparent',
|
||||
'focus-within:border-transparent focus-within:ring-transparent'
|
||||
]
|
||||
},
|
||||
error: {
|
||||
|
@ -83,7 +89,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||
className={clsx(
|
||||
'flex-1 truncate border-none bg-transparent px-3 text-sm outline-none placeholder:text-ink-faint',
|
||||
(right || (icon && iconPosition === 'right')) && 'pr-0',
|
||||
icon && iconPosition === 'left' && 'pl-0'
|
||||
icon && iconPosition === 'left' && 'pl-0',
|
||||
size === 'xs' && '!py-0',
|
||||
props.inputElementClassName
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
|
@ -19,7 +19,7 @@ export const selectStyles = cva(
|
|||
default: ['bg-app-input', 'border-app-line']
|
||||
},
|
||||
size: {
|
||||
sm: 'h-[30px]',
|
||||
sm: 'h-[25px] text-xs font-normal',
|
||||
md: 'h-[34px]',
|
||||
lg: 'h-[38px]'
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
export { cva, cx } from 'class-variance-authority';
|
||||
export * from './Button';
|
||||
export * from './CheckBox';
|
||||
export { ContextMenu, useContextMenuContext } from './ContextMenu';
|
||||
export { ContextMenu, useContextMenuContext, ContextMenuDivItem } from './ContextMenu';
|
||||
export { DropdownMenu, useDropdownMenuContext } from './DropdownMenu';
|
||||
export * from './Dialog';
|
||||
export * as Dropdown from './Dropdown';
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
--color-app: var(--dark-hue), 15%, 13%;
|
||||
--color-app-box: var(--dark-hue), 15%, 18%;
|
||||
--color-app-dark-box: var(--dark-hue), 15%, 15%;
|
||||
--color-app-darker-box: var(--dark-hue), 16%, 11%;
|
||||
--color-app-light-box: var(--dark-hue), 15%, 34%;
|
||||
--color-app-overlay: var(--dark-hue), 15%, 17%;
|
||||
--color-app-input: var(--dark-hue), 15%, 20%;
|
||||
|
@ -80,6 +81,7 @@
|
|||
--color-app: var(--light-hue), 5%, 100%;
|
||||
--color-app-box: var(--light-hue), 5%, 98%;
|
||||
--color-app-dark-box: var(--light-hue), 5%, 97%;
|
||||
--color-app-darker-box: var(--light-hue), 5%, 95%;
|
||||
--color-app-light-box: var(--light-hue), 5%, 100%;
|
||||
--color-app-overlay: var(--light-hue), 5%, 100%;
|
||||
--color-app-overlay-shade: var(--light-hue), 15%, 95%;
|
||||
|
|
|
@ -65,6 +65,7 @@ module.exports = function (app, options) {
|
|||
DEFAULT: alpha('--color-app'),
|
||||
box: alpha('--color-app-box'),
|
||||
darkBox: alpha('--color-app-dark-box'),
|
||||
darkerBox: alpha('--color-app-darker-box'),
|
||||
lightBox: alpha('--color-app-light-box'),
|
||||
overlay: alpha('--color-app-overlay'),
|
||||
input: alpha('--color-app-input'),
|
||||
|
|