diff --git a/apps/mobile/.svgrrc.js b/apps/mobile/.svgrrc.js new file mode 100644 index 000000000000..bc0cc209771c --- /dev/null +++ b/apps/mobile/.svgrrc.js @@ -0,0 +1,5 @@ +module.exports = { + icon: true, + typescript: true, + svgProps: { fill: 'currentColor' } +}; diff --git a/apps/mobile/src/screens/p2p/index.tsx b/apps/mobile/src/screens/p2p/index.tsx index cf3980bbdd9e..150df083525d 100644 --- a/apps/mobile/src/screens/p2p/index.tsx +++ b/apps/mobile/src/screens/p2p/index.tsx @@ -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'); diff --git a/apps/mobile/src/types/declarations.d.ts b/apps/mobile/src/types/declarations.d.ts index 508a6affbb1c..60cd6ed11783 100644 --- a/apps/mobile/src/types/declarations.d.ts +++ b/apps/mobile/src/types/declarations.d.ts @@ -1,8 +1,8 @@ declare module '*.svg' { import React from 'react'; import { SvgProps } from 'react-native-svg'; - const content: React.FC; - export default content; + // TODO: This is probably not working as intended + export const ReactComponent: React.FC>; } declare module '*.png' { diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index 0bca93862d83..40be4998ad12 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -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; diff --git a/core/src/api/search.rs b/core/src/api/search.rs index 5a183870189c..f5dc918a1b0b 100644 --- a/core/src/api/search.rs +++ b/core/src/api/search.rs @@ -35,8 +35,9 @@ struct OptionalRange { to: Option, } -#[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 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), } } } diff --git a/core/src/preferences/library.rs b/core/src/preferences/library.rs new file mode 100644 index 000000000000..3156242680ab --- /dev/null +++ b/core/src/preferences/library.rs @@ -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>, +} + +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 { + 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, +} + +#[derive(Clone, Serialize, Deserialize, Type, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ExplorerSettings { + layout_mode: Option, + grid_item_size: Option, + media_columns: Option, + media_aspect_square: Option, + open_on_double_click: Option, + show_bytes_in_grid_view: Option, + col_sizes: Option>, + // temporary + #[serde(skip_serializing_if = "Option::is_none")] + order: Option>, +} + +#[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(), + } + } +} diff --git a/core/src/preferences/mod.rs b/core/src/preferences/mod.rs index c036af351146..101d86255c7b 100644 --- a/core/src/preferences/mod.rs +++ b/core/src/preferences/mod.rs @@ -1,104 +1,32 @@ -use crate::prisma::PrismaClient; - -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, -} +mod library; -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 { - 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, -} - -#[derive(Clone, Serialize, Deserialize, Type, Debug)] -pub struct LocationViewSettings { - layout: ExplorerLayout, - list: ListViewSettings, -} +pub use kv::*; +pub use library::*; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use specta::Type; -#[derive(Clone, Serialize, Deserialize, Type, Default, Debug)] -pub struct ListViewSettings { - columns: HashMap, - sort_col: Option, -} +use std::collections::HashMap; -#[derive(Clone, Serialize, Deserialize, Type, Default, Debug)] -pub struct ListViewColumnSettings { - hide: bool, - size: Option, -} +use uuid::Uuid; #[derive(Clone, Serialize, Deserialize, Type, Debug)] -pub enum ExplorerLayout { - Grid, - List, - Media, -} +#[specta(inline)] +pub struct Settings(V); -impl Preferences for HashMap +impl Preferences for HashMap> 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; diff --git a/interface/ErrorFallback.tsx b/interface/ErrorFallback.tsx index b3b01ae0cfe1..733e757582fc 100644 --- a/interface/ErrorFallback.tsx +++ b/interface/ErrorFallback.tsx @@ -2,7 +2,7 @@ import { captureException } from '@sentry/browser'; import { FallbackProps } from 'react-error-boundary'; import { useRouteError } from 'react-router'; import { useDebugState } from '@sd/client'; -import { Button } from '@sd/ui'; +import { Button, Dialogs } from '@sd/ui'; import { showAlertDialog } from './components'; import { useOperatingSystem, useTheme } from './hooks'; @@ -83,6 +83,7 @@ export function ErrorPage({ (isMacOS ? ' rounded-lg' : '') } > +

