[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 { listen } from '@tauri-apps/api/event';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect, useMemo, useRef, useState } from 'react';
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { RspcProvider } from '@sd/client';
import {
createRoutes,
ErrorPage,
KeybindEvent,
PlatformProvider,
routes,
SpacedriveInterface,
SpacedriveInterfaceRoot,
SpacedriveRouterProvider,
TabsContext
} from '@sd/interface';
import { RouteTitleContext } from '@sd/interface/hooks/useRouteTitle';
@ -17,8 +19,6 @@ import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState';
import '@sd/ui/style/style.scss';
import { useOperatingSystem } from '@sd/interface/hooks';
import * as commands from './commands';
import { platform } from './platform';
import { queryClient } from './query';
@ -80,11 +80,15 @@ export default function App() {
// we have a minimum delay between creating new tabs as react router can't handle creating tabs super fast
const TAB_CREATE_DELAY = 150;
const routes = createRoutes(platform);
function AppInner() {
const os = useOperatingSystem();
const [tabs, setTabs] = useState(() => [createTab()]);
const [tabIndex, setTabIndex] = useState(0);
function createTab() {
const history = createMemoryHistory();
const router = createMemoryRouterWithHistory({ routes: routes(os), history });
const router = createMemoryRouterWithHistory({ routes, history });
const dispose = router.subscribe((event) => {
setTabs((routers) => {
@ -107,22 +111,36 @@ function AppInner() {
});
return {
id: Math.random().toString(),
router,
history,
dispose,
element: document.createElement('div'),
currentIndex: 0,
maxIndex: 0,
title: 'New Tab'
};
}
const [tabs, setTabs] = useState(() => [createTab()]);
const [tabIndex, setTabIndex] = useState(0);
const tab = tabs[tabIndex]!;
const createTabPromise = useRef(Promise.resolve());
const ref = useRef<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 (
<RouteTitleContext.Provider
value={useMemo(
@ -151,12 +169,14 @@ function AppInner() {
createTabPromise.current = createTabPromise.current.then(
() =>
new Promise((res) => {
setTabs((tabs) => {
const newTabs = [...tabs, createTab()];
startTransition(() => {
setTabs((tabs) => {
const newTabs = [...tabs, createTab()];
setTabIndex(newTabs.length - 1);
setTabIndex(newTabs.length - 1);
return newTabs;
return newTabs;
});
});
setTimeout(res, TAB_CREATE_DELAY);
@ -164,29 +184,41 @@ function AppInner() {
);
},
removeTab(index: number) {
setTabs((tabs) => {
const tab = tabs[index];
if (!tab) return tabs;
startTransition(() => {
setTabs((tabs) => {
const tab = tabs[index];
if (!tab) return tabs;
tab.dispose();
tab.dispose();
tabs.splice(index, 1);
tabs.splice(index, 1);
setTabIndex(tabs.length - 1);
setTabIndex(Math.min(tabIndex, tabs.length - 1));
return [...tabs];
return [...tabs];
});
});
}
}}
>
<SpacedriveInterface
routing={{
router: tab.router,
routerKey: tabIndex,
currentIndex: tab.currentIndex,
maxIndex: tab.maxIndex
}}
/>
<SpacedriveInterfaceRoot>
{tabs.map((tab) =>
createPortal(
<SpacedriveRouterProvider
key={tab.id}
routing={{
routes,
visible: tabIndex === tabs.indexOf(tab),
router: tab.router,
currentIndex: tab.currentIndex,
maxIndex: tab.maxIndex
}}
/>,
tab.element
)
)}
<div ref={ref} />
</SpacedriveInterfaceRoot>
</TabsContext.Provider>
</RouteTitleContext.Provider>
);

View file

@ -2,8 +2,8 @@ import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query
import { useEffect, useRef, useState } from 'react';
import { createBrowserRouter } from 'react-router-dom';
import { RspcProvider } from '@sd/client';
import { Platform, PlatformProvider, routes, SpacedriveInterface } from '@sd/interface';
import { useOperatingSystem, useShowControls } from '@sd/interface/hooks';
import { createRoutes, Platform, PlatformProvider, SpacedriveRouterProvider } from '@sd/interface';
import { useShowControls } from '@sd/interface/hooks';
import demoData from './demoData.json';
import ScreenshotWrapper from './ScreenshotWrapper';
@ -75,10 +75,50 @@ const queryClient = new QueryClient({
}
});
const routes = createRoutes(platform);
function App() {
const os = useOperatingSystem();
const router = useRouter();
const domEl = useRef<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 = createBrowserRouter(routes(os));
const router = createBrowserRouter(createRoutes(platform));
router.subscribe((event) => {
setRouter((router) => {
@ -104,37 +144,5 @@ function App() {
};
});
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}>
<SpacedriveInterface
routing={{
...router,
routerKey: 0
}}
/>
</QueryClientProvider>
</PlatformProvider>
</RspcProvider>
</div>
</ScreenshotWrapper>
);
return router;
}
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 {
id Int @id @default(autoincrement())
pub_id Bytes @unique
filters Bytes?
search String?
filters String?
name String?
icon String?
description String?
order Int? // Add this line to include ordering
// order Int? // Add this line to include ordering
date_created DateTime?
date_modified DateTime?

View file

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

View file

@ -33,7 +33,7 @@ struct SearchData<T> {
items: Vec<T>,
}
#[derive(Deserialize, Type, Debug, Clone)]
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub enum SearchFilterArgs {
FilePath(FilePathFilterArgs),
@ -365,5 +365,5 @@ pub fn mount() -> AlphaRouter<Ctx> {
.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")]
pub enum ObjectHiddenFilter {
#[default]
@ -104,7 +104,7 @@ impl ObjectHiddenFilter {
}
}
#[derive(Deserialize, Type, Debug, Clone)]
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub enum ObjectFilterArgs {
Favorite(bool),

View file

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

View file

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

View file

@ -1,8 +1,12 @@
import { createContext, useContext } from 'react';
import { createRoutes } from './app';
export const RoutingContext = createContext<{
visible: boolean;
currentIndex: number;
maxIndex: number;
routes: ReturnType<typeof createRoutes>;
} | null>(null);
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 ExplorerContextMenu from './ParentContextMenu';
import { getQuickPreviewStore } from './QuickPreview/store';
import SearchOptions from './Search';
import { getExplorerStore, useExplorerStore } from './store';
import { useKeyRevealFinder } from './useKeyRevealFinder';
import View, { EmptyNotice, ExplorerViewProps } from './View';
@ -21,7 +20,6 @@ import 'react-slidedown/lib/slidedown.css';
interface Props {
emptyNotice?: ExplorerViewProps['emptyNotice'];
contextMenu?: () => ReactNode;
showFilterBar?: boolean;
}
/**

View file

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

View file

@ -1,11 +1,10 @@
import { UseInfiniteQueryOptions } from '@tanstack/react-query';
import { ExplorerItem, LibraryConfigWrapped, SearchData } from '@sd/client';
import { ExplorerItem, SearchData } from '@sd/client';
import { Ordering } from '../store';
import { UseExplorerSettings } from '../useExplorer';
export type UseExplorerInfiniteQueryArgs<TArg, TOrder extends Ordering> = {
library: LibraryConfigWrapped;
arg: TArg;
settings: UseExplorerSettings<TOrder>;
} & Pick<UseInfiniteQueryOptions<SearchData<ExplorerItem>>, 'enabled'>;
explorerSettings: UseExplorerSettings<TOrder>;
} & 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,
ObjectOrder,
ObjectSearchArgs,
useLibraryContext,
useRspcLibraryContext
} from '@sd/client';
import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery';
export function useObjectsInfiniteQuery({
library,
arg,
settings,
explorerSettings,
...args
}: UseExplorerInfiniteQueryArgs<ObjectSearchArgs, ObjectOrder>) {
const { library } = useLibraryContext();
const ctx = useRspcLibraryContext();
const explorerSettings = settings.useSettingsSnapshot();
const settings = explorerSettings.useSettingsSnapshot();
if (explorerSettings.order) {
arg.orderAndPagination = { orderOnly: explorerSettings.order };
if (settings.order) {
arg.orderAndPagination = { orderOnly: settings.order };
}
return useInfiniteQuery({
queryKey: ['search.objects', { library_id: library.uuid, arg }] as const,
queryFn: ({ pageParam, queryKey: [_, { arg }] }) => {
const cItem: Extract<ExplorerItem, { type: 'Object' }> = pageParam;
const { order } = explorerSettings;
const { order } = settings;
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,
FilePathOrder,
FilePathSearchArgs,
useLibraryContext,
useRspcLibraryContext
} from '@sd/client';
@ -12,16 +13,16 @@ import { getExplorerStore } from '../store';
import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery';
export function usePathsInfiniteQuery({
library,
arg,
settings,
explorerSettings,
...args
}: UseExplorerInfiniteQueryArgs<FilePathSearchArgs, FilePathOrder>) {
const { library } = useLibraryContext();
const ctx = useRspcLibraryContext();
const explorerSettings = settings.useSettingsSnapshot();
const settings = explorerSettings.useSettingsSnapshot();
if (explorerSettings.order) {
arg.orderAndPagination = { orderOnly: explorerSettings.order };
if (settings.order) {
arg.orderAndPagination = { orderOnly: settings.order };
if (arg.orderAndPagination.orderOnly.field === 'sizeInBytes') delete arg.take;
}
@ -29,7 +30,7 @@ export function usePathsInfiniteQuery({
queryKey: ['search.paths', { library_id: library.uuid, arg }] as const,
queryFn: ({ pageParam, queryKey: [_, { arg }] }) => {
const cItem: Extract<ExplorerItem, { type: 'Path' }> = pageParam;
const { order } = explorerSettings;
const { order } = settings;
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 { useDebouncedCallback } from 'use-debounce';
import { proxy, snapshot, subscribe, useSnapshot } from 'valtio';

View file

@ -1,11 +1,12 @@
import { X } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { useMatch, useNavigate, useResolvedPath } from 'react-router';
import { Link, NavLink } from 'react-router-dom';
import {
arraysEqual,
useBridgeQuery,
useFeatureFlag,
useLibraryMutation,
useLibraryQuery,
useOnlineLocations
} from '@sd/client';
@ -13,214 +14,200 @@ import { Button, Tooltip } from '@sd/ui';
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
import { Folder, Icon, SubtleButton } from '~/components';
import { useSavedSearches } from '../../Explorer/Search/SavedSearches';
import SidebarLink from './Link';
import LocationsContextMenu from './LocationsContextMenu';
import Section from './Section';
import { SeeMore } from './SeeMore';
import TagsContextMenu from './TagsContextMenu';
type SidebarGroup = {
name: string;
items: SidebarItem[];
};
export const LibrarySection = () => (
<>
<SavedSearches />
<Devices />
<Locations />
<Tags />
</>
);
type SidebarItem = {
name: string;
icon: React.ReactNode;
to: string;
position: number;
};
function SavedSearches() {
const savedSearches = useLibraryQuery(['search.saved.list']);
type TriggeredContextItem =
| {
type: 'location';
locationId: number;
}
| {
type: 'tag';
tagId: number;
};
const path = useResolvedPath('saved-search/:id');
const match = useMatch(path.pathname);
const currentSearchId = match?.params?.id;
export const LibrarySection = () => {
const node = useBridgeQuery(['nodeState']);
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
const tags = useLibraryQuery(['tags.list'], { keepPreviousData: true });
const onlineLocations = useOnlineLocations();
const isPairingEnabled = useFeatureFlag('p2pPairing');
const [showDummyNodesEasterEgg, setShowDummyNodesEasterEgg] = useState(false);
const [triggeredContextItem, setTriggeredContextItem] = useState<TriggeredContextItem | null>(
null
);
const currentIndex = currentSearchId
? savedSearches.data?.findIndex((s) => s.id === Number(currentSearchId))
: undefined;
// const savedSearches = useSavedSearches();
const navigate = useNavigate();
useEffect(() => {
const outsideClick = () => {
document.addEventListener('click', () => {
setTriggeredContextItem(null);
});
};
outsideClick();
return () => {
document.removeEventListener('click', outsideClick);
};
}, [triggeredContextItem]);
const deleteSavedSearch = useLibraryMutation(['search.saved.delete'], {
onSuccess() {
if (currentIndex !== undefined && savedSearches.data) {
const nextIndex = Math.min(currentIndex + 1, savedSearches.data.length - 2);
const search = savedSearches.data[nextIndex];
if (search) navigate(`saved-search/${search.id}`);
else navigate(`./`);
}
}
});
if (!savedSearches.data || savedSearches.data.length < 1) return null;
return (
<>
{/* {savedSearches.searches.length > 0 && (
<Section
name="Saved"
// actionArea={
// <Link to="settings/library/saved-searches">
// <SubtleButton />
// </Link>
// }
>
<SeeMore
items={savedSearches.searches}
renderItem={(search) => (
<SidebarLink
className="group/button relative w-full"
to={`search/${search.id}`}
key={search.id}
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Folder size={18} />
</div>
<span className="truncate">{search.name}</span>
<Button
className="absolute right-[2px] top-[2px] hidden rounded-full shadow group-hover/button:block"
size="icon"
variant="subtle"
onClick={() => savedSearches.removeSearch(search.id)}
>
<X weight="bold" className="text-ink-dull/50" />
</Button>
</SidebarLink>
)}
/>
</Section>
)} */}
<Section
name="Devices"
actionArea={
isPairingEnabled && (
<Link to="settings/library/nodes">
<SubtleButton />
</Link>
)
}
>
{node.data && (
<Section
name="Saved Searches"
// actionArea={
// <Link to="settings/library/saved-searches">
// <SubtleButton />
// </Link>
// }
>
<SeeMore>
{savedSearches.data.map((search, i) => (
<SidebarLink
className="group relative w-full"
to={`node/${node.data.id}`}
key={node.data.id}
className="group/button relative w-full"
to={`saved-search/${search.id}`}
key={search.id}
>
<Icon name="Laptop" size={20} className="mr-1" />
<span className="truncate">{node.data.name}</span>
</SidebarLink>
)}
<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>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Folder size={18} />
</div>
<Section
name="Locations"
actionArea={
<Link to="settings/library/locations">
<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={(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 />
</Link>
}
>
<SeeMore>
{locationsQuery.data?.map((location) => (
<LocationsContextMenu key={location.id} locationId={location.id}>
<SidebarLink
onContextMenu={() =>
setTriggeredContextItem({
type: 'location',
locationId: location.id
})
}
className={clsx(
triggeredContextItem?.type === 'location' &&
triggeredContextItem.locationId === location.id
? 'border-accent'
: 'border-transparent',
'group relative w-full border'
)}
to={`location/${location.id}`}
>
<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>
}
)
}
>
{node.data && (
<SidebarLink
className="group relative w-full"
to={`node/${node.data.id}`}
key={node.data.id}
>
<SeeMore>
{tags.data?.map((tag, index) => (
<TagsContextMenu tagId={tag.id} key={tag.id}>
<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>
<Icon name="Laptop" size={20} className="mr-1" />
<span className="truncate">{node.data.name}</span>
</SidebarLink>
)}
</>
<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 { useCallback, useState } from 'react';
import { useState } from 'react';
import { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client';
import { Button, Input } from '@sd/ui';
import { SearchOptionItem, SearchOptionSubMenu } from '.';
import { useSearchContext } from './Context';
import { AllKeys, FilterOption, getKey, updateFilterArgs, useSearchStore } from './store';
import { AllKeys, FilterOption, getKey } from './store';
import { UseSearch } from './useSearch';
import { FilterTypeCondition, filterTypeCondition } from './util';
export interface SearchFilter<
@ -38,63 +38,61 @@ export interface RenderSearchFilter<
Render: (props: {
filter: SearchFilterCRUD<TConditions>;
options: (FilterOption & { type: string })[];
search: UseSearch;
}) => JSX.Element;
// Apply is responsible for applying the filter to the search args
useOptions: (props: { search: string }) => FilterOption[];
}
export function useToggleOptionSelected() {
const { fixedArgsKeys } = useSearchContext();
export function useToggleOptionSelected({ search }: { search: UseSearch }) {
return ({
filter,
option,
select
}: {
filter: SearchFilterCRUD;
option: FilterOption;
select: boolean;
}) => {
search.updateDynamicFilters((dynamicFilters) => {
const key = getKey({ ...option, type: filter.name });
return useCallback(
({
filter,
option,
select
}: {
filter: SearchFilterCRUD;
option: FilterOption;
select: boolean;
}) =>
updateFilterArgs((args) => {
const key = getKey({ ...option, type: filter.name });
if (search.fixedFiltersKeys?.has(key)) return dynamicFilters;
if (fixedArgsKeys?.has(key)) return args;
const rawArg = dynamicFilters.find((arg) => filter.extract(arg));
const rawArg = args.find((arg) => filter.extract(arg));
if (!rawArg) {
const arg = filter.create(option.value);
dynamicFilters.push(arg);
} else {
const rawArgIndex = dynamicFilters.findIndex((arg) => filter.extract(arg))!;
if (!rawArg) {
const arg = filter.create(option.value);
args.push(arg);
const arg = filter.extract(rawArg)!;
if (select) {
if (rawArg) filter.applyAdd(arg, option);
} else {
const rawArgIndex = args.findIndex((arg) => filter.extract(arg))!;
const arg = filter.extract(rawArg)!;
if (select) {
if (rawArg) filter.applyAdd(arg, option);
} else {
if (!filter.applyRemove(arg, option)) args.splice(rawArgIndex, 1);
}
if (!filter.applyRemove(arg, option)) dynamicFilters.splice(rawArgIndex, 1);
}
}
return args;
}),
[fixedArgsKeys]
);
return dynamicFilters;
});
};
}
const FilterOptionList = ({
filter,
options
options,
search
}: {
filter: SearchFilterCRUD;
options: FilterOption[];
search: UseSearch;
}) => {
const store = useSearchStore();
const { fixedArgsKeys } = useSearchContext();
const { allFiltersKeys } = search;
const toggleOptionSelected = useToggleOptionSelected();
const toggleOptionSelected = useToggleOptionSelected({ search });
return (
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
@ -106,16 +104,14 @@ const FilterOptionList = ({
return (
<SearchOptionItem
selected={
store.filterArgsKeys.has(optionKey) || fixedArgsKeys?.has(optionKey)
}
setSelected={(value) =>
selected={allFiltersKeys.has(optionKey)}
setSelected={(value) => {
toggleOptionSelected({
filter,
option,
select: value
})
}
});
}}
key={option.value}
icon={option.icon}
>
@ -127,30 +123,30 @@ const FilterOptionList = ({
);
};
const FilterOptionText = ({ filter }: { filter: SearchFilterCRUD }) => {
const FilterOptionText = ({ filter, search }: { filter: SearchFilterCRUD; search: UseSearch }) => {
const [value, setValue] = useState('');
const { fixedArgsKeys } = useSearchContext();
const { fixedFiltersKeys } = search;
return (
<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)} />
<Button
variant="accent"
onClick={() => {
updateFilterArgs((args) => {
search.updateDynamicFilters((dynamicFilters) => {
const key = getKey({
type: filter.name,
name: value,
value
});
if (fixedArgsKeys?.has(key)) return args;
if (fixedFiltersKeys?.has(key)) return dynamicFilters;
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 { filterArgsKeys } = useSearchStore();
const { fixedArgsKeys } = useSearchContext();
const FilterOptionBoolean = ({
filter,
search
}: {
filter: SearchFilterCRUD;
search: UseSearch;
}) => {
const { fixedFiltersKeys, allFiltersKeys } = search;
const key = getKey({
type: filter.name,
@ -174,21 +174,21 @@ const FilterOptionBoolean = ({ filter }: { filter: SearchFilterCRUD }) => {
return (
<SearchOptionItem
icon={filter.icon}
selected={fixedArgsKeys?.has(key) || filterArgsKeys.has(key)}
selected={allFiltersKeys?.has(key)}
setSelected={() => {
updateFilterArgs((args) => {
if (fixedArgsKeys?.has(key)) return args;
search.updateDynamicFilters((dynamicFilters) => {
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) {
args.splice(index, 1);
dynamicFilters.splice(index, 1);
} else {
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);
},
applyAdd: (data, option) => {
if ('in' in data) data.in.push(option.value);
else data.notIn.push(option.value);
if ('in' in data) data.in = [...new Set([...data.in, option.value])];
else data.notIn = [...new Set([...data.notIn, option.value])];
return data;
},
@ -415,7 +415,9 @@ export const filterRegistry = [
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({
name: 'Tags',
@ -447,7 +449,9 @@ export const filterRegistry = [
icon: tag.color || 'CircleDashed'
}));
},
Render: ({ filter, options }) => <FilterOptionList filter={filter} options={options} />
Render: ({ filter, options, search }) => (
<FilterOptionList filter={filter} options={options} search={search} />
)
}),
createInOrNotInFilter({
name: 'Kind',
@ -481,7 +485,9 @@ export const filterRegistry = [
icon: kind + '20'
};
}),
Render: ({ filter, options }) => <FilterOptionList filter={filter} options={options} />
Render: ({ filter, options, search }) => (
<FilterOptionList filter={filter} options={options} search={search} />
)
}),
createTextMatchFilter({
name: 'Name',
@ -491,7 +497,7 @@ export const filterRegistry = [
},
create: (name) => ({ filePath: { name } }),
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
Render: ({ filter }) => <FilterOptionText filter={filter} />
Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
}),
createInOrNotInFilter({
name: 'Extension',
@ -508,7 +514,7 @@ export const filterRegistry = [
}));
},
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
Render: ({ filter }) => <FilterOptionText filter={filter} />
Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
}),
createBooleanFilter({
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
//

View file

@ -1,18 +1,15 @@
import clsx from 'clsx';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Input, ModifierKeys, Shortcut } from '@sd/ui';
import { SearchParamsSchema } from '~/app/route-schemas';
import { useOperatingSystem, useZodSearchParams } from '~/hooks';
import { useOperatingSystem } from '~/hooks';
import { keybindForOs } from '~/util/keybinds';
import { useSearchStore } from '../Explorer/Search/store';
import { useSearchContext } from '../Search';
import { useSearchStore } from '../Search/store';
export default () => {
const search = useSearchContext();
const searchRef = useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useZodSearchParams(SearchParamsSchema);
const searchStore = useSearchStore();
const os = useOperatingSystem(true);
@ -32,7 +29,6 @@ export default () => {
);
const blurHandler = useCallback((event: KeyboardEvent) => {
console.log('blurHandler');
if (event.key === 'Escape' && document.activeElement === searchRef.current) {
// Check if element is in focus, then remove it
event.preventDefault();
@ -50,27 +46,16 @@ export default () => {
};
}, [blurHandler, focusHandler]);
const [localValue, setLocalValue] = useState(searchParams.search ?? '');
useLayoutEffect(() => setLocalValue(searchParams.search ?? ''), [searchParams.search]);
const updateValueDebounced = useDebouncedCallback((value: string) => {
setSearchParams((p) => ({ ...p, search: value }), { replace: true });
}, 300);
const [value, setValue] = useState(search.search);
function updateValue(value: string) {
setLocalValue(value);
updateValueDebounced(value);
setValue(value);
search.setSearch(value);
}
function clearValue() {
setSearchParams(
(p) => {
delete p.search;
return { ...p };
},
{ replace: true }
);
setValue('');
search.setSearch('');
}
return (
@ -79,18 +64,17 @@ export default () => {
placeholder="Search"
className="mx-2 w-48 transition-all duration-200 focus-within:w-60"
size="sm"
value={localValue}
value={value}
onChange={(e) => updateValue(e.target.value)}
onBlur={() => {
if (localValue === '' && !searchStore.interactingWithSearchOptions) clearValue();
if (search.rawSearch === '' && !searchStore.interactingWithSearchOptions) {
clearValue();
search.setOpen(false);
}
}}
onFocus={() => updateValueDebounced(localValue)}
onFocus={() => search.setOpen(true)}
right={
<div
className={clsx(
'pointer-events-none flex h-7 items-center space-x-1 opacity-70 group-focus-within:hidden'
)}
>
<div className="pointer-events-none flex h-7 items-center space-x-1 opacity-70 group-focus-within:hidden">
{
<Shortcut
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 */
import { Icon } from '@phosphor-icons/react';
import { produce } from 'immer';
import { useEffect, useLayoutEffect, useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { proxy, ref, useSnapshot } from 'valtio';
import { proxyMap } from 'valtio/utils';
import { SearchFilterArgs } from '@sd/client';
import { useSearchContext } from './Context';
import { filterRegistry, FilterType, RenderSearchFilter } from './Filters';
export type SearchType = 'paths' | 'objects';
@ -28,42 +26,11 @@ export type AllKeys<T> = T extends any ? keyof T : never;
const searchStore = proxy({
interactingWithSearchOptions: false,
searchType: 'paths' as SearchType,
filterArgs: ref([] as SearchFilterArgs[]),
filterArgsKeys: ref(new Set<string>()),
filterOptions: ref(new Map<string, FilterOptionWithType[]>()),
// we register filters so we can search them
registeredFilters: proxyMap() as Map<string, FilterOptionWithType>
});
export function useSearchFilters<T extends SearchType>(
_searchType: T,
fixedArgs: SearchFilterArgs[]
) {
const { setFixedArgs, allFilterArgs, searchQuery } = useSearchContext();
// don't want the search bar to pop in after the top bar has loaded!
useLayoutEffect(() => {
resetSearchStore();
setFixedArgs(fixedArgs);
}, [fixedArgs]);
const searchQueryFilters = useMemo(() => {
const [name, ext] = searchQuery?.split('.') ?? [];
const filters: SearchFilterArgs[] = [];
if (name) filters.push({ filePath: { name: { contains: name } } });
if (ext) filters.push({ filePath: { extension: { in: [ext] } } });
return filters;
}, [searchQuery]);
return useMemo(
() => [...searchQueryFilters, ...allFilterArgs.map(({ arg }) => arg)],
[searchQueryFilters, allFilterArgs]
);
}
// this makes the filter unique and easily searchable using .includes
export const getKey = (filter: FilterOptionWithType) =>
`${filter.type}-${filter.name}-${filter.value}`;
@ -112,22 +79,6 @@ export function argsToOptions(args: SearchFilterArgs[], options: Map<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) => {
const { registeredFilters } = useSearchStore();
@ -142,10 +93,7 @@ export const useSearchRegisteredFilters = (query: string) => {
);
};
export const resetSearchStore = () => {
searchStore.filterArgs = ref([]);
searchStore.filterArgsKeys = ref(new Set());
};
export const resetSearchStore = () => {};
export const useSearchStore = () => useSnapshot(searchStore);

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 { IconTypes } from '@sd/assets/util';
import clsx from 'clsx';
import { InOrNotIn, Range, TextMatch } from '@sd/client';
import { Icon as SDIcon } from '~/components';
export const filterTypeCondition = {

View file

@ -3,21 +3,26 @@ import { Outlet } from 'react-router';
import { SearchFilterArgs } from '@sd/client';
import TopBar from '.';
import { SearchContextProvider } from '../Explorer/Search/Context';
const TopBarContext = createContext<ReturnType<typeof useContextValue> | null>(null);
function useContextValue() {
const [left, setLeft] = useState<HTMLDivElement | null>(null);
const [center, setCenter] = 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 [topBarHeight, setTopBarHeight] = useState(0);
return {
left,
setLeft,
center,
setCenter,
right,
setRight,
children,
setChildren,
fixedArgs,
setFixedArgs,
topBarHeight,
@ -30,10 +35,8 @@ export const Component = () => {
return (
<TopBarContext.Provider value={value}>
<SearchContextProvider>
<TopBar />
<Outlet />
</SearchContextProvider>
<TopBar />
<Outlet />
</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 { useTopBarContext } from './Layout';
interface Props {
interface Props extends PropsWithChildren {
left?: ReactNode;
center?: ReactNode;
right?: ReactNode;
}
export const TopBarPortal = ({ left, right }: Props) => {
export const TopBarPortal = ({ left, center, right, children }: Props) => {
const ctx = useTopBarContext();
return (
<>
{left && ctx.left && createPortal(left, ctx.left)}
{center && ctx.center && createPortal(center, ctx.center)}
{right && ctx.right && createPortal(right, ctx.right)}
{children && ctx.children && createPortal(children, ctx.children)}
</>
);
};

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { ArrowClockwise, Info } from '@phosphor-icons/react';
import { useCallback, useEffect, useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { stringify } from 'uuid';
import {
@ -8,7 +8,6 @@ import {
FilePathOrder,
Location,
ObjectKindEnum,
useLibraryContext,
useLibraryMutation,
useLibraryQuery,
useLibrarySubscription,
@ -29,29 +28,30 @@ import { useQuickRescan } from '~/hooks/useQuickRescan';
import Explorer from '../Explorer';
import { ExplorerContextProvider } from '../Explorer/Context';
import { usePathsInfiniteQuery } from '../Explorer/queries';
import { useSearchFilters } from '../Explorer/Search/store';
import { usePathsExplorerQuery } from '../Explorer/queries';
import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { useExplorerSearchParams } from '../Explorer/util';
import { EmptyNotice } from '../Explorer/View';
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
import SearchBar from '../Search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal';
import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
import LocationOptions from './LocationOptions';
export const Component = () => {
const [{ path }] = useExplorerSearchParams();
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
const location = useLibraryQuery(['locations.get', locationId], {
keepPreviousData: true,
suspense: true
});
return <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 onlineLocations = useOnlineLocations();
@ -59,16 +59,14 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin
const rescan = useQuickRescan();
const locationOnline = useMemo(() => {
const pub_id = location?.pub_id;
const pub_id = location.pub_id;
if (!pub_id) return false;
return onlineLocations.some((l) => arraysEqual(pub_id, l));
}, [location?.pub_id, onlineLocations]);
}, [location.pub_id, onlineLocations]);
const preferences = useLibraryQuery(['preferences.get']);
const updatePreferences = useLibraryMutation('preferences.update');
const isLocationIndexing = useIsLocationIndexing(location.id);
const settings = useMemo(() => {
const defaults = createDefaultExplorerSettings<FilePathOrder>({
order: { field: 'name', value: 'Asc' }
@ -114,21 +112,52 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin
location
});
const { items, count, loadMore, query } = useItems({
location,
settings: explorerSettings
const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
const fixedFilters = useMemo(
() => [
{ filePath: { locations: { in: [location.id] } } },
...(explorerSettingsSnapshot.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [])
],
[location.id, explorerSettingsSnapshot.layoutMode]
);
const search = useSearch({ fixedFilters });
const { layoutMode, mediaViewWithDescendants, showHiddenFiles } =
explorerSettings.useSettingsSnapshot();
const paths = usePathsExplorerQuery({
arg: {
filters: [
...search.allFilters,
{
filePath: {
path: {
location_id: location.id,
path: path ?? '',
include_descendants:
search.search !== '' ||
search.dynamicFilters.length > 0 ||
(layoutMode === 'media' && mediaViewWithDescendants)
}
}
},
!showHiddenFiles && { filePath: { hidden: false } }
].filter(Boolean) as any,
take
},
explorerSettings
});
const explorer = useExplorer({
items,
count,
loadMore,
isFetchingNextPage: query.isFetchingNextPage,
...paths,
isFetchingNextPage: paths.query.isFetchingNextPage,
isLoadingPreferences: preferences.isLoading,
settings: explorerSettings,
...(location && {
parent: { type: 'Location', location }
})
parent: { type: 'Location', location }
});
useLibrarySubscription(
@ -152,42 +181,53 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin
(path && path?.length > 1 ? getLastSectionOfPath(path) : location.name) ?? ''
);
const isLocationIndexing = useIsLocationIndexing(location.id);
return (
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal
left={
<div className="flex items-center gap-2">
<Folder size={22} className="mt-[-1px]" />
<span className="truncate text-sm font-medium">{title}</span>
{!locationOnline && (
<Tooltip label="Location is offline, you can still browse and organize.">
<Info className="text-ink-faint" />
</Tooltip>
)}
<LocationOptions location={location} path={path || ''} />
</div>
}
right={
<DefaultTopBarOptions
options={[
{
toolTipLabel: 'Reload',
onClick: () => rescan(location.id),
icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
individual: true,
showAtResolution: 'xl:flex'
}
]}
/>
}
/>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex items-center gap-2">
<Folder size={22} className="mt-[-1px]" />
<span className="truncate text-sm font-medium">{title}</span>
{!locationOnline && (
<Tooltip label="Location is offline, you can still browse and organize.">
<Info className="text-ink-faint" />
</Tooltip>
)}
<LocationOptions location={location} path={path || ''} />
</div>
}
right={
<DefaultTopBarOptions
options={[
{
toolTipLabel: 'Reload',
onClick: () => rescan(location.id),
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 ? (
<div className="flex h-full w-full items-center justify-center">
<Loader />
</div>
) : !preferences.isLoading ? (
<Explorer
showFilterBar
emptyNotice={
<EmptyNotice
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 {
if (path.endsWith('/')) {
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,
User
} from '@phosphor-icons/react';
import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr';
import { useFeatureFlag } from '@sd/client';
import { tw } from '@sd/ui';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
@ -97,6 +98,10 @@ export default () => {
<Icon component={TagSimple} />
Tags
</SidebarLink>
{/* <SidebarLink to="library/saved-searches">
<Icon component={MagnifyingGlass} />
Saved Searches
</SidebarLink> */}
<SidebarLink disabled to="library/clouds">
<Icon component={Cloud} />
Clouds

View file

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

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 { ObjectKindEnum, ObjectOrder, Tag, useLibraryContext, useLibraryQuery } from '@sd/client';
import { useMemo } from 'react';
import { ObjectKindEnum, ObjectOrder, useLibraryQuery } from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { Icon } from '~/components';
import { useRouteTitle, useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContextProvider } from '../Explorer/Context';
import { useObjectsInfiniteQuery } from '../Explorer/queries';
import { useSearchFilters } from '../Explorer/Search/store';
import { useObjectsExplorerQuery } from '../Explorer/queries/useObjectsExplorerQuery';
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { EmptyNotice } from '../Explorer/View';
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
import SearchBar from '../Search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal';
export function Component() {
const { id: tagId } = useZodRouteParams(LocationIdParamsSchema);
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
useRouteTitle(tag.data?.name ?? 'Tag');
useRouteTitle(tag.data!.name ?? 'Tag');
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<ObjectOrder>({
order: null
}),
[]
),
settings: useMemo(() => {
return createDefaultExplorerSettings<ObjectOrder>({ order: null });
}, []),
orderingKeys: objectOrderingKeysSchema
});
const { items, count, loadMore, query } = useItems({
tag: tag.data!,
settings: explorerSettings
const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
const fixedFilters = useMemo(
() => [
{ object: { tags: { in: [tag.data!.id] } } },
...(explorerSettingsSnapshot.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [])
],
[tag.data, explorerSettingsSnapshot.layoutMode]
);
const search = useSearch({
fixedFilters
});
const objects = useObjectsExplorerQuery({
arg: { take: 100, filters: search.allFilters },
explorerSettings
});
const explorer = useExplorer({
items,
count,
loadMore,
...objects,
isFetchingNextPage: objects.query.isFetchingNextPage,
settings: explorerSettings,
...(tag.data && {
parent: { type: 'Tag', tag: tag.data }
})
parent: { type: 'Tag', tag: tag.data! }
});
return (
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal
left={
<div className="flex flex-row items-center gap-2">
<div
className="h-[14px] w-[14px] shrink-0 rounded-full"
style={{ backgroundColor: tag?.data?.color || '#efefef' }}
/>
<span className="truncate text-sm font-medium">{tag?.data?.name}</span>
</div>
}
right={<DefaultTopBarOptions />}
/>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<div
className="h-[14px] w-[14px] shrink-0 rounded-full"
style={{ backgroundColor: tag.data!.color || '#efefef' }}
/>
<span className="truncate text-sm font-medium">{tag?.data?.name}</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
showFilterBar
emptyNotice={
<EmptyNotice
loading={query.isFetching}
icon={<Icon name="Tags" size={128} />}
message="No items assigned to this tag."
/>
@ -73,39 +91,3 @@ export function Component() {
</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 { Navigate, Outlet, useMatches, type RouteObject } from 'react-router-dom';
import { currentLibraryCache, useCachedLibraries } from '@sd/client';
import { Navigate, Outlet, redirect, useMatches, type RouteObject } from 'react-router-dom';
import { currentLibraryCache, getCachedLibraries, useCachedLibraries } from '@sd/client';
import { Dialogs, Toaster } from '@sd/ui';
import { RouterErrorBoundary } from '~/ErrorFallback';
import { useOperatingSystem } from '~/hooks';
import { useRoutingContext } from '~/RoutingContext';
import { Platform } from '..';
import libraryRoutes from './$libraryId';
import onboardingRoutes from './onboarding';
import { RootContext } from './RootContext';
import './style.scss';
import { useOperatingSystem } from '~/hooks';
import { OperatingSystem } from '..';
const Index = () => {
const libraries = useCachedLibraries();
if (libraries.status !== 'success') return null;
if (libraries.data.length === 0) return <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
// the `usePlausiblePageViewMonitor` hook, as early as possible (ideally within the layout itself).
// the hook should only be included if there's a valid `ClientContext` (so not onboarding)
export const routes = (os: OperatingSystem) => {
return [
export const createRoutes = (platform: Platform) =>
[
{
element: <Wrapper />,
Component: () => {
const rawPath = useRawRoutePath();
return (
<RootContext.Provider value={{ rawPath }}>
<Outlet />
<Dialogs />
<Toaster position="bottom-right" expand={true} />
</RootContext.Provider>
);
},
errorElement: <RouterErrorBoundary />,
children: [
{
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',
lazy: () => import('./onboarding/Layout'),
children: onboardingRoutes(os)
children: onboardingRoutes
},
{
path: ':libraryId',
lazy: () => import('./$libraryId/Layout'),
children: libraryRoutes
loader: async ({ params: { libraryId } }) => {
const libraries = await getCachedLibraries();
const library = libraries.find((l) => l.uuid === libraryId);
if (!library) {
const firstLibrary = libraries[0];
if (firstLibrary) return redirect(`/${firstLibrary.uuid}`);
else return redirect('/onboarding');
}
return null;
},
children: libraryRoutes(platform)
}
]
}
] satisfies RouteObject[];
};
/**
* Combines the `path` segments of the current route into a single string.
@ -75,10 +100,10 @@ export const routes = (os: OperatingSystem) => {
* but not the values used in the route params.
*/
const useRawRoutePath = () => {
const { routes } = useRoutingContext();
// `useMatches` returns a list of each matched RouteObject,
// we grab the last one as it contains all previous route segments.
const lastMatchId = useMatches().slice(-1)[0]?.id;
const os = useOperatingSystem();
const rawPath = useMemo(() => {
const [rawPath] =
@ -100,11 +125,11 @@ const useRawRoutePath = () => {
// `path` found, chuck it on the end
return [`${rawPath}/${item.path}`, item];
},
['' as string, { children: routes(os) }] as const
['' as string, { children: routes }] as const
) ?? [];
return rawPath ?? '/';
}, [lastMatchId, os]);
}, [lastMatchId, routes]);
return rawPath;
};

View file

@ -4,8 +4,6 @@ import { useMatch, useNavigate } from 'react-router';
import { getOnboardingStore, unlockOnboardingScreen, useOnboardingStore } from '@sd/client';
import { useOperatingSystem } from '~/hooks';
import routes from '.';
export default function OnboardingProgress() {
const obStore = useOnboardingStore();
const navigate = useNavigate();
@ -21,17 +19,26 @@ export default function OnboardingProgress() {
unlockOnboardingScreen(currentScreen, getOnboardingStore().unlockedScreens);
}, [currentScreen]);
const routes = [
'alpha',
'new-library',
os === 'macOS' && 'full-disk',
'locations',
'privacy',
'creating-library'
].filter(Boolean);
return (
<div className="flex w-full items-center justify-center">
<div className="flex items-center justify-center space-x-1">
{routes(os).map(({ path }) => {
{routes.map((path) => {
if (!path) return null;
return (
<button
key={path}
disabled={!obStore.unlockedScreens.includes(path)}
onClick={() => navigate(`./${path}`, { replace: true })}
onClick={() => navigate(path, { replace: true })}
className={clsx(
'h-2 w-2 rounded-full transition hover:bg-ink disabled:opacity-10',
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 { OperatingSystem } from '~/util/Platform';
import Alpha from './alpha';
import { useOnboardingContext } from './context';
@ -20,16 +19,25 @@ const Index = () => {
return <Navigate to="alpha" replace />;
};
const onboardingRoutes = (os: OperatingSystem) => {
return [
{ index: true, element: <Index /> },
{ path: 'alpha', element: <Alpha /> },
{ path: 'new-library', element: <NewLibrary /> },
...(os === 'macOS' ? [{ element: <FullDisk />, path: 'full-disk' }] : []),
{ path: 'locations', element: <Locations /> },
{ path: 'privacy', element: <Privacy /> },
{ path: 'creating-library', element: <CreatingLibrary /> }
] satisfies RouteObject[];
};
export default [
{
index: true,
loader: () => {
if (getOnboardingStore().lastActiveScreen)
return redirect(`/onboarding/${getOnboardingStore().lastActiveScreen}`);
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 { useSnapshot } from 'valtio';
import { valtioPersist } from '@sd/client';
import { OperatingSystem } from '~/util/Platform';
import { useOperatingSystem } from './useOperatingSystem';
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
//as we adopt different shortcuts for different platforms
@ -14,18 +16,16 @@ type Shortcut = {
action: string;
keys: {
[K in OperatingSystem | 'all']?: string[];
}
};
icons: {
[K in OperatingSystem | 'all']?: string[];
}
}
type ShortcutCategory = {
};
};
type ShortcutCategory = {
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: {
description: 'To perform actions and operations',
toggleJobManager: {
@ -38,7 +38,7 @@ export const ShortcutState: Record<string, ShortcutCategory>
macOS: [modifierSymbols.Meta.macOS as string, 'J'],
all: [modifierSymbols.Control.Other, 'J']
}
},
}
},
Pages: {
description: 'Different pages in the app',
@ -74,12 +74,9 @@ export const ShortcutState: Record<string, ShortcutCategory>
macOS: [
modifierSymbols.Shift.macOS as string,
modifierSymbols.Meta.macOS as string,
'T'],
all: [
modifierSymbols.Shift.Other,
modifierSymbols.Control.Other,
'T'
]
],
all: [modifierSymbols.Shift.Other, modifierSymbols.Control.Other, 'T']
}
}
},
@ -114,8 +111,8 @@ export const ShortcutState: Record<string, ShortcutCategory>
all: ['Control', '3']
},
icons: {
macOS: [modifierSymbols.Meta.macOS as string, '3'],
all: [modifierSymbols.Control.Other, '3']
macOS: [modifierSymbols.Meta.macOS as string, '3'],
all: [modifierSymbols.Control.Other, '3']
}
},
showHiddenFiles: {
@ -125,7 +122,11 @@ export const ShortcutState: Record<string, ShortcutCategory>
all: ['Control', 'Shift', '.']
},
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']
}
},
@ -136,7 +137,11 @@ export const ShortcutState: Record<string, ShortcutCategory>
all: ['Alt', 'Control', 'KeyP']
},
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']
}
},
@ -147,7 +152,11 @@ export const ShortcutState: Record<string, ShortcutCategory>
all: ['Alt', 'Control', 'KeyM']
},
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']
}
},
@ -317,7 +326,7 @@ export const ShortcutState: Record<string, ShortcutCategory>
},
icons: {
all: ['Escape']
},
}
},
explorerDown: {
action: 'Navigate files downwards',
@ -354,9 +363,9 @@ export const ShortcutState: Record<string, ShortcutCategory>
icons: {
all: ['ArrowRight']
}
},
},
}
}
}
};
export type ShortcutKeybinds = {
[C in ShortcutCategories]: {
@ -365,52 +374,52 @@ export type ShortcutKeybinds = {
action: string;
keys: {
[K in OperatingSystem | 'all']?: string[];
}
};
icons: {
[K in OperatingSystem | 'all']?: string[];
}
}[]
}
}
};
}[];
};
};
//data being re-arranged for keybindings page
export const keybindingsData = () => {
let shortcuts = {} as ShortcutKeybinds
let shortcuts = {} as ShortcutKeybinds;
for (const category in ShortcutState) {
const shortcutCategory = ShortcutState[category as ShortcutCategories] as ShortcutCategory;
const categoryShortcuts: Array<Shortcut> = [];
for (const shortcut in shortcutCategory) {
if (shortcut === 'description') continue;
const { keys, icons, action } = shortcutCategory[shortcut as ShortcutKeys] ?? {};
if (keys && icons && action) {
const categoryShortcut = {
icons,
action,
keys,
if (shortcut === 'description') continue;
const { keys, icons, action } = shortcutCategory[shortcut as ShortcutKeys] ?? {};
if (keys && icons && action) {
const categoryShortcut = {
icons,
action,
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;
}
};
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
//A union type of all categories would return the 'description' only
type ShortcutKeys = Exclude<
GetShortcutKeys<"Pages"> | GetShortcutKeys<"Dialogs"> | GetShortcutKeys<"Explorer">,
"description"
>
GetShortcutKeys<'Pages'> | GetShortcutKeys<'Dialogs'> | GetShortcutKeys<'Explorer'>,
'description'
>;
const shortcutsStore = valtioPersist('sd-shortcuts', ShortcutState);
@ -424,21 +433,25 @@ export function getShortcutsStore() {
export const useShortcut = (shortcut: ShortcutKeys, func: (e: KeyboardEvent) => void) => {
const os = useOperatingSystem();
const shortcutsStore = getShortcutsStore();
const shortcutsStore = useShortcutsStore();
const triggeredShortcut = () => {
const shortcuts: Record<ShortcutKeys, string[]> = {} as any;
for (const category in shortcutsStore) {
const shortcutCategory = shortcutsStore[category as ShortcutCategories];
for (const shortcut in shortcutCategory) {
if (shortcut === 'description') continue;
const keys = shortcutCategory[shortcut as ShortcutKeys]?.keys;
shortcuts[shortcut as ShortcutKeys] = (keys?.[os] || keys?.all) as string[];
}
}
return shortcuts[shortcut] as string[];
};
useKeys(triggeredShortcut(), func);
const shortcuts: Record<ShortcutKeys, string[]> = {} as any;
for (const category in shortcutsStore) {
const shortcutCategory = shortcutsStore[category as ShortcutCategories];
for (const shortcut in shortcutCategory) {
if (shortcut === 'description') continue;
const keys = shortcutCategory[shortcut as ShortcutKeys]?.keys;
shortcuts[shortcut as ShortcutKeys] = (keys?.[os] || keys?.all) as string[];
}
}
return shortcuts[shortcut] as string[];
};
const { visible } = useRoutingContext();
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 { init, Integrations } from '@sentry/browser';
import { defaultContext } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { PropsWithChildren, Suspense } from 'react';
import { RouterProvider, RouterProviderProps } from 'react-router-dom';
import {
NotificationContextProvider,
@ -18,6 +18,7 @@ import {
} from '@sd/client';
import { TooltipProvider } from '@sd/ui';
import { createRoutes } from './app';
import { P2P, useP2PErrorToast } from './app/p2p';
import { WithPrismTheme } from './components/TextViewer/prism';
import ErrorFallback, { BetterErrorBoundary } from './ErrorFallback';
@ -60,41 +61,54 @@ const Devtools = () => {
export type Router = RouterProviderProps['router'];
export const SpacedriveInterface = (props: {
export function SpacedriveRouterProvider(props: {
routing: {
routes: ReturnType<typeof createRoutes>;
visible: boolean;
router: Router;
routerKey: number;
currentIndex: 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();
useP2PErrorToast();
useInvalidateQuery();
useTheme();
return (
<BetterErrorBoundary FallbackComponent={ErrorFallback}>
<TooltipProvider>
<P2PContextProvider>
<NotificationContextProvider>
<RoutingContext.Provider
value={{
currentIndex: props.routing.currentIndex,
maxIndex: props.routing.maxIndex
}}
>
<Suspense>
<BetterErrorBoundary FallbackComponent={ErrorFallback}>
<TooltipProvider>
<P2PContextProvider>
<NotificationContextProvider>
<P2P />
<Devtools />
<WithPrismTheme />
<RouterProvider
key={props.routing.routerKey}
router={props.routing.router}
/>
</RoutingContext.Provider>
</NotificationContextProvider>
</P2PContextProvider>
</TooltipProvider>
</BetterErrorBoundary>
{children}
</NotificationContextProvider>
</P2PContextProvider>
</TooltipProvider>
</BetterErrorBoundary>
</Suspense>
);
};
}

View file

@ -29,6 +29,7 @@
"@tanstack/react-query-devtools": "^4.36.1",
"@tanstack/react-table": "^8.10.7",
"@tanstack/react-virtual": "3.0.0-beta.66",
"@total-typescript/ts-reset": "^0.5.1",
"@virtual-grid/react": "^1.1.0",
"class-variance-authority": "^0.7.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.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
{ 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: "tags.get", input: LibraryArgs<number>, result: Tag | null } |
{ 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.spacedrop", input: SpacedropArgs, result: string } |
{ key: "preferences.update", input: LibraryArgs<LibraryPreferences>, result: null } |
{ key: "search.saved.create", input: LibraryArgs<{ name: string; search?: string | null; filters?: string | null; description?: string | null; icon?: string | null }>, result: null } |
{ key: "search.saved.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.create", input: LibraryArgs<TagCreateArgs>, result: Tag } |
{ key: "tags.delete", input: LibraryArgs<number>, result: null } |
@ -111,6 +116,8 @@ export type Procedures = {
{ 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 }
/**
@ -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 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 SearchFilterArgs = { filePath: FilePathFilterArgs } | { object: ObjectFilterArgs }

View file

@ -2,7 +2,7 @@ import { createContext, PropsWithChildren, useContext, useMemo } from 'react';
import { LibraryConfigWrapped } from '../core';
import { valtioPersist } from '../lib';
import { useBridgeQuery } from '../rspc';
import { nonLibraryClient, useBridgeQuery } from '../rspc';
// The name of the localStorage key for caching library data
const libraryCacheLocalStorageKey = 'sd-library-list';
@ -27,6 +27,25 @@ export const useCachedLibraries = () =>
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 {
currentLibraryId: string | null;
libraries: ReturnType<typeof useCachedLibraries>;

View file

@ -691,6 +691,9 @@ importers:
'@tanstack/react-virtual':
specifier: 3.0.0-beta.66
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':
specifier: ^1.1.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
dev: false
/@total-typescript/ts-reset@0.5.1:
resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==}
dev: false
/@trysound/sax@0.2.0:
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}