[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>
This commit is contained in:
Jamie Pine 2023-11-16 22:58:44 -08:00 committed by GitHub
parent 0ef65fce2d
commit 27f077ea0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
98 changed files with 4123 additions and 4046 deletions

926
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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())

View file

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

View 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));
}
}
}
}

View 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
View 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())
}

View 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))
}
}
}
}

View 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(())
})
})
}

View 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)),
})
}
}

View file

@ -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::*;

View file

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

View file

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

View 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>
// );
// });
// }

View 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;
}

View 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'];

View 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'
// }))
// });
}
};
};

View 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>
);
})}
</>
);
});

View 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;

View 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;
};

View file

@ -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 && (

View file

@ -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;
}
}

View file

@ -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([

View file

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

View file

@ -95,7 +95,7 @@ export const EphemeralSection = () => {
return (
<SidebarLink
className="group relative w-full"
to={`network/34`}
to="./network"
key={index}
>
<SidebarIcon name="Globe" />

View file

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

View file

@ -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={

View file

@ -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 && (

View file

@ -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'
)}
/>

View file

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

View file

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

View file

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

View file

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

View file

@ -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={

View file

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

View file

@ -228,7 +228,6 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
</Tooltip>
}
right={<DefaultTopBarOptions />}
noSearch={true}
/>
<Explorer
emptyNotice={

View file

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

View file

@ -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(() => {

View file

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

View file

@ -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 };
};

View file

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

View file

@ -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 }}
/>
);
};

View file

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

View file

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

View file

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

View file

@ -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>
// );
// };

View file

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

View file

@ -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 = () => {

View file

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

View file

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

View 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>
)}
</>
);
};

View file

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

View 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;
// };

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,017 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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
};

View file

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

View file

@ -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: {

View file

@ -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',

View file

@ -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
View file

View 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();

View file

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

View file

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

View file

@ -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%;

View file

@ -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'),

File diff suppressed because it is too large Load diff