APP CRASHED

We're past the event horizon...

@@ -114,8 +115,8 @@ export function ErrorPage({
 						

diff --git a/interface/app/$libraryId/Explorer/Context.tsx b/interface/app/$libraryId/Explorer/Context.tsx index 64d5031149f9..3a8b8781e21d 100644 --- a/interface/app/$libraryId/Explorer/Context.tsx +++ b/interface/app/$libraryId/Explorer/Context.tsx @@ -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(null); +const ExplorerContext = createContext | null>(null); export const useExplorerContext = () => { const ctx = useContext(ExplorerContext); @@ -14,3 +15,10 @@ export const useExplorerContext = () => { return ctx; }; + +export const ExplorerContextProvider = ({ + explorer, + children +}: PropsWithChildren<{ + explorer: UseExplorer; +}>) => {children}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx index 734dd1e90868..be61353e8915 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx @@ -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 {}; }, diff --git a/interface/app/$libraryId/Explorer/DismissibleNotice.tsx b/interface/app/$libraryId/Explorer/DismissibleNotice.tsx index bff8e74a4320..4e2c92642e36 100644 --- a/interface/app/$libraryId/Explorer/DismissibleNotice.tsx +++ b/interface/app/$libraryId/Explorer/DismissibleNotice.tsx @@ -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: }, - 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: - }, - columns: undefined -} satisfies Record; + } + // columns: undefined +} satisfies Record; export default () => { - const { layoutMode } = useExplorerStore(); + const settings = useExplorerContext().useSettingsSnapshot(); - const notice = notices[layoutMode]; + const notice = notices[settings.layoutMode]; if (!notice) return null; diff --git a/interface/app/$libraryId/Explorer/FilePath/LayeredFileIcon.tsx b/interface/app/$libraryId/Explorer/FilePath/LayeredFileIcon.tsx new file mode 100644 index 000000000000..1cba9b356b79 --- /dev/null +++ b/interface/app/$libraryId/Explorer/FilePath/LayeredFileIcon.tsx @@ -0,0 +1,26 @@ +import { type ImgHTMLAttributes } from 'react'; +import { type ObjectKindKey } from '@sd/client'; +import { getLayeredIcon } from '@sd/assets/util'; + +interface LayeredFileIconProps extends ImgHTMLAttributes { + kind: ObjectKindKey; + extension: string | null; +} + +const LayeredFileIcon = ({ kind, extension, ...props }: LayeredFileIconProps) => { + const iconImg = ; + const IconComponent = extension ? getLayeredIcon(kind, extension) : null; + + return IconComponent == null ? ( + iconImg + ) : ( +
+ {iconImg} +
+ +
+
+ ); +}; + +export default LayeredFileIcon; diff --git a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx index 0839199e9fa3..1320d80b89a7 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx @@ -1,15 +1,18 @@ import { getIcon, iconNames } from '@sd/assets/util'; import clsx from 'clsx'; import { + CSSProperties, ImgHTMLAttributes, + RefObject, VideoHTMLAttributes, memo, useEffect, useLayoutEffect, + useMemo, 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'; @@ -17,6 +20,7 @@ import { pdfViewerEnabled } from '~/util/pdfViewer'; import { useExplorerContext } from '../Context'; import { getExplorerStore } from '../store'; import { useExplorerItemData } from '../util'; +import LayeredFileIcon from './LayeredFileIcon'; import classes from './Thumb.module.scss'; const THUMB_TYPE = { @@ -33,6 +37,9 @@ export interface ThumbProps { size?: number; cover?: boolean; frame?: boolean; + blackBars?: boolean; + blackBarsSize?: number; + extension?: boolean; mediaControls?: boolean; pauseVideo?: boolean; className?: string; @@ -108,7 +115,7 @@ export const FileThumb = memo((props: ThumbProps) => { default: setSrc( getIcon( - itemData.isDir ? 'Folder' : itemData.kind, + itemData.isDir || parent?.type === 'Node' ? 'Folder' : itemData.kind, isDark, itemData.extension, itemData.isDir @@ -136,7 +143,7 @@ export const FileThumb = memo((props: ThumbProps) => { }} className={clsx( 'relative flex shrink-0 items-center justify-center', - loaded ? 'visible' : 'invisible', + !loaded && 'invisible', !props.size && 'h-full w-full', props.cover && 'overflow-hidden', props.className @@ -197,7 +204,12 @@ export const FileThumb = memo((props: ThumbProps) => { onError={onError} paused={props.pauseVideo} controls={props.mediaControls} - className={clsx(className, props.frame && frameClassName)} + blackBars={props.blackBars} + blackBarsSize={props.blackBarsSize} + className={clsx( + className, + props.frame && !props.blackBars && frameClassName + )} /> ); @@ -247,15 +259,19 @@ export const FileThumb = memo((props: ThumbProps) => { ? 'min-h-full min-w-full object-cover object-center' : className, - props.frame && - (itemData.kind !== 'Video' || thumbType == 'original') + props.frame && (itemData.kind !== 'Video' || !props.blackBars) ? frameClassName : null )} crossOrigin={thumbType !== 'original' ? 'anonymous' : undefined} // Here it is ok, because it is not a react attr - videoBars={itemData.kind === 'Video' && !props.cover} + blackBars={ + props.blackBars && itemData.kind === 'Video' && !props.cover + } + blackBarsSize={props.blackBarsSize} extension={ - itemData.extension && itemData.kind === 'Video' + props.extension && + itemData.extension && + itemData.kind === 'Video' ? itemData.extension : undefined } @@ -264,8 +280,10 @@ export const FileThumb = memo((props: ThumbProps) => { default: return ( - setLoaded(false)} decoding={props.size ? 'async' : 'sync'} @@ -281,27 +299,25 @@ export const FileThumb = memo((props: ThumbProps) => { interface ThumbnailProps extends ImgHTMLAttributes { cover?: boolean; - videoBars?: boolean; + blackBars?: boolean; + blackBarsSize?: number; extension?: string; } const Thumbnail = memo( ({ crossOrigin, - videoBars, + blackBars, + blackBarsSize, extension, cover, - onError, className, ...props }: ThumbnailProps) => { const ref = useRef(null); - const [size, setSize] = useState<{ width: number; height: number }>(); - - useCallbackToWatchResize(({ width, height }) => setSize({ width, height }), [], ref); - - const videoBarSize = (size: number) => Math.floor(size / 10); + const size = useSize(ref); + const { style: blackBarsStyle } = useBlackBars(size, blackBarsSize); return ( <> @@ -310,27 +326,9 @@ const Thumbnail = memo( // https://github.com/facebook/react/issues/14035#issuecomment-642227899 {...(crossOrigin ? { crossOrigin } : {})} ref={ref} - onError={(e) => { - onError?.(e); - setSize(undefined); - }} draggable={false} - className={clsx(className, videoBars && 'rounded border-black')} - style={ - videoBars - ? size - ? size.height >= size.width - ? { - borderLeftWidth: videoBarSize(size.height), - borderRightWidth: videoBarSize(size.height) - } - : { - borderTopWidth: videoBarSize(size.width), - borderBottomWidth: videoBarSize(size.width) - } - : {} - : {} - } + style={{ ...(blackBars ? blackBarsStyle : {}) }} + className={clsx(blackBars && size.width === 0 && 'invisible', className)} {...props} /> @@ -360,11 +358,16 @@ const Thumbnail = memo( interface VideoProps extends VideoHTMLAttributes { paused?: boolean; + blackBars?: boolean; + blackBarsSize?: number; } -const Video = memo(({ paused, ...props }: VideoProps) => { +const Video = memo(({ paused, blackBars, blackBarsSize, className, ...props }: VideoProps) => { const ref = useRef(null); + const size = useSize(ref); + const { style: blackBarsStyle } = useBlackBars(size, blackBarsSize); + useEffect(() => { if (!ref.current) return; paused ? ref.current.pause() : ref.current.play(); @@ -390,9 +393,49 @@ const Video = memo(({ paused, ...props }: VideoProps) => { }} playsInline draggable={false} + style={{ ...(blackBars ? blackBarsStyle : {}) }} + className={clsx(blackBars && size.width === 0 && 'invisible', className)} {...props} >

Video preview is not supported.

); }); + +const useSize = (ref: RefObject) => { + const [size, setSize] = useState({ width: 0, height: 0 }); + + useCallbackToWatchResize(({ width, height }) => setSize({ width, height }), [], ref); + + return size; +}; + +const useBlackBars = (videoSize: { width: number; height: number }, blackBarsSize?: number) => { + return useMemo(() => { + const { width, height } = videoSize; + + const orientation = height > width ? 'vertical' : 'horizontal'; + + const barSize = + blackBarsSize || + Math.floor(Math.ceil(orientation === 'vertical' ? height : width) / 10); + + const xBarSize = orientation === 'vertical' ? barSize : 0; + const yBarSize = orientation === 'horizontal' ? barSize : 0; + + return { + size: { + x: xBarSize, + y: yBarSize + }, + style: { + borderLeftWidth: xBarSize, + borderRightWidth: xBarSize, + borderTopWidth: yBarSize, + borderBottomWidth: yBarSize, + borderColor: 'black', + borderRadius: 4 + } satisfies CSSProperties + }; + }, [videoSize, blackBarsSize]); +}; diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index c31e71bde672..eb0f2073fa3e 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -93,16 +93,19 @@ const Thumbnails = ({ items }: { items: ExplorerItem[] }) => { {lastThreeItems.map((item, i, thumbs) => ( 1} + pauseVideo={!!explorerStore.quickViewObject || thumbs.length > 1} className={clsx( thumbs.length > 1 && '!absolute', i === 0 && thumbs.length > 1 && 'z-30 !h-[76%] !w-[76%]', i === 1 && 'z-20 !h-[80%] !w-[80%] rotate-[-5deg]', i === 2 && 'z-10 !h-[84%] !w-[84%] rotate-[7deg]' )} - pauseVideo={!!explorerStore.quickViewObject || thumbs.length > 1} - frame={thumbs.length > 1} childClassName={(type) => type !== 'icon' && thumbs.length > 1 ? 'shadow-md shadow-app-shade' diff --git a/interface/app/$libraryId/Explorer/OptionsPanel.tsx b/interface/app/$libraryId/Explorer/OptionsPanel.tsx index 19ea802e3aef..d0732237b890 100644 --- a/interface/app/$libraryId/Explorer/OptionsPanel.tsx +++ b/interface/app/$libraryId/Explorer/OptionsPanel.tsx @@ -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 = { - '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 (
- {(explorerStore.layoutMode === 'grid' || explorerStore.layoutMode === 'media') && ( + {(settings.layoutMode === 'grid' || settings.layoutMode === 'media') && (
Item size - {explorerStore.layoutMode === 'grid' ? ( + {settings.layoutMode === 'grid' ? ( { - getExplorerStore().gridItemSize = value[0] || 100; + explorer.settingsStore.gridItemSize = value[0] || 100; }} - defaultValue={[explorerStore.gridItemSize]} + defaultValue={[settings.gridItemSize]} max={200} step={10} min={60} /> ) : ( { - if (val !== undefined) { - getExplorerStore().mediaColumns = 10 - val; - } + if (val !== undefined) + explorer.settingsStore.mediaColumns = 10 - val; }} /> )}
)} - {explorerStore.layoutMode === 'grid' && ( + {settings.layoutMode === 'grid' && (
Gap {
)} - {(explorerStore.layoutMode === 'grid' || explorerStore.layoutMode === 'media') && ( + {(settings.layoutMode === 'grid' || settings.layoutMode === 'media') && (
Sort by @@ -88,12 +93,18 @@ export default () => {
Direction { - getExplorerConfigStore().openOnDoubleClick = value === 'openFile'; + explorer.settingsStore.openOnDoubleClick = value; }} > - Open File - Quick Preview + {doubleClickActions.options.map((option) => ( + + {option.description} + + ))}
); }; + +const doubleClickActions = z.union([ + z.literal('openFile').describe('Open File'), + z.literal('quickPreview').describe('Quick Preview') +]); diff --git a/interface/app/$libraryId/Explorer/TopBarOptions.tsx b/interface/app/$libraryId/Explorer/TopBarOptions.tsx index ad6df4b01157..9b63b0963ede 100644 --- a/interface/app/$libraryId/Explorer/TopBarOptions.tsx +++ b/interface/app/$libraryId/Explorer/TopBarOptions.tsx @@ -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: , - topBarActive: explorerStore.layoutMode === 'grid', - onClick: () => (getExplorerStore().layoutMode = 'grid'), + topBarActive: settings.layoutMode === 'grid', + onClick: () => (explorer.settingsStore.layoutMode = 'grid'), showAtResolution: 'sm:flex' }, { toolTipLabel: 'List view', icon: , - 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: , - topBarActive: explorerStore.layoutMode === 'media', - onClick: () => (getExplorerStore().layoutMode = 'media'), + topBarActive: settings.layoutMode === 'media', + onClick: () => (explorer.settingsStore.layoutMode = 'media'), showAtResolution: 'sm:flex' } ]; diff --git a/interface/app/$libraryId/Explorer/View/GridList.tsx b/interface/app/$libraryId/Explorer/View/GridList.tsx index b8250580c35b..3764919ff1b1 100644 --- a/interface/app/$libraryId/Explorer/View/GridList.tsx +++ b/interface/app/$libraryId/Explorer/View/GridList.tsx @@ -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 }); diff --git a/interface/app/$libraryId/Explorer/View/GridView.tsx b/interface/app/$libraryId/Explorer/View/GridView.tsx index 6fb01c9ceee3..b2b85aa1ed67 100644 --- a/interface/app/$libraryId/Explorer/View/GridView.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView.tsx @@ -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 && @@ -33,7 +35,13 @@ const GridViewItem = memo(({ data, selected, cut, isRenaming, renamable }: GridV
- +
diff --git a/interface/app/$libraryId/Explorer/View/ListView.tsx b/interface/app/$libraryId/Explorer/View/ListView.tsx index 22f49bb46b48..a848c3b705f9 100644 --- a/interface/app/$libraryId/Explorer/View/ListView.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView.tsx @@ -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[]>( () => [ { id: 'name', header: 'Name', minSize: 200, - size: 350, + size: settings.colSizes['name'], maxSize: undefined, meta: { className: '!overflow-visible !text-ink' }, accessorFn: (file) => { @@ -161,6 +169,7 @@ export default () => {
@@ -178,6 +187,7 @@ export default () => { { id: 'kind', header: 'Type', + size: settings.colSizes['kind'], enableSorting: false, accessorFn: (file) => { return isPath(file) && file.item.is_dir @@ -198,7 +208,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 +219,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 +238,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 +262,7 @@ export default () => { } } ], - [explorer.selectedItems] + [explorer.selectedItems, settings.colSizes] ); const table = useReactTable({ @@ -710,7 +725,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 +765,6 @@ export default () => { table.setColumnSizing({ ...sizings, name: nameWidth }); setLocked(true); } else table.setColumnSizing(sizings); - setSized(true); } }, []); @@ -973,6 +986,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 +1028,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 +1058,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 +1081,7 @@ export default () => {
{ /> )} - {isSorted ? ( - explorerStore.orderByDirection === - 'Asc' ? ( - - ) : ( - - ) - ) : null} + {orderingDirection === 'Asc' && ( + + )} + {orderingDirection === 'Desc' && ( + + )}
@@ -1085,7 +1108,6 @@ export default () => { header.getResizeHandler()( e ); - setResizing(true); setLocked(false); diff --git a/interface/app/$libraryId/Explorer/View/MediaView.tsx b/interface/app/$libraryId/Explorer/View/MediaView.tsx index c9fb61c6db6a..346773a4dcae 100644 --- a/interface/app/$libraryId/Explorer/View/MediaView.tsx +++ b/interface/app/$libraryId/Explorer/View/MediaView.tsx @@ -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 ( { >