[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>
This commit is contained in:
Brendan Allan 2023-11-25 11:16:26 +11:00 committed by GitHub
parent 59ef3b94de
commit 908a13130c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1950 additions and 1970 deletions

View file

@ -2,14 +2,16 @@ import { createMemoryHistory } from '@remix-run/router';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { appWindow } from '@tauri-apps/api/window'; 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 { RspcProvider } from '@sd/client';
import { import {
createRoutes,
ErrorPage, ErrorPage,
KeybindEvent, KeybindEvent,
PlatformProvider, PlatformProvider,
routes, SpacedriveInterfaceRoot,
SpacedriveInterface, SpacedriveRouterProvider,
TabsContext TabsContext
} from '@sd/interface'; } from '@sd/interface';
import { RouteTitleContext } from '@sd/interface/hooks/useRouteTitle'; 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 '@sd/ui/style/style.scss';
import { useOperatingSystem } from '@sd/interface/hooks';
import * as commands from './commands'; import * as commands from './commands';
import { platform } from './platform'; import { platform } from './platform';
import { queryClient } from './query'; 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 // 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 TAB_CREATE_DELAY = 150;
const routes = createRoutes(platform);
function AppInner() { function AppInner() {
const os = useOperatingSystem(); const [tabs, setTabs] = useState(() => [createTab()]);
const [tabIndex, setTabIndex] = useState(0);
function createTab() { function createTab() {
const history = createMemoryHistory(); const history = createMemoryHistory();
const router = createMemoryRouterWithHistory({ routes: routes(os), history }); const router = createMemoryRouterWithHistory({ routes, history });
const dispose = router.subscribe((event) => { const dispose = router.subscribe((event) => {
setTabs((routers) => { setTabs((routers) => {
@ -107,22 +111,36 @@ function AppInner() {
}); });
return { return {
id: Math.random().toString(),
router, router,
history, history,
dispose, dispose,
element: document.createElement('div'),
currentIndex: 0, currentIndex: 0,
maxIndex: 0, maxIndex: 0,
title: 'New Tab' title: 'New Tab'
}; };
} }
const [tabs, setTabs] = useState(() => [createTab()]);
const [tabIndex, setTabIndex] = useState(0);
const tab = tabs[tabIndex]!; const tab = tabs[tabIndex]!;
const createTabPromise = useRef(Promise.resolve()); const createTabPromise = useRef(Promise.resolve());
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const div = ref.current;
if (!div) return;
div.appendChild(tab.element);
return () => {
while (div.firstChild) {
div.removeChild(div.firstChild);
}
};
}, [tab.element]);
return ( return (
<RouteTitleContext.Provider <RouteTitleContext.Provider
value={useMemo( value={useMemo(
@ -151,12 +169,14 @@ function AppInner() {
createTabPromise.current = createTabPromise.current.then( createTabPromise.current = createTabPromise.current.then(
() => () =>
new Promise((res) => { new Promise((res) => {
setTabs((tabs) => { startTransition(() => {
const newTabs = [...tabs, createTab()]; setTabs((tabs) => {
const newTabs = [...tabs, createTab()];
setTabIndex(newTabs.length - 1); setTabIndex(newTabs.length - 1);
return newTabs; return newTabs;
});
}); });
setTimeout(res, TAB_CREATE_DELAY); setTimeout(res, TAB_CREATE_DELAY);
@ -164,29 +184,41 @@ function AppInner() {
); );
}, },
removeTab(index: number) { removeTab(index: number) {
setTabs((tabs) => { startTransition(() => {
const tab = tabs[index]; setTabs((tabs) => {
if (!tab) return 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];
});
}); });
} }
}} }}
> >
<SpacedriveInterface <SpacedriveInterfaceRoot>
routing={{ {tabs.map((tab) =>
router: tab.router, createPortal(
routerKey: tabIndex, <SpacedriveRouterProvider
currentIndex: tab.currentIndex, key={tab.id}
maxIndex: tab.maxIndex routing={{
}} routes,
/> visible: tabIndex === tabs.indexOf(tab),
router: tab.router,
currentIndex: tab.currentIndex,
maxIndex: tab.maxIndex
}}
/>,
tab.element
)
)}
<div ref={ref} />
</SpacedriveInterfaceRoot>
</TabsContext.Provider> </TabsContext.Provider>
</RouteTitleContext.Provider> </RouteTitleContext.Provider>
); );

View file

@ -2,8 +2,8 @@ import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createBrowserRouter } from 'react-router-dom'; import { createBrowserRouter } from 'react-router-dom';
import { RspcProvider } from '@sd/client'; import { RspcProvider } from '@sd/client';
import { Platform, PlatformProvider, routes, SpacedriveInterface } from '@sd/interface'; import { createRoutes, Platform, PlatformProvider, SpacedriveRouterProvider } from '@sd/interface';
import { useOperatingSystem, useShowControls } from '@sd/interface/hooks'; import { useShowControls } from '@sd/interface/hooks';
import demoData from './demoData.json'; import demoData from './demoData.json';
import ScreenshotWrapper from './ScreenshotWrapper'; import ScreenshotWrapper from './ScreenshotWrapper';
@ -75,10 +75,50 @@ const queryClient = new QueryClient({
} }
}); });
const routes = createRoutes(platform);
function App() { function App() {
const os = useOperatingSystem(); const router = useRouter();
const domEl = useRef<HTMLDivElement>(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 (
<ScreenshotWrapper showControls={!!showControls}>
<div ref={domEl} className="App">
<RspcProvider queryClient={queryClient}>
<PlatformProvider platform={platform}>
<QueryClientProvider client={queryClient}>
<SpacedriveRouterProvider
routing={{
...router,
routes,
visible: true
}}
/>
</QueryClientProvider>
</PlatformProvider>
</RspcProvider>
</div>
</ScreenshotWrapper>
);
}
export default App;
function useRouter() {
const [router, setRouter] = useState(() => { const [router, setRouter] = useState(() => {
const router = createBrowserRouter(routes(os)); const router = createBrowserRouter(createRoutes(platform));
router.subscribe((event) => { router.subscribe((event) => {
setRouter((router) => { setRouter((router) => {
@ -104,37 +144,5 @@ function App() {
}; };
}); });
const domEl = useRef<HTMLDivElement>(null); return router;
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 (
<ScreenshotWrapper showControls={!!showControls}>
<div ref={domEl} className="App">
<RspcProvider queryClient={queryClient}>
<PlatformProvider platform={platform}>
<QueryClientProvider client={queryClient}>
<SpacedriveInterface
routing={{
...router,
routerKey: 0
}}
/>
</QueryClientProvider>
</PlatformProvider>
</RspcProvider>
</div>
</ScreenshotWrapper>
);
} }
export default App;

View file

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

View file

@ -536,11 +536,15 @@ model Notification {
model SavedSearch { model SavedSearch {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
pub_id Bytes @unique pub_id Bytes @unique
filters Bytes?
search String?
filters String?
name String? name String?
icon String? icon String?
description String? description String?
order Int? // Add this line to include ordering // order Int? // Add this line to include ordering
date_created DateTime? date_created DateTime?
date_modified DateTime? date_modified DateTime?

View file

@ -51,7 +51,7 @@ impl FilePathOrder {
} }
} }
#[derive(Deserialize, Type, Debug, Clone)] #[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum FilePathFilterArgs { pub enum FilePathFilterArgs {
Locations(InOrNotIn<file_path::id::Type>), Locations(InOrNotIn<file_path::id::Type>),

View file

@ -33,7 +33,7 @@ struct SearchData<T> {
items: Vec<T>, items: Vec<T>,
} }
#[derive(Deserialize, Type, Debug, Clone)] #[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum SearchFilterArgs { pub enum SearchFilterArgs {
FilePath(FilePathFilterArgs), FilePath(FilePathFilterArgs),
@ -365,5 +365,5 @@ pub fn mount() -> AlphaRouter<Ctx> {
.await? as u32) .await? as u32)
}) })
}) })
// .merge("saved.", saved::mount()) .merge("saved.", saved::mount())
} }

View file

@ -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")] #[serde(rename_all = "camelCase")]
pub enum ObjectHiddenFilter { pub enum ObjectHiddenFilter {
#[default] #[default]
@ -104,7 +104,7 @@ impl ObjectHiddenFilter {
} }
} }
#[derive(Deserialize, Type, Debug, Clone)] #[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum ObjectFilterArgs { pub enum ObjectFilterArgs {
Favorite(bool), Favorite(bool),

View file

@ -5,76 +5,69 @@ use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use uuid::Uuid; 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}; use super::{Ctx, R};
#[derive(Serialize, Type, Deserialize, Clone, Debug)]
pub struct Filter {
pub value: String,
pub name: String,
pub icon: Option<String>,
pub filter_type: i32,
}
#[derive(Serialize, Type, Deserialize, Clone, Debug)]
pub struct SavedSearchCreateArgs {
pub name: Option<String>,
pub filters: Option<Vec<Filter>>,
pub description: Option<String>,
pub icon: Option<String>,
}
#[derive(Serialize, Type, Deserialize, Clone, Debug)]
pub struct SavedSearchUpdateArgs {
pub id: i32,
pub name: Option<String>,
pub filters: Option<Vec<Filter>>,
pub description: Option<String>,
pub icon: Option<String>,
}
impl SavedSearchCreateArgs {
pub async fn exec(
self,
Library { db, .. }: &Library,
) -> prisma_client_rust::Result<saved_search::Data> {
print!("SavedSearchCreateArgs {:?}", self);
let pub_id = Uuid::new_v4().as_bytes().to_vec();
let date_created: DateTime<FixedOffset> = Utc::now().into();
db.saved_search()
.create(
pub_id,
chain_optional_iter(
[saved_search::date_created::set(Some(date_created))],
[
self.name.map(Some).map(saved_search::name::set),
self.filters
.map(|f| serde_json::to_string(&f).unwrap().into_bytes())
.map(Some)
.map(saved_search::filters::set),
self.description
.map(Some)
.map(saved_search::description::set),
self.icon.map(Some).map(saved_search::icon::set),
],
),
)
.exec()
.await
}
}
pub(crate) fn mount() -> AlphaRouter<Ctx> { pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router() R.router()
.procedure("create", { .procedure("create", {
R.with2(library()) R.with2(library()).mutation({
.mutation(|(_, library), args: SavedSearchCreateArgs| async move { #[derive(Serialize, Type, Deserialize, Clone, Debug)]
args.exec(&library).await?; #[specta(inline)]
// invalidate_query!(library, "search.saved.list"); pub struct Args {
pub name: String,
#[specta(optional)]
pub search: Option<String>,
#[specta(optional)]
pub filters: Option<String>,
#[specta(optional)]
pub description: Option<String>,
#[specta(optional)]
pub icon: Option<String>,
}
|(_, library), args: Args| async move {
let pub_id = Uuid::new_v4().as_bytes().to_vec();
let date_created: DateTime<FixedOffset> = 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::<serde_json::Value>(&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(()) Ok(())
}) }
})
}) })
.procedure("get", { .procedure("get", {
R.with2(library()) R.with2(library())
@ -88,87 +81,43 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}) })
}) })
.procedure("list", { .procedure("list", {
#[derive(Serialize, Type, Deserialize, Clone)]
pub struct SavedSearchResponse {
pub id: i32,
pub pub_id: Vec<u8>,
pub name: Option<String>,
pub icon: Option<String>,
pub description: Option<String>,
pub order: Option<i32>,
pub date_created: Option<DateTime<FixedOffset>>,
pub date_modified: Option<DateTime<FixedOffset>>,
pub filters: Option<Vec<Filter>>,
}
R.with2(library()).query(|(_, library), _: ()| async move { R.with2(library()).query(|(_, library), _: ()| async move {
let searches: Vec<saved_search::Data> = library Ok(library
.db .db
.saved_search() .saved_search()
.find_many(vec![]) .find_many(vec![])
// .order_by(saved_search::order::order(prisma::SortOrder::Desc)) // .order_by(saved_search::order::order(prisma::SortOrder::Desc))
.exec() .exec()
.await?; .await?)
let result: Result<Vec<SavedSearchResponse>, _> = 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<Filter> = serde_json::from_str(&filters_string).unwrap();
Ok(SavedSearchResponse {
id: search.id,
pub_id: search.pub_id,
name: search.name,
icon: search.icon,
description: search.description,
order: search.order,
date_created: search.date_created,
date_modified: search.date_modified,
filters: Some(filters),
})
})
.collect(); // Collects the Result, if there is any Err it will be propagated.
result
}) })
}) })
.procedure("update", { .procedure("update", {
R.with2(library()) R.with2(library()).mutation({
.mutation(|(_, library), args: SavedSearchUpdateArgs| async move { saved_search::partial_unchecked!(Args {
let mut params = vec![]; name
description
if let Some(name) = args.name { icon
params.push(saved_search::name::set(Some(name))); search
} filters
});
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)));
}
|(_, 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()))); params.push(saved_search::date_modified::set(Some(Utc::now().into())));
library library
.db .db
.saved_search() .saved_search()
.update(saved_search::id::equals(args.id), params) .update_unchecked(saved_search::id::equals(id), params)
.exec() .exec()
.await?; .await?;
// invalidate_query!(library, "search.saved.list"); invalidate_query!(library, "search.saved.list");
invalidate_query!(library, "search.saved.get");
Ok(()) Ok(())
}) }
})
}) })
.procedure("delete", { .procedure("delete", {
R.with2(library()) R.with2(library())
@ -179,7 +128,11 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.delete(saved_search::id::equals(search_id)) .delete(saved_search::id::equals(search_id))
.exec() .exec()
.await?; .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(()) Ok(())
}) })
}) })

View file

@ -2,7 +2,7 @@ use sd_prisma::prisma;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
#[derive(Deserialize, Type, Debug, Clone)] #[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum Range<T> { pub enum Range<T> {
From(T), From(T),
@ -56,7 +56,7 @@ pub enum OrderAndPagination<TId, TOrder, TCursor> {
Cursor { id: TId, cursor: TCursor }, Cursor { id: TId, cursor: TCursor },
} }
#[derive(Deserialize, Type, Debug, Clone)] #[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum InOrNotIn<T> { pub enum InOrNotIn<T> {
In(Vec<T>), In(Vec<T>),
@ -85,7 +85,7 @@ impl<T> InOrNotIn<T> {
} }
} }
#[derive(Deserialize, Type, Debug, Clone)] #[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum TextMatch { pub enum TextMatch {
Contains(String), Contains(String),

View file

