mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-14 01:54:04 +00:00
[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:
parent
a0a1c67664
commit
fe51d54075
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
96
core/src/preferences/library.rs
Normal file
96
core/src/preferences/library.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 {};
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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')
|
||||
]);
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
];
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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')
|
||||
]);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 ?? '' })
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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()
|
||||
});
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
Loading…
Reference in a new issue