mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 12:13:27 +00:00
[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:
parent
59ef3b94de
commit
908a13130c
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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>),
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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(())
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
// );
|
||||
// });
|
||||
// }
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
||||
// }))
|
||||
// });
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './useExplorerInfiniteQuery';
|
||||
export * from './usePathsInfiniteQuery';
|
||||
export * from './useObjectsInfiniteQuery';
|
||||
export * from './usePathsExplorerQuery';
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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>>;
|
|
@ -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);
|
||||
}
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
134
interface/app/$libraryId/Search/AppliedFilters.tsx
Normal file
134
interface/app/$libraryId/Search/AppliedFilters.tsx
Normal 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`;
|
||||
}
|
|
@ -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
|
||||
//
|
|
@ -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'])}
|
33
interface/app/$libraryId/Search/context.tsx
Normal file
33
interface/app/$libraryId/Search/context.tsx
Normal 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>;
|
||||
}
|
303
interface/app/$libraryId/Search/index.tsx
Normal file
303
interface/app/$libraryId/Search/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
|
187
interface/app/$libraryId/Search/useSearch.ts
Normal file
187
interface/app/$libraryId/Search/useSearch.ts
Normal 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>;
|
|
@ -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 = {
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 ?? ''} />
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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);
|
||||
|
|
125
interface/app/$libraryId/saved-search/$id.tsx
Normal file
125
interface/app/$libraryId/saved-search/$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
// );
|
||||
// };
|
|
@ -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
|
||||
|
|
|
@ -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') },
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
||||
|
|
0
interface/app/$libraryId/tag/Component.1.tsx
Normal file
0
interface/app/$libraryId/tag/Component.1.tsx
Normal file
0
interface/app/$libraryId/tag/Component.tsx
Normal file
0
interface/app/$libraryId/tag/Component.tsx
Normal 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;
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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;
|
||||
// };
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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'}
|
||||
|
|
Loading…
Reference in a new issue