@ -1,8 +1,12 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { createRoutes } from './app';
export const RoutingContext = createContext<{ export const RoutingContext = createContext<{
visible: boolean;
currentIndex: number; currentIndex: number;
maxIndex: number; maxIndex: number;
routes: ReturnType<typeof createRoutes>;
} | null>(null); } | null>(null);
export function useRoutingContext() { export function useRoutingContext() {

View file

@ -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<HTMLDivElement, { onClick: () => void }>(({ onClick }, ref) => {
return (
<div
ref={ref}
className="flex h-full items-center rounded-r border-l border-app-darkerBox/70 px-1.5 py-0.5 text-sm hover:bg-app-lightBox/30"
onClick={onClick}
>
<RenderIcon className="h-3 w-3" icon={X} />
</div>
);
});
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<HTMLDivElement, UIEvent>) => {
const element = e.currentTarget;
const scroll = element.scrollLeft / (element.scrollWidth - element.clientWidth);
setScroll(Math.round(scroll * 100) / 100);
};
return (
<div className="relative flex h-full flex-1 items-center overflow-hidden">
<div
className="no-scrollbar flex h-full items-center gap-2 overflow-y-auto"
onScroll={handleScroll}
>
{searchCtx.searchQuery && searchCtx.searchQuery !== '' && (
<FilterContainer>
<StaticSection>
<RenderIcon className="h-4 w-4" icon={MagnifyingGlass} />
<FilterText>{searchCtx.searchQuery}</FilterText>
</StaticSection>
<CloseTab onClick={() => searchCtx.setSearchQuery('')} />
</FilterContainer>
)}
{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 (
<FilterContainer key={`${filter.name}-${index}`}>
<StaticSection>
<RenderIcon className="h-4 w-4" icon={filter.icon} />
<FilterText>{filter.name}</FilterText>
</StaticSection>
<InteractiveSection className="border-l">
{/* {Object.entries(filter.conditions).map(([value, displayName]) => (
<div key={value}>{displayName}</div>
))} */}
{
(filter.conditions as any)[
filter.getCondition(filter.extract(arg) as any) as any
]
}
</InteractiveSection>
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm">
{activeOptions && (
<>
{activeOptions.length === 1 ? (
<RenderIcon
className="h-4 w-4"
icon={activeOptions[0]!.icon}
/>
) : (
<div
className="relative"
style={{ width: `${activeOptions.length * 12}px` }}
>
{activeOptions.map((option, index) => (
<div
key={index}
className="absolute -top-2 left-0"
style={{
zIndex: activeOptions.length - index,
left: `${index * 10}px`
}}
>
<RenderIcon
className="h-4 w-4"
icon={option.icon}
/>
</div>
))}
</div>
)}
{activeOptions.length > 1
? `${activeOptions.length} ${pluralize(filter.name)}`
: activeOptions[0]?.name}
</>
)}
</InteractiveSection>
{removalIndex !== null && (
<CloseTab
onClick={() => {
updateFilterArgs((args) => {
args.splice(removalIndex, 1);
return args;
});
}}
/>
)}
</FilterContainer>
);
})}
</div>
{scroll > 0.1 && <FiltersOverflowShade className="left-0 rotate-180" />}
{scroll < 0.9 && <FiltersOverflowShade className="right-0" />}
</div>
);
};
function pluralize(word?: string) {
if (word?.endsWith('s')) return word;
return `${word}s`;
}
// {
// groupedFilters?.map((group) => {
// const showRemoveButton = group.filters.some((filter) => filter.canBeRemoved);
// const meta = filterRegistry.find((f) => f.name === group.type);
// return (
// <FilterContainer key={group.type}>
// <StaticSection>
// <RenderIcon className="h-4 w-4" icon={meta?.icon} />
// <FilterText>{meta?.name}</FilterText>
// </StaticSection>
// {meta?.conditions && (
// <InteractiveSection className="border-l">
// {/* {Object.values(meta.conditions).map((condition) => (
// <div key={condition}>{condition}</div>
// ))} */}
// is
// </InteractiveSection>
// )}
// <InteractiveSection className="border-app-darkerBox/70 gap-1 border-l py-0.5 pl-1.5 pr-2 text-sm">
// {group.filters.length > 1 && (
// <div
// className="relative"
// style={{ width: `${group.filters.length * 12}px` }}
// >
// {group.filters.map((filter, index) => (
// <div
// key={index}
// className="absolute -top-2 left-0"
// style={{
// zIndex: group.filters.length - index,
// left: `${index * 10}px`
// }}
// >
// <RenderIcon className="h-4 w-4" icon={filter.icon} />
// </div>
// ))}
// </div>
// )}
// {group.filters.length === 1 && (
// <RenderIcon className="h-4 w-4" icon={group.filters[0]?.icon} />
// )}
// {group.filters.length > 1
// ? `${group.filters.length} ${pluralize(meta?.name)}`
// : group.filters[0]?.name}
// </InteractiveSection>
// {showRemoveButton && (
// <CloseTab
// onClick={() =>
// group.filters.forEach((filter) => {
// if (filter.canBeRemoved) {
// deselectFilterOption(filter);
// }
// })
// }
// />
// )}
// </FilterContainer>
// );
// });
// }

View file

@ -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<ReturnType<typeof useContextValue> | 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 <Context.Provider value={useContextValue()}>{children}</Context.Provider>;
};
export function useSearchContext() {
const ctx = useContext(Context);
if (!ctx) throw new Error('SearchContextProvider not found!');
return ctx;
}

View file

@ -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<number | null>(null);
return {
// searches,
loadSearch: (id: number) => {
// const search = searches?.find((search) => search.id === id);
// if (search) {
// TODO
// search.filters?.forEach(({ filter_type, name, value, icon }) => {
// const filter: Filter = {
// type: filter_type,
// name,
// value,
// icon: icon || ''
// };
// const key = getKey(filter);
// searchStore.registeredFilters.set(key, filter);
// selectFilter(filter, true);
// });
// }
},
removeSearch: (id: number) => {
// removeSavedSearch.mutate(id);
},
saveSearch: (name: string) => {
// createSavedSearch.mutate({
// name,
// description: '',
// icon: '',
// filters: filters.map((filter) => ({
// filter_type: filter.type,
// name: filter.name,
// value: filter.value,
// icon: filter.icon || 'Folder'
// }))
// });
}
};
};

View file

