[ENG-607] Explorer persist options (#1189)

* Structs for views

* hook up new preferences types to preferences system

* wip - draft

* laters useEffect, hi explorer context :D

* fix types

* wip

* hm

* preferences wip

* tweaks

* use search::SortOrder

* Refactor explorer settings to use useExplorer and useExplorerSettings (#1226)

refactor to use useExplorer and useExplorerSettings

* preferences

---------

Co-authored-by: Brendan Allan <brendonovich@outlook.com>
Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com>
This commit is contained in:
ameer2468 2023-08-18 11:19:54 +03:00 committed by GitHub
parent a0a1c67664
commit fe51d54075
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 721 additions and 433 deletions

View file

@ -1,4 +1,4 @@
import { useBridgeMutation, useFeatureFlag, useLibraryContext, useP2PEvents } from '@sd/client';
import { useFeatureFlag, useP2PEvents } from '@sd/client';
export function P2P() {
// const pairingResponse = useBridgeMutation('p2p.pairingResponse');

View file

@ -32,7 +32,7 @@ mod nodes;
pub mod notifications;
mod p2p;
mod preferences;
mod search;
pub(crate) mod search;
mod sync;
mod tags;
pub mod utils;

View file

@ -35,8 +35,9 @@ struct OptionalRange<T> {
to: Option<T>,
}
#[derive(Deserialize, Type, Debug, Clone, Copy)]
enum SortOrder {
#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy)]
#[serde(rename_all = "PascalCase")]
pub enum SortOrder {
Asc,
Desc,
}
@ -50,9 +51,9 @@ impl From<SortOrder> for prisma::SortOrder {
}
}
#[derive(Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")]
enum FilePathSearchOrdering {
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
pub enum FilePathSearchOrdering {
Name(SortOrder),
SizeInBytes(SortOrder),
DateCreated(SortOrder),
@ -183,16 +184,18 @@ impl FilePathFilterArgs {
}
}
#[derive(Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase")]
enum ObjectSearchOrdering {
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
pub enum ObjectSearchOrdering {
DateAccessed(SortOrder),
Kind(SortOrder),
}
impl ObjectSearchOrdering {
fn get_sort_order(&self) -> prisma::SortOrder {
(*match self {
Self::DateAccessed(v) => v,
Self::Kind(v) => v,
})
.into()
}
@ -200,8 +203,10 @@ impl ObjectSearchOrdering {
fn into_param(self) -> object::OrderByWithRelationParam {
let dir = self.get_sort_order();
use object::*;
match self {
Self::DateAccessed(_) => date_accessed::order(dir),
Self::Kind(_) => kind::order(dir),
}
}
}

View file

@ -0,0 +1,96 @@
use crate::api::search;
use crate::prisma::PrismaClient;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::collections::HashMap;
use uuid::Uuid;
use super::*;
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub struct LibraryPreferences {
#[serde(default)]
#[specta(optional)]
location: HashMap<Uuid, Settings<LocationSettings>>,
}
impl LibraryPreferences {
pub async fn write(self, db: &PrismaClient) -> prisma_client_rust::Result<()> {
let kvs = self.to_kvs();
db._batch(kvs.into_upserts(db)).await?;
Ok(())
}
pub async fn read(db: &PrismaClient) -> prisma_client_rust::Result<Self> {
let kvs = db.preference().find_many(vec![]).exec().await?;
let prefs = PreferenceKVs::new(
kvs.into_iter()
.filter_map(|data| {
let a = rmpv::decode::read_value(&mut data.value?.as_slice()).unwrap();
Some((PreferenceKey::new(data.key), PreferenceValue::from_value(a)))
})
.collect(),
);
Ok(prefs.parse())
}
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub struct LocationSettings {
explorer: ExplorerSettings<search::FilePathSearchOrdering>,
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ExplorerSettings<TOrder> {
layout_mode: Option<ExplorerLayout>,
grid_item_size: Option<i32>,
media_columns: Option<i32>,
media_aspect_square: Option<bool>,
open_on_double_click: Option<DoubleClickAction>,
show_bytes_in_grid_view: Option<bool>,
col_sizes: Option<BTreeMap<String, i32>>,
// temporary
#[serde(skip_serializing_if = "Option::is_none")]
order: Option<Option<TOrder>>,
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub enum ExplorerLayout {
Grid,
List,
Media,
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub enum DoubleClickAction {
OpenFile,
QuickPreview,
}
impl Preferences for LibraryPreferences {
fn to_kvs(self) -> PreferenceKVs {
let Self { location } = self;
location.to_kvs().with_prefix("location")
}
fn from_entries(mut entries: Entries) -> Self {
Self {
location: entries
.remove("location")
.map(|value| HashMap::from_entries(value.expect_nested()))
.unwrap_or_default(),
}
}
}

View file

@ -1,104 +1,32 @@
use crate::prisma::PrismaClient;
mod kv;
mod library;
pub use kv::*;
pub use library::*;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use specta::Type;
use tracing::error;
use uuid::Uuid;
mod kv;
pub use kv::*;
// Preferences are a set of types that are serialized as a list of key-value pairs,
// where nested type keys are serialized as a dot-separated path.
// They are serailized as a list because this allows preferences to be a synchronisation boundary,
// whereas their values (referred to as settings) will be overwritten.
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
pub struct LibraryPreferences {
#[serde(default)]
#[specta(optional)]
location: HashMap<Uuid, LocationPreferences>,
}
#[specta(inline)]
pub struct Settings<V>(V);
impl LibraryPreferences {
pub async fn write(self, db: &PrismaClient) -> prisma_client_rust::Result<()> {
let kvs = self.to_kvs();
db._batch(kvs.into_upserts(db)).await?;
Ok(())
}
pub async fn read(db: &PrismaClient) -> prisma_client_rust::Result<Self> {
let kvs = db.preference().find_many(vec![]).exec().await?;
let prefs = PreferenceKVs::new(
kvs.into_iter()
.filter_map(|data| {
rmpv::decode::read_value(&mut data.value?.as_slice())
.map_err(|e| error!("{e:#?}"))
.ok()
.map(|value| {
(
PreferenceKey::new(data.key),
PreferenceValue::from_value(value),
)
})
})
.collect(),
);
Ok(prefs.parse())
}
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
pub struct LocationPreferences {
/// View settings for the location - all writes are overwrites!
#[specta(optional)]
view: Option<LocationViewSettings>,
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
pub struct LocationViewSettings {
layout: ExplorerLayout,
list: ListViewSettings,
}
#[derive(Clone, Serialize, Deserialize, Type, Default, Debug)]
pub struct ListViewSettings {
columns: HashMap<String, ListViewColumnSettings>,
sort_col: Option<String>,
}
#[derive(Clone, Serialize, Deserialize, Type, Default, Debug)]
pub struct ListViewColumnSettings {
hide: bool,
size: Option<i32>,
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
pub enum ExplorerLayout {
Grid,
List,
Media,
}
impl<V> Preferences for HashMap<Uuid, V>
impl<V> Preferences for HashMap<Uuid, Settings<V>>
where
V: Preferences,
V: Serialize + DeserializeOwned,
{
fn to_kvs(self) -> PreferenceKVs {
PreferenceKVs::new(
self.into_iter()
.flat_map(|(id, value)| {
.map(|(id, value)| {
let mut buf = Uuid::encode_buffer();
let id = id.as_simple().encode_lower(&mut buf);
value.to_kvs().with_prefix(id)
(PreferenceKey::new(id), PreferenceValue::new(value))
})
.collect(),
)
@ -107,52 +35,15 @@ where
fn from_entries(entries: Entries) -> Self {
entries
.into_iter()
.map(|(key, value)| {
(
Uuid::parse_str(&key).expect("invalid uuid in preferences"),
V::from_entries(value.expect_nested()),
)
})
.map(|(key, entry)| (Uuid::parse_str(&key).unwrap(), entry.expect_value()))
.collect()
}
}
impl Preferences for LibraryPreferences {
fn to_kvs(self) -> PreferenceKVs {
let Self { location } = self;
location.to_kvs().with_prefix("location")
}
fn from_entries(mut entries: Entries) -> Self {
Self {
location: entries
.remove("location")
.map(|value| HashMap::from_entries(value.expect_nested()))
.unwrap_or_default(),
}
}
}
impl Preferences for LocationPreferences {
fn to_kvs(self) -> PreferenceKVs {
let Self { view } = self;
PreferenceKVs::new(
[view.map(|view| (PreferenceKey::new("view"), PreferenceValue::new(view)))]
.into_iter()
.flatten()
.collect(),
)
}
fn from_entries(mut entries: Entries) -> Self {
Self {
view: entries.remove("view").map(|view| view.expect_value()),
}
}
}
// Preferences are a set of types that are serialized as a list of key-value pairs,
// where nested type keys are serialized as a dot-separated path.
// They are serailized as a list because this allows preferences to be a synchronisation boundary,
// whereas their values (referred to as settings) will be overwritten.
pub trait Preferences {
fn to_kvs(self) -> PreferenceKVs;
fn from_entries(entries: Entries) -> Self;

View file

@ -1,11 +1,12 @@
import { createContext, useContext } from 'react';
import { PropsWithChildren, createContext, useContext } from 'react';
import { Ordering } from './store';
import { UseExplorer } from './useExplorer';
/**
* Context that must wrap anything to do with the explorer.
* This includes explorer views, the inspector, and top bar items.
*/
export const ExplorerContext = createContext<UseExplorer | null>(null);
const ExplorerContext = createContext<UseExplorer<Ordering> | null>(null);
export const useExplorerContext = () => {
const ctx = useContext(ExplorerContext);
@ -14,3 +15,10 @@ export const useExplorerContext = () => {
return ctx;
};
export const ExplorerContextProvider = <TOrdering extends Ordering>({
explorer,
children
}: PropsWithChildren<{
explorer: UseExplorer<TOrdering>;
}>) => <ExplorerContext.Provider value={explorer as any}>{children}</ExplorerContext.Provider>;

View file

@ -4,6 +4,7 @@ import { ContextMenu, ModifierKeys } from '@sd/ui';
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
import { isNonEmpty } from '~/util';
import { Platform } from '~/util/Platform';
import { useExplorerContext } from '../Context';
import { RevealInNativeExplorerBase } from '../RevealInNativeExplorer';
import { useExplorerViewContext } from '../ViewContext';
import { getExplorerStore, useExplorerStore } from '../store';
@ -50,9 +51,10 @@ export const Details = new ConditionalItem({
export const Rename = new ConditionalItem({
useCondition: () => {
const { selectedItems } = useContextMenuContext();
const explorerStore = useExplorerStore();
if (explorerStore.layoutMode === 'media' || selectedItems.length > 1) return null;
const settings = useExplorerContext().useSettingsSnapshot();
if (settings.layoutMode === 'media' || selectedItems.length > 1) return null;
return {};
},

View file

@ -7,10 +7,12 @@ import {
Video_Light
} from '@sd/assets/icons';
import { ReactNode } from 'react';
import { ExplorerLayout } from '@sd/client';
import DismissibleNotice from '~/components/DismissibleNotice';
import { useIsDark } from '~/hooks';
import { dismissibleNoticeStore } from '~/hooks/useDismissibleNoticeStore';
import { ExplorerLayoutMode, useExplorerStore } from './store';
import { useExplorerContext } from './Context';
import { useExplorerStore } from './store';
const MediaViewIcon = () => {
const isDark = useIsDark();
@ -54,7 +56,7 @@ const notices = {
"Get a visual overview of your files with Grid View. This view displays your files and folders as thumbnail images, making it easy to quickly identify the file you're looking for.",
icon: <CollectionIcon />
},
rows: {
list: {
key: 'listView',
title: 'List View',
description:
@ -67,14 +69,14 @@ const notices = {
description:
'Discover photos and videos easily, Media View will show results starting at the current location including sub directories.',
icon: <MediaViewIcon />
},
columns: undefined
} satisfies Record<ExplorerLayoutMode, Notice | undefined>;
}
// columns: undefined
} satisfies Record<ExplorerLayout, Notice | undefined>;
export default () => {
const { layoutMode } = useExplorerStore();
const settings = useExplorerContext().useSettingsSnapshot();
const notice = notices[layoutMode];
const notice = notices[settings.layoutMode];
if (!notice) return null;

View file

@ -9,7 +9,7 @@ import {
useRef,
useState
} from 'react';
import { ExplorerItem, getItemFilePath, getItemLocation, useLibraryContext } from '@sd/client';
import { ExplorerItem, getItemFilePath, useLibraryContext } from '@sd/client';
import { PDFViewer, TEXTViewer } from '~/components';
import { useCallbackToWatchResize, useIsDark } from '~/hooks';
import { usePlatform } from '~/util/Platform';

View file

@ -1,56 +1,53 @@
import { RadixCheckbox, Select, SelectOption, Slider, tw } from '@sd/ui';
import { type SortOrder, SortOrderSchema } from '~/app/route-schemas';
import { getExplorerConfigStore, useExplorerConfigStore } from './config';
import { FilePathSearchOrderingKeys, getExplorerStore, useExplorerStore } from './store';
import { RadixCheckbox, Select, SelectOption, Slider, tw, z } from '@sd/ui';
import { SortOrderSchema } from '~/app/route-schemas';
import { useExplorerContext } from './Context';
import {
createOrdering,
getExplorerStore,
getOrderingDirection,
orderingKey,
useExplorerStore
} from './store';
const Subheading = tw.div`text-ink-dull mb-1 text-xs font-medium`;
export const sortOptions: Record<FilePathSearchOrderingKeys, string> = {
'none': 'None',
'name': 'Name',
'sizeInBytes': 'Size',
'dateCreated': 'Date created',
'dateModified': 'Date modified',
'dateIndexed': 'Date indexed',
'object.dateAccessed': 'Date accessed'
};
export default () => {
const explorerStore = useExplorerStore();
const explorerConfig = useExplorerConfigStore();
const explorer = useExplorerContext();
const settings = explorer.useSettingsSnapshot();
return (
<div className="flex w-80 flex-col gap-4 p-4">
{(explorerStore.layoutMode === 'grid' || explorerStore.layoutMode === 'media') && (
{(settings.layoutMode === 'grid' || settings.layoutMode === 'media') && (
<div>
<Subheading>Item size</Subheading>
{explorerStore.layoutMode === 'grid' ? (
{settings.layoutMode === 'grid' ? (
<Slider
onValueChange={(value) => {
getExplorerStore().gridItemSize = value[0] || 100;
explorer.settingsStore.gridItemSize = value[0] || 100;
}}
defaultValue={[explorerStore.gridItemSize]}
defaultValue={[settings.gridItemSize]}
max={200}
step={10}
min={60}
/>
) : (
<Slider
defaultValue={[10 - explorerStore.mediaColumns]}
defaultValue={[10 - settings.mediaColumns]}
min={0}
max={6}
step={2}
onValueChange={([val]) => {
if (val !== undefined) {
getExplorerStore().mediaColumns = 10 - val;
}
if (val !== undefined)
explorer.settingsStore.mediaColumns = 10 - val;
}}
/>
)}
</div>
)}
{explorerStore.layoutMode === 'grid' && (
{settings.layoutMode === 'grid' && (
<div>
<Subheading>Gap</Subheading>
<Slider
@ -65,21 +62,29 @@ export default () => {
</div>
)}
{(explorerStore.layoutMode === 'grid' || explorerStore.layoutMode === 'media') && (
{(settings.layoutMode === 'grid' || settings.layoutMode === 'media') && (
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<Subheading>Sort by</Subheading>
<Select
value={explorerStore.orderBy}
value={settings.order ? orderingKey(settings.order) : 'none'}
size="sm"
className="w-full"
onChange={(value) =>
(getExplorerStore().orderBy = value as FilePathSearchOrderingKeys)
}
onChange={(key) => {
if (key === 'none') explorer.settingsStore.order = null;
else
explorer.settingsStore.order = createOrdering(
key,
explorer.settingsStore.order
? getOrderingDirection(explorer.settingsStore.order)
: 'Asc'
);
}}
>
{Object.entries(sortOptions).map(([value, text]) => (
<SelectOption key={value} value={value}>
{text}
<SelectOption value="none">None</SelectOption>
{explorer.orderingKeys?.options.map((option) => (
<SelectOption key={option.value} value={option.value}>
{option.description}
</SelectOption>
))}
</Select>
@ -88,12 +93,18 @@ export default () => {
<div className="flex flex-col">
<Subheading>Direction</Subheading>
<Select
value={explorerStore.orderByDirection}
value={settings.order ? getOrderingDirection(settings.order) : 'Asc'}
size="sm"
className="w-full"
onChange={(value) =>
(getExplorerStore().orderByDirection = value as SortOrder)
}
disabled={explorer.settingsStore.order === null}
onChange={(order) => {
if (explorer.settingsStore.order === null) return;
explorer.settingsStore.order = createOrdering(
orderingKey(explorer.settingsStore.order),
order
);
}}
>
{SortOrderSchema.options.map((o) => (
<SelectOption key={o.value} value={o.value}>
@ -105,29 +116,29 @@ export default () => {
</div>
)}
{explorerStore.layoutMode === 'grid' && (
{settings.layoutMode === 'grid' && (
<RadixCheckbox
checked={explorerStore.showBytesInGridView}
checked={settings.showBytesInGridView}
label="Show Object size"
name="showBytesInGridView"
onCheckedChange={(value) => {
if (typeof value === 'boolean') {
getExplorerStore().showBytesInGridView = value;
}
if (typeof value !== 'boolean') return;
explorer.settingsStore.showBytesInGridView = value;
}}
className="mt-1"
/>
)}
{explorerStore.layoutMode === 'media' && (
{settings.layoutMode === 'media' && (
<RadixCheckbox
checked={explorerStore.mediaAspectSquare}
checked={settings.mediaAspectSquare}
label="Show square thumbnails"
name="mediaAspectSquare"
onCheckedChange={(value) => {
if (typeof value === 'boolean') {
getExplorerStore().mediaAspectSquare = value;
}
if (typeof value !== 'boolean') return;
explorer.settingsStore.mediaAspectSquare = value;
}}
className="mt-1"
/>
@ -136,15 +147,23 @@ export default () => {
<Subheading>Double click action</Subheading>
<Select
className="w-full"
value={explorerConfig.openOnDoubleClick ? 'openFile' : 'quickPreview'}
value={settings.openOnDoubleClick}
onChange={(value) => {
getExplorerConfigStore().openOnDoubleClick = value === 'openFile';
explorer.settingsStore.openOnDoubleClick = value;
}}
>
<SelectOption value="openFile">Open File</SelectOption>
<SelectOption value="quickPreview">Quick Preview</SelectOption>
{doubleClickActions.options.map((option) => (
<SelectOption key={option.value} value={option.value}>
{option.description}
</SelectOption>
))}
</Select>
</div>
</div>
);
};
const doubleClickActions = z.union([
z.literal('openFile').describe('Open File'),
z.literal('quickPreview').describe('Quick Preview')
]);

View file

@ -20,20 +20,23 @@ import { useExplorerSearchParams } from './util';
export const useExplorerTopBarOptions = () => {
const explorerStore = useExplorerStore();
const explorer = useExplorerContext();
const settings = explorer.useSettingsSnapshot();
const viewOptions: ToolOption[] = [
{
toolTipLabel: 'Grid view',
icon: <SquaresFour className={TOP_BAR_ICON_STYLE} />,
topBarActive: explorerStore.layoutMode === 'grid',
onClick: () => (getExplorerStore().layoutMode = 'grid'),
topBarActive: settings.layoutMode === 'grid',
onClick: () => (explorer.settingsStore.layoutMode = 'grid'),
showAtResolution: 'sm:flex'
},
{
toolTipLabel: 'List view',
icon: <Rows className={TOP_BAR_ICON_STYLE} />,
topBarActive: explorerStore.layoutMode === 'rows',
onClick: () => (getExplorerStore().layoutMode = 'rows'),
topBarActive: settings.layoutMode === 'list',
onClick: () => (explorer.settingsStore.layoutMode = 'list'),
showAtResolution: 'sm:flex'
},
// {
@ -46,8 +49,8 @@ export const useExplorerTopBarOptions = () => {
{
toolTipLabel: 'Media view',
icon: <MonitorPlay className={TOP_BAR_ICON_STYLE} />,
topBarActive: explorerStore.layoutMode === 'media',
onClick: () => (getExplorerStore().layoutMode = 'media'),
topBarActive: settings.layoutMode === 'media',
onClick: () => (explorer.settingsStore.layoutMode = 'media'),
showAtResolution: 'sm:flex'
}
];

View file

@ -98,6 +98,7 @@ export default ({ children }: { children: RenderItem }) => {
const isChrome = CHROME_REGEX.test(navigator.userAgent);
const explorer = useExplorerContext();
const settings = explorer.useSettingsSnapshot();
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
@ -108,9 +109,8 @@ export default ({ children }: { children: RenderItem }) => {
const [dragFromThumbnail, setDragFromThumbnail] = useState(false);
const itemDetailsHeight =
explorerStore.gridItemSize / 4 + (explorerStore.showBytesInGridView ? 20 : 0);
const itemHeight = explorerStore.gridItemSize + itemDetailsHeight;
const itemDetailsHeight = settings.gridItemSize / 4 + (settings.showBytesInGridView ? 20 : 0);
const itemHeight = settings.gridItemSize + itemDetailsHeight;
const grid = useGridList({
ref: explorerView.ref,
@ -119,19 +119,19 @@ export default ({ children }: { children: RenderItem }) => {
onLoadMore: explorer.loadMore,
rowsBeforeLoadMore: explorer.rowsBeforeLoadMore,
size:
explorerStore.layoutMode === 'grid'
? { width: explorerStore.gridItemSize, height: itemHeight }
settings.layoutMode === 'grid'
? { width: settings.gridItemSize, height: itemHeight }
: undefined,
columns: explorerStore.layoutMode === 'media' ? explorerStore.mediaColumns : undefined,
columns: settings.layoutMode === 'media' ? settings.mediaColumns : undefined,
getItemId: (index) => {
const item = explorer.items?.[index];
return item ? explorerItemHash(item) : undefined;
},
getItemData: (index) => explorer.items?.[index],
padding: explorerView.padding || explorerStore.layoutMode === 'grid' ? 12 : undefined,
padding: explorerView.padding || settings.layoutMode === 'grid' ? 12 : undefined,
gap:
explorerView.gap ||
(explorerStore.layoutMode === 'grid' ? explorerStore.gridGap : undefined),
(settings.layoutMode === 'grid' ? explorerStore.gridGap : undefined),
top: explorerView.top
});

View file

@ -18,9 +18,11 @@ interface GridViewItemProps {
}
const GridViewItem = memo(({ data, selected, cut, isRenaming, renamable }: GridViewItemProps) => {
const explorer = useExplorerContext();
const { showBytesInGridView, gridItemSize } = explorer.useSettingsSnapshot();
const filePathData = getItemFilePath(data);
const location = getItemLocation(data);
const { showBytesInGridView, gridItemSize } = useExplorerStore();
const showSize =
!filePathData?.is_dir &&

View file

@ -16,6 +16,7 @@ import { useKey, useMutationObserver, useWindowEventListener } from 'rooks';
import useResizeObserver from 'use-resize-observer';
import {
ExplorerItem,
ExplorerSettings,
FilePath,
ObjectKind,
byteSize,
@ -34,7 +35,8 @@ import { useExplorerContext } from '../Context';
import { FileThumb } from '../FilePath/Thumb';
import { InfoPill } from '../Inspector';
import { useExplorerViewContext } from '../ViewContext';
import { FilePathSearchOrderingKeys, getExplorerStore, isCut, useExplorerStore } from '../store';
import { createOrdering, getOrderingDirection, orderingKey } from '../store';
import { isCut } from '../store';
import { ExplorerItemHash } from '../useExplorer';
import { explorerItemHash } from '../util';
import RenamableItemText from './RenamableItemText';
@ -91,7 +93,7 @@ type Range = [ExplorerItemHash, ExplorerItemHash];
export default () => {
const explorer = useExplorerContext();
const explorerStore = useExplorerStore();
const settings = explorer.useSettingsSnapshot();
const explorerView = useExplorerViewContext();
const layout = useLayoutContext();
@ -126,19 +128,25 @@ export default () => {
const scrollBarWidth = 8;
const rowHeight = 45;
const { width: tableWidth = 0 } = useResizeObserver({ ref: tableRef });
const { width: headerWidth = 0 } = useResizeObserver({ ref: tableHeaderRef });
const getFileName = (path: FilePath) => `${path.name}${path.extension && `.${path.extension}`}`;
useEffect(() => {
//we need this to trigger a re-render with the updated column sizes from the store
if (!resizing) {
setColumnSizing(explorer.settingsStore.colSizes);
}
}, [resizing, explorer.settingsStore.colSizes]);
const columns = useMemo<ColumnDef<ExplorerItem>[]>(
() => [
{
id: 'name',
header: 'Name',
minSize: 200,
size: 350,
size: settings.colSizes['name'],
maxSize: undefined,
meta: { className: '!overflow-visible !text-ink' },
accessorFn: (file) => {
@ -178,6 +186,7 @@ export default () => {
{
id: 'kind',
header: 'Type',
size: settings.colSizes['kind'],
enableSorting: false,
accessorFn: (file) => {
return isPath(file) && file.item.is_dir
@ -198,7 +207,7 @@ export default () => {
{
id: 'sizeInBytes',
header: 'Size',
size: 100,
size: settings.colSizes['sizeInBytes'],
accessorFn: (file) => {
const file_path = getItemFilePath(file);
if (!file_path || !file_path.size_in_bytes_bytes) return;
@ -209,11 +218,13 @@ export default () => {
{
id: 'dateCreated',
header: 'Date Created',
size: settings.colSizes['dateCreated'],
accessorFn: (file) => dayjs(file.item.date_created).format('MMM Do YYYY')
},
{
id: 'dateModified',
header: 'Date Modified',
size: settings.colSizes['dateModified'],
accessorFn: (file) =>
dayjs(getItemFilePath(file)?.date_modified).format('MMM Do YYYY')
},
@ -226,17 +237,20 @@ export default () => {
{
id: 'dateAccessed',
header: 'Date Accessed',
size: settings.colSizes['dateAccessed'],
accessorFn: (file) =>
getItemObject(file)?.date_accessed &&
dayjs(getItemObject(file)?.date_accessed).format('MMM Do YYYY')
},
{
id: 'contentId',
header: 'Content ID',
enableSorting: false,
size: 180,
size: settings.colSizes['contentId'],
accessorFn: (file) => getExplorerItemData(file).casId
},
{
id: 'objectId',
header: 'Object ID',
enableSorting: false,
size: 180,
@ -247,7 +261,7 @@ export default () => {
}
}
],
[explorer.selectedItems]
[explorer.selectedItems, settings.colSizes]
);
const table = useReactTable({
@ -710,7 +724,6 @@ export default () => {
const nameColumnMinSize = table.getColumn('name')?.columnDef.minSize;
const newNameSize =
(nameSize || 0) + tableWidth - paddingX * 2 - scrollBarWidth - tableLength;
return {
...sizing,
...(nameSize !== undefined && nameColumnMinSize !== undefined
@ -751,7 +764,6 @@ export default () => {
table.setColumnSizing({ ...sizings, name: nameWidth });
setLocked(true);
} else table.setColumnSizing(sizings);
setSized(true);
}
}, []);
@ -973,6 +985,9 @@ export default () => {
useWindowEventListener('mouseup', () => {
if (resizing) {
setTimeout(() => {
//we need to update the store to trigger a DB update
explorer.settingsStore.colSizes =
columnSizing as typeof explorer.settingsStore.colSizes;
setResizing(false);
if (layout?.ref.current) {
layout.ref.current.style.cursor = '';
@ -1012,8 +1027,11 @@ export default () => {
{headerGroup.headers.map((header, i) => {
const size = header.column.getSize();
const isSorted =
explorerStore.orderBy === header.id;
const orderingDirection =
settings.order &&
orderingKey(settings.order) === header.id
? getOrderingDirection(settings.order)
: null;
const cellContent = flexRender(
header.column.columnDef.header,
@ -1039,15 +1057,21 @@ export default () => {
if (resizing) return;
if (header.column.getCanSort()) {
if (isSorted) {
getExplorerStore().orderByDirection =
explorerStore.orderByDirection ===
'Asc'
? 'Desc'
: 'Asc';
if (orderingDirection) {
explorer.settingsStore.order =
createOrdering(
header.id,
orderingDirection ===
'Asc'
? 'Desc'
: 'Asc'
);
} else {
getExplorerStore().orderBy =
header.id as FilePathSearchOrderingKeys;
explorer.settingsStore.order =
createOrdering(
header.id,
'Asc'
);
}
}
}}
@ -1056,7 +1080,7 @@ export default () => {
<div
className={clsx(
'flex items-center justify-between gap-3',
isSorted
orderingDirection !== null
? 'text-ink'
: 'text-ink-dull'
)}
@ -1068,14 +1092,12 @@ export default () => {
/>
)}
{isSorted ? (
explorerStore.orderByDirection ===
'Asc' ? (
<CaretUp className="shrink-0 text-ink-faint" />
) : (
<CaretDown className="shrink-0 text-ink-faint" />
)
) : null}
{orderingDirection === 'Asc' && (
<CaretUp className="shrink-0 text-ink-faint" />
)}
{orderingDirection === 'Desc' && (
<CaretDown className="shrink-0 text-ink-faint" />
)}
<div
onClick={(e) =>
@ -1085,7 +1107,6 @@ export default () => {
header.getResizeHandler()(
e
);
setResizing(true);
setLocked(false);

View file

@ -4,6 +4,7 @@ import { memo } from 'react';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import { ViewItem } from '.';
import { useExplorerContext } from '../Context';
import { FileThumb } from '../FilePath/Thumb';
import { getExplorerStore, useExplorerStore } from '../store';
import GridList from './GridList';
@ -15,7 +16,7 @@ interface MediaViewItemProps {
}
const MediaViewItem = memo(({ data, selected, cut }: MediaViewItemProps) => {
const explorerStore = useExplorerStore();
const settings = useExplorerContext().useSettingsSnapshot();
return (
<ViewItem
@ -33,11 +34,8 @@ const MediaViewItem = memo(({ data, selected, cut }: MediaViewItemProps) => {
>
<FileThumb
data={data}
cover={explorerStore.mediaAspectSquare}
className={clsx(
!explorerStore.mediaAspectSquare && 'px-1',
cut && 'opacity-60'
)}
cover={settings.mediaAspectSquare}
className={clsx(!settings.mediaAspectSquare && 'px-1', cut && 'opacity-60')}
/>
<Button

View file

@ -35,7 +35,7 @@ import { QuickPreview } from '../QuickPreview';
import { useQuickPreviewContext } from '../QuickPreview/Context';
import { type ExplorerViewContext, ViewContext, useExplorerViewContext } from '../ViewContext';
import { useExplorerConfigStore } from '../config';
import { getExplorerStore, useExplorerStore } from '../store';
import { getExplorerStore } from '../store';
import GridView from './GridView';
import ListView from './ListView';
import MediaView from './MediaView';
@ -47,6 +47,7 @@ interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
const explorer = useExplorerContext();
const explorerView = useExplorerViewContext();
const explorerConfig = useExplorerConfigStore();
const navigate = useNavigate();
@ -172,7 +173,7 @@ export default memo(({ className, style, emptyNotice, ...contextProps }: Explore
const quickPreviewCtx = useQuickPreviewContext();
const { layoutMode, quickViewObject } = useExplorerStore();
const { layoutMode } = explorer.useSettingsSnapshot();
const ref = useRef<HTMLDivElement>(null);
@ -221,7 +222,7 @@ export default memo(({ className, style, emptyNotice, ...contextProps }: Explore
}
>
{layoutMode === 'grid' && <GridView />}
{layoutMode === 'rows' && <ListView />}
{layoutMode === 'list' && <ListView />}
{layoutMode === 'media' && <MediaView />}
</ViewContext.Provider>
) : (
@ -234,7 +235,7 @@ export default memo(({ className, style, emptyNotice, ...contextProps }: Explore
});
export const EmptyNotice = (props: { icon?: Icon | ReactNode; message?: ReactNode }) => {
const { layoutMode } = useExplorerStore();
const { layoutMode } = useExplorerContext().useSettingsSnapshot();
const emptyNoticeIcon = (icon?: Icon) => {
const Icon =
@ -243,7 +244,7 @@ export const EmptyNotice = (props: { icon?: Icon | ReactNode; message?: ReactNod
grid: GridFour,
media: MonitorPlay,
columns: Columns,
rows: Rows
list: Rows
}[layoutMode];
return <Icon size={100} opacity={0.3} />;

View file

@ -1,8 +1,8 @@
import { useSnapshot } from 'valtio';
import { valtioPersist } from '@sd/client';
import { DoubleClickAction, valtioPersist } from '@sd/client';
export const explorerConfigStore = valtioPersist('explorer-config', {
openOnDoubleClick: true
openOnDoubleClick: 'openFile' as DoubleClickAction
});
export function useExplorerConfigStore() {

View file

@ -1,19 +1,14 @@
import { proxy, useSnapshot } from 'valtio';
import { proxySet } from 'valtio/utils';
import { ExplorerItem, FilePathSearchOrdering, ObjectSearchOrdering, resetStore } from '@sd/client';
import { SortOrder } from '~/app/route-schemas';
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}${'' extends P ? '' : '.'}${P}`
: never
: never;
type Leaves<T> = T extends object ? { [K in keyof T]-?: Join<K, Leaves<T[K]>> }[keyof T] : '';
type UnionKeys<T> = T extends any ? Leaves<T> : never;
export type ExplorerLayoutMode = 'rows' | 'grid' | 'columns' | 'media';
import { z } from 'zod';
import {
DoubleClickAction,
ExplorerItem,
ExplorerLayout,
ExplorerSettings,
SortOrder,
resetStore
} from '@sd/client';
export enum ExplorerKind {
Location,
@ -21,10 +16,79 @@ export enum ExplorerKind {
Space
}
export type CutCopyType = 'Cut' | 'Copy';
export type Ordering = { field: string; value: SortOrder | Ordering };
// branded type for added type-safety
export type OrderingKey = string & {};
export type FilePathSearchOrderingKeys = UnionKeys<FilePathSearchOrdering> | 'none';
export type ObjectSearchOrderingKeys = UnionKeys<ObjectSearchOrdering> | 'none';
type OrderingValue<T extends Ordering, K extends string> = Extract<T, { field: K }>['value'];
export type OrderingKeys<T extends Ordering> = T extends Ordering
? {
[K in T['field']]: OrderingValue<T, K> extends SortOrder
? K
: OrderingValue<T, K> extends Ordering
? `${K}.${OrderingKeys<OrderingValue<T, K>>}`
: never;
}[T['field']]
: never;
export function orderingKey(ordering: Ordering): OrderingKey {
let base = ordering.field;
if (typeof ordering.value === 'object') {
base += `.${orderingKey(ordering.value)}`;
}
return base;
}
export function createOrdering<TOrdering extends Ordering = Ordering>(
key: OrderingKey,
value: SortOrder
): TOrdering {
return key
.split('.')
.reverse()
.reduce((acc, field, i) => {
if (i === 0)
return {
field,
value
};
else return { field, value: acc };
}, {} as any);
}
export function getOrderingDirection(ordering: Ordering): SortOrder {
if (typeof ordering.value === 'object') return getOrderingDirection(ordering.value);
else return ordering.value;
}
export const createDefaultExplorerSettings = <TOrder extends Ordering>({
order
}: {
order: TOrder | null;
}) =>
({
order,
layoutMode: 'grid' as ExplorerLayout,
gridItemSize: 110 as number,
showBytesInGridView: true as boolean,
mediaColumns: 8 as number,
mediaAspectSquare: false as boolean,
openOnDoubleClick: 'openFile' as DoubleClickAction,
colSizes: {
kind: 150,
name: 350,
sizeInBytes: 100,
dateModified: 150,
dateIndexed: 150,
dateCreated: 150,
dateAccessed: 150,
contentId: 180,
objectId: 180
}
} satisfies ExplorerSettings<TOrder>);
type CutCopyState =
| {
@ -38,20 +102,12 @@ type CutCopyState =
};
const state = {
layoutMode: 'grid' as ExplorerLayoutMode,
gridItemSize: 110,
listItemSize: 40,
showBytesInGridView: true,
tagAssignMode: false,
showInspector: false,
mediaPlayerVolume: 0.7,
newThumbnails: proxySet() as Set<string>,
cutCopyState: { type: 'Idle' } as CutCopyState,
quickViewObject: null as ExplorerItem | null,
mediaColumns: 8,
mediaAspectSquare: false,
orderBy: 'dateCreated' as FilePathSearchOrderingKeys,
orderByDirection: 'Desc' as SortOrder,
groupBy: 'none',
isDragging: false,
gridGap: 8
@ -64,7 +120,7 @@ export function flattenThumbnailKey(thumbKey: string[]) {
// Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.
const explorerStore = proxy({
...state,
reset: () => resetStore(explorerStore, state),
reset: (_state?: typeof state) => resetStore(explorerStore, _state || state),
addNewThumbnail: (thumbKey: string[]) => {
explorerStore.newThumbnails.add(flattenThumbnailKey(thumbKey));
},
@ -87,3 +143,17 @@ export function isCut(id: number) {
const state = explorerStore.cutCopyState;
return state.type === 'Cut' && state.sourcePathIds.includes(id);
}
export const filePathOrderingKeysSchema = z.union([
z.literal('name').describe('Name'),
z.literal('sizeInBytes').describe('Size'),
z.literal('dateModified').describe('Date Modified'),
z.literal('dateIndexed').describe('Date Indexed'),
z.literal('dateCreated').describe('Date Created'),
z.literal('object.dateAccessed').describe('Date Accessed')
]);
export const objectOrderingKeysSchema = z.union([
z.literal('dateAccessed').describe('Date Accessed'),
z.literal('kind').describe('Kind')
]);

View file

@ -1,5 +1,8 @@
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { ExplorerItem, FilePath, Location, NodeState, Tag } from '@sd/client';
import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { proxy, snapshot, subscribe, useSnapshot } from 'valtio';
import { z } from 'zod';
import { ExplorerItem, ExplorerSettings, FilePath, Location, NodeState, Tag } from '@sd/client';
import { Ordering, OrderingKeys, createDefaultExplorerSettings } from './store';
import { explorerItemHash } from './util';
export type ExplorerParent =
@ -17,7 +20,7 @@ export type ExplorerParent =
node: NodeState;
};
export interface UseExplorerProps {
export interface UseExplorerProps<TOrder extends Ordering> {
items: ExplorerItem[] | null;
parent?: ExplorerParent;
loadMore?: () => void;
@ -35,6 +38,7 @@ export interface UseExplorerProps {
* @defaultValue `true`
*/
selectable?: boolean;
settings: ReturnType<typeof useExplorerSettings<TOrder>>;
}
export type ExplorerItemMeta = {
@ -48,7 +52,10 @@ export type ExplorerItemHash = `${ExplorerItemMeta['type']}:${ExplorerItemMeta['
* Controls top-level config and state for the explorer.
* View- and inspector-specific state is not handled here.
*/
export function useExplorer(props: UseExplorerProps) {
export function useExplorer<TOrder extends Ordering>({
settings,
...props
}: UseExplorerProps<TOrder>) {
const scrollRef = useRef<HTMLDivElement>(null);
return {
@ -57,6 +64,7 @@ export function useExplorer(props: UseExplorerProps) {
rowsBeforeLoadMore: 5,
selectable: true,
scrollRef,
...settings,
// Provided values
...props,
// Selected items
@ -64,7 +72,43 @@ export function useExplorer(props: UseExplorerProps) {
};
}
export type UseExplorer = ReturnType<typeof useExplorer>;
export type UseExplorer<TOrder extends Ordering> = ReturnType<typeof useExplorer<TOrder>>;
export function useExplorerSettings<TOrder extends Ordering>({
settings,
onSettingsChanged,
orderingKeys
}: {
settings: ReturnType<typeof createDefaultExplorerSettings<TOrder>>;
onSettingsChanged: (settings: ExplorerSettings<TOrder>) => any;
orderingKeys?: z.ZodUnion<
[z.ZodLiteral<OrderingKeys<TOrder>>, ...z.ZodLiteral<OrderingKeys<TOrder>>[]]
>;
}) {
const [store] = useState(() => proxy(settings));
useEffect(() => {
Object.assign(store, settings);
}, [store, settings]);
useEffect(
() =>
subscribe(store, () => {
onSettingsChanged(snapshot(store) as ExplorerSettings<TOrder>);
}),
[onSettingsChanged, store]
);
return {
useSettingsSnapshot: () => useSnapshot(store),
settingsStore: store,
orderingKeys
};
}
export type UseExplorerSettings<TOrder extends Ordering> = ReturnType<
typeof useExplorerSettings<TOrder>
>;
function useSelectedItems(items: ExplorerItem[] | null) {
// Doing pointer lookups for hashes is a bit faster than assembling a bunch of strings

View file

@ -1,31 +1,10 @@
import { useMemo } from 'react';
import { ExplorerItem, FilePathSearchOrdering, getExplorerItemData } from '@sd/client';
import { ExplorerItem, getExplorerItemData } from '@sd/client';
import { ExplorerParamsSchema } from '~/app/route-schemas';
import { useZodSearchParams } from '~/hooks';
import { flattenThumbnailKey, useExplorerStore } from './store';
import { ExplorerItemHash } from './useExplorer';
export function useExplorerOrder(): FilePathSearchOrdering | undefined {
const explorerStore = useExplorerStore();
const ordering = useMemo(() => {
if (explorerStore.orderBy === 'none') return undefined;
const obj = {};
explorerStore.orderBy.split('.').reduce((acc, next, i, all) => {
if (all.length - 1 === i) acc[next] = explorerStore.orderByDirection;
else acc[next] = {};
return acc[next];
}, obj as any);
return obj as FilePathSearchOrdering;
}, [explorerStore.orderBy, explorerStore.orderByDirection]);
return ordering;
}
export function useExplorerSearchParams() {
return useZodSearchParams(ExplorerParamsSchema);
}

View file

@ -1,7 +1,12 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { stringify } from 'uuid';
import {
ExplorerSettings,
FilePathSearchOrdering,
useLibraryContext,
useLibraryMutation,
useLibraryQuery,
useLibrarySubscription,
useRspcLibraryContext
@ -10,21 +15,90 @@ import { LocationIdParamsSchema } from '~/app/route-schemas';
import { Folder } from '~/components';
import { useKeyDeleteFile, useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import { ExplorerContextProvider } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
import { useExplorer } from '../Explorer/useExplorer';
import { useExplorerOrder, useExplorerSearchParams } from '../Explorer/util';
import {
createDefaultExplorerSettings,
filePathOrderingKeysSchema,
getExplorerStore
} from '../Explorer/store';
import { UseExplorerSettings, useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { useExplorerSearchParams } from '../Explorer/util';
import { TopBarPortal } from '../TopBar/Portal';
import LocationOptions from './LocationOptions';
export const Component = () => {
const [{ path }] = useExplorerSearchParams();
const queryClient = useQueryClient();
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
const location = useLibraryQuery(['locations.get', locationId]);
const preferences = useLibraryQuery(['preferences.get']);
const updatePreferences = useLibraryMutation('preferences.update');
const settings = useMemo(() => {
const defaults = createDefaultExplorerSettings<FilePathSearchOrdering>({
order: {
field: 'name',
value: 'Asc'
}
});
if (!location.data) return defaults;
const pubId = stringify(location.data.pub_id);
const settings = preferences.data?.location?.[pubId]?.explorer;
if (!settings) return defaults;
for (const [key, value] of Object.entries(settings)) {
if (value !== null) Object.assign(defaults, { [key]: value });
}
return defaults;
}, [location.data, preferences.data?.location]);
const onSettingsChanged = useDebouncedCallback(
async (settings: ExplorerSettings<FilePathSearchOrdering>) => {
if (!location.data) return;
const pubId = stringify(location.data.pub_id);
try {
await updatePreferences.mutateAsync({
location: {
[pubId]: {
explorer: settings
}
}
});
queryClient.invalidateQueries(['preferences.get']);
} catch (e) {
alert('An error has occurred while updating your preferences.');
}
},
500
);
const explorerSettings = useExplorerSettings<FilePathSearchOrdering>({
settings,
onSettingsChanged,
orderingKeys: filePathOrderingKeysSchema
});
const { items, loadMore } = useItems({ locationId, settings: explorerSettings });
const explorer = useExplorer({
items,
loadMore,
parent: location.data
? {
type: 'Location',
location: location.data
}
: undefined,
settings: explorerSettings
});
useLibrarySubscription(
[
'locations.quickRescan',
@ -36,19 +110,6 @@ export const Component = () => {
{ onData() {} }
);
const { items, loadMore } = useItems({ locationId });
const explorer = useExplorer({
items,
loadMore,
parent: location.data
? {
type: 'Location',
location: location.data
}
: undefined
});
useEffect(() => {
// Using .call to silence eslint exhaustive deps warning.
// If clearSelectedItems referenced 'this' then this wouldn't work
@ -58,7 +119,7 @@ export const Component = () => {
useKeyDeleteFile(explorer.selectedItems, location.data?.id);
return (
<ExplorerContext.Provider value={explorer}>
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal
left={
<div className="group flex flex-row items-center space-x-2">
@ -79,17 +140,23 @@ export const Component = () => {
/>
<Explorer />
</ExplorerContext.Provider>
</ExplorerContextProvider>
);
};
const useItems = ({ locationId }: { locationId: number }) => {
const useItems = ({
locationId,
settings
}: {
locationId: number;
settings: UseExplorerSettings<FilePathSearchOrdering>;
}) => {
const [{ path, take }] = useExplorerSearchParams();
const ctx = useRspcLibraryContext();
const { library } = useLibraryContext();
const explorerState = useExplorerStore();
const explorerSettings = settings.useSettingsSnapshot();
const query = useInfiniteQuery({
queryKey: [
@ -97,10 +164,10 @@ const useItems = ({ locationId }: { locationId: number }) => {
{
library_id: library.uuid,
arg: {
order: useExplorerOrder(),
order: explorerSettings.order,
filter: {
locationId,
...(explorerState.layoutMode === 'media'
...(explorerSettings.layoutMode === 'media'
? { object: { kind: [5, 7] } }
: { path: path ?? '' })
},

View file

@ -1,11 +1,13 @@
import { Laptop } from '@sd/assets/icons';
import { useMemo } from 'react';
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
import { NodeIdParamsSchema } from '~/app/route-schemas';
import { useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import { ExplorerContextProvider } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer } from '../Explorer/useExplorer';
import { createDefaultExplorerSettings } from '../Explorer/store';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { TopBarPortal } from '../TopBar/Portal';
export const Component = () => {
@ -15,6 +17,17 @@ export const Component = () => {
const nodeState = useBridgeQuery(['nodeState']);
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<never>({
order: null
}),
[]
),
onSettingsChanged: () => {}
});
const explorer = useExplorer({
items: query.data || null,
parent: nodeState.data
@ -22,11 +35,12 @@ export const Component = () => {
type: 'Node',
node: nodeState.data
}
: undefined
: undefined,
settings: explorerSettings
});
return (
<ExplorerContext.Provider value={explorer}>
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal
left={
<div className="group flex flex-row items-center space-x-2">
@ -45,6 +59,6 @@ export const Component = () => {
/>
<Explorer />
</ExplorerContext.Provider>
</ExplorerContextProvider>
);
};

View file

@ -1,8 +1,15 @@
import { iconNames } from '@sd/assets/util';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Category, useLibraryContext, useRspcLibraryContext } from '@sd/client';
import {
Category,
ObjectSearchOrdering,
useLibraryContext,
useRspcLibraryContext
} from '@sd/client';
import { useExplorerContext } from '../Explorer/Context';
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
import { UseExplorerSettings } from '../Explorer/useExplorer';
export const IconForCategory: Partial<Record<Category, string>> = {
Recents: iconNames.Collection,
@ -23,15 +30,39 @@ export const IconForCategory: Partial<Record<Category, string>> = {
Trash: iconNames.Trash
};
export const IconToDescription = {
Recents: "See files you've recently opened or created",
Favorites: 'See files you have marked as favorites',
Albums: 'Organize your photos and videos into albums',
Photos: 'View all photos in your library',
Videos: 'View all videos in your library',
Movies: 'View all movies in your library',
Music: 'View all music in your library',
Documents: 'View all documents in your library',
Downloads: 'View all downloads in your library',
Encrypted: 'View all encrypted files in your library',
Projects: 'View all projects in your library',
Applications: 'View all applications in your library',
Archives: 'View all archives in your library',
Databases: 'View all databases in your library',
Games: 'View all games in your library',
Books: 'View all books in your library',
Contacts: 'View all contacts in your library',
Trash: 'View all files in your trash'
};
const OBJECT_CATEGORIES: Category[] = ['Recents', 'Favorites'];
// this is a gross function so it's in a separate hook :)
export function useItems(category: Category) {
const explorerStore = useExplorerStore();
export function useItems(
category: Category,
explorerSettings: UseExplorerSettings<ObjectSearchOrdering>
) {
const settings = explorerSettings.useSettingsSnapshot();
const rspc = useRspcLibraryContext();
const { library } = useLibraryContext();
const kind = explorerStore.layoutMode === 'media' ? [5, 7] : undefined;
const kind = settings.layoutMode === 'media' ? [5, 7] : undefined;
const isObjectQuery = OBJECT_CATEGORIES.includes(category);

View file

@ -1,56 +1,53 @@
import { getIcon } from '@sd/assets/util';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import 'react-loading-skeleton/dist/skeleton.css';
import { Category } from '@sd/client';
import { useSnapshot } from 'valtio';
import { Category, ObjectSearchOrdering } from '@sd/client';
import { useIsDark } from '../../../hooks';
import { ExplorerContext } from '../Explorer/Context';
import { ExplorerContextProvider } from '../Explorer/Context';
import ContextMenu, { ObjectItems } from '../Explorer/ContextMenu';
import { Conditional } from '../Explorer/ContextMenu/ConditionalItem';
import { Inspector } from '../Explorer/Inspector';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import View from '../Explorer/View';
import { useExplorerStore } from '../Explorer/store';
import { useExplorer } from '../Explorer/useExplorer';
import {
createDefaultExplorerSettings,
objectOrderingKeysSchema,
useExplorerStore
} from '../Explorer/store';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { usePageLayoutContext } from '../PageLayout/Context';
import { TopBarPortal } from '../TopBar/Portal';
import Statistics from '../overview/Statistics';
import { Categories, CategoryList } from './Categories';
import { IconForCategory, useItems } from './data';
const IconToDescription = {
Recents: "See files you've recently opened or created",
Favorites: 'See files you have marked as favorites',
Albums: 'Organize your photos and videos into albums',
Photos: 'View all photos in your library',
Videos: 'View all videos in your library',
Movies: 'View all movies in your library',
Music: 'View all music in your library',
Documents: 'View all documents in your library',
Downloads: 'View all downloads in your library',
Encrypted: 'View all encrypted files in your library',
Projects: 'View all projects in your library',
Applications: 'View all applications in your library',
Archives: 'View all archives in your library',
Databases: 'View all databases in your library',
Games: 'View all games in your library',
Books: 'View all books in your library',
Contacts: 'View all contacts in your library',
Trash: 'View all files in your trash'
};
import { Categories } from './Categories';
import { IconForCategory, IconToDescription, useItems } from './data';
export const Component = () => {
const explorerStore = useExplorerStore();
const isDark = useIsDark();
const page = usePageLayoutContext();
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<ObjectSearchOrdering>({
order: null
}),
[]
),
onSettingsChanged: () => {},
orderingKeys: objectOrderingKeysSchema
});
const [selectedCategory, setSelectedCategory] = useState<Category>('Recents');
const { items, loadMore } = useItems(selectedCategory);
const { items, loadMore } = useItems(selectedCategory, explorerSettings);
const explorer = useExplorer({
items,
loadMore,
scrollRef: page.ref
scrollRef: page.ref,
settings: explorerSettings
});
useEffect(() => {
@ -60,8 +57,10 @@ export const Component = () => {
if (scrollTop > 100) page.ref.current.scrollTo({ top: 100 });
}, [selectedCategory, page.ref]);
const settings = useSnapshot(explorer.settingsStore);
return (
<ExplorerContext.Provider value={explorer}>
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Statistics />
@ -71,7 +70,7 @@ export const Component = () => {
<div className="flex flex-1">
<View
top={68}
className={explorerStore.layoutMode === 'rows' ? 'min-w-0' : undefined}
className={settings.layoutMode === 'list' ? 'min-w-0' : undefined}
contextMenu={
<ContextMenu>
{() => <Conditional items={[ObjectItems.RemoveFromRecents]} />}
@ -96,11 +95,11 @@ export const Component = () => {
{explorerStore.showInspector && (
<Inspector
showThumbnail={explorerStore.layoutMode !== 'media'}
showThumbnail={settings.layoutMode !== 'media'}
className="custom-scroll inspector-scroll sticky top-[68px] h-full w-[260px] shrink-0 bg-app pb-4 pl-1.5 pr-1"
/>
)}
</div>
</ExplorerContext.Provider>
</ExplorerContextProvider>
);
};

View file

@ -1,19 +1,21 @@
import { MagnifyingGlass } from 'phosphor-react';
import { Suspense, memo, useDeferredValue, useMemo } from 'react';
import { getExplorerItemData, useLibraryQuery } from '@sd/client';
import { FilePathSearchOrdering, getExplorerItemData, useLibraryQuery } from '@sd/client';
import { SearchParams, SearchParamsSchema } from '~/app/route-schemas';
import { useZodSearchParams } from '~/hooks';
import Explorer from './Explorer';
import { ExplorerContext } from './Explorer/Context';
import { ExplorerContextProvider } from './Explorer/Context';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { EmptyNotice } from './Explorer/View';
import { getExplorerStore, useExplorerStore } from './Explorer/store';
import { useExplorer } from './Explorer/useExplorer';
import {
createDefaultExplorerSettings,
filePathOrderingKeysSchema,
getExplorerStore
} from './Explorer/store';
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
import { TopBarPortal } from './TopBar/Portal';
const SearchExplorer = memo((props: { args: SearchParams }) => {
const explorerStore = useExplorerStore();
const { search, ...args } = props.args;
const query = useLibraryQuery(['search.paths', { ...args, filter: { search } }], {
@ -22,10 +24,27 @@ const SearchExplorer = memo((props: { args: SearchParams }) => {
onSuccess: () => getExplorerStore().resetNewThumbnails()
});
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<FilePathSearchOrdering>({
order: {
field: 'name',
value: 'Asc'
}
}),
[]
),
onSettingsChanged: () => {},
orderingKeys: filePathOrderingKeysSchema
});
const settingsSnapshot = explorerSettings.useSettingsSnapshot();
const items = useMemo(() => {
const items = query.data?.items ?? null;
if (explorerStore.layoutMode !== 'media') return items;
if (settingsSnapshot.layoutMode !== 'media') return items;
return (
items?.filter((item) => {
@ -33,21 +52,22 @@ const SearchExplorer = memo((props: { args: SearchParams }) => {
return kind === 'Video' || kind === 'Image';
}) || null
);
}, [query.data, explorerStore.layoutMode]);
}, [query.data, settingsSnapshot.layoutMode]);
const explorer = useExplorer({
items
items,
settings: explorerSettings
});
return (
<>
{search ? (
<ExplorerContext.Provider value={explorer}>
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Explorer
emptyNotice={<EmptyNotice message={`No results found for "${search}"`} />}
/>
</ExplorerContext.Provider>
</ExplorerContextProvider>
) : (
<div className="flex flex-1 flex-col items-center justify-center">
<MagnifyingGlass size={110} className="mb-5 text-ink-faint" opacity={0.3} />

View file

@ -1,12 +1,14 @@
import { getIcon, iconNames } from '@sd/assets/util';
import { useLibraryQuery } from '@sd/client';
import { useMemo } from 'react';
import { ObjectSearchOrdering, useLibraryQuery } from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import { ExplorerContextProvider } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { EmptyNotice } from '../Explorer/View';
import { useExplorer } from '../Explorer/useExplorer';
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { TopBarPortal } from '../TopBar/Portal';
export const Component = () => {
@ -23,6 +25,18 @@ export const Component = () => {
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<ObjectSearchOrdering>({
order: null
}),
[]
),
onSettingsChanged: () => {},
orderingKeys: objectOrderingKeysSchema
});
const explorer = useExplorer({
items: explorerData.data?.items || null,
parent: tag.data
@ -30,11 +44,12 @@ export const Component = () => {
type: 'Tag',
tag: tag.data
}
: undefined
: undefined,
settings: explorerSettings
});
return (
<ExplorerContext.Provider value={explorer}>
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Explorer
emptyNotice={
@ -44,6 +59,6 @@ export const Component = () => {
/>
}
/>
</ExplorerContext.Provider>
</ExplorerContextProvider>
);
};

View file

@ -23,7 +23,10 @@ export type PathParams = z.infer<typeof PathParamsSchema>;
export const SearchParamsSchema = PathParamsSchema.extend({
take: z.coerce.number().optional(),
order: z
.union([z.object({ name: SortOrderSchema }), z.object({ name: SortOrderSchema })])
.union([
z.object({ field: z.literal('name'), value: SortOrderSchema }),
z.object({ field: z.literal('sizeInBytes'), value: SortOrderSchema })
])
.optional(),
search: z.string().optional()
});

View file

@ -111,11 +111,15 @@ export type CreateLibraryArgs = { name: LibraryName }
export type DiskType = "SSD" | "HDD" | "Removable"
export type DoubleClickAction = "openFile" | "quickPreview"
export type EditLibraryArgs = { id: string; name: LibraryName | null; description: MaybeUndefined<string> }
export type ExplorerItem = { type: "Path"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: FilePathWithObject } | { type: "Object"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: Location }
export type ExplorerLayout = "Grid" | "List" | "Media"
export type ExplorerLayout = "grid" | "list" | "media"
export type ExplorerSettings<TOrder> = { layoutMode: ExplorerLayout | null; gridItemSize: number | null; mediaColumns: number | null; mediaAspectSquare: boolean | null; openOnDoubleClick: DoubleClickAction | null; showBytesInGridView: boolean | null; colSizes: { [key: string]: number } | null; order?: TOrder | null }
export type FileCopierJobInit = { source_location_id: number; target_location_id: number; sources_file_path_ids: number[]; target_location_relative_directory_path: string; target_file_name_suffix: string | null }
@ -131,7 +135,7 @@ export type FilePathFilterArgs = { locationId?: number | null; search?: string |
export type FilePathSearchArgs = { take?: number | null; order?: FilePathSearchOrdering | null; cursor?: number[] | null; filter?: FilePathFilterArgs; groupDirectories?: boolean }
export type FilePathSearchOrdering = { name: SortOrder } | { sizeInBytes: SortOrder } | { dateCreated: SortOrder } | { dateModified: SortOrder } | { dateIndexed: SortOrder } | { object: ObjectSearchOrdering }
export type FilePathSearchOrdering = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } | { field: "dateIndexed"; value: SortOrder } | { field: "object"; value: ObjectSearchOrdering }
export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; device: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: Object | null }
@ -187,14 +191,10 @@ export type LibraryConfigWrapped = { uuid: string; config: LibraryConfig }
export type LibraryName = string
export type LibraryPreferences = { location?: { [key: string]: LocationPreferences } }
export type LibraryPreferences = { location?: { [key: string]: LocationSettings } }
export type LightScanArgs = { location_id: number; sub_path: string }
export type ListViewColumnSettings = { hide: boolean; size: number | null }
export type ListViewSettings = { columns: { [key: string]: ListViewColumnSettings }; sort_col: string | null }
export type Location = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; instance_id: number | null }
/**
@ -204,7 +204,7 @@ export type Location = { id: number; pub_id: number[]; name: string | null; path
*/
export type LocationCreateArgs = { path: string; dry_run: boolean; indexer_rules_ids: number[] }
export type LocationPreferences = { view?: LocationViewSettings | null }
export type LocationSettings = { explorer: ExplorerSettings<FilePathSearchOrdering> }
/**
* `LocationUpdateArgs` is the argument received from the client using `rspc` to update a location.
@ -216,8 +216,6 @@ export type LocationPreferences = { view?: LocationViewSettings | null }
*/
export type LocationUpdateArgs = { id: number; name: string | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; indexer_rules_ids: number[] }
export type LocationViewSettings = { layout: ExplorerLayout; list: ListViewSettings }
export type LocationWithIndexerRules = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; instance_id: number | null; indexer_rules: { indexer_rule: IndexerRule }[] }
export type MaybeNot<T> = T | { not: T }
@ -249,7 +247,7 @@ export type ObjectHiddenFilter = "exclude" | "include"
export type ObjectSearchArgs = { take?: number | null; order?: ObjectSearchOrdering | null; cursor?: number[] | null; filter?: ObjectFilterArgs }
export type ObjectSearchOrdering = { dateAccessed: SortOrder }
export type ObjectSearchOrdering = { field: "dateAccessed"; value: SortOrder } | { field: "kind"; value: SortOrder }
export type ObjectValidatorArgs = { id: number; path: string }

View file

@ -42,34 +42,34 @@ export const Select = forwardRef(
<TValue extends string = string>(
props: PropsWithChildren<SelectProps<TValue>>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
return (
<div ref={ref}>
<RS.Root
defaultValue={props.value}
value={props.value}
onValueChange={props.onChange}
disabled={props.disabled}
) => (
<div ref={ref}>
<RS.Root
defaultValue={props.value}
value={props.value}
onValueChange={props.onChange}
disabled={props.disabled}
>
<RS.Trigger
className={selectStyles({ size: props.size, className: props.className })}
>
<RS.Trigger
className={selectStyles({ size: props.size, className: props.className })}
>
<RS.Value placeholder={props.placeholder} />
<RS.Icon className="ml-2">
<ChevronDouble className="text-ink-dull" />
</RS.Icon>
</RS.Trigger>
<RS.Value placeholder={props.placeholder} />
<RS.Icon className="ml-2">
<ChevronDouble className="text-ink-dull" />
</RS.Icon>
</RS.Trigger>
<RS.Portal>
<RS.Content className="z-50 rounded-md border border-app-line bg-app-box shadow-2xl shadow-app-shade/20 ">
<RS.Viewport className="p-1">{props.children}</RS.Viewport>
</RS.Content>
</RS.Portal>
</RS.Root>
</div>
);
}
);
<RS.Portal>
<RS.Content className="z-50 rounded-md border border-app-line bg-app-box shadow-2xl shadow-app-shade/20 ">
<RS.Viewport className="p-1">{props.children}</RS.Viewport>
</RS.Content>
</RS.Portal>
</RS.Root>
</div>
)
) as <TValue extends string = string>(
props: PropsWithChildren<SelectProps<TValue>> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => JSX.Element;
export function SelectOption(props: PropsWithChildren<{ value: string; default?: boolean }>) {
return (