From d4190aab98a0d052a119aa709477eafd123adbfb Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 25 Nov 2023 11:16:26 +1100 Subject: [PATCH] [ENG-1435] Saved Searches (#1810) * saved search CRUD (not perfect) * saved search settings page * minor improvements * fix search filter text apply * serach in setting * reduce new tab flicker * fix tab delete index * temporarily remove hover effect from applied filters * fix types * fix progress * fix double-add for inOrNotIn * no more saved searches settings page * redirect on saved search delete * cleaner * fix filter checkbox double fire * types --------- Co-authored-by: Utku Bakir <74243531+utkubakir@users.noreply.github.com> --- apps/desktop/src/App.tsx | 90 +++-- apps/web/src/App.tsx | 82 ++-- .../migration.sql | 24 ++ core/prisma/schema.prisma | 8 +- core/src/api/search/file_path.rs | 2 +- core/src/api/search/mod.rs | 4 +- core/src/api/search/object.rs | 4 +- core/src/api/search/saved.rs | 203 ++++------ core/src/api/search/utils.rs | 6 +- interface/RoutingContext.tsx | 4 + .../Explorer/Search/AppliedFilters.tsx | 211 ---------- .../$libraryId/Explorer/Search/Context.tsx | 157 -------- .../Explorer/Search/SavedSearches.tsx | 50 --- .../app/$libraryId/Explorer/Search/index.tsx | 242 ------------ interface/app/$libraryId/Explorer/index.tsx | 2 - .../app/$libraryId/Explorer/queries/index.ts | 1 + .../queries/useExplorerInfiniteQuery.ts | 7 +- .../Explorer/queries/useExplorerQuery.ts | 20 + .../queries/useObjectsExplorerQuery.ts | 18 + .../queries/useObjectsInfiniteQuery.ts | 13 +- .../Explorer/queries/usePathsExplorerQuery.ts | 18 + .../Explorer/queries/usePathsInfiniteQuery.ts | 13 +- .../app/$libraryId/Explorer/useExplorer.ts | 1 + .../Layout/Sidebar/LibrarySection.tsx | 369 +++++++++--------- .../app/$libraryId/Search/AppliedFilters.tsx | 134 +++++++ .../{Explorer => }/Search/Filters.tsx | 148 +++---- .../{TopBar => Search}/SearchBar.tsx | 50 +-- interface/app/$libraryId/Search/context.tsx | 33 ++ interface/app/$libraryId/Search/index.tsx | 303 ++++++++++++++ .../{Explorer => }/Search/store.tsx | 56 +-- interface/app/$libraryId/Search/useSearch.ts | 187 +++++++++ .../$libraryId/{Explorer => }/Search/util.tsx | 1 - interface/app/$libraryId/TopBar/Layout.tsx | 13 +- interface/app/$libraryId/TopBar/Portal.tsx | 9 +- interface/app/$libraryId/TopBar/index.tsx | 23 +- interface/app/$libraryId/ephemeral.tsx | 7 +- interface/app/$libraryId/index.tsx | 60 +-- interface/app/$libraryId/location/$id.tsx | 201 +++++----- interface/app/$libraryId/saved-search/$id.tsx | 125 ++++++ interface/app/$libraryId/search.tsx | 114 ------ interface/app/$libraryId/settings/Sidebar.tsx | 5 + .../app/$libraryId/settings/library/index.tsx | 1 + .../settings/library/saved-searches/index.tsx | 121 ++++++ interface/app/$libraryId/tag/$id.tsx | 126 +++--- interface/app/$libraryId/tag/Component.1.tsx | 0 interface/app/$libraryId/tag/Component.tsx | 0 interface/app/index.tsx | 109 ++++-- interface/app/onboarding/Progress.tsx | 15 +- interface/app/onboarding/index.tsx | 36 +- interface/hooks/useSearchFilter.tsx | 88 ----- interface/hooks/useSearchStore.tsx | 151 ------- interface/hooks/useShortcut.ts | 153 ++++---- interface/index.tsx | 64 +-- interface/package.json | 1 + packages/client/src/core.ts | 9 + .../client/src/hooks/useClientContext.tsx | 21 +- pnpm-lock.yaml | Bin 806975 -> 807238 bytes 57 files changed, 1943 insertions(+), 1970 deletions(-) create mode 100644 core/prisma/migrations/20231121173834_filters_string/migration.sql delete mode 100644 interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx delete mode 100644 interface/app/$libraryId/Explorer/Search/Context.tsx delete mode 100644 interface/app/$libraryId/Explorer/Search/SavedSearches.tsx delete mode 100644 interface/app/$libraryId/Explorer/Search/index.tsx create mode 100644 interface/app/$libraryId/Explorer/queries/useExplorerQuery.ts create mode 100644 interface/app/$libraryId/Explorer/queries/useObjectsExplorerQuery.ts create mode 100644 interface/app/$libraryId/Explorer/queries/usePathsExplorerQuery.ts create mode 100644 interface/app/$libraryId/Search/AppliedFilters.tsx rename interface/app/$libraryId/{Explorer => }/Search/Filters.tsx (80%) rename interface/app/$libraryId/{TopBar => Search}/SearchBar.tsx (58%) create mode 100644 interface/app/$libraryId/Search/context.tsx create mode 100644 interface/app/$libraryId/Search/index.tsx rename interface/app/$libraryId/{Explorer => }/Search/store.tsx (64%) create mode 100644 interface/app/$libraryId/Search/useSearch.ts rename interface/app/$libraryId/{Explorer => }/Search/util.tsx (96%) create mode 100644 interface/app/$libraryId/saved-search/$id.tsx delete mode 100644 interface/app/$libraryId/search.tsx create mode 100644 interface/app/$libraryId/settings/library/saved-searches/index.tsx create mode 100644 interface/app/$libraryId/tag/Component.1.tsx create mode 100644 interface/app/$libraryId/tag/Component.tsx delete mode 100644 interface/hooks/useSearchFilter.tsx delete mode 100644 interface/hooks/useSearchStore.tsx diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index add9d7116..cc680e652 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -2,14 +2,16 @@ import { createMemoryHistory } from '@remix-run/router'; import { QueryClientProvider } from '@tanstack/react-query'; import { listen } from '@tauri-apps/api/event'; import { appWindow } from '@tauri-apps/api/window'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { startTransition, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { RspcProvider } from '@sd/client'; import { + createRoutes, ErrorPage, KeybindEvent, PlatformProvider, - routes, - SpacedriveInterface, + SpacedriveInterfaceRoot, + SpacedriveRouterProvider, TabsContext } from '@sd/interface'; import { RouteTitleContext } from '@sd/interface/hooks/useRouteTitle'; @@ -17,8 +19,6 @@ import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState'; import '@sd/ui/style/style.scss'; -import { useOperatingSystem } from '@sd/interface/hooks'; - import * as commands from './commands'; import { platform } from './platform'; import { queryClient } from './query'; @@ -80,11 +80,15 @@ export default function App() { // we have a minimum delay between creating new tabs as react router can't handle creating tabs super fast const TAB_CREATE_DELAY = 150; +const routes = createRoutes(platform); + function AppInner() { - const os = useOperatingSystem(); + const [tabs, setTabs] = useState(() => [createTab()]); + const [tabIndex, setTabIndex] = useState(0); + function createTab() { const history = createMemoryHistory(); - const router = createMemoryRouterWithHistory({ routes: routes(os), history }); + const router = createMemoryRouterWithHistory({ routes, history }); const dispose = router.subscribe((event) => { setTabs((routers) => { @@ -107,22 +111,36 @@ function AppInner() { }); return { + id: Math.random().toString(), router, history, dispose, + element: document.createElement('div'), currentIndex: 0, maxIndex: 0, title: 'New Tab' }; } - const [tabs, setTabs] = useState(() => [createTab()]); - const [tabIndex, setTabIndex] = useState(0); - const tab = tabs[tabIndex]!; const createTabPromise = useRef(Promise.resolve()); + const ref = useRef(null); + + useEffect(() => { + const div = ref.current; + if (!div) return; + + div.appendChild(tab.element); + + return () => { + while (div.firstChild) { + div.removeChild(div.firstChild); + } + }; + }, [tab.element]); + return ( new Promise((res) => { - setTabs((tabs) => { - const newTabs = [...tabs, createTab()]; + startTransition(() => { + setTabs((tabs) => { + const newTabs = [...tabs, createTab()]; - setTabIndex(newTabs.length - 1); + setTabIndex(newTabs.length - 1); - return newTabs; + return newTabs; + }); }); setTimeout(res, TAB_CREATE_DELAY); @@ -164,29 +184,41 @@ function AppInner() { ); }, removeTab(index: number) { - setTabs((tabs) => { - const tab = tabs[index]; - if (!tab) return tabs; + startTransition(() => { + setTabs((tabs) => { + const tab = tabs[index]; + if (!tab) return tabs; - tab.dispose(); + tab.dispose(); - tabs.splice(index, 1); + tabs.splice(index, 1); - setTabIndex(tabs.length - 1); + setTabIndex(Math.min(tabIndex, tabs.length - 1)); - return [...tabs]; + return [...tabs]; + }); }); } }} > - + + {tabs.map((tab) => + createPortal( + , + tab.element + ) + )} +
+ ); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index de10f027d..d5013d45b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,8 +2,8 @@ import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query import { useEffect, useRef, useState } from 'react'; import { createBrowserRouter } from 'react-router-dom'; import { RspcProvider } from '@sd/client'; -import { Platform, PlatformProvider, routes, SpacedriveInterface } from '@sd/interface'; -import { useOperatingSystem, useShowControls } from '@sd/interface/hooks'; +import { createRoutes, Platform, PlatformProvider, SpacedriveRouterProvider } from '@sd/interface'; +import { useShowControls } from '@sd/interface/hooks'; import demoData from './demoData.json'; import ScreenshotWrapper from './ScreenshotWrapper'; @@ -75,10 +75,50 @@ const queryClient = new QueryClient({ } }); +const routes = createRoutes(platform); + function App() { - const os = useOperatingSystem(); + const router = useRouter(); + + const domEl = useRef(null); + const { isEnabled: showControls } = useShowControls(); + + useEffect(() => window.parent.postMessage('spacedrive-hello', '*'), []); + + if ( + import.meta.env.VITE_SD_DEMO_MODE === 'true' && + // quick and dirty check for if we've already rendered lol + domEl === null + ) { + hydrate(queryClient, demoData); + } + + return ( + +
+ + + + + + + +
+
+ ); +} + +export default App; + +function useRouter() { const [router, setRouter] = useState(() => { - const router = createBrowserRouter(routes(os)); + const router = createBrowserRouter(createRoutes(platform)); router.subscribe((event) => { setRouter((router) => { @@ -104,37 +144,5 @@ function App() { }; }); - const domEl = useRef(null); - const { isEnabled: showControls } = useShowControls(); - - useEffect(() => window.parent.postMessage('spacedrive-hello', '*'), []); - - if ( - import.meta.env.VITE_SD_DEMO_MODE === 'true' && - // quick and dirty check for if we've already rendered lol - domEl === null - ) { - hydrate(queryClient, demoData); - } - - return ( - -
- - - - - - - -
-
- ); + return router; } - -export default App; diff --git a/core/prisma/migrations/20231121173834_filters_string/migration.sql b/core/prisma/migrations/20231121173834_filters_string/migration.sql new file mode 100644 index 000000000..65e52bafa --- /dev/null +++ b/core/prisma/migrations/20231121173834_filters_string/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `order` on the `saved_search` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_saved_search" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "pub_id" BLOB NOT NULL, + "filters" TEXT, + "name" TEXT, + "icon" TEXT, + "description" TEXT, + "date_created" DATETIME, + "date_modified" DATETIME +); +INSERT INTO "new_saved_search" ("date_created", "date_modified", "description", "filters", "icon", "id", "name", "pub_id") SELECT "date_created", "date_modified", "description", "filters", "icon", "id", "name", "pub_id" FROM "saved_search"; +DROP TABLE "saved_search"; +ALTER TABLE "new_saved_search" RENAME TO "saved_search"; +CREATE UNIQUE INDEX "saved_search_pub_id_key" ON "saved_search"("pub_id"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index c24e62ff7..3cdb34228 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -536,11 +536,15 @@ model Notification { model SavedSearch { id Int @id @default(autoincrement()) pub_id Bytes @unique - filters Bytes? + + search String? + filters String? + name String? icon String? description String? - order Int? // Add this line to include ordering + // order Int? // Add this line to include ordering + date_created DateTime? date_modified DateTime? diff --git a/core/src/api/search/file_path.rs b/core/src/api/search/file_path.rs index 427032a35..7172b751a 100644 --- a/core/src/api/search/file_path.rs +++ b/core/src/api/search/file_path.rs @@ -51,7 +51,7 @@ impl FilePathOrder { } } -#[derive(Deserialize, Type, Debug, Clone)] +#[derive(Serialize, Deserialize, Type, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum FilePathFilterArgs { Locations(InOrNotIn), diff --git a/core/src/api/search/mod.rs b/core/src/api/search/mod.rs index c5c1feed2..16556abfe 100644 --- a/core/src/api/search/mod.rs +++ b/core/src/api/search/mod.rs @@ -33,7 +33,7 @@ struct SearchData { items: Vec, } -#[derive(Deserialize, Type, Debug, Clone)] +#[derive(Serialize, Deserialize, Type, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum SearchFilterArgs { FilePath(FilePathFilterArgs), @@ -365,5 +365,5 @@ pub fn mount() -> AlphaRouter { .await? as u32) }) }) - // .merge("saved.", saved::mount()) + .merge("saved.", saved::mount()) } diff --git a/core/src/api/search/object.rs b/core/src/api/search/object.rs index 23ede56e0..943f3b60c 100644 --- a/core/src/api/search/object.rs +++ b/core/src/api/search/object.rs @@ -84,7 +84,7 @@ impl ObjectOrder { } } -#[derive(Deserialize, Type, Debug, Default, Clone, Copy)] +#[derive(Serialize, Deserialize, Type, Debug, Default, Clone, Copy)] #[serde(rename_all = "camelCase")] pub enum ObjectHiddenFilter { #[default] @@ -104,7 +104,7 @@ impl ObjectHiddenFilter { } } -#[derive(Deserialize, Type, Debug, Clone)] +#[derive(Serialize, Deserialize, Type, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum ObjectFilterArgs { Favorite(bool), diff --git a/core/src/api/search/saved.rs b/core/src/api/search/saved.rs index cafa55db2..a600b918a 100644 --- a/core/src/api/search/saved.rs +++ b/core/src/api/search/saved.rs @@ -5,76 +5,69 @@ use serde::{Deserialize, Serialize}; use specta::Type; use uuid::Uuid; -use crate::{api::utils::library, library::Library, prisma::saved_search}; +use crate::{api::utils::library, invalidate_query, 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, - pub filter_type: i32, -} - -#[derive(Serialize, Type, Deserialize, Clone, Debug)] -pub struct SavedSearchCreateArgs { - pub name: Option, - pub filters: Option>, - pub description: Option, - pub icon: Option, -} - -#[derive(Serialize, Type, Deserialize, Clone, Debug)] -pub struct SavedSearchUpdateArgs { - pub id: i32, - pub name: Option, - pub filters: Option>, - pub description: Option, - pub icon: Option, -} - -impl SavedSearchCreateArgs { - pub async fn exec( - self, - Library { db, .. }: &Library, - ) -> prisma_client_rust::Result { - print!("SavedSearchCreateArgs {:?}", self); - let pub_id = Uuid::new_v4().as_bytes().to_vec(); - let date_created: DateTime = 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 { R.router() .procedure("create", { - R.with2(library()) - .mutation(|(_, library), args: SavedSearchCreateArgs| async move { - args.exec(&library).await?; - // invalidate_query!(library, "search.saved.list"); + R.with2(library()).mutation({ + #[derive(Serialize, Type, Deserialize, Clone, Debug)] + #[specta(inline)] + pub struct Args { + pub name: String, + #[specta(optional)] + pub search: Option, + #[specta(optional)] + pub filters: Option, + #[specta(optional)] + pub description: Option, + #[specta(optional)] + pub icon: Option, + } + + |(_, library), args: Args| async move { + let pub_id = Uuid::new_v4().as_bytes().to_vec(); + let date_created: DateTime = Utc::now().into(); + + library + .db + .saved_search() + .create( + pub_id, + chain_optional_iter( + [ + saved_search::date_created::set(Some(date_created)), + saved_search::name::set(Some(args.name)), + ], + [ + args.filters + .map(|s| { + serde_json::to_string( + &serde_json::from_str::(&s) + .unwrap(), + ) + .unwrap() + }) + .map(Some) + .map(saved_search::filters::set), + args.search.map(Some).map(saved_search::search::set), + args.description + .map(Some) + .map(saved_search::description::set), + args.icon.map(Some).map(saved_search::icon::set), + ], + ), + ) + .exec() + .await?; + + invalidate_query!(library, "search.saved.list"); + Ok(()) - }) + } + }) }) .procedure("get", { R.with2(library()) @@ -88,87 +81,43 @@ pub(crate) fn mount() -> AlphaRouter { }) }) .procedure("list", { - #[derive(Serialize, Type, Deserialize, Clone)] - pub struct SavedSearchResponse { - pub id: i32, - pub pub_id: Vec, - pub name: Option, - pub icon: Option, - pub description: Option, - pub order: Option, - pub date_created: Option>, - pub date_modified: Option>, - pub filters: Option>, - } R.with2(library()).query(|(_, library), _: ()| async move { - let searches: Vec = library + Ok(library .db .saved_search() .find_many(vec![]) // .order_by(saved_search::order::order(prisma::SortOrder::Desc)) .exec() - .await?; - let result: Result, _> = searches - .into_iter() - .map(|search| { - let filters_bytes = search.filters.unwrap_or_default(); - - let filters_string = String::from_utf8(filters_bytes).unwrap(); - let filters: Vec = 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 + .await?) }) }) .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))); - } + R.with2(library()).mutation({ + saved_search::partial_unchecked!(Args { + name + description + icon + search + filters + }); + |(_, library), (id, args): (saved_search::id::Type, Args)| async move { + let mut params = args.to_params(); params.push(saved_search::date_modified::set(Some(Utc::now().into()))); library .db .saved_search() - .update(saved_search::id::equals(args.id), params) + .update_unchecked(saved_search::id::equals(id), params) .exec() .await?; - // invalidate_query!(library, "search.saved.list"); + invalidate_query!(library, "search.saved.list"); + invalidate_query!(library, "search.saved.get"); Ok(()) - }) + } + }) }) .procedure("delete", { R.with2(library()) @@ -179,7 +128,11 @@ pub(crate) fn mount() -> AlphaRouter { .delete(saved_search::id::equals(search_id)) .exec() .await?; - // invalidate_query!(library, "search.saved.list"); + + invalidate_query!(library, "search.saved.list"); + // disabled as it's messing with pre-delete navigation + // invalidate_query!(library, "search.saved.get"); + Ok(()) }) }) diff --git a/core/src/api/search/utils.rs b/core/src/api/search/utils.rs index 4d7f81e52..ba68c29f8 100644 --- a/core/src/api/search/utils.rs +++ b/core/src/api/search/utils.rs @@ -2,7 +2,7 @@ use sd_prisma::prisma; use serde::{Deserialize, Serialize}; use specta::Type; -#[derive(Deserialize, Type, Debug, Clone)] +#[derive(Serialize, Deserialize, Type, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum Range { From(T), @@ -56,7 +56,7 @@ pub enum OrderAndPagination { Cursor { id: TId, cursor: TCursor }, } -#[derive(Deserialize, Type, Debug, Clone)] +#[derive(Serialize, Deserialize, Type, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum InOrNotIn { In(Vec), @@ -85,7 +85,7 @@ impl InOrNotIn { } } -#[derive(Deserialize, Type, Debug, Clone)] +#[derive(Serialize, Deserialize, Type, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum TextMatch { Contains(String), diff --git a/interface/RoutingContext.tsx b/interface/RoutingContext.tsx index 78fbf7ad6..ae182590a 100644 --- a/interface/RoutingContext.tsx +++ b/interface/RoutingContext.tsx @@ -1,8 +1,12 @@ import { createContext, useContext } from 'react'; +import { createRoutes } from './app'; + export const RoutingContext = createContext<{ + visible: boolean; currentIndex: number; maxIndex: number; + routes: ReturnType; } | null>(null); export function useRoutingContext() { diff --git a/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx b/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx deleted file mode 100644 index 4b1495f1f..000000000 --- a/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { MagnifyingGlass, X } from '@phosphor-icons/react'; -import { forwardRef, useState } from 'react'; -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 shrink-0 h-6`; - -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 void }>(({ onClick }, ref) => { - return ( -
- -
- ); -}); - -const FiltersOverflowShade = tw.div`from-app-darkerBox/80 absolute w-10 bg-gradient-to-l to-transparent h-6`; - -export const AppliedOptions = () => { - const searchState = useSearchStore(); - const searchCtx = useSearchContext(); - - const [scroll, setScroll] = useState(0); - - const handleScroll = (e: React.UIEvent) => { - const element = e.currentTarget; - const scroll = element.scrollLeft / (element.scrollWidth - element.clientWidth); - setScroll(Math.round(scroll * 100) / 100); - }; - - return ( -
-
- {searchCtx.searchQuery && searchCtx.searchQuery !== '' && ( - - - - {searchCtx.searchQuery} - - searchCtx.setSearchQuery('')} /> - - )} - {searchCtx.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 ( - - - - {filter.name} - - - {/* {Object.entries(filter.conditions).map(([value, displayName]) => ( -
{displayName}
- ))} */} - { - (filter.conditions as any)[ - filter.getCondition(filter.extract(arg) as any) as any - ] - } -
- - - {activeOptions && ( - <> - {activeOptions.length === 1 ? ( - - ) : ( -
- {activeOptions.map((option, index) => ( -
- -
- ))} -
- )} - {activeOptions.length > 1 - ? `${activeOptions.length} ${pluralize(filter.name)}` - : activeOptions[0]?.name} - - )} -
- - {removalIndex !== null && ( - { - updateFilterArgs((args) => { - args.splice(removalIndex, 1); - - return args; - }); - }} - /> - )} -
- ); - })} -
- - {scroll > 0.1 && } - {scroll < 0.9 && } -
- ); -}; - -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 ( -// -// -// -// {meta?.name} -// -// {meta?.conditions && ( -// -// {/* {Object.values(meta.conditions).map((condition) => ( -//
{condition}
-// ))} */} -// is -//
-// )} - -// -// {group.filters.length > 1 && ( -//
-// {group.filters.map((filter, index) => ( -//
-// -//
-// ))} -//
-// )} -// {group.filters.length === 1 && ( -// -// )} -// {group.filters.length > 1 -// ? `${group.filters.length} ${pluralize(meta?.name)}` -// : group.filters[0]?.name} -//
- -// {showRemoveButton && ( -// -// group.filters.forEach((filter) => { -// if (filter.canBeRemoved) { -// deselectFilterOption(filter); -// } -// }) -// } -// /> -// )} -//
-// ); -// }); -// } diff --git a/interface/app/$libraryId/Explorer/Search/Context.tsx b/interface/app/$libraryId/Explorer/Search/Context.tsx deleted file mode 100644 index 3690c9210..000000000 --- a/interface/app/$libraryId/Explorer/Search/Context.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { - createContext, - PropsWithChildren, - useContext, - useEffect, - useLayoutEffect, - useMemo -} from 'react'; -import { useSearchParams as useRawSearchParams } from 'react-router-dom'; -import { z } from 'zod'; -import { SearchFilterArgs } from '@sd/client'; -import { useZodSearchParams } from '~/hooks'; - -import { useTopBarContext } from '../../TopBar/Layout'; -import { filterRegistry } from './Filters'; -import { argsToOptions, getKey, getSearchStore, updateFilterArgs, useSearchStore } from './store'; - -const Context = createContext | null>(null); - -const SEARCH_PARAMS = z.object({ - search: z.string().optional(), - filters: z.string().optional() -}); - -function useContextValue() { - const [searchParams] = useZodSearchParams(SEARCH_PARAMS); - 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 }) => - 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 - }) - ); - - if (searchParams.filters) { - const args: SearchFilterArgs[] = JSON.parse(searchParams.filters); - - for (const [index, arg] of args.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, searchParams.filters]); - - useLayoutEffect(() => { - const filters = searchParams.filters; - if (!filters) return; - - updateFilterArgs(() => JSON.parse(filters)); - }, [searchParams.filters]); - - const [_, setRawSearchParams] = useRawSearchParams(); - - useEffect(() => { - if (!searchState.filterArgs) return; - - if (searchState.filterArgs.length < 1) { - setRawSearchParams( - (p) => { - p.delete('filters'); - return p; - }, - - { replace: true } - ); - } else { - setRawSearchParams( - (p) => { - p.set('filters', JSON.stringify(searchState.filterArgs)); - return p; - }, - - { replace: true } - ); - } - }, [searchState.filterArgs, setRawSearchParams]); - - return { - setFixedArgs, - fixedArgs, - fixedArgsKeys, - allFilterArgs, - searchQuery: searchParams.search, - setSearchQuery(value: string) { - setRawSearchParams((p) => { - p.set('search', value); - return p; - }); - }, - clearSearchQuery() { - setRawSearchParams((p) => { - p.delete('search'); - return p; - }); - }, - isSearching: searchParams.search !== undefined - }; -} - -export const SearchContextProvider = ({ children }: PropsWithChildren) => { - return {children}; -}; - -export function useSearchContext() { - const ctx = useContext(Context); - - if (!ctx) throw new Error('SearchContextProvider not found!'); - - return ctx; -} diff --git a/interface/app/$libraryId/Explorer/Search/SavedSearches.tsx b/interface/app/$libraryId/Explorer/Search/SavedSearches.tsx deleted file mode 100644 index bd1e38411..000000000 --- a/interface/app/$libraryId/Explorer/Search/SavedSearches.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// 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(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' - // })) - // }); - } - }; -}; diff --git a/interface/app/$libraryId/Explorer/Search/index.tsx b/interface/app/$libraryId/Explorer/Search/index.tsx deleted file mode 100644 index 34c275766..000000000 --- a/interface/app/$libraryId/Explorer/Search/index.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { CaretRight, FunnelSimple, Icon } from '@phosphor-icons/react'; -import { IconTypes } from '@sd/assets/util'; -import clsx from 'clsx'; -import { memo, PropsWithChildren, useDeferredValue, useEffect, 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, - resetSearchStore, - 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 ( -
- {props.selected !== undefined && ( - - )} - - {props.children} -
- ); -}; - -// for individual items in a submenu, defined in Options -export const SearchOptionItem = (props: SearchOptionItemProps) => { - return ( - { - event.preventDefault(); - props.setSelected?.(!props.selected); - }} - variant="dull" - > - - - ); -}; - -export const SearchOptionSubMenu = (props: SearchOptionItemProps & { name?: string }) => { - return ( - - {props.name} - - } - className={clsx(MENU_STYLES, '-mt-1.5')} - > - {props.children} - - ); -}; - -export const Separator = () => ; - -const SearchOptions = () => { - const searchState = useSearchStore(); - const searchCtx = useSearchContext(); - - 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); - } - - useEffect(() => { - return () => resetSearchStore(); - }, []); - - return ( -
{ - getSearchStore().interactingWithSearchOptions = true; - }} - onMouseLeave={() => { - getSearchStore().interactingWithSearchOptions = false; - }} - className="flex h-[45px] w-full flex-row items-center gap-4 bg-black/10 px-4" - > - {/* - - Paths - - */} - - - e.stopPropagation()} - className={MENU_STYLES} - trigger={ - - } - > - setSearch(e.target.value)} - autoFocus - autoComplete="off" - autoCorrect="off" - variant="transparent" - placeholder="Filter..." - /> - - {_search === '' ? ( - filterRegistry.map((filter) => ( - - )) - ) : ( - - )} - - - - {/* 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. */} - - - { - // searchState.filterArgs.length > 0 && ( - // - // - // Save Search - // - // } - // > - //
- // setNewFilterName(e.target.value)} - // autoFocus - // variant="default" - // placeholder="Name" - // className="w-[130px]" - // /> - // {/* */} - //
- //
) - } - - searchCtx.clearSearchQuery()} - className="ml-2 rounded-lg border border-app-line bg-app-box px-2 py-1 text-[10.5px] tracking-widest shadow" - > - ESC - -
- ); -}; - -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 ( - - toggleOptionSelected({ - filter: filter as SearchFilterCRUD, - option, - select - }) - } - key={option.key} - > -
- - {filter.name} - - - {option.name} -
-
- ); - })} - - ); -}); diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index fabbaf389..93c692649 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -10,7 +10,6 @@ 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'; @@ -21,7 +20,6 @@ import 'react-slidedown/lib/slidedown.css'; interface Props { emptyNotice?: ExplorerViewProps['emptyNotice']; contextMenu?: () => ReactNode; - showFilterBar?: boolean; } /** diff --git a/interface/app/$libraryId/Explorer/queries/index.ts b/interface/app/$libraryId/Explorer/queries/index.ts index 254abf092..b3681d78c 100644 --- a/interface/app/$libraryId/Explorer/queries/index.ts +++ b/interface/app/$libraryId/Explorer/queries/index.ts @@ -1,3 +1,4 @@ export * from './useExplorerInfiniteQuery'; export * from './usePathsInfiniteQuery'; export * from './useObjectsInfiniteQuery'; +export * from './usePathsExplorerQuery'; diff --git a/interface/app/$libraryId/Explorer/queries/useExplorerInfiniteQuery.ts b/interface/app/$libraryId/Explorer/queries/useExplorerInfiniteQuery.ts index 781f94318..8aa2454ec 100644 --- a/interface/app/$libraryId/Explorer/queries/useExplorerInfiniteQuery.ts +++ b/interface/app/$libraryId/Explorer/queries/useExplorerInfiniteQuery.ts @@ -1,11 +1,10 @@ import { UseInfiniteQueryOptions } from '@tanstack/react-query'; -import { ExplorerItem, LibraryConfigWrapped, SearchData } from '@sd/client'; +import { ExplorerItem, SearchData } from '@sd/client'; import { Ordering } from '../store'; import { UseExplorerSettings } from '../useExplorer'; export type UseExplorerInfiniteQueryArgs = { - library: LibraryConfigWrapped; arg: TArg; - settings: UseExplorerSettings; -} & Pick>, 'enabled'>; + explorerSettings: UseExplorerSettings; +} & Pick>, 'enabled' | 'suspense'>; diff --git a/interface/app/$libraryId/Explorer/queries/useExplorerQuery.ts b/interface/app/$libraryId/Explorer/queries/useExplorerQuery.ts new file mode 100644 index 000000000..cef450742 --- /dev/null +++ b/interface/app/$libraryId/Explorer/queries/useExplorerQuery.ts @@ -0,0 +1,20 @@ +import { UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import { SearchData } from '@sd/client'; + +export function useExplorerQuery( + query: UseInfiniteQueryResult>, + count: UseQueryResult +) { + const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) ?? null, [query.data]); + + const loadMore = useCallback(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage.call(undefined); + } + }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); + + return { query, items, loadMore, count: count.data }; +} + +export type UseExplorerQuery = ReturnType>; diff --git a/interface/app/$libraryId/Explorer/queries/useObjectsExplorerQuery.ts b/interface/app/$libraryId/Explorer/queries/useObjectsExplorerQuery.ts new file mode 100644 index 000000000..852ecfbae --- /dev/null +++ b/interface/app/$libraryId/Explorer/queries/useObjectsExplorerQuery.ts @@ -0,0 +1,18 @@ +import { ObjectOrder, ObjectSearchArgs, useLibraryQuery } from '@sd/client'; + +import { UseExplorerSettings } from '../useExplorer'; +import { useExplorerQuery } from './useExplorerQuery'; +import { useObjectsInfiniteQuery } from './useObjectsInfiniteQuery'; + +export function useObjectsExplorerQuery(props: { + arg: ObjectSearchArgs; + explorerSettings: UseExplorerSettings; +}) { + const query = useObjectsInfiniteQuery(props); + + const count = useLibraryQuery(['search.objectsCount', { filters: props.arg.filters }], { + enabled: query.isSuccess + }); + + return useExplorerQuery(query, count); +} diff --git a/interface/app/$libraryId/Explorer/queries/useObjectsInfiniteQuery.ts b/interface/app/$libraryId/Explorer/queries/useObjectsInfiniteQuery.ts index effe43009..4da209df8 100644 --- a/interface/app/$libraryId/Explorer/queries/useObjectsInfiniteQuery.ts +++ b/interface/app/$libraryId/Explorer/queries/useObjectsInfiniteQuery.ts @@ -4,29 +4,30 @@ import { ObjectCursor, ObjectOrder, ObjectSearchArgs, + useLibraryContext, useRspcLibraryContext } from '@sd/client'; import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery'; export function useObjectsInfiniteQuery({ - library, arg, - settings, + explorerSettings, ...args }: UseExplorerInfiniteQueryArgs) { + const { library } = useLibraryContext(); const ctx = useRspcLibraryContext(); - const explorerSettings = settings.useSettingsSnapshot(); + const settings = explorerSettings.useSettingsSnapshot(); - if (explorerSettings.order) { - arg.orderAndPagination = { orderOnly: explorerSettings.order }; + if (settings.order) { + arg.orderAndPagination = { orderOnly: settings.order }; } return useInfiniteQuery({ queryKey: ['search.objects', { library_id: library.uuid, arg }] as const, queryFn: ({ pageParam, queryKey: [_, { arg }] }) => { const cItem: Extract = pageParam; - const { order } = explorerSettings; + const { order } = settings; let orderAndPagination: (typeof arg)['orderAndPagination']; diff --git a/interface/app/$libraryId/Explorer/queries/usePathsExplorerQuery.ts b/interface/app/$libraryId/Explorer/queries/usePathsExplorerQuery.ts new file mode 100644 index 000000000..8be798050 --- /dev/null +++ b/interface/app/$libraryId/Explorer/queries/usePathsExplorerQuery.ts @@ -0,0 +1,18 @@ +import { FilePathOrder, FilePathSearchArgs, useLibraryQuery } from '@sd/client'; + +import { UseExplorerSettings } from '../useExplorer'; +import { useExplorerQuery } from './useExplorerQuery'; +import { usePathsInfiniteQuery } from './usePathsInfiniteQuery'; + +export function usePathsExplorerQuery(props: { + arg: FilePathSearchArgs; + explorerSettings: UseExplorerSettings; +}) { + const query = usePathsInfiniteQuery(props); + + const count = useLibraryQuery(['search.pathsCount', { filters: props.arg.filters }], { + enabled: query.isSuccess + }); + + return useExplorerQuery(query, count); +} diff --git a/interface/app/$libraryId/Explorer/queries/usePathsInfiniteQuery.ts b/interface/app/$libraryId/Explorer/queries/usePathsInfiniteQuery.ts index f3b16fa76..3e722a7ca 100644 --- a/interface/app/$libraryId/Explorer/queries/usePathsInfiniteQuery.ts +++ b/interface/app/$libraryId/Explorer/queries/usePathsInfiniteQuery.ts @@ -5,6 +5,7 @@ import { FilePathObjectCursor, FilePathOrder, FilePathSearchArgs, + useLibraryContext, useRspcLibraryContext } from '@sd/client'; @@ -12,16 +13,16 @@ import { getExplorerStore } from '../store'; import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery'; export function usePathsInfiniteQuery({ - library, arg, - settings, + explorerSettings, ...args }: UseExplorerInfiniteQueryArgs) { + const { library } = useLibraryContext(); const ctx = useRspcLibraryContext(); - const explorerSettings = settings.useSettingsSnapshot(); + const settings = explorerSettings.useSettingsSnapshot(); - if (explorerSettings.order) { - arg.orderAndPagination = { orderOnly: explorerSettings.order }; + if (settings.order) { + arg.orderAndPagination = { orderOnly: settings.order }; if (arg.orderAndPagination.orderOnly.field === 'sizeInBytes') delete arg.take; } @@ -29,7 +30,7 @@ export function usePathsInfiniteQuery({ queryKey: ['search.paths', { library_id: library.uuid, arg }] as const, queryFn: ({ pageParam, queryKey: [_, { arg }] }) => { const cItem: Extract = pageParam; - const { order } = explorerSettings; + const { order } = settings; let orderAndPagination: (typeof arg)['orderAndPagination']; diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index 66b28f2e1..8a2594ef7 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -1,3 +1,4 @@ +import { UseInfiniteQueryResult } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { proxy, snapshot, subscribe, useSnapshot } from 'valtio'; diff --git a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx index 92d837235..e05bc9ce5 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx @@ -1,11 +1,12 @@ import { X } from '@phosphor-icons/react'; import clsx from 'clsx'; -import { useEffect, useState } from 'react'; +import { useMatch, useNavigate, useResolvedPath } from 'react-router'; import { Link, NavLink } from 'react-router-dom'; import { arraysEqual, useBridgeQuery, useFeatureFlag, + useLibraryMutation, useLibraryQuery, useOnlineLocations } from '@sd/client'; @@ -13,214 +14,200 @@ import { Button, Tooltip } from '@sd/ui'; import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton'; import { Folder, Icon, SubtleButton } from '~/components'; -import { useSavedSearches } from '../../Explorer/Search/SavedSearches'; import SidebarLink from './Link'; import LocationsContextMenu from './LocationsContextMenu'; import Section from './Section'; import { SeeMore } from './SeeMore'; import TagsContextMenu from './TagsContextMenu'; -type SidebarGroup = { - name: string; - items: SidebarItem[]; -}; +export const LibrarySection = () => ( + <> + + + + + +); -type SidebarItem = { - name: string; - icon: React.ReactNode; - to: string; - position: number; -}; +function SavedSearches() { + const savedSearches = useLibraryQuery(['search.saved.list']); -type TriggeredContextItem = - | { - type: 'location'; - locationId: number; - } - | { - type: 'tag'; - tagId: number; - }; + const path = useResolvedPath('saved-search/:id'); + const match = useMatch(path.pathname); + const currentSearchId = match?.params?.id; -export const LibrarySection = () => { - const node = useBridgeQuery(['nodeState']); - const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); - const tags = useLibraryQuery(['tags.list'], { keepPreviousData: true }); - const onlineLocations = useOnlineLocations(); - const isPairingEnabled = useFeatureFlag('p2pPairing'); - const [showDummyNodesEasterEgg, setShowDummyNodesEasterEgg] = useState(false); - const [triggeredContextItem, setTriggeredContextItem] = useState( - null - ); + const currentIndex = currentSearchId + ? savedSearches.data?.findIndex((s) => s.id === Number(currentSearchId)) + : undefined; - // const savedSearches = useSavedSearches(); + const navigate = useNavigate(); - useEffect(() => { - const outsideClick = () => { - document.addEventListener('click', () => { - setTriggeredContextItem(null); - }); - }; - outsideClick(); - return () => { - document.removeEventListener('click', outsideClick); - }; - }, [triggeredContextItem]); + const deleteSavedSearch = useLibraryMutation(['search.saved.delete'], { + onSuccess() { + if (currentIndex !== undefined && savedSearches.data) { + const nextIndex = Math.min(currentIndex + 1, savedSearches.data.length - 2); + + const search = savedSearches.data[nextIndex]; + + if (search) navigate(`saved-search/${search.id}`); + else navigate(`./`); + } + } + }); + + if (!savedSearches.data || savedSearches.data.length < 1) return null; return ( - <> - {/* {savedSearches.searches.length > 0 && ( -
- // - // - // } - > - ( - -
- -
- - {search.name} - -
- )} - /> -
- )} */} -
- - - ) - } - > - {node.data && ( +
+ // + // + // } + > + + {savedSearches.data.map((search, i) => ( - - {node.data.name} - - )} - - - -
+
+ +
-
+ {search.name} + + + + ))} + +
+ ); +} + +function Devices() { + const node = useBridgeQuery(['nodeState']); + const isPairingEnabled = useFeatureFlag('p2pPairing'); + + return ( +
- } - > - - {locationsQuery.data?.map((location) => ( - - - setTriggeredContextItem({ - type: 'location', - locationId: location.id - }) - } - className={clsx( - triggeredContextItem?.type === 'location' && - triggeredContextItem.locationId === location.id - ? 'border-accent' - : 'border-transparent', - 'group relative w-full border' - )} - to={`location/${location.id}`} - > -
- -
- arraysEqual(location.pub_id, l) - ) - ? 'bg-green-500' - : 'bg-red-500' - )} - /> -
- - {location.name} - - - ))} - - -
- {!!tags.data?.length && ( -
- - - } + ) + } + > + {node.data && ( + - - {tags.data?.map((tag, index) => ( - - - setTriggeredContextItem({ - type: 'tag', - tagId: tag.id - }) - } - className={clsx( - triggeredContextItem?.type === 'tag' && - triggeredContextItem?.tagId === tag.id - ? 'border-accent' - : 'border-transparent', - 'border' - )} - to={`tag/${tag.id}`} - > -
- {tag.name} - - - ))} - -
+ + {node.data.name} + )} - + + + +
); -}; +} + +function Locations() { + const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const onlineLocations = useOnlineLocations(); + + return ( +
+ + + } + > + + {locationsQuery.data?.map((location) => ( + + +
+ +
arraysEqual(location.pub_id, l)) + ? 'bg-green-500' + : 'bg-red-500' + )} + /> +
+ + {location.name} + + + ))} + + +
+ ); +} + +function Tags() { + const tags = useLibraryQuery(['tags.list'], { keepPreviousData: true }); + + if (!tags.data?.length) return; + + return ( +
+ + + } + > + + {tags.data?.map((tag) => ( + + +
+ {tag.name} + + + ))} + +
+ ); +} diff --git a/interface/app/$libraryId/Search/AppliedFilters.tsx b/interface/app/$libraryId/Search/AppliedFilters.tsx new file mode 100644 index 000000000..c438a5b49 --- /dev/null +++ b/interface/app/$libraryId/Search/AppliedFilters.tsx @@ -0,0 +1,134 @@ +import { MagnifyingGlass, X } from '@phosphor-icons/react'; +import { forwardRef } from 'react'; +import { SearchFilterArgs } from '@sd/client'; +import { tw } from '@sd/ui'; + +import { useSearchContext } from '.'; +import { filterRegistry } from './Filters'; +import { useSearchStore } from './store'; +import { RenderIcon } from './util'; + +export const FilterContainer = tw.div`flex flex-row items-center rounded bg-app-box overflow-hidden shrink-0 h-6`; + +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 void }>(({ onClick }, ref) => { + return ( +
+ +
+ ); +}); + +export const AppliedFilters = ({ allowRemove = true }: { allowRemove?: boolean }) => { + const search = useSearchContext(); + + return ( + <> + {search.search && ( + + + + {search.search} + + {allowRemove && search.setRawSearch('')} />} + + )} + {search.mergedFilters.map(({ arg, removalIndex }, index) => { + const filter = filterRegistry.find((f) => f.extract(arg)); + if (!filter) return; + + return ( + { + search.updateDynamicFilters((dyanmicFilters) => { + dyanmicFilters.splice(removalIndex, 1); + + return dyanmicFilters; + }); + } + : undefined + } + /> + ); + })} + + ); +}; + +export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: () => void }) { + const searchStore = useSearchStore(); + + const filter = filterRegistry.find((f) => f.extract(arg)); + if (!filter) return; + + const activeOptions = filter.argsToOptions( + filter.extract(arg)! as any, + searchStore.filterOptions + ); + + return ( + + + + {filter.name} + + + {/* {Object.entries(filter.conditions).map(([value, displayName]) => ( +
{displayName}
+ ))} */} + {(filter.conditions as any)[filter.getCondition(filter.extract(arg) as any) as any]} +
+ + + {activeOptions && ( + <> + {activeOptions.length === 1 ? ( + + ) : ( +
+ {activeOptions.map((option, index) => ( +
+ +
+ ))} +
+ )} + {activeOptions.length > 1 + ? `${activeOptions.length} ${pluralize(filter.name)}` + : activeOptions[0]?.name} + + )} +
+ + {onDelete && } +
+ ); +} + +function pluralize(word?: string) { + if (word?.endsWith('s')) return word; + return `${word}s`; +} diff --git a/interface/app/$libraryId/Explorer/Search/Filters.tsx b/interface/app/$libraryId/Search/Filters.tsx similarity index 80% rename from interface/app/$libraryId/Explorer/Search/Filters.tsx rename to interface/app/$libraryId/Search/Filters.tsx index e05d5ee94..f5dfa8e14 100644 --- a/interface/app/$libraryId/Explorer/Search/Filters.tsx +++ b/interface/app/$libraryId/Search/Filters.tsx @@ -1,11 +1,11 @@ import { CircleDashed, Cube, Folder, Icon, SelectionSlash, Textbox } from '@phosphor-icons/react'; -import { useCallback, useState } from 'react'; +import { 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 { AllKeys, FilterOption, getKey } from './store'; +import { UseSearch } from './useSearch'; import { FilterTypeCondition, filterTypeCondition } from './util'; export interface SearchFilter< @@ -38,63 +38,61 @@ export interface RenderSearchFilter< Render: (props: { filter: SearchFilterCRUD; options: (FilterOption & { type: string })[]; + search: UseSearch; }) => JSX.Element; // Apply is responsible for applying the filter to the search args useOptions: (props: { search: string }) => FilterOption[]; } -export function useToggleOptionSelected() { - const { fixedArgsKeys } = useSearchContext(); +export function useToggleOptionSelected({ search }: { search: UseSearch }) { + return ({ + filter, + option, + select + }: { + filter: SearchFilterCRUD; + option: FilterOption; + select: boolean; + }) => { + search.updateDynamicFilters((dynamicFilters) => { + const key = getKey({ ...option, type: filter.name }); - return useCallback( - ({ - filter, - option, - select - }: { - filter: SearchFilterCRUD; - option: FilterOption; - select: boolean; - }) => - updateFilterArgs((args) => { - const key = getKey({ ...option, type: filter.name }); + if (search.fixedFiltersKeys?.has(key)) return dynamicFilters; - if (fixedArgsKeys?.has(key)) return args; + const rawArg = dynamicFilters.find((arg) => filter.extract(arg)); - const rawArg = args.find((arg) => filter.extract(arg)); + if (!rawArg) { + const arg = filter.create(option.value); + dynamicFilters.push(arg); + } else { + const rawArgIndex = dynamicFilters.findIndex((arg) => filter.extract(arg))!; - if (!rawArg) { - const arg = filter.create(option.value); - args.push(arg); + const arg = filter.extract(rawArg)!; + + if (select) { + if (rawArg) filter.applyAdd(arg, option); } 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); - } + if (!filter.applyRemove(arg, option)) dynamicFilters.splice(rawArgIndex, 1); } + } - return args; - }), - [fixedArgsKeys] - ); + return dynamicFilters; + }); + }; } const FilterOptionList = ({ filter, - options + options, + search }: { filter: SearchFilterCRUD; options: FilterOption[]; + search: UseSearch; }) => { - const store = useSearchStore(); - const { fixedArgsKeys } = useSearchContext(); + const { allFiltersKeys } = search; - const toggleOptionSelected = useToggleOptionSelected(); + const toggleOptionSelected = useToggleOptionSelected({ search }); return ( @@ -106,16 +104,14 @@ const FilterOptionList = ({ return ( + selected={allFiltersKeys.has(optionKey)} + setSelected={(value) => { toggleOptionSelected({ filter, option, select: value - }) - } + }); + }} key={option.value} icon={option.icon} > @@ -127,30 +123,30 @@ const FilterOptionList = ({ ); }; -const FilterOptionText = ({ filter }: { filter: SearchFilterCRUD }) => { +const FilterOptionText = ({ filter, search }: { filter: SearchFilterCRUD; search: UseSearch }) => { const [value, setValue] = useState(''); - const { fixedArgsKeys } = useSearchContext(); + const { fixedFiltersKeys } = search; return ( - + setValue(e.target.value)} /> + } + > + setSearch(e.target.value)} + autoFocus + autoComplete="off" + autoCorrect="off" + variant="transparent" + placeholder="Filter..." + /> + + {searchQuery === '' ? ( + filterRegistry.map((filter) => ( + + )) + ) : ( + + )} + + + ); +} + +function SaveSearchButton() { + const search = useSearchContext(); + const popover = usePopover(); + + const [name, setName] = useState(''); + + const saveSearch = useLibraryMutation('search.saved.create'); + + return ( + + + Save Search + + } + > +
+ setName(e.target.value)} + autoFocus + variant="default" + placeholder="Name" + className="w-[130px]" + /> + +
+
+ ); +} + +function EscapeButton() { + const search = useSearchContext(); + + useKeybind(['Escape'], () => { + search.setSearch(''); + search.setOpen(false); + }); + + return ( + { + search.setSearch(''); + search.setOpen(false); + }} + className="ml-2 rounded-lg border border-app-line bg-app-box px-2 py-1 text-[10.5px] tracking-widest shadow" + > + ESC + + ); +} diff --git a/interface/app/$libraryId/Explorer/Search/store.tsx b/interface/app/$libraryId/Search/store.tsx similarity index 64% rename from interface/app/$libraryId/Explorer/Search/store.tsx rename to interface/app/$libraryId/Search/store.tsx index 262c7f2ca..99b5e9724 100644 --- a/interface/app/$libraryId/Explorer/Search/store.tsx +++ b/interface/app/$libraryId/Search/store.tsx @@ -1,12 +1,10 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { Icon } from '@phosphor-icons/react'; -import { produce } from 'immer'; -import { useEffect, useLayoutEffect, useMemo } from 'react'; +import { useEffect, 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'; @@ -28,42 +26,11 @@ export type AllKeys = T extends any ? keyof T : never; const searchStore = proxy({ interactingWithSearchOptions: false, searchType: 'paths' as SearchType, - filterArgs: ref([] as SearchFilterArgs[]), - filterArgsKeys: ref(new Set()), filterOptions: ref(new Map()), // we register filters so we can search them registeredFilters: proxyMap() as Map }); -export function useSearchFilters( - _searchType: T, - fixedArgs: SearchFilterArgs[] -) { - const { setFixedArgs, allFilterArgs, searchQuery } = useSearchContext(); - - // 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] = searchQuery?.split('.') ?? []; - - const filters: SearchFilterArgs[] = []; - - if (name) filters.push({ filePath: { name: { contains: name } } }); - if (ext) filters.push({ filePath: { extension: { in: [ext] } } }); - - return filters; - }, [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}`; @@ -112,22 +79,6 @@ export function argsToOptions(args: SearchFilterArgs[], options: Map 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(); @@ -142,10 +93,7 @@ export const useSearchRegisteredFilters = (query: string) => { ); }; -export const resetSearchStore = () => { - searchStore.filterArgs = ref([]); - searchStore.filterArgsKeys = ref(new Set()); -}; +export const resetSearchStore = () => {}; export const useSearchStore = () => useSnapshot(searchStore); diff --git a/interface/app/$libraryId/Search/useSearch.ts b/interface/app/$libraryId/Search/useSearch.ts new file mode 100644 index 000000000..6e4a633b2 --- /dev/null +++ b/interface/app/$libraryId/Search/useSearch.ts @@ -0,0 +1,187 @@ +import { produce } from 'immer'; +import { useCallback, useMemo, useState } from 'react'; +import { useDebouncedValue } from 'rooks'; +import { useDebouncedCallback } from 'use-debounce'; +import { SearchFilterArgs } from '@sd/client'; + +import { filterRegistry } from './Filters'; +import { argsToOptions, getKey, useSearchStore } from './store'; + +export interface UseSearchProps { + open?: boolean; + search?: string; + /** + * Filters that cannot be removed + */ + fixedFilters?: SearchFilterArgs[]; + /** + * Filters that can be removed. + * When this value changes dynamic filters stored internally will reset. + */ + dynamicFilters?: SearchFilterArgs[]; +} + +export function useSearch(props?: UseSearchProps) { + const [open, setOpen] = useState(false); + if (props?.open !== undefined && open !== props.open) setOpen(props.open); + + const searchState = useSearchStore(); + + // Filters that can't be removed + + const fixedFilters = useMemo(() => props?.fixedFilters ?? [], [props?.fixedFilters]); + + const fixedFiltersAsOptions = useMemo( + () => argsToOptions(fixedFilters, searchState.filterOptions), + [fixedFilters, searchState.filterOptions] + ); + + const fixedFiltersKeys: Set = useMemo(() => { + return new Set( + fixedFiltersAsOptions.map(({ arg, filter }) => + getKey({ + type: filter.name, + name: arg.name, + value: arg.value + }) + ) + ); + }, [fixedFiltersAsOptions]); + + // Filters that can be removed + + const [dynamicFilters, setDynamicFilters] = useState(props?.dynamicFilters ?? []); + const [dynamicFiltersFromProps, setDynamicFiltersFromProps] = useState(props?.dynamicFilters); + + if (dynamicFiltersFromProps !== props?.dynamicFilters) { + setDynamicFiltersFromProps(props?.dynamicFilters); + setDynamicFilters(props?.dynamicFilters ?? []); + } + + const dynamicFiltersAsOptions = useMemo( + () => argsToOptions(dynamicFilters, searchState.filterOptions), + [dynamicFilters, searchState.filterOptions] + ); + + const dynamicFiltersKeys: Set = useMemo(() => { + return new Set( + dynamicFiltersAsOptions.map(({ arg, filter }) => + getKey({ + type: filter.name, + name: arg.name, + value: arg.value + }) + ) + ); + }, [dynamicFiltersAsOptions]); + + const updateDynamicFilters = useCallback( + (cb: (args: SearchFilterArgs[]) => SearchFilterArgs[]) => + setDynamicFilters((filters) => produce(filters, cb)), + [] + ); + + // Merging of filters that should be ORed + + const mergedFilters = useMemo(() => { + const value: { arg: SearchFilterArgs; removalIndex: number | null }[] = fixedFilters.map( + (arg) => ({ + arg, + removalIndex: null + }) + ); + + for (const [index, arg] of dynamicFilters.entries()) { + const filter = filterRegistry.find((f) => f.extract(arg)); + if (!filter) continue; + + const fixedEquivalentIndex = fixedFilters.findIndex( + (a) => filter.extract(a) !== undefined + ); + + if (fixedEquivalentIndex !== -1) { + const merged = filter.merge( + filter.extract(fixedFilters[fixedEquivalentIndex]!)! as any, + filter.extract(arg)! as any + ); + + value[fixedEquivalentIndex] = { + arg: filter.create(merged), + removalIndex: fixedEquivalentIndex + }; + } else { + value.push({ + arg, + removalIndex: index + }); + } + } + + return value; + }, [fixedFilters, dynamicFilters]); + + // Filters generated from the search query + + // rawSearch should only ever be read by the search input + const [search, setSearch] = useState(props?.search ?? ''); + const [searchFromProps, setSearchFromProps] = useState(props?.search); + + if (searchFromProps !== props?.search) { + setSearchFromProps(props?.search); + setSearch(props?.search ?? ''); + } + + const searchFilters = useMemo(() => { + const [name, ext] = search.split('.') ?? []; + + const filters: SearchFilterArgs[] = []; + + if (name) filters.push({ filePath: { name: { contains: name } } }); + if (ext) filters.push({ filePath: { extension: { in: [ext] } } }); + + return filters; + }, [search]); + + // All filters combined together + const allFilters = useMemo( + () => [...mergedFilters.map((v) => v.arg), ...searchFilters], + [mergedFilters, searchFilters] + ); + + const allFiltersAsOptions = useMemo( + () => argsToOptions(allFilters, searchState.filterOptions), + [searchState.filterOptions, allFilters] + ); + + const allFiltersKeys: Set = useMemo(() => { + return new Set( + allFiltersAsOptions.map(({ arg, filter }) => + getKey({ + type: filter.name, + name: arg.name, + value: arg.value + }) + ) + ); + }, [allFiltersAsOptions]); + + return { + open, + setOpen, + fixedFilters, + fixedFiltersKeys, + search, + rawSearch: search, + setRawSearch: setSearch, + setSearch: useDebouncedCallback(setSearch, 300), + dynamicFilters, + setDynamicFilters, + updateDynamicFilters, + dynamicFiltersKeys, + mergedFilters, + allFilters, + allFiltersKeys + }; +} + +export type UseSearch = ReturnType; diff --git a/interface/app/$libraryId/Explorer/Search/util.tsx b/interface/app/$libraryId/Search/util.tsx similarity index 96% rename from interface/app/$libraryId/Explorer/Search/util.tsx rename to interface/app/$libraryId/Search/util.tsx index 0f6c77cfa..6662996eb 100644 --- a/interface/app/$libraryId/Explorer/Search/util.tsx +++ b/interface/app/$libraryId/Search/util.tsx @@ -1,7 +1,6 @@ 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'; export const filterTypeCondition = { diff --git a/interface/app/$libraryId/TopBar/Layout.tsx b/interface/app/$libraryId/TopBar/Layout.tsx index c88ad2fa5..6456a183d 100644 --- a/interface/app/$libraryId/TopBar/Layout.tsx +++ b/interface/app/$libraryId/TopBar/Layout.tsx @@ -3,21 +3,26 @@ import { Outlet } from 'react-router'; import { SearchFilterArgs } from '@sd/client'; import TopBar from '.'; -import { SearchContextProvider } from '../Explorer/Search/Context'; const TopBarContext = createContext | null>(null); function useContextValue() { const [left, setLeft] = useState(null); + const [center, setCenter] = useState(null); const [right, setRight] = useState(null); + const [children, setChildren] = useState(null); const [fixedArgs, setFixedArgs] = useState(null); const [topBarHeight, setTopBarHeight] = useState(0); return { left, setLeft, + center, + setCenter, right, setRight, + children, + setChildren, fixedArgs, setFixedArgs, topBarHeight, @@ -30,10 +35,8 @@ export const Component = () => { return ( - - - - + + ); }; diff --git a/interface/app/$libraryId/TopBar/Portal.tsx b/interface/app/$libraryId/TopBar/Portal.tsx index da46f8c67..8d9d97b66 100644 --- a/interface/app/$libraryId/TopBar/Portal.tsx +++ b/interface/app/$libraryId/TopBar/Portal.tsx @@ -1,19 +1,22 @@ -import { type ReactNode } from 'react'; +import { PropsWithChildren, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useTopBarContext } from './Layout'; -interface Props { +interface Props extends PropsWithChildren { left?: ReactNode; + center?: ReactNode; right?: ReactNode; } -export const TopBarPortal = ({ left, right }: Props) => { +export const TopBarPortal = ({ left, center, right, children }: Props) => { const ctx = useTopBarContext(); return ( <> {left && ctx.left && createPortal(left, ctx.left)} + {center && ctx.center && createPortal(center, ctx.center)} {right && ctx.right && createPortal(right, ctx.right)} + {children && ctx.children && createPortal(children, ctx.children)} ); }; diff --git a/interface/app/$libraryId/TopBar/index.tsx b/interface/app/$libraryId/TopBar/index.tsx index 614c91c08..4359faa76 100644 --- a/interface/app/$libraryId/TopBar/index.tsx +++ b/interface/app/$libraryId/TopBar/index.tsx @@ -5,15 +5,12 @@ import { useKey } from 'rooks'; import useResizeObserver from 'use-resize-observer'; import { Tooltip } from '@sd/ui'; import { useKeyMatcher, useOperatingSystem, useShowControls } from '~/hooks'; +import { useRoutingContext } from '~/RoutingContext'; import { useTabsContext } from '~/TabsContext'; -import SearchOptions from '../Explorer/Search'; -import { useSearchContext } from '../Explorer/Search/Context'; -import { useSearchStore } from '../Explorer/Search/store'; import { useExplorerStore } from '../Explorer/store'; import { useTopBarContext } from './Layout'; import { NavigationButtons } from './NavigationButtons'; -import SearchBar from './SearchBar'; const TopBar = () => { const transparentBg = useShowControls().transparentBg; @@ -22,7 +19,6 @@ const TopBar = () => { const tabs = useTabsContext(); const ctx = useTopBarContext(); - const searchCtx = useSearchContext(); useResizeObserver({ ref, @@ -38,7 +34,7 @@ const TopBar = () => { useLayoutEffect(() => { const height = ref.current!.getBoundingClientRect().height; ctx.setTopBarHeight.call(undefined, height); - }, [ctx.setTopBarHeight, searchCtx.isSearching]); + }, [ctx.setTopBarHeight]); return (
{
- {ctx.fixedArgs && } +
-
+
{tabs && } - {searchCtx.isSearching && ( - <> -
- - - )} +
); }; @@ -155,9 +146,11 @@ function Tabs() { function useTabKeybinds(props: { addTab(): void; removeTab(index: number): void }) { const ctx = useTabsContext()!; const os = useOperatingSystem(); + const { visible } = useRoutingContext(); // these keybinds aren't part of the regular shortcuts system as they're desktop-only useKey(['t'], (e) => { + if (!visible) return; if ((os === 'macOS' && !e.metaKey) || (os !== 'macOS' && !e.ctrlKey)) return; e.stopPropagation(); @@ -166,6 +159,7 @@ function useTabKeybinds(props: { addTab(): void; removeTab(index: number): void }); useKey(['w'], (e) => { + if (!visible) return; if ((os === 'macOS' && !e.metaKey) || (os !== 'macOS' && !e.ctrlKey)) return; e.stopPropagation(); @@ -174,6 +168,7 @@ function useTabKeybinds(props: { addTab(): void; removeTab(index: number): void }); useKey(['ArrowLeft', 'ArrowRight'], (e) => { + if (!visible) return; // TODO: figure out non-macos keybind if ((os === 'macOS' && !(e.metaKey && e.altKey)) || os !== 'macOS') return; diff --git a/interface/app/$libraryId/ephemeral.tsx b/interface/app/$libraryId/ephemeral.tsx index b69fffafc..310f0b3d3 100644 --- a/interface/app/$libraryId/ephemeral.tsx +++ b/interface/app/$libraryId/ephemeral.tsx @@ -58,8 +58,6 @@ const NOTICE_ITEMS: { icon: keyof typeof iconNames; name: string }[] = [ ]; const EphemeralNotice = ({ path }: { path: string }) => { - useRouteTitle(path); - const isDark = useIsDark(); const { ephemeral: dismissed } = useDismissibleNoticeStore(); @@ -156,9 +154,10 @@ const EphemeralNotice = ({ path }: { path: string }) => { }; const EphemeralExplorer = memo((props: { args: PathParams }) => { - const os = useOperatingSystem(); const { path } = props.args; + const os = useOperatingSystem(); + const explorerSettings = useExplorerSettings({ settings: useMemo( () => @@ -248,6 +247,8 @@ export const Component = () => { const path = useDeferredValue(pathParams); + useRouteTitle(path.path ?? ''); + return ( diff --git a/interface/app/$libraryId/index.tsx b/interface/app/$libraryId/index.tsx index 779930978..989607927 100644 --- a/interface/app/$libraryId/index.tsx +++ b/interface/app/$libraryId/index.tsx @@ -1,6 +1,7 @@ -import type { RouteObject } from 'react-router-dom'; -import { Navigate } from 'react-router-dom'; +import { redirect } from '@remix-run/router'; +import { Navigate, type RouteObject } from 'react-router-dom'; import { useHomeDir } from '~/hooks/useHomeDir'; +import { Platform } from '~/util/Platform'; import settingsRoutes from './settings'; @@ -23,8 +24,11 @@ const explorerRoutes: RouteObject[] = [ { path: 'location/:id', lazy: () => import('./location/$id') }, { path: 'node/:id', lazy: () => import('./node/$id') }, { path: 'tag/:id', lazy: () => import('./tag/$id') }, - { path: 'network', lazy: () => import('./network') } - // { path: 'search/:id', lazy: () => import('./search') } + { path: 'network', lazy: () => import('./network') }, + { + path: 'saved-search/:id', + lazy: () => import('./saved-search/$id') + } ]; // Routes that should render with the top bar - pretty much everything except @@ -34,25 +38,33 @@ const topBarRoutes: RouteObject = { children: [...explorerRoutes, pageRoutes] }; -export default [ - { - index: true, - Component: () => { - const homeDir = useHomeDir(); +export default (platform: Platform) => + [ + { + index: true, + Component: () => { + const homeDir = useHomeDir(); - if (homeDir.data) - return ( - - ); + if (homeDir.data) + return ( + + ); - return ; - } - }, - topBarRoutes, - { - path: 'settings', - lazy: () => import('./settings/Layout'), - children: settingsRoutes - }, - { path: '*', lazy: () => import('./404') } -] satisfies RouteObject[]; + return ; + }, + loader: async () => { + if (!platform.userHomeDir) return null; + const homeDir = await platform.userHomeDir(); + return redirect(`ephemeral/0?${new URLSearchParams({ path: homeDir })}`); + } + }, + topBarRoutes, + { + path: 'settings', + lazy: () => import('./settings/Layout'), + children: settingsRoutes + }, + { path: '*', lazy: () => import('./404') } + ] satisfies RouteObject[]; diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx index 449992285..7313eccd4 100644 --- a/interface/app/$libraryId/location/$id.tsx +++ b/interface/app/$libraryId/location/$id.tsx @@ -1,5 +1,5 @@ import { ArrowClockwise, Info } from '@phosphor-icons/react'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { stringify } from 'uuid'; import { @@ -8,7 +8,6 @@ import { FilePathOrder, Location, ObjectKindEnum, - useLibraryContext, useLibraryMutation, useLibraryQuery, useLibrarySubscription, @@ -29,29 +28,30 @@ import { useQuickRescan } from '~/hooks/useQuickRescan'; import Explorer from '../Explorer'; import { ExplorerContextProvider } from '../Explorer/Context'; -import { usePathsInfiniteQuery } from '../Explorer/queries'; -import { useSearchFilters } from '../Explorer/Search/store'; +import { usePathsExplorerQuery } from '../Explorer/queries'; import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store'; import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; -import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; +import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { useExplorerSearchParams } from '../Explorer/util'; import { EmptyNotice } from '../Explorer/View'; +import SearchOptions, { SearchContextProvider, useSearch } from '../Search'; +import SearchBar from '../Search/SearchBar'; import { TopBarPortal } from '../TopBar/Portal'; import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions'; import LocationOptions from './LocationOptions'; export const Component = () => { - const [{ path }] = useExplorerSearchParams(); const { id: locationId } = useZodRouteParams(LocationIdParamsSchema); const location = useLibraryQuery(['locations.get', locationId], { keepPreviousData: true, suspense: true }); - return ; + return ; }; -const LocationExplorer = ({ location, path }: { location: Location; path?: string }) => { +const LocationExplorer = ({ location }: { location: Location; path?: string }) => { + const [{ path, take }] = useExplorerSearchParams(); const rspc = useRspcLibraryContext(); const onlineLocations = useOnlineLocations(); @@ -59,16 +59,14 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin const rescan = useQuickRescan(); const locationOnline = useMemo(() => { - const pub_id = location?.pub_id; + const pub_id = location.pub_id; if (!pub_id) return false; return onlineLocations.some((l) => arraysEqual(pub_id, l)); - }, [location?.pub_id, onlineLocations]); + }, [location.pub_id, onlineLocations]); const preferences = useLibraryQuery(['preferences.get']); const updatePreferences = useLibraryMutation('preferences.update'); - const isLocationIndexing = useIsLocationIndexing(location.id); - const settings = useMemo(() => { const defaults = createDefaultExplorerSettings({ order: { field: 'name', value: 'Asc' } @@ -114,21 +112,52 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin location }); - const { items, count, loadMore, query } = useItems({ - location, - settings: explorerSettings + const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + + const fixedFilters = useMemo( + () => [ + { filePath: { locations: { in: [location.id] } } }, + ...(explorerSettingsSnapshot.layoutMode === 'media' + ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] + : []) + ], + [location.id, explorerSettingsSnapshot.layoutMode] + ); + + const search = useSearch({ fixedFilters }); + + const { layoutMode, mediaViewWithDescendants, showHiddenFiles } = + explorerSettings.useSettingsSnapshot(); + + const paths = usePathsExplorerQuery({ + arg: { + filters: [ + ...search.allFilters, + { + filePath: { + path: { + location_id: location.id, + path: path ?? '', + include_descendants: + search.search !== '' || + search.dynamicFilters.length > 0 || + (layoutMode === 'media' && mediaViewWithDescendants) + } + } + }, + !showHiddenFiles && { filePath: { hidden: false } } + ].filter(Boolean) as any, + take + }, + explorerSettings }); const explorer = useExplorer({ - items, - count, - loadMore, - isFetchingNextPage: query.isFetchingNextPage, + ...paths, + isFetchingNextPage: paths.query.isFetchingNextPage, isLoadingPreferences: preferences.isLoading, settings: explorerSettings, - ...(location && { - parent: { type: 'Location', location } - }) + parent: { type: 'Location', location } }); useLibrarySubscription( @@ -152,42 +181,53 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin (path && path?.length > 1 ? getLastSectionOfPath(path) : location.name) ?? '' ); + const isLocationIndexing = useIsLocationIndexing(location.id); + return ( - - - {title} - {!locationOnline && ( - - - - )} - -
- } - right={ - rescan(location.id), - icon: , - individual: true, - showAtResolution: 'xl:flex' - } - ]} - /> - } - /> + + } + left={ +
+ + {title} + {!locationOnline && ( + + + + )} + +
+ } + right={ + rescan(location.id), + icon: , + individual: true, + showAtResolution: 'xl:flex' + } + ]} + /> + } + > + {search.open && ( + <> +
+ + + )} +
+
{isLocationIndexing ? (
) : !preferences.isLoading ? ( } @@ -200,67 +240,6 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin ); }; -const useItems = ({ - location, - settings -}: { - location: Location; - settings: UseExplorerSettings; -}) => { - const [{ path, take }] = useExplorerSearchParams(); - - const { library } = useLibraryContext(); - - const explorerSettings = settings.useSettingsSnapshot(); - - // 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] - ); - - const baseFilters = useSearchFilters('paths', fixedFilters); - - const filters = [...baseFilters]; - - filters.push({ - filePath: { - path: { - location_id: location.id, - path: path ?? '', - include_descendants: - explorerSettings.layoutMode === 'media' && - explorerSettings.mediaViewWithDescendants - } - } - }); - - if (!explorerSettings.showHiddenFiles) filters.push({ filePath: { hidden: false } }); - - const query = usePathsInfiniteQuery({ - 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(() => { - if (query.hasNextPage && !query.isFetchingNextPage) { - query.fetchNextPage.call(undefined); - } - }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); - - return { query, items, loadMore, count: count.data }; -}; - function getLastSectionOfPath(path: string): string | undefined { if (path.endsWith('/')) { path = path.slice(0, -1); diff --git a/interface/app/$libraryId/saved-search/$id.tsx b/interface/app/$libraryId/saved-search/$id.tsx new file mode 100644 index 000000000..d47d4e9d6 --- /dev/null +++ b/interface/app/$libraryId/saved-search/$id.tsx @@ -0,0 +1,125 @@ +import { MagnifyingGlass } from '@phosphor-icons/react'; +import { getIcon, iconNames } from '@sd/assets/util'; +import { useMemo } from 'react'; +import { FilePathOrder, SearchFilterArgs, useLibraryMutation, useLibraryQuery } from '@sd/client'; +import { Button } from '@sd/ui'; +import { SearchIdParamsSchema } from '~/app/route-schemas'; +import { useRouteTitle, useZodRouteParams } from '~/hooks'; + +import Explorer from '../Explorer'; +import { ExplorerContextProvider } from '../Explorer/Context'; +import { usePathsExplorerQuery } 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 SearchOptions, { SearchContextProvider, useSearch, useSearchContext } from '../Search'; +import SearchBar from '../Search/SearchBar'; +import { TopBarPortal } from '../TopBar/Portal'; + +export const Component = () => { + const { id } = useZodRouteParams(SearchIdParamsSchema); + + const savedSearch = useLibraryQuery(['search.saved.get', id], { + suspense: true + }); + + useRouteTitle(savedSearch.data?.name ?? ''); + + const explorerSettings = useExplorerSettings({ + settings: useMemo(() => { + return createDefaultExplorerSettings({ + order: { field: 'name', value: 'Asc' } + }); + }, []), + orderingKeys: filePathOrderingKeysSchema + }); + + const rawFilters = savedSearch.data?.filters; + + const dynamicFilters = useMemo(() => { + if (rawFilters) return JSON.parse(rawFilters) as SearchFilterArgs[]; + }, [rawFilters]); + + const search = useSearch({ + open: true, + search: savedSearch.data?.search ?? undefined, + dynamicFilters + }); + + const paths = usePathsExplorerQuery({ + arg: { filters: search.allFilters, take: 50 }, + explorerSettings + }); + + const explorer = useExplorer({ + ...paths, + isFetchingNextPage: paths.query.isFetchingNextPage, + settings: explorerSettings + }); + + return ( + + + } + left={ +
+ + + {savedSearch.data?.name} + +
+ } + right={} + > +
+ + {(search.dynamicFilters !== dynamicFilters || + search.search !== savedSearch.data?.search) && ( + + )} + +
+
+ + } + message={ + search.search + ? `No results found for "${search.search}"` + : 'Search for files...' + } + /> + } + /> +
+ ); +}; + +function SaveButton({ searchId }: { searchId: number }) { + const updateSavedSearch = useLibraryMutation(['search.saved.update']); + + const search = useSearchContext(); + + return ( + + ); +} diff --git a/interface/app/$libraryId/search.tsx b/interface/app/$libraryId/search.tsx deleted file mode 100644 index 0c811bd5c..000000000 --- a/interface/app/$libraryId/search.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// 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 { 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 useItems = (searchParams: SearchParams, id: number) => { -// const { library } = useLibraryContext(); -// const explorerSettings = useExplorerSettings({ -// settings: createDefaultExplorerSettings({ -// order: { -// field: 'name', -// value: 'Asc' -// } -// }), -// orderingKeys: filePathOrderingKeysSchema -// }); - -// const searchFilters = useSearchFilters('paths', []); - -// const savedSearches = useSavedSearches(); - -// useEffect(() => { -// if (id) { -// getSearchStore().isSearching = true; -// savedSearches.loadSearch(id); -// } -// }, [id]); - -// const take = 50; // Specify the number of items to fetch per query - -// const query = usePathsInfiniteQuery({ -// arg: { filter: searchFilters, take }, -// library, -// // @ts-ignore todo: fix -// settings: explorerSettings, -// suspense: true -// }); - -// const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) ?? [], [query.data]); - -// return { items, query }; -// }; - -// const SearchExplorer = ({ id, searchParams }: { id: number; searchParams: SearchParams }) => { -// const { items, query } = useItems(searchParams, id); - -// const explorerSettings = useExplorerSettings({ -// settings: createDefaultExplorerSettings({ -// order: { -// field: 'name', -// value: 'Asc' -// } -// }), -// orderingKeys: filePathOrderingKeysSchema -// }); - -// const explorer = useExplorer({ -// items, -// settings: explorerSettings -// }); - -// return ( -// -// -// -// Search -//
-// } -// right={} -// /> -// } -// message={ -// searchParams.search -// ? `No results found for "${searchParams.search}"` -// : 'Search for files...' -// } -// /> -// } -// /> -// -// ); -// }; - -// export const Component = () => { -// const [searchParams] = useZodSearchParams(SearchParamsSchema); -// const { id } = useZodRouteParams(SearchIdParamsSchema); - -// const search = useDeferredValue(searchParams); - -// return ( -// -// -// -// ); -// }; diff --git a/interface/app/$libraryId/settings/Sidebar.tsx b/interface/app/$libraryId/settings/Sidebar.tsx index 546917472..dd28aba3b 100644 --- a/interface/app/$libraryId/settings/Sidebar.tsx +++ b/interface/app/$libraryId/settings/Sidebar.tsx @@ -14,6 +14,7 @@ import { TagSimple, User } from '@phosphor-icons/react'; +import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr'; import { useFeatureFlag } from '@sd/client'; import { tw } from '@sd/ui'; import { useOperatingSystem } from '~/hooks/useOperatingSystem'; @@ -97,6 +98,10 @@ export default () => { Tags + {/* + + Saved Searches + */} Clouds diff --git a/interface/app/$libraryId/settings/library/index.tsx b/interface/app/$libraryId/settings/library/index.tsx index 77d9cbbbd..6ab86fa74 100644 --- a/interface/app/$libraryId/settings/library/index.tsx +++ b/interface/app/$libraryId/settings/library/index.tsx @@ -11,6 +11,7 @@ export default [ { path: 'sync', lazy: () => import('./sync') }, { path: 'general', lazy: () => import('./general') }, { path: 'tags', lazy: () => import('./tags') }, + // { path: 'saved-searches', lazy: () => import('./saved-searches') }, //this is for edit in tags context menu { path: 'tags/:id', lazy: () => import('./tags') }, { path: 'nodes', lazy: () => import('./nodes') }, diff --git a/interface/app/$libraryId/settings/library/saved-searches/index.tsx b/interface/app/$libraryId/settings/library/saved-searches/index.tsx new file mode 100644 index 000000000..ca53aa86f --- /dev/null +++ b/interface/app/$libraryId/settings/library/saved-searches/index.tsx @@ -0,0 +1,121 @@ +import { Trash } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import { useMemo, useState } from 'react'; +import { + SavedSearch, + SearchFilterArgs, + useLibraryMutation, + useLibraryQuery, + useZodForm +} from '@sd/client'; +import { Button, Card, Form, InputField, Label, Tooltip, z } from '@sd/ui'; +import { SearchContextProvider, useSearch } from '~/app/$libraryId/Search'; +import { AppliedFilters } from '~/app/$libraryId/Search/AppliedFilters'; +import { Heading } from '~/app/$libraryId/settings/Layout'; +import { useDebouncedFormWatch } from '~/hooks'; + +export const Component = () => { + const savedSearches = useLibraryQuery(['search.saved.list'], { suspense: true }); + + const [selectedSearchId, setSelectedSearchId] = useState( + savedSearches.data![0]?.id ?? null + ); + + const selectedSearch = useMemo(() => { + if (selectedSearchId === null) return null; + + return savedSearches.data!.find((s) => s.id == selectedSearchId) ?? null; + }, [selectedSearchId, savedSearches.data]); + + return ( + <> + +
+ + {savedSearches.data?.map((search) => ( + + ))} + + {selectedSearch ? ( + setSelectedSearchId(null)} + /> + ) : ( +
No Search Selected
+ )} +
+ + ); +}; + +const schema = z.object({ + name: z.string() +}); + +function EditForm({ savedSearch, onDelete }: { savedSearch: SavedSearch; onDelete: () => void }) { + const updateSavedSearch = useLibraryMutation('search.saved.update'); + const deleteSavedSearch = useLibraryMutation('search.saved.delete'); + + const form = useZodForm({ + schema, + mode: 'onChange', + defaultValues: { + name: savedSearch.name ?? '' + }, + reValidateMode: 'onChange' + }); + + useDebouncedFormWatch(form, (data) => { + updateSavedSearch.mutate([savedSearch.id, { name: data.name ?? '' }]); + }); + + const fixedFilters = useMemo(() => { + if (savedSearch.filters === null) return []; + + return JSON.parse(savedSearch.filters) as SearchFilterArgs[]; + }, [savedSearch.filters]); + + const search = useSearch({ search: savedSearch.search ?? undefined, fixedFilters }); + + return ( +
+
+
+ + +
+
+ +
+ + + +
+
+
+
+ ); +} diff --git a/interface/app/$libraryId/tag/$id.tsx b/interface/app/$libraryId/tag/$id.tsx index f09987ebe..95aa9e2e2 100644 --- a/interface/app/$libraryId/tag/$id.tsx +++ b/interface/app/$libraryId/tag/$id.tsx @@ -1,70 +1,88 @@ -import { useCallback, useMemo } from 'react'; -import { ObjectKindEnum, ObjectOrder, Tag, useLibraryContext, useLibraryQuery } from '@sd/client'; +import { useMemo } from 'react'; +import { ObjectKindEnum, ObjectOrder, useLibraryQuery } from '@sd/client'; import { LocationIdParamsSchema } from '~/app/route-schemas'; import { Icon } from '~/components'; import { useRouteTitle, useZodRouteParams } from '~/hooks'; import Explorer from '../Explorer'; import { ExplorerContextProvider } from '../Explorer/Context'; -import { useObjectsInfiniteQuery } from '../Explorer/queries'; -import { useSearchFilters } from '../Explorer/Search/store'; +import { useObjectsExplorerQuery } from '../Explorer/queries/useObjectsExplorerQuery'; import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store'; import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; -import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; +import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { EmptyNotice } from '../Explorer/View'; +import SearchOptions, { SearchContextProvider, useSearch } from '../Search'; +import SearchBar from '../Search/SearchBar'; import { TopBarPortal } from '../TopBar/Portal'; export function Component() { const { id: tagId } = useZodRouteParams(LocationIdParamsSchema); const tag = useLibraryQuery(['tags.get', tagId], { suspense: true }); - useRouteTitle(tag.data?.name ?? 'Tag'); + useRouteTitle(tag.data!.name ?? 'Tag'); const explorerSettings = useExplorerSettings({ - settings: useMemo( - () => - createDefaultExplorerSettings({ - order: null - }), - [] - ), + settings: useMemo(() => { + return createDefaultExplorerSettings({ order: null }); + }, []), orderingKeys: objectOrderingKeysSchema }); - const { items, count, loadMore, query } = useItems({ - tag: tag.data!, - settings: explorerSettings + const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + + const fixedFilters = useMemo( + () => [ + { object: { tags: { in: [tag.data!.id] } } }, + ...(explorerSettingsSnapshot.layoutMode === 'media' + ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] + : []) + ], + [tag.data, explorerSettingsSnapshot.layoutMode] + ); + + const search = useSearch({ + fixedFilters + }); + + const objects = useObjectsExplorerQuery({ + arg: { take: 100, filters: search.allFilters }, + explorerSettings }); const explorer = useExplorer({ - items, - count, - loadMore, + ...objects, + isFetchingNextPage: objects.query.isFetchingNextPage, settings: explorerSettings, - ...(tag.data && { - parent: { type: 'Tag', tag: tag.data } - }) + parent: { type: 'Tag', tag: tag.data! } }); return ( - -
- {tag?.data?.name} -
- } - right={} - /> + + } + left={ +
+
+ {tag?.data?.name} +
+ } + right={} + > + {search.open && ( + <> +
+ + + )} + + } message="No items assigned to this tag." /> @@ -73,39 +91,3 @@ export function Component() { ); } - -function useItems({ tag, settings }: { tag: Tag; settings: UseExplorerSettings }) { - const { library } = useLibraryContext(); - - const explorerSettings = settings.useSettingsSnapshot(); - - 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, filters }, - settings - }); - - const items = useMemo(() => query.data?.pages?.flatMap((d) => d.items) ?? null, [query.data]); - - const loadMore = useCallback(() => { - if (query.hasNextPage && !query.isFetchingNextPage) { - query.fetchNextPage.call(undefined); - } - }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); - - return { query, items, loadMore, count: count.data }; -} diff --git a/interface/app/$libraryId/tag/Component.1.tsx b/interface/app/$libraryId/tag/Component.1.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/interface/app/$libraryId/tag/Component.tsx b/interface/app/$libraryId/tag/Component.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/interface/app/index.tsx b/interface/app/index.tsx index 379d290c1..4f8ba6957 100644 --- a/interface/app/index.tsx +++ b/interface/app/index.tsx @@ -1,73 +1,98 @@ import { useMemo } from 'react'; -import { Navigate, Outlet, useMatches, type RouteObject } from 'react-router-dom'; -import { currentLibraryCache, useCachedLibraries } from '@sd/client'; +import { Navigate, Outlet, redirect, useMatches, type RouteObject } from 'react-router-dom'; +import { currentLibraryCache, getCachedLibraries, useCachedLibraries } from '@sd/client'; import { Dialogs, Toaster } from '@sd/ui'; import { RouterErrorBoundary } from '~/ErrorFallback'; +import { useOperatingSystem } from '~/hooks'; +import { useRoutingContext } from '~/RoutingContext'; +import { Platform } from '..'; import libraryRoutes from './$libraryId'; import onboardingRoutes from './onboarding'; import { RootContext } from './RootContext'; import './style.scss'; -import { useOperatingSystem } from '~/hooks'; - -import { OperatingSystem } from '..'; - -const Index = () => { - const libraries = useCachedLibraries(); - - if (libraries.status !== 'success') return null; - - if (libraries.data.length === 0) return ; - - const currentLibrary = libraries.data.find((l) => l.uuid === currentLibraryCache.id); - - const libraryId = currentLibrary ? currentLibrary.uuid : libraries.data[0]?.uuid; - - return ; -}; - -const Wrapper = () => { - const rawPath = useRawRoutePath(); - - return ( - - - - - - ); -}; - // NOTE: all route `Layout`s below should contain // the `usePlausiblePageViewMonitor` hook, as early as possible (ideally within the layout itself). // the hook should only be included if there's a valid `ClientContext` (so not onboarding) -export const routes = (os: OperatingSystem) => { - return [ +export const createRoutes = (platform: Platform) => + [ { - element: , + Component: () => { + const rawPath = useRawRoutePath(); + + return ( + + + + + + ); + }, errorElement: , children: [ { index: true, - element: + Component: () => { + const libraries = useCachedLibraries(); + + if (libraries.status !== 'success') return null; + + if (libraries.data.length === 0) + return ; + + const currentLibrary = libraries.data.find( + (l) => l.uuid === currentLibraryCache.id + ); + + const libraryId = currentLibrary + ? currentLibrary.uuid + : libraries.data[0]?.uuid; + + return ; + }, + loader: async () => { + const libraries = await getCachedLibraries(); + + const currentLibrary = libraries.find( + (l) => l.uuid === currentLibraryCache.id + ); + + const libraryId = currentLibrary ? currentLibrary.uuid : libraries[0]?.uuid; + + if (libraryId === undefined) return redirect('/onboarding'); + + return redirect(`/${libraryId}`); + } }, { path: 'onboarding', lazy: () => import('./onboarding/Layout'), - children: onboardingRoutes(os) + children: onboardingRoutes }, { path: ':libraryId', lazy: () => import('./$libraryId/Layout'), - children: libraryRoutes + loader: async ({ params: { libraryId } }) => { + const libraries = await getCachedLibraries(); + const library = libraries.find((l) => l.uuid === libraryId); + + if (!library) { + const firstLibrary = libraries[0]; + + if (firstLibrary) return redirect(`/${firstLibrary.uuid}`); + else return redirect('/onboarding'); + } + + return null; + }, + children: libraryRoutes(platform) } ] } ] satisfies RouteObject[]; -}; /** * Combines the `path` segments of the current route into a single string. @@ -75,10 +100,10 @@ export const routes = (os: OperatingSystem) => { * but not the values used in the route params. */ const useRawRoutePath = () => { + const { routes } = useRoutingContext(); // `useMatches` returns a list of each matched RouteObject, // we grab the last one as it contains all previous route segments. const lastMatchId = useMatches().slice(-1)[0]?.id; - const os = useOperatingSystem(); const rawPath = useMemo(() => { const [rawPath] = @@ -100,11 +125,11 @@ const useRawRoutePath = () => { // `path` found, chuck it on the end return [`${rawPath}/${item.path}`, item]; }, - ['' as string, { children: routes(os) }] as const + ['' as string, { children: routes }] as const ) ?? []; return rawPath ?? '/'; - }, [lastMatchId, os]); + }, [lastMatchId, routes]); return rawPath; }; diff --git a/interface/app/onboarding/Progress.tsx b/interface/app/onboarding/Progress.tsx index 3efe87f39..207cb37b3 100644 --- a/interface/app/onboarding/Progress.tsx +++ b/interface/app/onboarding/Progress.tsx @@ -4,8 +4,6 @@ import { useMatch, useNavigate } from 'react-router'; import { getOnboardingStore, unlockOnboardingScreen, useOnboardingStore } from '@sd/client'; import { useOperatingSystem } from '~/hooks'; -import routes from '.'; - export default function OnboardingProgress() { const obStore = useOnboardingStore(); const navigate = useNavigate(); @@ -21,17 +19,26 @@ export default function OnboardingProgress() { unlockOnboardingScreen(currentScreen, getOnboardingStore().unlockedScreens); }, [currentScreen]); + const routes = [ + 'alpha', + 'new-library', + os === 'macOS' && 'full-disk', + 'locations', + 'privacy', + 'creating-library' + ].filter(Boolean); + return (
- {routes(os).map(({ path }) => { + {routes.map((path) => { if (!path) return null; return (