@ -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 (
<div className="flex items-center gap-2">
{props.selected !== undefined && (
<RadixCheckbox checked={props.selected} onCheckedChange={props.setSelected} />
)}
<RenderIcon icon={props.icon} />
{props.children}
</div>
);
};
// for individual items in a submenu, defined in Options
export const SearchOptionItem = (props: SearchOptionItemProps) => {
return (
<DropdownMenu.Item
onSelect={(event) => {
event.preventDefault();
props.setSelected?.(!props.selected);
}}
variant="dull"
>
<SearchOptionItemInternals {...props} />
</DropdownMenu.Item>
);
};
export const SearchOptionSubMenu = (props: SearchOptionItemProps & { name?: string }) => {
return (
<DropdownMenu.SubMenu
trigger={
<ContextMenuDivItem rightArrow variant="dull">
<SearchOptionItemInternals {...props}>{props.name}</SearchOptionItemInternals>
</ContextMenuDivItem>
}
className={clsx(MENU_STYLES, '-mt-1.5')}
>
{props.children}
</DropdownMenu.SubMenu>
);
};
export const Separator = () => <DropdownMenu.Separator className="!border-app-line" />;
const SearchOptions = () => {
const searchState = useSearchStore();
const 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 (
<div
onMouseEnter={() => {
getSearchStore().interactingWithSearchOptions = true;
}}
onMouseLeave={() => {
getSearchStore().interactingWithSearchOptions = false;
}}
className="flex h-[45px] w-full flex-row items-center gap-4 bg-black/10 px-4"
>
{/* <OptionContainer className="flex flex-row items-center">
<FilterContainer>
<InteractiveSection>Paths</InteractiveSection>
</FilterContainer>
</OptionContainer> */}
<OptionContainer className="shrink-0">
<DropdownMenu.Root
onKeyDown={(e) => e.stopPropagation()}
className={MENU_STYLES}
trigger={
<Button className="flex flex-row gap-1" size="xs" variant="dotted">
<FunnelSimple />
Add Filter
</Button>
}
>
<Input
value={_search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
autoComplete="off"
autoCorrect="off"
variant="transparent"
placeholder="Filter..."
/>
<Separator />
{_search === '' ? (
filterRegistry.map((filter) => (
<filter.Render
key={filter.name}
filter={filter as any}
options={searchState.filterOptions.get(filter.name)!}
/>
))
) : (
<SearchResults search={search} />
)}
</DropdownMenu.Root>
</OptionContainer>
{/* We're keeping AppliedOptions to the right of the "Add Filter" button because its not worth rebuilding the dropdown with custom logic to lock the position as the trigger will move if to the right of the applied options and that is bad UX. */}
<AppliedOptions />
{
// searchState.filterArgs.length > 0 && (
// <DropdownMenu.Root
// className={clsx(MENU_STYLES)}
// trigger={
// <Button className="flex shrink-0 flex-row" size="xs" variant="dotted">
// <Plus weight="bold" className="mr-1" />
// Save Search
// </Button>
// }
// >
// <div className="mx-1.5 my-1 flex flex-row items-center overflow-hidden">
// <Input
// value={newFilterName}
// onChange={(e) => setNewFilterName(e.target.value)}
// autoFocus
// variant="default"
// placeholder="Name"
// className="w-[130px]"
// />
// {/* <Button
// onClick={() => {
// if (!newFilterName) return;
// savedSearches.saveSearch(newFilterName);
// setNewFilterName('');
// }}
// className="ml-2"
// variant="accent"
// >
// Save
// </Button> */}
// </div>
// </DropdownMenu.Root>)
}
<kbd
onClick={() => 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
</kbd>
</div>
);
};
export default SearchOptions;
const SearchResults = memo(({ search }: { search: string }) => {
const { fixedArgsKeys } = useSearchContext();
const searchState = useSearchStore();
const searchResults = useSearchRegisteredFilters(search);
const toggleOptionSelected = useToggleOptionSelected();
return (
<>
{searchResults.map((option) => {
const filter = filterRegistry.find((f) => f.name === option.type);
if (!filter) return;
return (
<SearchOptionItem
selected={
searchState.filterArgsKeys.has(option.key) ||
fixedArgsKeys?.has(option.key)
}
setSelected={(select) =>
toggleOptionSelected({
filter: filter as SearchFilterCRUD,
option,
select
})
}
key={option.key}
>
<div className="mr-4 flex flex-row items-center gap-1.5">
<RenderIcon icon={filter.icon} />
<span className="text-ink-dull">{filter.name}</span>
<CaretRight weight="bold" className="text-ink-dull/70" />
<RenderIcon icon={option.icon} />
{option.name}
</div>
</SearchOptionItem>
);
})}
</>
);
});

View file

@ -10,7 +10,6 @@ import DismissibleNotice from './DismissibleNotice';
import { Inspector, INSPECTOR_WIDTH } from './Inspector'; import { Inspector, INSPECTOR_WIDTH } from './Inspector';
import ExplorerContextMenu from './ParentContextMenu'; import ExplorerContextMenu from './ParentContextMenu';
import { getQuickPreviewStore } from './QuickPreview/store'; import { getQuickPreviewStore } from './QuickPreview/store';
import SearchOptions from './Search';
import { getExplorerStore, useExplorerStore } from './store'; import { getExplorerStore, useExplorerStore } from './store';
import { useKeyRevealFinder } from './useKeyRevealFinder'; import { useKeyRevealFinder } from './useKeyRevealFinder';
import View, { EmptyNotice, ExplorerViewProps } from './View'; import View, { EmptyNotice, ExplorerViewProps } from './View';
@ -21,7 +20,6 @@ import 'react-slidedown/lib/slidedown.css';
interface Props { interface Props {
emptyNotice?: ExplorerViewProps['emptyNotice']; emptyNotice?: ExplorerViewProps['emptyNotice'];
contextMenu?: () => ReactNode; contextMenu?: () => ReactNode;
showFilterBar?: boolean;
} }
/** /**

View file

@ -1,3 +1,4 @@
export * from './useExplorerInfiniteQuery'; export * from './useExplorerInfiniteQuery';
export * from './usePathsInfiniteQuery'; export * from './usePathsInfiniteQuery';
export * from './useObjectsInfiniteQuery'; export * from './useObjectsInfiniteQuery';
export * from './usePathsExplorerQuery';

View file

@ -1,11 +1,10 @@
import { UseInfiniteQueryOptions } from '@tanstack/react-query'; import { UseInfiniteQueryOptions } from '@tanstack/react-query';
import { ExplorerItem, LibraryConfigWrapped, SearchData } from '@sd/client'; import { ExplorerItem, SearchData } from '@sd/client';
import { Ordering } from '../store'; import { Ordering } from '../store';
import { UseExplorerSettings } from '../useExplorer'; import { UseExplorerSettings } from '../useExplorer';
export type UseExplorerInfiniteQueryArgs<TArg, TOrder extends Ordering> = { export type UseExplorerInfiniteQueryArgs<TArg, TOrder extends Ordering> = {
library: LibraryConfigWrapped;
arg: TArg; arg: TArg;
settings: UseExplorerSettings<TOrder>; explorerSettings: UseExplorerSettings<TOrder>;
} & Pick<UseInfiniteQueryOptions<SearchData<ExplorerItem>>, 'enabled'>; } & Pick<UseInfiniteQueryOptions<SearchData<ExplorerItem>>, 'enabled' | 'suspense'>;

View file

@ -0,0 +1,20 @@
import { UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { SearchData } from '@sd/client';
export function useExplorerQuery<Q>(
query: UseInfiniteQueryResult<SearchData<Q>>,
count: UseQueryResult<number>
) {
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<Q> = ReturnType<typeof useExplorerQuery<Q>>;

View file

@ -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<ObjectOrder>;
}) {
const query = useObjectsInfiniteQuery(props);
const count = useLibraryQuery(['search.objectsCount', { filters: props.arg.filters }], {
enabled: query.isSuccess
});
return useExplorerQuery(query, count);
}

View file

@ -4,29 +4,30 @@ import {
ObjectCursor, ObjectCursor,
ObjectOrder, ObjectOrder,
ObjectSearchArgs, ObjectSearchArgs,
useLibraryContext,
useRspcLibraryContext useRspcLibraryContext
} from '@sd/client'; } from '@sd/client';
import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery'; import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery';
export function useObjectsInfiniteQuery({ export function useObjectsInfiniteQuery({
library,
arg, arg,
settings, explorerSettings,
...args ...args
}: UseExplorerInfiniteQueryArgs<ObjectSearchArgs, ObjectOrder>) { }: UseExplorerInfiniteQueryArgs<ObjectSearchArgs, ObjectOrder>) {
const { library } = useLibraryContext();
const ctx = useRspcLibraryContext(); const ctx = useRspcLibraryContext();
const explorerSettings = settings.useSettingsSnapshot(); const settings = explorerSettings.useSettingsSnapshot();
if (explorerSettings.order) { if (settings.order) {
arg.orderAndPagination = { orderOnly: explorerSettings.order }; arg.orderAndPagination = { orderOnly: settings.order };
} }
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ['search.objects', { library_id: library.uuid, arg }] as const, queryKey: ['search.objects', { library_id: library.uuid, arg }] as const,
queryFn: ({ pageParam, queryKey: [_, { arg }] }) => { queryFn: ({ pageParam, queryKey: [_, { arg }] }) => {
const cItem: Extract<ExplorerItem, { type: 'Object' }> = pageParam; const cItem: Extract<ExplorerItem, { type: 'Object' }> = pageParam;
const { order } = explorerSettings; const { order } = settings;
let orderAndPagination: (typeof arg)['orderAndPagination']; let orderAndPagination: (typeof arg)['orderAndPagination'];

View file

@ -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<FilePathOrder>;
}) {
const query = usePathsInfiniteQuery(props);
const count = useLibraryQuery(['search.pathsCount', { filters: props.arg.filters }], {
enabled: query.isSuccess
});
return useExplorerQuery(query, count);
}

View file

@ -5,6 +5,7 @@ import {
FilePathObjectCursor, FilePathObjectCursor,
FilePathOrder, FilePathOrder,
FilePathSearchArgs, FilePathSearchArgs,
useLibraryContext,
useRspcLibraryContext useRspcLibraryContext
} from '@sd/client'; } from '@sd/client';
@ -12,16 +13,16 @@ import { getExplorerStore } from '../store';
import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery'; import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery';
export function usePathsInfiniteQuery({ export function usePathsInfiniteQuery({
library,
arg, arg,
settings, explorerSettings,
...args ...args
}: UseExplorerInfiniteQueryArgs<FilePathSearchArgs, FilePathOrder>) { }: UseExplorerInfiniteQueryArgs<FilePathSearchArgs, FilePathOrder>) {
const { library } = useLibraryContext();
const ctx = useRspcLibraryContext(); const ctx = useRspcLibraryContext();
const explorerSettings = settings.useSettingsSnapshot(); const settings = explorerSettings.useSettingsSnapshot();
if (explorerSettings.order) { if (settings.order) {
arg.orderAndPagination = { orderOnly: explorerSettings.order }; arg.orderAndPagination = { orderOnly: settings.order };
if (arg.orderAndPagination.orderOnly.field === 'sizeInBytes') delete arg.take; 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, queryKey: ['search.paths', { library_id: library.uuid, arg }] as const,
queryFn: ({ pageParam, queryKey: [_, { arg }] }) => { queryFn: ({ pageParam, queryKey: [_, { arg }] }) => {
const cItem: Extract<ExplorerItem, { type: 'Path' }> = pageParam; const cItem: Extract<ExplorerItem, { type: 'Path' }> = pageParam;
const { order } = explorerSettings; const { order } = settings;
let orderAndPagination: (typeof arg)['orderAndPagination']; let orderAndPagination: (typeof arg)['orderAndPagination'];

View file

@ -1,3 +1,4 @@
import { UseInfiniteQueryResult } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { proxy, snapshot, subscribe, useSnapshot } from 'valtio'; import { proxy, snapshot, subscribe, useSnapshot } from 'valtio';

View file

@ -1,11 +1,12 @@
import { X } from '@phosphor-icons/react'; import { X } from '@phosphor-icons/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useMatch, useNavigate, useResolvedPath } from 'react-router';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { import {
arraysEqual, arraysEqual,
useBridgeQuery, useBridgeQuery,
useFeatureFlag, useFeatureFlag,
useLibraryMutation,
useLibraryQuery, useLibraryQuery,
useOnlineLocations useOnlineLocations
} from '@sd/client'; } from '@sd/client';
@ -13,214 +14,200 @@ import { Button, Tooltip } from '@sd/ui';
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton'; import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
import { Folder, Icon, SubtleButton } from '~/components'; import { Folder, Icon, SubtleButton } from '~/components';
import { useSavedSearches } from '../../Explorer/Search/SavedSearches';
import SidebarLink from './Link'; import SidebarLink from './Link';
import LocationsContextMenu from './LocationsContextMenu'; import LocationsContextMenu from './LocationsContextMenu';
import Section from './Section'; import Section from './Section';
import { SeeMore } from './SeeMore'; import { SeeMore } from './SeeMore';
import TagsContextMenu from './TagsContextMenu'; import TagsContextMenu from './TagsContextMenu';
type SidebarGroup = { export const LibrarySection = () => (
name: string; <>
items: SidebarItem[]; <SavedSearches />
}; <Devices />
<Locations />
<Tags />
</>
);
type SidebarItem = { function SavedSearches() {
name: string; const savedSearches = useLibraryQuery(['search.saved.list']);
icon: React.ReactNode;
to: string;
position: number;
};
type TriggeredContextItem = const path = useResolvedPath('saved-search/:id');
| { const match = useMatch(path.pathname);
type: 'location'; const currentSearchId = match?.params?.id;
locationId: number;
}
| {
type: 'tag';
tagId: number;
};
export const LibrarySection = () => { const currentIndex = currentSearchId
const node = useBridgeQuery(['nodeState']); ? savedSearches.data?.findIndex((s) => s.id === Number(currentSearchId))
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); : undefined;
const tags = useLibraryQuery(['tags.list'], { keepPreviousData: true });
const onlineLocations = useOnlineLocations();
const isPairingEnabled = useFeatureFlag('p2pPairing');
const [showDummyNodesEasterEgg, setShowDummyNodesEasterEgg] = useState(false);
const [triggeredContextItem, setTriggeredContextItem] = useState<TriggeredContextItem | null>(
null
);
// const savedSearches = useSavedSearches(); const navigate = useNavigate();
useEffect(() => { const deleteSavedSearch = useLibraryMutation(['search.saved.delete'], {
const outsideClick = () => { onSuccess() {
document.addEventListener('click', () => { if (currentIndex !== undefined && savedSearches.data) {
setTriggeredContextItem(null); const nextIndex = Math.min(currentIndex + 1, savedSearches.data.length - 2);
});
}; const search = savedSearches.data[nextIndex];
outsideClick();
return () => { if (search) navigate(`saved-search/${search.id}`);
document.removeEventListener('click', outsideClick); else navigate(`./`);
}; }
}, [triggeredContextItem]); }
});
if (!savedSearches.data || savedSearches.data.length < 1) return null;
return ( return (
<> <Section
{/* {savedSearches.searches.length > 0 && ( name="Saved Searches"
<Section // actionArea={
name="Saved" // <Link to="settings/library/saved-searches">
// actionArea={ // <SubtleButton />
// <Link to="settings/library/saved-searches"> // </Link>
// <SubtleButton /> // }
// </Link> >
// } <SeeMore>
> {savedSearches.data.map((search, i) => (
<SeeMore
items={savedSearches.searches}
renderItem={(search) => (
<SidebarLink
className="group/button relative w-full"
to={`search/${search.id}`}
key={search.id}
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Folder size={18} />
</div>
<span className="truncate">{search.name}</span>
<Button
className="absolute right-[2px] top-[2px] hidden rounded-full shadow group-hover/button:block"
size="icon"
variant="subtle"
onClick={() => savedSearches.removeSearch(search.id)}
>
<X weight="bold" className="text-ink-dull/50" />
</Button>
</SidebarLink>
)}
/>
</Section>
)} */}
<Section
name="Devices"
actionArea={
isPairingEnabled && (
<Link to="settings/library/nodes">
<SubtleButton />
</Link>
)
}
>
{node.data && (
<SidebarLink <SidebarLink
className="group relative w-full" className="group/button relative w-full"
to={`node/${node.data.id}`} to={`saved-search/${search.id}`}
key={node.data.id} key={search.id}
> >
<Icon name="Laptop" size={20} className="mr-1" /> <div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<span className="truncate">{node.data.name}</span> <Folder size={18} />
</SidebarLink> </div>
)}
<Tooltip
label="Coming soon! This alpha release doesn't include library sync, it will be ready very soon."
position="right"
>
<Button disabled variant="dotted" className="mt-1 w-full">
Add Device
</Button>
</Tooltip>
</Section>
<Section <span className="truncate">{search.name}</span>
name="Locations"
actionArea={ <Button
<Link to="settings/library/locations"> className="absolute right-[2px] top-[2px] hidden rounded-full shadow group-hover/button:block"
size="icon"
variant="subtle"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
deleteSavedSearch.mutate(search.id);
}}
>
<X size={10} weight="bold" className="text-ink-dull/50" />
</Button>
</SidebarLink>
))}
</SeeMore>
</Section>
);
}
function Devices() {
const node = useBridgeQuery(['nodeState']);
const isPairingEnabled = useFeatureFlag('p2pPairing');
return (
<Section
name="Devices"
actionArea={
isPairingEnabled && (
<Link to="settings/library/nodes">
<SubtleButton /> <SubtleButton />
</Link> </Link>
} )
> }
<SeeMore> >
{locationsQuery.data?.map((location) => ( {node.data && (
<LocationsContextMenu key={location.id} locationId={location.id}> <SidebarLink
<SidebarLink className="group relative w-full"
onContextMenu={() => to={`node/${node.data.id}`}
setTriggeredContextItem({ key={node.data.id}
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}`}
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Icon name="Folder" size={18} />
<div
className={clsx(
'absolute bottom-0.5 right-0 h-1.5 w-1.5 rounded-full',
onlineLocations.some((l) =>
arraysEqual(location.pub_id, l)
)
? 'bg-green-500'
: 'bg-red-500'
)}
/>
</div>
<span className="truncate">{location.name}</span>
</SidebarLink>
</LocationsContextMenu>
))}
</SeeMore>
<AddLocationButton className="mt-1" />
</Section>
{!!tags.data?.length && (
<Section
name="Tags"
actionArea={
<NavLink to="settings/library/tags">
<SubtleButton />
</NavLink>
}
> >
<SeeMore> <Icon name="Laptop" size={20} className="mr-1" />
{tags.data?.map((tag, index) => ( <span className="truncate">{node.data.name}</span>
<TagsContextMenu tagId={tag.id} key={tag.id}> </SidebarLink>
<SidebarLink
onContextMenu={() =>
setTriggeredContextItem({
type: 'tag',
tagId: tag.id
})
}
className={clsx(
triggeredContextItem?.type === 'tag' &&
triggeredContextItem?.tagId === tag.id
? 'border-accent'
: 'border-transparent',
'border'
)}
to={`tag/${tag.id}`}
>
<div
className="h-[12px] w-[12px] shrink-0 rounded-full"
style={{ backgroundColor: tag.color || '#efefef' }}
/>
<span className="ml-1.5 truncate text-sm">{tag.name}</span>
</SidebarLink>
</TagsContextMenu>
))}
</SeeMore>
</Section>
)} )}
</> <Tooltip
label="Coming soon! This alpha release doesn't include library sync, it will be ready very soon."
position="right"
>
<Button disabled variant="dotted" className="mt-1 w-full">
Add Device
</Button>
</Tooltip>
</Section>
); );
}; }
function Locations() {
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
const onlineLocations = useOnlineLocations();
return (
<Section
name="Locations"
actionArea={
<Link to="settings/library/locations">
<SubtleButton />
</Link>
}
>
<SeeMore>
{locationsQuery.data?.map((location) => (
<LocationsContextMenu key={location.id} locationId={location.id}>
<SidebarLink
className="borderradix-state-closed:border-transparent group relative w-full radix-state-open:border-accent"
to={`location/${location.id}`}
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Icon name="Folder" size={18} />
<div
className={clsx(
'absolute bottom-0.5 right-0 h-1.5 w-1.5 rounded-full',
onlineLocations.some((l) => arraysEqual(location.pub_id, l))
? 'bg-green-500'
: 'bg-red-500'
)}
/>
</div>
<span className="truncate">{location.name}</span>
</SidebarLink>
</LocationsContextMenu>
))}
</SeeMore>
<AddLocationButton className="mt-1" />
</Section>
);
}
function Tags() {
const tags = useLibraryQuery(['tags.list'], { keepPreviousData: true });
if (!tags.data?.length) return;
return (
<Section
name="Tags"
actionArea={
<NavLink to="settings/library/tags">
<SubtleButton />
</NavLink>
}
>
<SeeMore>
{tags.data?.map((tag) => (
<TagsContextMenu tagId={tag.id} key={tag.id}>
<SidebarLink
className="border radix-state-closed:border-transparent radix-state-open:border-accent"
to={`tag/${tag.id}`}
>
<div
className="h-[12px] w-[12px] shrink-0 rounded-full"
style={{ backgroundColor: tag.color || '#efefef' }}
/>
<span className="ml-1.5 truncate text-sm">{tag.name}</span>
</SidebarLink>
</TagsContextMenu>
))}
</SeeMore>
</Section>
);
}

View file

@ -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<HTMLDivElement, { onClick: () => void }>(({ onClick }, ref) => {
return (
<div
ref={ref}
className="flex h-full items-center rounded-r border-l border-app-darkerBox/70 px-1.5 py-0.5 text-sm hover:bg-app-lightBox/30"
onClick={onClick}
>
<RenderIcon className="h-3 w-3" icon={X} />
</div>
);
});
export const AppliedFilters = ({ allowRemove = true }: { allowRemove?: boolean }) => {
const search = useSearchContext();
return (
<>
{search.search && (
<FilterContainer>
<StaticSection>
<RenderIcon className="h-4 w-4" icon={MagnifyingGlass} />
<FilterText>{search.search}</FilterText>
</StaticSection>
{allowRemove && <CloseTab onClick={() => search.setRawSearch('')} />}
</FilterContainer>
)}
{search.mergedFilters.map(({ arg, removalIndex }, index) => {
const filter = filterRegistry.find((f) => f.extract(arg));
if (!filter) return;
return (
<FilterArg
key={`${filter.name}-${index}`}
arg={arg}
onDelete={
removalIndex !== null && allowRemove
? () => {
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 (
<FilterContainer>
<StaticSection>
<RenderIcon className="h-4 w-4" icon={filter.icon} />
<FilterText>{filter.name}</FilterText>
</StaticSection>
<InteractiveSection className="border-l">
{/* {Object.entries(filter.conditions).map(([value, displayName]) => (
<div key={value}>{displayName}</div>
))} */}
{(filter.conditions as any)[filter.getCondition(filter.extract(arg) as any) as any]}
</InteractiveSection>
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm">
{activeOptions && (
<>
{activeOptions.length === 1 ? (
<RenderIcon className="h-4 w-4" icon={activeOptions[0]!.icon} />
) : (
<div
className="relative"
style={{ width: `${activeOptions.length * 12}px` }}
>
{activeOptions.map((option, index) => (
<div
key={index}
className="absolute -top-2 left-0"
style={{
zIndex: activeOptions.length - index,
left: `${index * 10}px`
}}
>
<RenderIcon className="h-4 w-4" icon={option.icon} />
</div>
))}
</div>
)}
{activeOptions.length > 1
? `${activeOptions.length} ${pluralize(filter.name)}`
: activeOptions[0]?.name}
</>
)}
</InteractiveSection>
{onDelete && <CloseTab onClick={onDelete} />}
</FilterContainer>
);
}
function pluralize(word?: string) {
if (word?.endsWith('s')) return word;
return `${word}s`;
}

View file

@ -1,11 +1,11 @@
import { CircleDashed, Cube, Folder, Icon, SelectionSlash, Textbox } from '@phosphor-icons/react'; 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 { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client';
import { Button, Input } from '@sd/ui'; import { Button, Input } from '@sd/ui';
import { SearchOptionItem, SearchOptionSubMenu } from '.'; import { SearchOptionItem, SearchOptionSubMenu } from '.';
import { useSearchContext } from './Context'; import { AllKeys, FilterOption, getKey } from './store';
import { AllKeys, FilterOption, getKey, updateFilterArgs, useSearchStore } from './store'; import { UseSearch } from './useSearch';
import { FilterTypeCondition, filterTypeCondition } from './util'; import { FilterTypeCondition, filterTypeCondition } from './util';
export interface SearchFilter< export interface SearchFilter<
@ -38,63 +38,61 @@ export interface RenderSearchFilter<
Render: (props: { Render: (props: {
filter: SearchFilterCRUD<TConditions>; filter: SearchFilterCRUD<TConditions>;
options: (FilterOption & { type: string })[]; options: (FilterOption & { type: string })[];
search: UseSearch;
}) => JSX.Element; }) => JSX.Element;
// Apply is responsible for applying the filter to the search args // Apply is responsible for applying the filter to the search args
useOptions: (props: { search: string }) => FilterOption[]; useOptions: (props: { search: string }) => FilterOption[];
} }
export function useToggleOptionSelected() { export function useToggleOptionSelected({ search }: { search: UseSearch }) {
const { fixedArgsKeys } = useSearchContext(); return ({
filter,
option,
select
}: {
filter: SearchFilterCRUD;
option: FilterOption;
select: boolean;
}) => {
search.updateDynamicFilters((dynamicFilters) => {
const key = getKey({ ...option, type: filter.name });
return useCallback( if (search.fixedFiltersKeys?.has(key)) return dynamicFilters;
({
filter,
option,
select
}: {
filter: SearchFilterCRUD;
option: FilterOption;
select: boolean;
}) =>
updateFilterArgs((args) => {
const key = getKey({ ...option, type: filter.name });
if (fixedArgsKeys?.has(key)) return args; const rawArg = 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.extract(rawArg)!;
const arg = filter.create(option.value);
args.push(arg); if (select) {
if (rawArg) filter.applyAdd(arg, option);
} else { } else {
const rawArgIndex = args.findIndex((arg) => filter.extract(arg))!; if (!filter.applyRemove(arg, option)) dynamicFilters.splice(rawArgIndex, 1);
const arg = filter.extract(rawArg)!;
if (select) {
if (rawArg) filter.applyAdd(arg, option);
} else {
if (!filter.applyRemove(arg, option)) args.splice(rawArgIndex, 1);
}
} }
}
return args; return dynamicFilters;
}), });
[fixedArgsKeys] };
);
} }
const FilterOptionList = ({ const FilterOptionList = ({
filter, filter,
options options,
search
}: { }: {
filter: SearchFilterCRUD; filter: SearchFilterCRUD;
options: FilterOption[]; options: FilterOption[];
search: UseSearch;
}) => { }) => {
const store = useSearchStore(); const { allFiltersKeys } = search;
const { fixedArgsKeys } = useSearchContext();
const toggleOptionSelected = useToggleOptionSelected(); const toggleOptionSelected = useToggleOptionSelected({ search });
return ( return (
<SearchOptionSubMenu name={filter.name} icon={filter.icon}> <SearchOptionSubMenu name={filter.name} icon={filter.icon}>
@ -106,16 +104,14 @@ const FilterOptionList = ({
return ( return (
<SearchOptionItem <SearchOptionItem
selected={ selected={allFiltersKeys.has(optionKey)}
store.filterArgsKeys.has(optionKey) || fixedArgsKeys?.has(optionKey) setSelected={(value) => {
}
setSelected={(value) =>
toggleOptionSelected({ toggleOptionSelected({
filter, filter,
option, option,
select: value select: value
}) });
} }}
key={option.value} key={option.value}
icon={option.icon} 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 [value, setValue] = useState('');
const { fixedArgsKeys } = useSearchContext(); const { fixedFiltersKeys } = search;
return ( return (
<SearchOptionSubMenu name={filter.name} icon={filter.icon}> <SearchOptionSubMenu name={filter.name} icon={filter.icon} className="flex flex-row gap-2">
<Input value={value} onChange={(e) => setValue(e.target.value)} /> <Input value={value} onChange={(e) => setValue(e.target.value)} />
<Button <Button
variant="accent" variant="accent"
onClick={() => { onClick={() => {
updateFilterArgs((args) => { search.updateDynamicFilters((dynamicFilters) => {
const key = getKey({ const key = getKey({
type: filter.name, type: filter.name,
name: value, name: value,
value value
}); });
if (fixedArgsKeys?.has(key)) return args; if (fixedFiltersKeys?.has(key)) return dynamicFilters;
const arg = filter.create(value); const arg = filter.create(value);
args.push(arg); dynamicFilters.push(arg);
return args; return dynamicFilters;
}); });
}} }}
> >
@ -160,10 +156,14 @@ const FilterOptionText = ({ filter }: { filter: SearchFilterCRUD }) => {
); );
}; };
const FilterOptionBoolean = ({ filter }: { filter: SearchFilterCRUD }) => { const FilterOptionBoolean = ({
const { filterArgsKeys } = useSearchStore(); filter,
search
const { fixedArgsKeys } = useSearchContext(); }: {
filter: SearchFilterCRUD;
search: UseSearch;
}) => {
const { fixedFiltersKeys, allFiltersKeys } = search;
const key = getKey({ const key = getKey({
type: filter.name, type: filter.name,
@ -174,21 +174,21 @@ const FilterOptionBoolean = ({ filter }: { filter: SearchFilterCRUD }) => {
return ( return (
<SearchOptionItem <SearchOptionItem
icon={filter.icon} icon={filter.icon}
selected={fixedArgsKeys?.has(key) || filterArgsKeys.has(key)} selected={allFiltersKeys?.has(key)}
setSelected={() => { setSelected={() => {
updateFilterArgs((args) => { search.updateDynamicFilters((dynamicFilters) => {
if (fixedArgsKeys?.has(key)) return args; if (fixedFiltersKeys?.has(key)) return dynamicFilters;
const index = args.findIndex((f) => filter.extract(f) !== undefined); const index = dynamicFilters.findIndex((f) => filter.extract(f) !== undefined);
if (index !== -1) { if (index !== -1) {
args.splice(index, 1); dynamicFilters.splice(index, 1);
} else { } else {
const arg = filter.create(true); const arg = filter.create(true);
args.push(arg); dynamicFilters.push(arg);
} }
return args; return dynamicFilters;
}); });
}} }}
> >
@ -248,8 +248,8 @@ function createInOrNotInFilter<T extends string | number>(
return filter.argsToOptions(values, options); return filter.argsToOptions(values, options);
}, },
applyAdd: (data, option) => { applyAdd: (data, option) => {
if ('in' in data) data.in.push(option.value); if ('in' in data) data.in = [...new Set([...data.in, option.value])];
else data.notIn.push(option.value); else data.notIn = [...new Set([...data.notIn, option.value])];
return data; return data;
}, },
@ -415,7 +415,9 @@ export const filterRegistry = [
icon: 'Folder' // Spacedrive folder icon icon: 'Folder' // Spacedrive folder icon
})); }));
}, },
Render: ({ filter, options }) => <FilterOptionList filter={filter} options={options} /> Render: ({ filter, options, search }) => (
<FilterOptionList filter={filter} options={options} search={search} />
)
}), }),
createInOrNotInFilter({ createInOrNotInFilter({
name: 'Tags', name: 'Tags',
@ -447,7 +449,9 @@ export const filterRegistry = [
icon: tag.color || 'CircleDashed' icon: tag.color || 'CircleDashed'
})); }));
}, },
Render: ({ filter, options }) => <FilterOptionList filter={filter} options={options} /> Render: ({ filter, options, search }) => (
<FilterOptionList filter={filter} options={options} search={search} />
)
}), }),
createInOrNotInFilter({ createInOrNotInFilter({
name: 'Kind', name: 'Kind',
@ -481,7 +485,9 @@ export const filterRegistry = [
icon: kind + '20' icon: kind + '20'
}; };
}), }),
Render: ({ filter, options }) => <FilterOptionList filter={filter} options={options} /> Render: ({ filter, options, search }) => (
<FilterOptionList filter={filter} options={options} search={search} />
)
}), }),
createTextMatchFilter({ createTextMatchFilter({
name: 'Name', name: 'Name',
@ -491,7 +497,7 @@ export const filterRegistry = [
}, },
create: (name) => ({ filePath: { name } }), create: (name) => ({ filePath: { name } }),
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
Render: ({ filter }) => <FilterOptionText filter={filter} /> Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
}), }),
createInOrNotInFilter({ createInOrNotInFilter({
name: 'Extension', name: 'Extension',
@ -508,7 +514,7 @@ export const filterRegistry = [
})); }));
}, },
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
Render: ({ filter }) => <FilterOptionText filter={filter} /> Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
}), }),
createBooleanFilter({ createBooleanFilter({
name: 'Hidden', name: 'Hidden',
@ -526,7 +532,7 @@ export const filterRegistry = [
} }
]; ];
}, },
Render: ({ filter }) => <FilterOptionBoolean filter={filter} /> Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={search} />
}) })
// idk how to handle this rn since include_descendants is part of 'path' now // idk how to handle this rn since include_descendants is part of 'path' now
// //

View file

@ -1,18 +1,15 @@
import clsx from 'clsx'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { Input, ModifierKeys, Shortcut } from '@sd/ui'; import { Input, ModifierKeys, Shortcut } from '@sd/ui';
import { SearchParamsSchema } from '~/app/route-schemas'; import { useOperatingSystem } from '~/hooks';
import { useOperatingSystem, useZodSearchParams } from '~/hooks';
import { keybindForOs } from '~/util/keybinds'; import { keybindForOs } from '~/util/keybinds';
import { useSearchStore } from '../Explorer/Search/store'; import { useSearchContext } from '../Search';
import { useSearchStore } from '../Search/store';
export default () => { export default () => {
const search = useSearchContext();
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useZodSearchParams(SearchParamsSchema);
const searchStore = useSearchStore(); const searchStore = useSearchStore();
const os = useOperatingSystem(true); const os = useOperatingSystem(true);
@ -32,7 +29,6 @@ export default () => {
); );
const blurHandler = useCallback((event: KeyboardEvent) => { const blurHandler = useCallback((event: KeyboardEvent) => {
console.log('blurHandler');
if (event.key === 'Escape' && document.activeElement === searchRef.current) { if (event.key === 'Escape' && document.activeElement === searchRef.current) {
// Check if element is in focus, then remove it // Check if element is in focus, then remove it
event.preventDefault(); event.preventDefault();
@ -50,27 +46,16 @@ export default () => {
}; };
}, [blurHandler, focusHandler]); }, [blurHandler, focusHandler]);
const [localValue, setLocalValue] = useState(searchParams.search ?? ''); const [value, setValue] = useState(search.search);
useLayoutEffect(() => setLocalValue(searchParams.search ?? ''), [searchParams.search]);
const updateValueDebounced = useDebouncedCallback((value: string) => {
setSearchParams((p) => ({ ...p, search: value }), { replace: true });
}, 300);
function updateValue(value: string) { function updateValue(value: string) {
setLocalValue(value); setValue(value);
updateValueDebounced(value); search.setSearch(value);
} }
function clearValue() { function clearValue() {
setSearchParams( setValue('');
(p) => { search.setSearch('');
delete p.search;
return { ...p };
},
{ replace: true }
);
} }
return ( return (
@ -79,18 +64,17 @@ export default () => {
placeholder="Search" placeholder="Search"
className="mx-2 w-48 transition-all duration-200 focus-within:w-60" className="mx-2 w-48 transition-all duration-200 focus-within:w-60"
size="sm" size="sm"
value={localValue} value={value}
onChange={(e) => updateValue(e.target.value)} onChange={(e) => updateValue(e.target.value)}
onBlur={() => { onBlur={() => {
if (localValue === '' && !searchStore.interactingWithSearchOptions) clearValue(); if (search.rawSearch === '' && !searchStore.interactingWithSearchOptions) {
clearValue();
search.setOpen(false);
}
}} }}
onFocus={() => updateValueDebounced(localValue)} onFocus={() => search.setOpen(true)}
right={ right={
<div <div className="pointer-events-none flex h-7 items-center space-x-1 opacity-70 group-focus-within:hidden">
className={clsx(
'pointer-events-none flex h-7 items-center space-x-1 opacity-70 group-focus-within:hidden'
)}
>
{ {
<Shortcut <Shortcut
chars={keybind([ModifierKeys.Control], ['F'])} chars={keybind([ModifierKeys.Control], ['F'])}

View file

@ -0,0 +1,33 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { filterRegistry } from './Filters';
import { useRegisterSearchFilterOptions } from './store';
import { UseSearch } from './useSearch';
const SearchContext = createContext<UseSearch | null>(null);
export function useSearchContext() {
const ctx = useContext(SearchContext);
if (!ctx) {
throw new Error('useSearchContext must be used within a SearchProvider');
}
return ctx;
}
export function SearchContextProvider({
children,
search
}: { search: UseSearch } & PropsWithChildren) {
for (const filter of filterRegistry) {
const options = filter
.useOptions({ search: search.search })
.map((o) => ({ ...o, type: filter.name }));
// eslint-disable-next-line react-hooks/rules-of-hooks
useRegisterSearchFilterOptions(filter, options);
}
return <SearchContext.Provider value={search}>{children}</SearchContext.Provider>;
}

View file

@ -0,0 +1,303 @@
import { CaretRight, FunnelSimple, Icon, Plus } from '@phosphor-icons/react';
import { IconTypes } from '@sd/assets/util';
import clsx from 'clsx';
import { memo, PropsWithChildren, useDeferredValue, useState } from 'react';
import { useLibraryMutation } from '@sd/client';
import {
Button,
ContextMenuDivItem,
DropdownMenu,
Input,
Popover,
RadixCheckbox,
tw,
usePopover
} from '@sd/ui';
import { useKeybind } from '~/hooks';
import { AppliedFilters } from './AppliedFilters';
import { useSearchContext } from './context';
import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters';
import { getSearchStore, useSearchRegisteredFilters, useSearchStore } from './store';
import { UseSearch } from './useSearch';
import { RenderIcon } from './util';
export * from './useSearch';
export * from './context';
// const Label = tw.span`text-ink-dull mr-2 text-xs`;
export const OptionContainer = tw.div`flex flex-row items-center`;
const FiltersOverflowShade = tw.div`from-app-darkerBox/80 absolute w-10 bg-gradient-to-l to-transparent h-6`;
interface SearchOptionItemProps extends PropsWithChildren {
selected?: boolean;
setSelected?: (selected: boolean) => void;
icon?: Icon | IconTypes | string;
}
const MENU_STYLES = `!rounded-md border !border-app-line !bg-app-box`;
// One component so all items have the same styling, including the submenu
const SearchOptionItemInternals = (props: SearchOptionItemProps) => {
return (
<div className="flex items-center gap-2">
{props.selected !== undefined && <RadixCheckbox checked={props.selected} />}
<RenderIcon icon={props.icon} />
{props.children}
</div>
);
};
// for individual items in a submenu, defined in Options
export const SearchOptionItem = (props: SearchOptionItemProps) => {
return (
<DropdownMenu.Item
onSelect={(event) => {
console.log('onSelect');
event.preventDefault();
props.setSelected?.(!props.selected);
}}
variant="dull"
>
<SearchOptionItemInternals {...props} />
</DropdownMenu.Item>
);
};
export const SearchOptionSubMenu = (
props: SearchOptionItemProps & { name?: string; className?: string }
) => {
return (
<DropdownMenu.SubMenu
trigger={
<ContextMenuDivItem rightArrow variant="dull">
<SearchOptionItemInternals {...props}>{props.name}</SearchOptionItemInternals>
</ContextMenuDivItem>
}
className={clsx(MENU_STYLES, '-mt-1.5', props.className)}
>
{props.children}
</DropdownMenu.SubMenu>
);
};
export const Separator = () => <DropdownMenu.Separator className="!border-app-line" />;
const SearchOptions = ({ allowExit, children }: { allowExit?: boolean } & PropsWithChildren) => {
const search = useSearchContext();
const [scroll, setScroll] = useState(0);
const handleScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
const element = e.currentTarget;
const scroll = element.scrollLeft / (element.scrollWidth - element.clientWidth);
setScroll(Math.round(scroll * 100) / 100);
};
return (
<div
onMouseEnter={() => {
getSearchStore().interactingWithSearchOptions = true;
}}
onMouseLeave={() => {
getSearchStore().interactingWithSearchOptions = false;
}}
className="flex h-[45px] w-full flex-row items-center gap-4 bg-black/10 px-4"
>
{/* <OptionContainer className="flex flex-row items-center">
<FilterContainer>
<InteractiveSection>Paths</InteractiveSection>
</FilterContainer>
</OptionContainer> */}
<AddFilterButton />
{/* 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. */}
<div className="relative flex h-full flex-1 items-center overflow-hidden">
<div
className="no-scrollbar flex h-full items-center gap-2 overflow-y-auto"
onScroll={handleScroll}
>
<AppliedFilters />
</div>
{scroll > 0.1 && <FiltersOverflowShade className="left-0 rotate-180" />}
{scroll < 0.9 && <FiltersOverflowShade className="right-0" />}
</div>
{children ?? (
<>
{(search.dynamicFilters.length > 0 || search.search !== '') && (
<SaveSearchButton />
)}
<EscapeButton />
</>
)}
</div>
);
};
export default SearchOptions;
const SearchResults = memo(
({ searchQuery, search }: { searchQuery: string; search: UseSearch }) => {
const { allFiltersKeys } = search;
const searchResults = useSearchRegisteredFilters(searchQuery);
const toggleOptionSelected = useToggleOptionSelected({ search });
return (
<>
{searchResults.map((option) => {
const filter = filterRegistry.find((f) => f.name === option.type);
if (!filter) return;
return (
<SearchOptionItem
selected={allFiltersKeys?.has(option.key)}
setSelected={(select) =>
toggleOptionSelected({
filter: filter as SearchFilterCRUD,
option,
select
})
}
key={option.key}
>
<div className="mr-4 flex flex-row items-center gap-1.5">
<RenderIcon icon={filter.icon} />
<span className="text-ink-dull">{filter.name}</span>
<CaretRight weight="bold" className="text-ink-dull/70" />
<RenderIcon icon={option.icon} />
{option.name}
</div>
</SearchOptionItem>
);
})}
</>
);
}
);
function AddFilterButton() {
const search = useSearchContext();
const searchState = useSearchStore();
const [searchQuery, setSearch] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
return (
<OptionContainer className="shrink-0">
<DropdownMenu.Root
onKeyDown={(e) => e.stopPropagation()}
className={MENU_STYLES}
trigger={
<Button className="flex flex-row gap-1" size="xs" variant="dotted">
<FunnelSimple />
Add Filter
</Button>
}
>
<Input
value={searchQuery}
onChange={(e) => setSearch(e.target.value)}
autoFocus
autoComplete="off"
autoCorrect="off"
variant="transparent"
placeholder="Filter..."
/>
<Separator />
{searchQuery === '' ? (
filterRegistry.map((filter) => (
<filter.Render
key={filter.name}
filter={filter as any}
options={searchState.filterOptions.get(filter.name)!}
search={search}
/>
))
) : (
<SearchResults searchQuery={deferredSearchQuery} search={search} />
)}
</DropdownMenu.Root>
</OptionContainer>
);
}
function SaveSearchButton() {
const search = useSearchContext();
const popover = usePopover();
const [name, setName] = useState('');
const saveSearch = useLibraryMutation('search.saved.create');
return (
<Popover
popover={popover}
className={MENU_STYLES}
trigger={
<Button className="flex shrink-0 flex-row" size="xs" variant="dotted">
<Plus weight="bold" className="mr-1" />
Save Search
</Button>
}
>
<div className="mx-1.5 my-1 flex flex-row items-center overflow-hidden">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
variant="default"
placeholder="Name"
className="w-[130px]"
/>
<Button
onClick={() => {
if (!name) return;
saveSearch.mutate({
name,
search: search.search,
filters: JSON.stringify(search.mergedFilters.map((f) => f.arg)),
description: null,
icon: null
});
setName('');
}}
className="ml-2"
variant="accent"
>
Save
</Button>
</div>
</Popover>
);
}
function EscapeButton() {
const search = useSearchContext();
useKeybind(['Escape'], () => {
search.setSearch('');
search.setOpen(false);
});
return (
<kbd
onClick={() => {
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
</kbd>
);
}

View file

@ -1,12 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import { Icon } from '@phosphor-icons/react'; import { Icon } from '@phosphor-icons/react';
import { produce } from 'immer'; import { useEffect, useMemo } from 'react';
import { useEffect, useLayoutEffect, useMemo } from 'react';
import { proxy, ref, useSnapshot } from 'valtio'; import { proxy, ref, useSnapshot } from 'valtio';
import { proxyMap } from 'valtio/utils'; import { proxyMap } from 'valtio/utils';
import { SearchFilterArgs } from '@sd/client'; import { SearchFilterArgs } from '@sd/client';
import { useSearchContext } from './Context';
import { filterRegistry, FilterType, RenderSearchFilter } from './Filters'; import { filterRegistry, FilterType, RenderSearchFilter } from './Filters';
export type SearchType = 'paths' | 'objects'; export type SearchType = 'paths' | 'objects';
@ -28,42 +26,11 @@ export type AllKeys<T> = T extends any ? keyof T : never;
const searchStore = proxy({ const searchStore = proxy({
interactingWithSearchOptions: false, interactingWithSearchOptions: false,
searchType: 'paths' as SearchType, searchType: 'paths' as SearchType,
filterArgs: ref([] as SearchFilterArgs[]),
filterArgsKeys: ref(new Set<string>()),
filterOptions: ref(new Map<string, FilterOptionWithType[]>()), filterOptions: ref(new Map<string, FilterOptionWithType[]>()),
// we register filters so we can search them // we register filters so we can search them
registeredFilters: proxyMap() as Map<string, FilterOptionWithType> registeredFilters: proxyMap() as Map<string, FilterOptionWithType>
}); });
export function useSearchFilters<T extends SearchType>(
_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 // this makes the filter unique and easily searchable using .includes
export const getKey = (filter: FilterOptionWithType) => export const getKey = (filter: FilterOptionWithType) =>
`${filter.type}-${filter.name}-${filter.value}`; `${filter.type}-${filter.name}-${filter.value}`;
@ -112,22 +79,6 @@ export function argsToOptions(args: SearchFilterArgs[], options: Map<string, Fil
}); });
} }
export function updateFilterArgs(fn: (args: SearchFilterArgs[]) => SearchFilterArgs[]) {
searchStore.filterArgs = ref(produce(searchStore.filterArgs, fn));
searchStore.filterArgsKeys = ref(
new Set(
argsToOptions(searchStore.filterArgs, searchStore.filterOptions).map(
({ arg, filter }) =>
getKey({
type: filter.name,
name: arg.name,
value: arg.value
})
)
)
);
}
export const useSearchRegisteredFilters = (query: string) => { export const useSearchRegisteredFilters = (query: string) => {
const { registeredFilters } = useSearchStore(); const { registeredFilters } = useSearchStore();
@ -142,10 +93,7 @@ export const useSearchRegisteredFilters = (query: string) => {
); );
}; };
export const resetSearchStore = () => { export const resetSearchStore = () => {};
searchStore.filterArgs = ref([]);
searchStore.filterArgsKeys = ref(new Set());
};
export const useSearchStore = () => useSnapshot(searchStore); export const useSearchStore = () => useSnapshot(searchStore);

View file

@ -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<string> = 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<string> = useMemo(() => {
return new Set(
dynamicFiltersAsOptions.map(({ arg, filter }) =>
getKey({
type: filter.name,
name: arg.name,
value: arg.value
})
)
);
}, [dynamicFiltersAsOptions]);
const updateDynamicFilters = useCallback(
(cb: (args: SearchFilterArgs[]) => SearchFilterArgs[]) =>
setDynamicFilters((filters) => produce(filters, cb)),
[]
);
// 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<string> = 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<typeof useSearch>;

View file

@ -1,7 +1,6 @@
import { CircleDashed, Folder, Icon, Tag } from '@phosphor-icons/react'; import { CircleDashed, Folder, Icon, Tag } from '@phosphor-icons/react';
import { IconTypes } from '@sd/assets/util'; import { IconTypes } from '@sd/assets/util';
import clsx from 'clsx'; import clsx from 'clsx';
import { InOrNotIn, Range, TextMatch } from '@sd/client';
import { Icon as SDIcon } from '~/components'; import { Icon as SDIcon } from '~/components';
export const filterTypeCondition = { export const filterTypeCondition = {

View file

@ -3,21 +3,26 @@ import { Outlet } from 'react-router';
import { SearchFilterArgs } from '@sd/client'; import { SearchFilterArgs } from '@sd/client';
import TopBar from '.'; import TopBar from '.';
import { SearchContextProvider } from '../Explorer/Search/Context';
const TopBarContext = createContext<ReturnType<typeof useContextValue> | null>(null); const TopBarContext = createContext<ReturnType<typeof useContextValue> | null>(null);
function useContextValue() { function useContextValue() {
const [left, setLeft] = useState<HTMLDivElement | null>(null); const [left, setLeft] = useState<HTMLDivElement | null>(null);
const [center, setCenter] = useState<HTMLDivElement | null>(null);
const [right, setRight] = useState<HTMLDivElement | null>(null); const [right, setRight] = useState<HTMLDivElement | null>(null);
const [children, setChildren] = useState<HTMLDivElement | null>(null);
const [fixedArgs, setFixedArgs] = useState<SearchFilterArgs[] | null>(null); const [fixedArgs, setFixedArgs] = useState<SearchFilterArgs[] | null>(null);
const [topBarHeight, setTopBarHeight] = useState(0); const [topBarHeight, setTopBarHeight] = useState(0);
return { return {
left, left,
setLeft, setLeft,
center,
setCenter,
right, right,
setRight, setRight,
children,
setChildren,
fixedArgs, fixedArgs,
setFixedArgs, setFixedArgs,
topBarHeight, topBarHeight,
@ -30,10 +35,8 @@ export const Component = () => {
return ( return (
<TopBarContext.Provider value={value}> <TopBarContext.Provider value={value}>
<SearchContextProvider> <TopBar />
<TopBar /> <Outlet />
<Outlet />
</SearchContextProvider>
</TopBarContext.Provider> </TopBarContext.Provider>
); );
}; };

View file

@ -1,19 +1,22 @@
import { type ReactNode } from 'react'; import { PropsWithChildren, type ReactNode } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTopBarContext } from './Layout'; import { useTopBarContext } from './Layout';
interface Props { interface Props extends PropsWithChildren {
left?: ReactNode; left?: ReactNode;
center?: ReactNode;
right?: ReactNode; right?: ReactNode;
} }
export const TopBarPortal = ({ left, right }: Props) => { export const TopBarPortal = ({ left, center, right, children }: Props) => {
const ctx = useTopBarContext(); const ctx = useTopBarContext();
return ( return (
<> <>
{left && ctx.left && createPortal(left, ctx.left)} {left && ctx.left && createPortal(left, ctx.left)}
{center && ctx.center && createPortal(center, ctx.center)}
{right && ctx.right && createPortal(right, ctx.right)} {right && ctx.right && createPortal(right, ctx.right)}
{children && ctx.children && createPortal(children, ctx.children)}
</> </>
); );
}; };

View file

@ -5,15 +5,12 @@ import { useKey } from 'rooks';
import useResizeObserver from 'use-resize-observer'; import useResizeObserver from 'use-resize-observer';
import { Tooltip } from '@sd/ui'; import { Tooltip } from '@sd/ui';
import { useKeyMatcher, useOperatingSystem, useShowControls } from '~/hooks'; import { useKeyMatcher, useOperatingSystem, useShowControls } from '~/hooks';
import { useRoutingContext } from '~/RoutingContext';
import { useTabsContext } from '~/TabsContext'; 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 { useExplorerStore } from '../Explorer/store';
import { useTopBarContext } from './Layout'; import { useTopBarContext } from './Layout';
import { NavigationButtons } from './NavigationButtons'; import { NavigationButtons } from './NavigationButtons';
import SearchBar from './SearchBar';
const TopBar = () => { const TopBar = () => {
const transparentBg = useShowControls().transparentBg; const transparentBg = useShowControls().transparentBg;
@ -22,7 +19,6 @@ const TopBar = () => {
const tabs = useTabsContext(); const tabs = useTabsContext();
const ctx = useTopBarContext(); const ctx = useTopBarContext();
const searchCtx = useSearchContext();
useResizeObserver({ useResizeObserver({
ref, ref,
@ -38,7 +34,7 @@ const TopBar = () => {
useLayoutEffect(() => { useLayoutEffect(() => {
const height = ref.current!.getBoundingClientRect().height; const height = ref.current!.getBoundingClientRect().height;
ctx.setTopBarHeight.call(undefined, height); ctx.setTopBarHeight.call(undefined, height);
}, [ctx.setTopBarHeight, searchCtx.isSearching]); }, [ctx.setTopBarHeight]);
return ( return (
<div <div
@ -65,19 +61,14 @@ const TopBar = () => {
<div ref={ctx.setLeft} className="overflow-hidden" /> <div ref={ctx.setLeft} className="overflow-hidden" />
</div> </div>
{ctx.fixedArgs && <SearchBar />} <div ref={ctx.setCenter} />
<div ref={ctx.setRight} className={clsx(ctx.fixedArgs && 'flex-1')} /> <div ref={ctx.setRight} className="flex-1" />
</div> </div>
{tabs && <Tabs />} {tabs && <Tabs />}
{searchCtx.isSearching && ( <div ref={ctx.setChildren} />
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</div> </div>
); );
}; };
@ -155,9 +146,11 @@ function Tabs() {
function useTabKeybinds(props: { addTab(): void; removeTab(index: number): void }) { function useTabKeybinds(props: { addTab(): void; removeTab(index: number): void }) {
const ctx = useTabsContext()!; const ctx = useTabsContext()!;
const os = useOperatingSystem(); const os = useOperatingSystem();
const { visible } = useRoutingContext();
// these keybinds aren't part of the regular shortcuts system as they're desktop-only // these keybinds aren't part of the regular shortcuts system as they're desktop-only
useKey(['t'], (e) => { useKey(['t'], (e) => {
if (!visible) return;
if ((os === 'macOS' && !e.metaKey) || (os !== 'macOS' && !e.ctrlKey)) return; if ((os === 'macOS' && !e.metaKey) || (os !== 'macOS' && !e.ctrlKey)) return;
e.stopPropagation(); e.stopPropagation();
@ -166,6 +159,7 @@ function useTabKeybinds(props: { addTab(): void; removeTab(index: number): void
}); });
useKey(['w'], (e) => { useKey(['w'], (e) => {
if (!visible) return;
if ((os === 'macOS' && !e.metaKey) || (os !== 'macOS' && !e.ctrlKey)) return; if ((os === 'macOS' && !e.metaKey) || (os !== 'macOS' && !e.ctrlKey)) return;
e.stopPropagation(); e.stopPropagation();
@ -174,6 +168,7 @@ function useTabKeybinds(props: { addTab(): void; removeTab(index: number): void
}); });
useKey(['ArrowLeft', 'ArrowRight'], (e) => { useKey(['ArrowLeft', 'ArrowRight'], (e) => {
if (!visible) return;
// TODO: figure out non-macos keybind // TODO: figure out non-macos keybind
if ((os === 'macOS' && !(e.metaKey && e.altKey)) || os !== 'macOS') return; if ((os === 'macOS' && !(e.metaKey && e.altKey)) || os !== 'macOS') return;

View file

@ -58,8 +58,6 @@ const NOTICE_ITEMS: { icon: keyof typeof iconNames; name: string }[] = [
]; ];
const EphemeralNotice = ({ path }: { path: string }) => { const EphemeralNotice = ({ path }: { path: string }) => {
useRouteTitle(path);
const isDark = useIsDark(); const isDark = useIsDark();
const { ephemeral: dismissed } = useDismissibleNoticeStore(); const { ephemeral: dismissed } = useDismissibleNoticeStore();
@ -156,9 +154,10 @@ const EphemeralNotice = ({ path }: { path: string }) => {
}; };
const EphemeralExplorer = memo((props: { args: PathParams }) => { const EphemeralExplorer = memo((props: { args: PathParams }) => {
const os = useOperatingSystem();
const { path } = props.args; const { path } = props.args;
const os = useOperatingSystem();
const explorerSettings = useExplorerSettings({ const explorerSettings = useExplorerSettings({
settings: useMemo( settings: useMemo(
() => () =>
@ -248,6 +247,8 @@ export const Component = () => {
const path = useDeferredValue(pathParams); const path = useDeferredValue(pathParams);
useRouteTitle(path.path ?? '');
return ( return (
<Suspense> <Suspense>
<EphemeralNotice path={path.path ?? ''} /> <EphemeralNotice path={path.path ?? ''} />

View file

@ -1,6 +1,7 @@
import type { RouteObject } from 'react-router-dom'; import { redirect } from '@remix-run/router';
import { Navigate } from 'react-router-dom'; import { Navigate, type RouteObject } from 'react-router-dom';
import { useHomeDir } from '~/hooks/useHomeDir'; import { useHomeDir } from '~/hooks/useHomeDir';
import { Platform } from '~/util/Platform';
import settingsRoutes from './settings'; import settingsRoutes from './settings';
@ -23,8 +24,11 @@ const explorerRoutes: RouteObject[] = [
{ path: 'location/:id', lazy: () => import('./location/$id') }, { path: 'location/:id', lazy: () => import('./location/$id') },
{ path: 'node/:id', lazy: () => import('./node/$id') }, { path: 'node/:id', lazy: () => import('./node/$id') },
{ path: 'tag/:id', lazy: () => import('./tag/$id') }, { path: 'tag/:id', lazy: () => import('./tag/$id') },
{ path: 'network', lazy: () => import('./network') } { path: 'network', lazy: () => import('./network') },
// { path: 'search/:id', lazy: () => import('./search') } {
path: 'saved-search/:id',
lazy: () => import('./saved-search/$id')
}
]; ];
// Routes that should render with the top bar - pretty much everything except // Routes that should render with the top bar - pretty much everything except
@ -34,25 +38,33 @@ const topBarRoutes: RouteObject = {
children: [...explorerRoutes, pageRoutes] children: [...explorerRoutes, pageRoutes]
}; };
export default [ export default (platform: Platform) =>
{ [
index: true, {
Component: () => { index: true,
const homeDir = useHomeDir(); Component: () => {
const homeDir = useHomeDir();
if (homeDir.data) if (homeDir.data)
return ( return (
<Navigate to={`ephemeral/0?${new URLSearchParams({ path: homeDir.data })}`} /> <Navigate
); to={`ephemeral/0?${new URLSearchParams({ path: homeDir.data })}`}
/>
);
return <Navigate to="network" />; return <Navigate to="network" />;
} },
}, loader: async () => {
topBarRoutes, if (!platform.userHomeDir) return null;
{ const homeDir = await platform.userHomeDir();
path: 'settings', return redirect(`ephemeral/0?${new URLSearchParams({ path: homeDir })}`);
lazy: () => import('./settings/Layout'), }
children: settingsRoutes },
}, topBarRoutes,
{ path: '*', lazy: () => import('./404') } {
] satisfies RouteObject[]; path: 'settings',
lazy: () => import('./settings/Layout'),
children: settingsRoutes
},
{ path: '*', lazy: () => import('./404') }
] satisfies RouteObject[];

View file

@ -1,5 +1,5 @@
import { ArrowClockwise, Info } from '@phosphor-icons/react'; import { ArrowClockwise, Info } from '@phosphor-icons/react';
import { useCallback, useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { stringify } from 'uuid'; import { stringify } from 'uuid';
import { import {
@ -8,7 +8,6 @@ import {
FilePathOrder, FilePathOrder,
Location, Location,
ObjectKindEnum, ObjectKindEnum,
useLibraryContext,
useLibraryMutation, useLibraryMutation,
useLibraryQuery, useLibraryQuery,
useLibrarySubscription, useLibrarySubscription,
@ -29,29 +28,30 @@ import { useQuickRescan } from '~/hooks/useQuickRescan';
import Explorer from '../Explorer'; import Explorer from '../Explorer';
import { ExplorerContextProvider } from '../Explorer/Context'; import { ExplorerContextProvider } from '../Explorer/Context';
import { usePathsInfiniteQuery } from '../Explorer/queries'; import { usePathsExplorerQuery } from '../Explorer/queries';
import { useSearchFilters } from '../Explorer/Search/store';
import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store'; import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { useExplorerSearchParams } from '../Explorer/util'; import { useExplorerSearchParams } from '../Explorer/util';
import { EmptyNotice } from '../Explorer/View'; import { EmptyNotice } from '../Explorer/View';
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
import SearchBar from '../Search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal'; import { TopBarPortal } from '../TopBar/Portal';
import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions'; import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
import LocationOptions from './LocationOptions'; import LocationOptions from './LocationOptions';
export const Component = () => { export const Component = () => {
const [{ path }] = useExplorerSearchParams();
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema); const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
const location = useLibraryQuery(['locations.get', locationId], { const location = useLibraryQuery(['locations.get', locationId], {
keepPreviousData: true, keepPreviousData: true,
suspense: true suspense: true
}); });
return <LocationExplorer path={path} location={location.data!} />; return <LocationExplorer location={location.data!} />;
}; };
const LocationExplorer = ({ location, path }: { location: Location; path?: string }) => { const LocationExplorer = ({ location }: { location: Location; path?: string }) => {
const [{ path, take }] = useExplorerSearchParams();
const rspc = useRspcLibraryContext(); const rspc = useRspcLibraryContext();
const onlineLocations = useOnlineLocations(); const onlineLocations = useOnlineLocations();
@ -59,16 +59,14 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin
const rescan = useQuickRescan(); const rescan = useQuickRescan();
const locationOnline = useMemo(() => { const locationOnline = useMemo(() => {
const pub_id = location?.pub_id; const pub_id = location.pub_id;
if (!pub_id) return false; if (!pub_id) return false;
return onlineLocations.some((l) => arraysEqual(pub_id, l)); return onlineLocations.some((l) => arraysEqual(pub_id, l));
}, [location?.pub_id, onlineLocations]); }, [location.pub_id, onlineLocations]);
const preferences = useLibraryQuery(['preferences.get']); const preferences = useLibraryQuery(['preferences.get']);
const updatePreferences = useLibraryMutation('preferences.update'); const updatePreferences = useLibraryMutation('preferences.update');
const isLocationIndexing = useIsLocationIndexing(location.id);
const settings = useMemo(() => { const settings = useMemo(() => {
const defaults = createDefaultExplorerSettings<FilePathOrder>({ const defaults = createDefaultExplorerSettings<FilePathOrder>({
order: { field: 'name', value: 'Asc' } order: { field: 'name', value: 'Asc' }
@ -114,21 +112,52 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin
location location
}); });
const { items, count, loadMore, query } = useItems({ const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
location,
settings: explorerSettings 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({ const explorer = useExplorer({
items, ...paths,
count, isFetchingNextPage: paths.query.isFetchingNextPage,
loadMore,
isFetchingNextPage: query.isFetchingNextPage,
isLoadingPreferences: preferences.isLoading, isLoadingPreferences: preferences.isLoading,
settings: explorerSettings, settings: explorerSettings,
...(location && { parent: { type: 'Location', location }
parent: { type: 'Location', location }
})
}); });
useLibrarySubscription( useLibrarySubscription(
@ -152,42 +181,53 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin
(path && path?.length > 1 ? getLastSectionOfPath(path) : location.name) ?? '' (path && path?.length > 1 ? getLastSectionOfPath(path) : location.name) ?? ''
); );
const isLocationIndexing = useIsLocationIndexing(location.id);
return ( return (
<ExplorerContextProvider explorer={explorer}> <ExplorerContextProvider explorer={explorer}>
<TopBarPortal <SearchContextProvider search={search}>
left={ <TopBarPortal
<div className="flex items-center gap-2"> center={<SearchBar />}
<Folder size={22} className="mt-[-1px]" /> left={
<span className="truncate text-sm font-medium">{title}</span> <div className="flex items-center gap-2">
{!locationOnline && ( <Folder size={22} className="mt-[-1px]" />
<Tooltip label="Location is offline, you can still browse and organize."> <span className="truncate text-sm font-medium">{title}</span>
<Info className="text-ink-faint" /> {!locationOnline && (
</Tooltip> <Tooltip label="Location is offline, you can still browse and organize.">
)} <Info className="text-ink-faint" />
<LocationOptions location={location} path={path || ''} /> </Tooltip>
</div> )}
} <LocationOptions location={location} path={path || ''} />
right={ </div>
<DefaultTopBarOptions }
options={[ right={
{ <DefaultTopBarOptions
toolTipLabel: 'Reload', options={[
onClick: () => rescan(location.id), {
icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />, toolTipLabel: 'Reload',
individual: true, onClick: () => rescan(location.id),
showAtResolution: 'xl:flex' icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
} individual: true,
]} showAtResolution: 'xl:flex'
/> }
} ]}
/> />
}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
{isLocationIndexing ? ( {isLocationIndexing ? (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Loader /> <Loader />
</div> </div>
) : !preferences.isLoading ? ( ) : !preferences.isLoading ? (
<Explorer <Explorer
showFilterBar
emptyNotice={ emptyNotice={
<EmptyNotice <EmptyNotice
icon={<Icon name="FolderNoSpace" size={128} />} icon={<Icon name="FolderNoSpace" size={128} />}
@ -200,67 +240,6 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin
); );
}; };
const useItems = ({
location,
settings
}: {
location: Location;
settings: UseExplorerSettings<FilePathOrder>;
}) => {
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 { function getLastSectionOfPath(path: string): string | undefined {
if (path.endsWith('/')) { if (path.endsWith('/')) {
path = path.slice(0, -1); path = path.slice(0, -1);

View file

@ -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<FilePathOrder>({
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 (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<MagnifyingGlass className="text-ink-dull" weight="bold" size={18} />
<span className="truncate text-sm font-medium">
{savedSearch.data?.name}
</span>
</div>
}
right={<DefaultTopBarOptions />}
>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions>
{(search.dynamicFilters !== dynamicFilters ||
search.search !== savedSearch.data?.search) && (
<SaveButton searchId={id} />
)}
</SearchOptions>
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<img className="h-32 w-32" src={getIcon(iconNames.FolderNoSpace)} />}
message={
search.search
? `No results found for "${search.search}"`
: 'Search for files...'
}
/>
}
/>
</ExplorerContextProvider>
);
};
function SaveButton({ searchId }: { searchId: number }) {
const updateSavedSearch = useLibraryMutation(['search.saved.update']);
const search = useSearchContext();
return (
<Button
className="flex shrink-0 flex-row"
size="xs"
variant="dotted"
onClick={() => {
updateSavedSearch.mutate([
searchId,
{
filters: JSON.stringify(search.dynamicFilters),
search: search.search
}
]);
}}
>
Save
</Button>
);
}

View file

@ -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 (
// <ExplorerContextProvider explorer={explorer}>
// <TopBarPortal
// left={
// <div className="flex flex-row items-center gap-2">
// <MagnifyingGlass className="text-ink-dull" weight="bold" size={18} />
// <span className="truncate text-sm font-medium">Search</span>
// </div>
// }
// right={<DefaultTopBarOptions />}
// />
// <Explorer
// showFilterBar
// emptyNotice={
// <EmptyNotice
// icon={<img className="h-32 w-32" src={getIcon(iconNames.FolderNoSpace)} />}
// message={
// searchParams.search
// ? `No results found for "${searchParams.search}"`
// : 'Search for files...'
// }
// />
// }
// />
// </ExplorerContextProvider>
// );
// };
// export const Component = () => {
// const [searchParams] = useZodSearchParams(SearchParamsSchema);
// const { id } = useZodRouteParams(SearchIdParamsSchema);
// const search = useDeferredValue(searchParams);
// return (
// <Suspense>
// <SearchExplorer id={id} searchParams={search} />
// </Suspense>
// );
// };

View file

@ -14,6 +14,7 @@ import {
TagSimple, TagSimple,
User User
} from '@phosphor-icons/react'; } from '@phosphor-icons/react';
import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr';
import { useFeatureFlag } from '@sd/client'; import { useFeatureFlag } from '@sd/client';
import { tw } from '@sd/ui'; import { tw } from '@sd/ui';
import { useOperatingSystem } from '~/hooks/useOperatingSystem'; import { useOperatingSystem } from '~/hooks/useOperatingSystem';
@ -97,6 +98,10 @@ export default () => {
<Icon component={TagSimple} /> <Icon component={TagSimple} />
Tags Tags
</SidebarLink> </SidebarLink>
{/* <SidebarLink to="library/saved-searches">
<Icon component={MagnifyingGlass} />
Saved Searches
</SidebarLink> */}
<SidebarLink disabled to="library/clouds"> <SidebarLink disabled to="library/clouds">
<Icon component={Cloud} /> <Icon component={Cloud} />
Clouds Clouds

View file

@ -11,6 +11,7 @@ export default [
{ path: 'sync', lazy: () => import('./sync') }, { path: 'sync', lazy: () => import('./sync') },
{ path: 'general', lazy: () => import('./general') }, { path: 'general', lazy: () => import('./general') },
{ path: 'tags', lazy: () => import('./tags') }, { path: 'tags', lazy: () => import('./tags') },
// { path: 'saved-searches', lazy: () => import('./saved-searches') },
//this is for edit in tags context menu //this is for edit in tags context menu
{ path: 'tags/:id', lazy: () => import('./tags') }, { path: 'tags/:id', lazy: () => import('./tags') },
{ path: 'nodes', lazy: () => import('./nodes') }, { path: 'nodes', lazy: () => import('./nodes') },

View file

@ -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<number | null>(
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 (
<>
<Heading title="Saved Searches" description="Manage your saved searches." />
<div className="flex flex-col gap-4 lg:flex-row">
<Card className="flex min-w-[14rem] flex-col gap-2 !px-2">
{savedSearches.data?.map((search) => (
<button
onClick={() => setSelectedSearchId(search.id)}
key={search.id}
className={clsx(
'w-full rounded px-1.5 py-0.5 text-left',
selectedSearch?.id === search.id && 'ring'
)}
>
<span className="text-xs text-white drop-shadow-md">{search.name}</span>
</button>
))}
</Card>
{selectedSearch ? (
<EditForm
key={selectedSearch.id}
savedSearch={selectedSearch}
onDelete={() => setSelectedSearchId(null)}
/>
) : (
<div className="text-sm font-medium text-gray-400">No Search Selected</div>
)}
</div>
</>
);
};
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 (
<Form form={form}>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-end gap-2">
<InputField label="Name" {...form.register('name')} />
<Button
variant="gray"
className="h-[38px]"
disabled={deleteSavedSearch.isLoading}
onClick={async () => {
await deleteSavedSearch.mutateAsync(savedSearch.id);
onDelete();
}}
>
<Tooltip label="Delete Tag">
<Trash className="h-4 w-4" />
</Tooltip>
</Button>
</div>
<div className="flex flex-col gap-1">
<Label className="font-medium">Filters</Label>
<div className="flex flex-col items-start gap-2">
<SearchContextProvider search={search}>
<AppliedFilters allowRemove={false} />
</SearchContextProvider>
</div>
</div>
</div>
</Form>
);
}

View file

@ -1,70 +1,88 @@
import { useCallback, useMemo } from 'react'; import { useMemo } from 'react';
import { ObjectKindEnum, ObjectOrder, Tag, useLibraryContext, useLibraryQuery } from '@sd/client'; import { ObjectKindEnum, ObjectOrder, useLibraryQuery } from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas'; import { LocationIdParamsSchema } from '~/app/route-schemas';
import { Icon } from '~/components'; import { Icon } from '~/components';
import { useRouteTitle, useZodRouteParams } from '~/hooks'; import { useRouteTitle, useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer'; import Explorer from '../Explorer';
import { ExplorerContextProvider } from '../Explorer/Context'; import { ExplorerContextProvider } from '../Explorer/Context';
import { useObjectsInfiniteQuery } from '../Explorer/queries'; import { useObjectsExplorerQuery } from '../Explorer/queries/useObjectsExplorerQuery';
import { useSearchFilters } from '../Explorer/Search/store';
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store'; import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { EmptyNotice } from '../Explorer/View'; import { EmptyNotice } from '../Explorer/View';
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
import SearchBar from '../Search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal'; import { TopBarPortal } from '../TopBar/Portal';
export function Component() { export function Component() {
const { id: tagId } = useZodRouteParams(LocationIdParamsSchema); const { id: tagId } = useZodRouteParams(LocationIdParamsSchema);
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true }); const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
useRouteTitle(tag.data?.name ?? 'Tag'); useRouteTitle(tag.data!.name ?? 'Tag');
const explorerSettings = useExplorerSettings({ const explorerSettings = useExplorerSettings({
settings: useMemo( settings: useMemo(() => {
() => return createDefaultExplorerSettings<ObjectOrder>({ order: null });
createDefaultExplorerSettings<ObjectOrder>({ }, []),
order: null
}),
[]
),
orderingKeys: objectOrderingKeysSchema orderingKeys: objectOrderingKeysSchema
}); });
const { items, count, loadMore, query } = useItems({ const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
tag: tag.data!,
settings: explorerSettings 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({ const explorer = useExplorer({
items, ...objects,
count, isFetchingNextPage: objects.query.isFetchingNextPage,
loadMore,
settings: explorerSettings, settings: explorerSettings,
...(tag.data && { parent: { type: 'Tag', tag: tag.data! }
parent: { type: 'Tag', tag: tag.data }
})
}); });
return ( return (
<ExplorerContextProvider explorer={explorer}> <ExplorerContextProvider explorer={explorer}>
<TopBarPortal <SearchContextProvider search={search}>
left={ <TopBarPortal
<div className="flex flex-row items-center gap-2"> center={<SearchBar />}
<div left={
className="h-[14px] w-[14px] shrink-0 rounded-full" <div className="flex flex-row items-center gap-2">
style={{ backgroundColor: tag?.data?.color || '#efefef' }} <div
/> className="h-[14px] w-[14px] shrink-0 rounded-full"
<span className="truncate text-sm font-medium">{tag?.data?.name}</span> style={{ backgroundColor: tag.data!.color || '#efefef' }}
</div> />
} <span className="truncate text-sm font-medium">{tag?.data?.name}</span>
right={<DefaultTopBarOptions />} </div>
/> }
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer <Explorer
showFilterBar
emptyNotice={ emptyNotice={
<EmptyNotice <EmptyNotice
loading={query.isFetching}
icon={<Icon name="Tags" size={128} />} icon={<Icon name="Tags" size={128} />}
message="No items assigned to this tag." message="No items assigned to this tag."
/> />
@ -73,39 +91,3 @@ export function Component() {
</ExplorerContextProvider> </ExplorerContextProvider>
); );
} }
function useItems({ tag, settings }: { tag: Tag; settings: UseExplorerSettings<ObjectOrder> }) {
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 };
}

View file

@ -1,73 +1,98 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Navigate, Outlet, useMatches, type RouteObject } from 'react-router-dom'; import { Navigate, Outlet, redirect, useMatches, type RouteObject } from 'react-router-dom';
import { currentLibraryCache, useCachedLibraries } from '@sd/client'; import { currentLibraryCache, getCachedLibraries, useCachedLibraries } from '@sd/client';
import { Dialogs, Toaster } from '@sd/ui'; import { Dialogs, Toaster } from '@sd/ui';
import { RouterErrorBoundary } from '~/ErrorFallback'; import { RouterErrorBoundary } from '~/ErrorFallback';
import { useOperatingSystem } from '~/hooks';
import { useRoutingContext } from '~/RoutingContext';
import { Platform } from '..';
import libraryRoutes from './$libraryId'; import libraryRoutes from './$libraryId';
import onboardingRoutes from './onboarding'; import onboardingRoutes from './onboarding';
import { RootContext } from './RootContext'; import { RootContext } from './RootContext';
import './style.scss'; 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 <Navigate to="onboarding" replace />;
const currentLibrary = libraries.data.find((l) => l.uuid === currentLibraryCache.id);
const libraryId = currentLibrary ? currentLibrary.uuid : libraries.data[0]?.uuid;
return <Navigate to={`${libraryId}`} replace />;
};
const Wrapper = () => {
const rawPath = useRawRoutePath();
return (
<RootContext.Provider value={{ rawPath }}>
<Outlet />
<Dialogs />
<Toaster position="bottom-right" expand={true} />
</RootContext.Provider>
);
};
// NOTE: all route `Layout`s below should contain // NOTE: all route `Layout`s below should contain
// the `usePlausiblePageViewMonitor` hook, as early as possible (ideally within the layout itself). // 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) // the hook should only be included if there's a valid `ClientContext` (so not onboarding)
export const routes = (os: OperatingSystem) => { export const createRoutes = (platform: Platform) =>
return [ [
{ {
element: <Wrapper />, Component: () => {
const rawPath = useRawRoutePath();
return (
<RootContext.Provider value={{ rawPath }}>
<Outlet />
<Dialogs />
<Toaster position="bottom-right" expand={true} />
</RootContext.Provider>
);
},
errorElement: <RouterErrorBoundary />, errorElement: <RouterErrorBoundary />,
children: [ children: [
{ {
index: true, index: true,
element: <Index /> Component: () => {
const libraries = useCachedLibraries();
if (libraries.status !== 'success') return null;
if (libraries.data.length === 0)
return <Navigate to="onboarding" replace />;
const currentLibrary = libraries.data.find(
(l) => l.uuid === currentLibraryCache.id
);
const libraryId = currentLibrary
? currentLibrary.uuid
: libraries.data[0]?.uuid;
return <Navigate to={`${libraryId}`} replace />;
},
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', path: 'onboarding',
lazy: () => import('./onboarding/Layout'), lazy: () => import('./onboarding/Layout'),
children: onboardingRoutes(os) children: onboardingRoutes
}, },
{ {
path: ':libraryId', path: ':libraryId',
lazy: () => import('./$libraryId/Layout'), 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[]; ] satisfies RouteObject[];
};
/** /**
* Combines the `path` segments of the current route into a single string. * 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. * but not the values used in the route params.
*/ */
const useRawRoutePath = () => { const useRawRoutePath = () => {
const { routes } = useRoutingContext();
// `useMatches` returns a list of each matched RouteObject, // `useMatches` returns a list of each matched RouteObject,
// we grab the last one as it contains all previous route segments. // we grab the last one as it contains all previous route segments.
const lastMatchId = useMatches().slice(-1)[0]?.id; const lastMatchId = useMatches().slice(-1)[0]?.id;
const os = useOperatingSystem();
const rawPath = useMemo(() => { const rawPath = useMemo(() => {
const [rawPath] = const [rawPath] =
@ -100,11 +125,11 @@ const useRawRoutePath = () => {
// `path` found, chuck it on the end // `path` found, chuck it on the end
return [`${rawPath}/${item.path}`, item]; return [`${rawPath}/${item.path}`, item];
}, },
['' as string, { children: routes(os) }] as const ['' as string, { children: routes }] as const
) ?? []; ) ?? [];
return rawPath ?? '/'; return rawPath ?? '/';
}, [lastMatchId, os]); }, [lastMatchId, routes]);
return rawPath; return rawPath;
}; };

View file

@ -4,8 +4,6 @@ import { useMatch, useNavigate } from 'react-router';
import { getOnboardingStore, unlockOnboardingScreen, useOnboardingStore } from '@sd/client'; import { getOnboardingStore, unlockOnboardingScreen, useOnboardingStore } from '@sd/client';
import { useOperatingSystem } from '~/hooks'; import { useOperatingSystem } from '~/hooks';
import routes from '.';
export default function OnboardingProgress() { export default function OnboardingProgress() {
const obStore = useOnboardingStore(); const obStore = useOnboardingStore();
const navigate = useNavigate(); const navigate = useNavigate();
@ -21,17 +19,26 @@ export default function OnboardingProgress() {
unlockOnboardingScreen(currentScreen, getOnboardingStore().unlockedScreens); unlockOnboardingScreen(currentScreen, getOnboardingStore().unlockedScreens);
}, [currentScreen]); }, [currentScreen]);
const routes = [
'alpha',
'new-library',
os === 'macOS' && 'full-disk',
'locations',
'privacy',
'creating-library'
].filter(Boolean);
return ( return (
<div className="flex w-full items-center justify-center"> <div className="flex w-full items-center justify-center">
<div className="flex items-center justify-center space-x-1"> <div className="flex items-center justify-center space-x-1">
{routes(os).map(({ path }) => { {routes.map((path) => {
if (!path) return null; if (!path) return null;
return ( return (
<button <button
key={path} key={path}
disabled={!obStore.unlockedScreens.includes(path)} disabled={!obStore.unlockedScreens.includes(path)}
onClick={() => navigate(`./${path}`, { replace: true })} onClick={() => navigate(path, { replace: true })}
className={clsx( className={clsx(
'h-2 w-2 rounded-full transition hover:bg-ink disabled:opacity-10', 'h-2 w-2 rounded-full transition hover:bg-ink disabled:opacity-10',
currentScreen === path ? 'bg-ink' : 'bg-ink-faint' currentScreen === path ? 'bg-ink' : 'bg-ink-faint'

View file

@ -1,6 +1,5 @@
import { Navigate, RouteObject } from 'react-router'; import { Navigate, redirect, RouteObject } from 'react-router';
import { getOnboardingStore } from '@sd/client'; import { getOnboardingStore } from '@sd/client';
import { OperatingSystem } from '~/util/Platform';
import Alpha from './alpha'; import Alpha from './alpha';
import { useOnboardingContext } from './context'; import { useOnboardingContext } from './context';
@ -20,16 +19,25 @@ const Index = () => {
return <Navigate to="alpha" replace />; return <Navigate to="alpha" replace />;
}; };
const onboardingRoutes = (os: OperatingSystem) => { export default [
return [ {
{ index: true, element: <Index /> }, index: true,
{ path: 'alpha', element: <Alpha /> }, loader: () => {
{ path: 'new-library', element: <NewLibrary /> }, if (getOnboardingStore().lastActiveScreen)
...(os === 'macOS' ? [{ element: <FullDisk />, path: 'full-disk' }] : []), return redirect(`/onboarding/${getOnboardingStore().lastActiveScreen}`);
{ path: 'locations', element: <Locations /> },
{ path: 'privacy', element: <Privacy /> },
{ path: 'creating-library', element: <CreatingLibrary /> }
] satisfies RouteObject[];
};
export default onboardingRoutes; return redirect(`/onboarding/alpha`);
},
element: <Index />
},
{ path: 'alpha', Component: Alpha },
// {
// element: <Login />,
// path: 'login'
// },
{ Component: NewLibrary, path: 'new-library' },
{ Component: FullDisk, path: 'full-disk' },
{ Component: Locations, path: 'locations' },
{ Component: Privacy, path: 'privacy' },
{ Component: CreatingLibrary, path: 'creating-library' }
] satisfies RouteObject[];

View file

@ -1,88 +0,0 @@
// import { useEffect, useState } from 'react';
// import { FilePathFilterArgs, ObjectKindEnum, useLibraryQuery } from '@sd/client';
// import { getSearchStore, SetFilter, useSearchStore } from '~/hooks';
// export interface SearchFilterOptions {
// locationId?: number;
// tags?: number[];
// objectKinds?: ObjectKindEnum[];
// }
// // Converts selected filters into a FilePathFilterArgs object for querying file paths
// const filtersToFilePathArgs = (filters: SetFilter[]): FilePathFilterArgs => {
// const filePathArgs: FilePathFilterArgs = {};
// // Iterate through selected filters and add them to the FilePathFilterArgs object
// filters.forEach((filter) => {
// switch (filter.categoryName) {
// case 'Location':
// filePathArgs.locationId = Number(filter.id);
// break;
// case 'Tagged':
// if (!filePathArgs.object) filePathArgs.object = {};
// if (!filePathArgs.object.tags) filePathArgs.object.tags = [];
// filePathArgs.object.tags.push(Number(filter.id));
// break;
// case 'Kind':
// if (!filePathArgs.object) filePathArgs.object = { kind: [] };
// filePathArgs.object.kind?.push(filter.id as unknown as ObjectKindEnum);
// break;
// }
// });
// return filePathArgs;
// };
// // Custom hook to manage search filters state and transform it to FilePathFilterArgs for further processing
// export const useSearchFilters = (options: SearchFilterOptions): FilePathFilterArgs => {
// const { locationId, tags, objectKinds } = options;
// const searchStore = useSearchStore();
// const [filePathArgs, setFilePathArgs] = useState<FilePathFilterArgs>({});
// useEffect(() => {
// const searchStore = getSearchStore();
// // If no filters are selected, initialize filters based on the provided options
// if (searchStore.selectedFilters.size === 0) {
// // handle location filter
// if (locationId) {
// const filter = searchStore.registerFilter(
// `${locationId}-${locationId}`,
// { id: locationId, name: '', icon: 'Folder' },
// 'Location'
// );
// searchStore.selectFilter(filter.key, true);
// }
// // handle tags filter
// tags?.forEach((tag) => {
// const tagFilter = searchStore.registerFilter(
// `${tag}-${tag}`,
// { id: tag, name: `${tag}`, icon: `${tag}` },
// 'Tag'
// );
// if (tagFilter) {
// searchStore.selectFilter(tagFilter.key, true);
// }
// });
// // handle object kinds filter
// objectKinds?.forEach((kind) => {
// const kindFilter = Array.from(searchStore.filters.values()).find(
// (filter) =>
// filter.categoryName === 'Kind' && filter.name === ObjectKindEnum[kind]
// );
// if (kindFilter) {
// searchStore.selectFilter(kindFilter.key, true);
// }
// });
// }
// // Convert selected filters to FilePathFilterArgs and update the state whenever selected filters change
// const selectedFiltersArray = Array.from(searchStore.selectedFilters.values());
// const updatedFilePathArgs = filtersToFilePathArgs(selectedFiltersArray);
// setFilePathArgs(updatedFilePathArgs);
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [locationId, tags, objectKinds, searchStore.selectedFilters, searchStore.filters]);
// return filePathArgs;
// };

View file

@ -1,151 +0,0 @@
// import { Icon } from '@phosphor-icons/react';
// import { IconTypes } from '@sd/assets/util';
// import { useEffect, useState } from 'react';
// import { proxy, useSnapshot } from 'valtio';
// import { proxyMap } from 'valtio/utils';
// // import { ObjectKind } from '@sd/client';
// type SearchType = 'paths' | 'objects' | 'tags';
// type SearchScope = 'directory' | 'location' | 'device' | 'library';
// interface FilterCategory {
// icon: string; // must be string
// name: string;
// }
// /// Filters are stored in a map, so they can be accessed by key
// export interface Filter {
// id: string | number;
// icon: Icon | IconTypes | string;
// name: string;
// }
// // Once a filter is registered, it is given a key and a category name
// export interface RegisteredFilter extends Filter {
// categoryName: string; // used to link filters to category
// key: string; // used to identify filters in the map
// }
// // Once a filter is selected, condition state is tracked
// export interface SetFilter extends RegisteredFilter {
// condition: boolean;
// category?: FilterCategory;
// }
// interface Filters {
// name: string;
// icon: string;
// filters: Filter[];
// }
// export type GroupedFilters = {
// [categoryName: string]: SetFilter[];
// };
// export function useCreateSearchFilter({ filters, name, icon }: Filters) {
// const [registeredFilters, setRegisteredFilters] = useState<RegisteredFilter[]>([]);
// useEffect(() => {
// const newRegisteredFilters: RegisteredFilter[] = [];
// searchStore.filterCategories.set(name, { name, icon });
// filters.map((filter) => {
// const registeredFilter = searchStore.registerFilter(
// // id doesn't have to be a particular format, just needs to be unique
// `${filter.id}-${filter.name}`,
// filter,
// name
// );
// newRegisteredFilters.push(registeredFilter);
// });
// setRegisteredFilters(newRegisteredFilters);
// console.log(getSearchStore());
// return () => {
// filters.forEach((filter) => {
// searchStore.unregisterFilter(`${filter.id}-${filter.name}`);
// });
// setRegisteredFilters([]); // or filter out the unregistered filters
// };
// }, []);
// return {
// name,
// icon,
// filters: registeredFilters // returning the registered filters with their keys
// };
// }
// const searchStore = proxy({
// isSearching: false,
// interactingWithSearchOptions: false,
// searchScope: 'directory',
// //
// // searchType: 'paths',
// // objectKind: null as typeof ObjectKind | null,
// // tagged: null as string[] | null,
// // dateRange: null as [Date, Date] | null
// filters: proxyMap() as Map<string, RegisteredFilter>,
// filterCategories: proxyMap() as Map<string, FilterCategory>,
// selectedFilters: proxyMap() as Map<string, SetFilter>,
// registerFilter: (key: string, filter: Filter, categoryName: string) => {
// searchStore.filters.set(key, { ...filter, key, categoryName });
// return searchStore.filters.get(key)!;
// },
// unregisterFilter: (key: string) => {
// searchStore.filters.delete(key);
// },
// selectFilter: (key: string, condition: boolean) => {
// searchStore.selectedFilters.set(key, { ...searchStore.filters.get(key)!, condition });
// },
// deselectFilter: (key: string) => {
// searchStore.selectedFilters.delete(key);
// },
// clearSelectedFilters: () => {
// searchStore.selectedFilters.clear();
// },
// getSelectedFilters: (): GroupedFilters => {
// return Array.from(searchStore.selectedFilters.values())
// .map((filter) => ({
// ...filter,
// category: searchStore.filterCategories.get(filter.categoryName)!
// }))
// .reduce((grouped, filter) => {
// if (!grouped[filter.categoryName]) {
// grouped[filter.categoryName] = [];
// }
// grouped[filter.categoryName]?.push(filter);
// return grouped;
// }, {} as GroupedFilters);
// },
// searchFilters: (query: string) => {
// if (!query) return searchStore.filters;
// return Array.from(searchStore.filters.values()).filter((filter) =>
// filter.name.toLowerCase().includes(query.toLowerCase())
// );
// },
// reset() {
// searchStore.searchScope = 'directory';
// searchStore.filters.clear();
// searchStore.filterCategories.clear();
// searchStore.selectedFilters.clear();
// }
// });
// export const useSearchStore = () => useSnapshot(searchStore);
// export const getSearchStore = () => searchStore;

View file

@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { useKeys } from 'rooks'; import { useKeys } from 'rooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { valtioPersist } from '@sd/client'; import { valtioPersist } from '@sd/client';
import { OperatingSystem } from '~/util/Platform';
import { useOperatingSystem } from './useOperatingSystem';
import { modifierSymbols } from '@sd/ui'; import { modifierSymbols } from '@sd/ui';
import { useRoutingContext } from '~/RoutingContext';
import { OperatingSystem } from '~/util/Platform';
import { useOperatingSystem } from './useOperatingSystem';
//This will be refactored in the near future //This will be refactored in the near future
//as we adopt different shortcuts for different platforms //as we adopt different shortcuts for different platforms
@ -14,18 +16,16 @@ type Shortcut = {
action: string; action: string;
keys: { keys: {
[K in OperatingSystem | 'all']?: string[]; [K in OperatingSystem | 'all']?: string[];
} };
icons: { icons: {
[K in OperatingSystem | 'all']?: string[]; [K in OperatingSystem | 'all']?: string[];
} };
} };
type ShortcutCategory = { type ShortcutCategory = {
description: string; description: string;
} & Record<string, any>; //todo: fix types } & Record<string, any>; //todo: fix types
export const ShortcutState: Record<string, ShortcutCategory> = {
export const ShortcutState: Record<string, ShortcutCategory>
= {
Dialogs: { Dialogs: {
description: 'To perform actions and operations', description: 'To perform actions and operations',
toggleJobManager: { toggleJobManager: {
@ -38,7 +38,7 @@ export const ShortcutState: Record<string, ShortcutCategory>
macOS: [modifierSymbols.Meta.macOS as string, 'J'], macOS: [modifierSymbols.Meta.macOS as string, 'J'],
all: [modifierSymbols.Control.Other, 'J'] all: [modifierSymbols.Control.Other, 'J']
} }
}, }
}, },
Pages: { Pages: {
description: 'Different pages in the app', description: 'Different pages in the app',
@ -74,12 +74,9 @@ export const ShortcutState: Record<string, ShortcutCategory>
macOS: [ macOS: [
modifierSymbols.Shift.macOS as string, modifierSymbols.Shift.macOS as string,
modifierSymbols.Meta.macOS as string, modifierSymbols.Meta.macOS as string,
'T'],
all: [
modifierSymbols.Shift.Other,
modifierSymbols.Control.Other,
'T' 'T'
] ],
all: [modifierSymbols.Shift.Other, modifierSymbols.Control.Other, 'T']
} }
} }
}, },
@ -114,8 +111,8 @@ export const ShortcutState: Record<string, ShortcutCategory>
all: ['Control', '3'] all: ['Control', '3']
}, },
icons: { icons: {
macOS: [modifierSymbols.Meta.macOS as string, '3'], macOS: [modifierSymbols.Meta.macOS as string, '3'],
all: [modifierSymbols.Control.Other, '3'] all: [modifierSymbols.Control.Other, '3']
} }
}, },
showHiddenFiles: { showHiddenFiles: {
@ -125,7 +122,11 @@ export const ShortcutState: Record<string, ShortcutCategory>
all: ['Control', 'Shift', '.'] all: ['Control', 'Shift', '.']
}, },
icons: { icons: {
macOS: [modifierSymbols.Meta.macOS as string, modifierSymbols.Shift.macOS as string, '.'], macOS: [
modifierSymbols.Meta.macOS as string,
modifierSymbols.Shift.macOS as string,
'.'
],
all: [modifierSymbols.Control.Other, 'h'] all: [modifierSymbols.Control.Other, 'h']
} }
}, },
@ -136,7 +137,11 @@ export const ShortcutState: Record<string, ShortcutCategory>
all: ['Alt', 'Control', 'KeyP'] all: ['Alt', 'Control', 'KeyP']
}, },
icons: { icons: {
macOS: [modifierSymbols.Alt.macOS as string, modifierSymbols.Meta.macOS as string, 'p'], macOS: [
modifierSymbols.Alt.macOS as string,
modifierSymbols.Meta.macOS as string,
'p'
],
all: [modifierSymbols.Alt.Other, modifierSymbols.Control.Other, 'p'] all: [modifierSymbols.Alt.Other, modifierSymbols.Control.Other, 'p']
} }
}, },
@ -147,7 +152,11 @@ export const ShortcutState: Record<string, ShortcutCategory>
all: ['Alt', 'Control', 'KeyM'] all: ['Alt', 'Control', 'KeyM']
}, },
icons: { icons: {
macOS: [modifierSymbols.Alt.macOS as string, modifierSymbols.Meta.macOS as string, 'm'], macOS: [
modifierSymbols.Alt.macOS as string,
modifierSymbols.Meta.macOS as string,
'm'
],
all: [modifierSymbols.Alt.Other, modifierSymbols.Control.Other, 'm'] all: [modifierSymbols.Alt.Other, modifierSymbols.Control.Other, 'm']
} }
}, },
@ -317,7 +326,7 @@ export const ShortcutState: Record<string, ShortcutCategory>
}, },
icons: { icons: {
all: ['Escape'] all: ['Escape']
}, }
}, },
explorerDown: { explorerDown: {
action: 'Navigate files downwards', action: 'Navigate files downwards',
@ -354,9 +363,9 @@ export const ShortcutState: Record<string, ShortcutCategory>
icons: { icons: {
all: ['ArrowRight'] all: ['ArrowRight']
} }
}, }
}, }
} };
export type ShortcutKeybinds = { export type ShortcutKeybinds = {
[C in ShortcutCategories]: { [C in ShortcutCategories]: {
@ -365,52 +374,52 @@ export type ShortcutKeybinds = {
action: string; action: string;
keys: { keys: {
[K in OperatingSystem | 'all']?: string[]; [K in OperatingSystem | 'all']?: string[];
} };
icons: { icons: {
[K in OperatingSystem | 'all']?: string[]; [K in OperatingSystem | 'all']?: string[];
} };
}[] }[];
} };
} };
//data being re-arranged for keybindings page //data being re-arranged for keybindings page
export const keybindingsData = () => { export const keybindingsData = () => {
let shortcuts = {} as ShortcutKeybinds let shortcuts = {} as ShortcutKeybinds;
for (const category in ShortcutState) { for (const category in ShortcutState) {
const shortcutCategory = ShortcutState[category as ShortcutCategories] as ShortcutCategory; const shortcutCategory = ShortcutState[category as ShortcutCategories] as ShortcutCategory;
const categoryShortcuts: Array<Shortcut> = []; const categoryShortcuts: Array<Shortcut> = [];
for (const shortcut in shortcutCategory) { for (const shortcut in shortcutCategory) {
if (shortcut === 'description') continue; if (shortcut === 'description') continue;
const { keys, icons, action } = shortcutCategory[shortcut as ShortcutKeys] ?? {}; const { keys, icons, action } = shortcutCategory[shortcut as ShortcutKeys] ?? {};
if (keys && icons && action) { if (keys && icons && action) {
const categoryShortcut = { const categoryShortcut = {
icons, icons,
action, action,
keys, keys
};
categoryShortcuts.push(categoryShortcut);
} }
categoryShortcuts.push(categoryShortcut); shortcuts = {
...shortcuts,
[category]: {
description: shortcutCategory.description,
shortcuts: categoryShortcuts
}
};
} }
shortcuts = { }
...shortcuts,
[category]: {
description: shortcutCategory.description,
shortcuts: categoryShortcuts,
}
};
}
}
return shortcuts; return shortcuts;
} };
export type ShortcutCategories = keyof typeof ShortcutState; export type ShortcutCategories = keyof typeof ShortcutState;
type GetShortcutKeys<Category extends ShortcutCategories> = keyof typeof ShortcutState[Category] type GetShortcutKeys<Category extends ShortcutCategories> = keyof (typeof ShortcutState)[Category];
//Not all shortcuts share the same keys (shortcuts) so this needs to be done like this //Not all shortcuts share the same keys (shortcuts) so this needs to be done like this
//A union type of all categories would return the 'description' only //A union type of all categories would return the 'description' only
type ShortcutKeys = Exclude< type ShortcutKeys = Exclude<
GetShortcutKeys<"Pages"> | GetShortcutKeys<"Dialogs"> | GetShortcutKeys<"Explorer">, GetShortcutKeys<'Pages'> | GetShortcutKeys<'Dialogs'> | GetShortcutKeys<'Explorer'>,
"description" 'description'
> >;
const shortcutsStore = valtioPersist('sd-shortcuts', ShortcutState); const shortcutsStore = valtioPersist('sd-shortcuts', ShortcutState);
@ -424,21 +433,25 @@ export function getShortcutsStore() {
export const useShortcut = (shortcut: ShortcutKeys, func: (e: KeyboardEvent) => void) => { export const useShortcut = (shortcut: ShortcutKeys, func: (e: KeyboardEvent) => void) => {
const os = useOperatingSystem(); const os = useOperatingSystem();
const shortcutsStore = getShortcutsStore(); const shortcutsStore = useShortcutsStore();
const triggeredShortcut = () => { const triggeredShortcut = () => {
const shortcuts: Record<ShortcutKeys, string[]> = {} as any; const shortcuts: Record<ShortcutKeys, string[]> = {} as any;
for (const category in shortcutsStore) { for (const category in shortcutsStore) {
const shortcutCategory = shortcutsStore[category as ShortcutCategories]; const shortcutCategory = shortcutsStore[category as ShortcutCategories];
for (const shortcut in shortcutCategory) { for (const shortcut in shortcutCategory) {
if (shortcut === 'description') continue; if (shortcut === 'description') continue;
const keys = shortcutCategory[shortcut as ShortcutKeys]?.keys; const keys = shortcutCategory[shortcut as ShortcutKeys]?.keys;
shortcuts[shortcut as ShortcutKeys] = (keys?.[os] || keys?.all) as string[]; shortcuts[shortcut as ShortcutKeys] = (keys?.[os] || keys?.all) as string[];
} }
} }
return shortcuts[shortcut] as string[]; return shortcuts[shortcut] as string[];
}; };
const { visible } = useRoutingContext();
useKeys(triggeredShortcut(), func); useKeys(triggeredShortcut(), (e) => {
if (!visible) return;
return func(e);
});
}; };

View file

@ -1,13 +1,13 @@
import { init, Integrations } from '@sentry/browser';
import '@fontsource/inter/variable.css'; import '@fontsource/inter/variable.css';
import { init, Integrations } from '@sentry/browser';
import { defaultContext } from '@tanstack/react-query'; import { defaultContext } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat'; import advancedFormat from 'dayjs/plugin/advancedFormat';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import { PropsWithChildren, Suspense } from 'react';
import { RouterProvider, RouterProviderProps } from 'react-router-dom'; import { RouterProvider, RouterProviderProps } from 'react-router-dom';
import { import {
NotificationContextProvider, NotificationContextProvider,
@ -18,6 +18,7 @@ import {
} from '@sd/client'; } from '@sd/client';
import { TooltipProvider } from '@sd/ui'; import { TooltipProvider } from '@sd/ui';
import { createRoutes } from './app';
import { P2P, useP2PErrorToast } from './app/p2p'; import { P2P, useP2PErrorToast } from './app/p2p';
import { WithPrismTheme } from './components/TextViewer/prism'; import { WithPrismTheme } from './components/TextViewer/prism';
import ErrorFallback, { BetterErrorBoundary } from './ErrorFallback'; import ErrorFallback, { BetterErrorBoundary } from './ErrorFallback';
@ -60,41 +61,54 @@ const Devtools = () => {
export type Router = RouterProviderProps['router']; export type Router = RouterProviderProps['router'];
export const SpacedriveInterface = (props: { export function SpacedriveRouterProvider(props: {
routing: { routing: {
routes: ReturnType<typeof createRoutes>;
visible: boolean;
router: Router; router: Router;
routerKey: number;
currentIndex: number; currentIndex: number;
maxIndex: number; maxIndex: number;
}; };
}) => { }) {
return (
<RoutingContext.Provider
value={{
routes: props.routing.routes,
visible: props.routing.visible,
currentIndex: props.routing.currentIndex,
maxIndex: props.routing.maxIndex
}}
>
<RouterProvider
router={props.routing.router}
future={{
v7_startTransition: true
}}
/>
</RoutingContext.Provider>
);
}
export function SpacedriveInterfaceRoot({ children }: PropsWithChildren) {
useLoadBackendFeatureFlags(); useLoadBackendFeatureFlags();
useP2PErrorToast(); useP2PErrorToast();
useInvalidateQuery(); useInvalidateQuery();
useTheme(); useTheme();
return ( return (
<BetterErrorBoundary FallbackComponent={ErrorFallback}> <Suspense>
<TooltipProvider> <BetterErrorBoundary FallbackComponent={ErrorFallback}>
<P2PContextProvider> <TooltipProvider>
<NotificationContextProvider> <P2PContextProvider>
<RoutingContext.Provider <NotificationContextProvider>
value={{
currentIndex: props.routing.currentIndex,
maxIndex: props.routing.maxIndex
}}
>
<P2P /> <P2P />
<Devtools /> <Devtools />
<WithPrismTheme /> <WithPrismTheme />
<RouterProvider {children}
key={props.routing.routerKey} </NotificationContextProvider>
router={props.routing.router} </P2PContextProvider>
/> </TooltipProvider>
</RoutingContext.Provider> </BetterErrorBoundary>
</NotificationContextProvider> </Suspense>
</P2PContextProvider>
</TooltipProvider>
</BetterErrorBoundary>
); );
}; }

View file

@ -29,6 +29,7 @@
"@tanstack/react-query-devtools": "^4.36.1", "@tanstack/react-query-devtools": "^4.36.1",
"@tanstack/react-table": "^8.10.7", "@tanstack/react-table": "^8.10.7",
"@tanstack/react-virtual": "3.0.0-beta.66", "@tanstack/react-virtual": "3.0.0-beta.66",
"@total-typescript/ts-reset": "^0.5.1",
"@virtual-grid/react": "^1.1.0", "@virtual-grid/react": "^1.1.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",

View file

@ -35,6 +35,8 @@ export type Procedures = {
{ key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } | { key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
{ key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } | { key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "search.pathsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } | { key: "search.pathsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
{ key: "search.saved.get", input: LibraryArgs<number>, result: SavedSearch | null } |
{ key: "search.saved.list", input: LibraryArgs<null>, result: SavedSearch[] } |
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } | { key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
{ key: "tags.get", input: LibraryArgs<number>, result: Tag | null } | { key: "tags.get", input: LibraryArgs<number>, result: Tag | null } |
{ key: "tags.getForObject", input: LibraryArgs<number>, result: Tag[] } | { key: "tags.getForObject", input: LibraryArgs<number>, result: Tag[] } |
@ -94,6 +96,9 @@ export type Procedures = {
{ key: "p2p.pairingResponse", input: [number, PairingDecision], result: null } | { key: "p2p.pairingResponse", input: [number, PairingDecision], result: null } |
{ key: "p2p.spacedrop", input: SpacedropArgs, result: string } | { key: "p2p.spacedrop", input: SpacedropArgs, result: string } |
{ key: "preferences.update", input: LibraryArgs<LibraryPreferences>, result: null } | { key: "preferences.update", input: LibraryArgs<LibraryPreferences>, result: null } |
{ key: "search.saved.create", input: LibraryArgs<{ name: string; search?: string | null; filters?: string | null; description?: string | null; icon?: string | null }>, result: null } |
{ key: "search.saved.delete", input: LibraryArgs<number>, result: null } |
{ key: "search.saved.update", input: LibraryArgs<[number, Args]>, result: null } |
{ key: "tags.assign", input: LibraryArgs<{ targets: Target[]; tag_id: number; unassign: boolean }>, result: null } | { key: "tags.assign", input: LibraryArgs<{ targets: Target[]; tag_id: number; unassign: boolean }>, result: null } |
{ key: "tags.create", input: LibraryArgs<TagCreateArgs>, result: Tag } | { key: "tags.create", input: LibraryArgs<TagCreateArgs>, result: Tag } |
{ key: "tags.delete", input: LibraryArgs<number>, result: null } | { key: "tags.delete", input: LibraryArgs<number>, result: null } |
@ -111,6 +116,8 @@ export type Procedures = {
{ key: "sync.newMessage", input: LibraryArgs<null>, result: null } { key: "sync.newMessage", input: LibraryArgs<null>, result: null }
}; };
export type Args = { search?: string | null; filters?: string | null; name?: string | null; icon?: string | null; description?: string | null }
export type AudioMetadata = { duration: number | null; audio_codec: string | null } export type AudioMetadata = { duration: number | null; audio_codec: string | null }
/** /**
@ -413,6 +420,8 @@ export type RuleKind = "AcceptFilesByGlob" | "RejectFilesByGlob" | "AcceptIfChil
export type SanitisedNodeConfig = { id: string; name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences } export type SanitisedNodeConfig = { id: string; name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences }
export type SavedSearch = { id: number; pub_id: number[]; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null }
export type SearchData<T> = { cursor: number[] | null; items: T[] } export type SearchData<T> = { cursor: number[] | null; items: T[] }
export type SearchFilterArgs = { filePath: FilePathFilterArgs } | { object: ObjectFilterArgs } export type SearchFilterArgs = { filePath: FilePathFilterArgs } | { object: ObjectFilterArgs }

View file

@ -2,7 +2,7 @@ import { createContext, PropsWithChildren, useContext, useMemo } from 'react';
import { LibraryConfigWrapped } from '../core'; import { LibraryConfigWrapped } from '../core';
import { valtioPersist } from '../lib'; import { valtioPersist } from '../lib';
import { useBridgeQuery } from '../rspc'; import { nonLibraryClient, useBridgeQuery } from '../rspc';
// The name of the localStorage key for caching library data // The name of the localStorage key for caching library data
const libraryCacheLocalStorageKey = 'sd-library-list'; const libraryCacheLocalStorageKey = 'sd-library-list';
@ -27,6 +27,25 @@ export const useCachedLibraries = () =>
onSuccess: (data) => localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data)) onSuccess: (data) => localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data))
}); });
export async function getCachedLibraries() {
const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
if (cachedData) {
// If we fail to load cached data, it's fine
try {
return JSON.parse(cachedData) as LibraryConfigWrapped[];
} catch (e) {
console.error("Error loading cached 'sd-library-list' data", e);
}
}
const libraries = await nonLibraryClient.query(['library.list']);
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(libraries));
return libraries;
}
export interface ClientContext { export interface ClientContext {
currentLibraryId: string | null; currentLibraryId: string | null;
libraries: ReturnType<typeof useCachedLibraries>; libraries: ReturnType<typeof useCachedLibraries>;

View file

@ -691,6 +691,9 @@ importers:
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: 3.0.0-beta.66 specifier: 3.0.0-beta.66
version: 3.0.0-beta.66(react@18.2.0) version: 3.0.0-beta.66(react@18.2.0)
'@total-typescript/ts-reset':
specifier: ^0.5.1
version: 0.5.1
'@virtual-grid/react': '@virtual-grid/react':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0(react-dom@18.2.0)(react@18.2.0) version: 1.1.0(react-dom@18.2.0)(react@18.2.0)
@ -8403,6 +8406,10 @@ packages:
'@testing-library/dom': 9.3.3 '@testing-library/dom': 9.3.3
dev: false dev: false
/@total-typescript/ts-reset@0.5.1:
resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==}
dev: false
/@trysound/sax@0.2.0: /@trysound/sax@0.2.0:
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}