From e8d3ad6005ffebcecd1a692fe78dd36ff0e3f0c7 Mon Sep 17 00:00:00 2001 From: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Date: Wed, 21 Jun 2023 23:34:45 -0700 Subject: [PATCH] [ENG-779] Finalize UI (#986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ENG-779] Finalize UI This is one branch with a variety of UI changes add tag select mode bar without functionality  fix group job status  add notice icon with info to stat icons add WIP notice to media view  add modal before add location with greyed out clouds remove disappearing add location button add WIP spacedrop page  bring back limited key manager UI  add options bar on search page without functionality  Add greyed out encrypt library button or setting See more button on locations Show locations on node screen Fix overview category left padding * key manager placeholder * stat info * nodes screen * location click yay * fix size in bytes Co-authored-by: Brendan Allan * small ui improvements * sh*tty see more button * last touches * fix merge boo boo * Fix mobile - Move `getItemObject`, `getItemFilePath`, `getItemLocation`, `getExplorerItemData` to @sd/core to allow mobile to use them * Formatting * Normalize displayed file size between all screens - Replace every use of internal formatBytes with byte-size dep --------- Co-authored-by: Brendan Allan Co-authored-by: Vítor Vasconcellos --- .cspell/project_words.txt | 1 + .../src/components/explorer/FileItem.tsx | 4 +- .../src/components/explorer/FileRow.tsx | 4 +- .../src/components/explorer/FileThumb.tsx | 7 +- .../explorer/sections/InfoTagPills.tsx | 13 +- .../modal/inspector/ActionsModal.tsx | 14 +- .../modal/inspector/FileInfoModal.tsx | 21 +- .../migration.sql | 2 + core/prisma/schema.prisma | 3 +- core/src/api/locations.rs | 15 +- core/src/api/nodes.rs | 86 ++-- core/src/api/search.rs | 2 +- core/src/library/config.rs | 36 +- core/src/location/file_path_helper/mod.rs | 6 +- core/src/location/indexer/mod.rs | 13 +- core/src/location/manager/watcher/utils.rs | 7 +- .../$libraryId/Explorer/File/ContextMenu.tsx | 12 +- .../Explorer/File/RenameTextBox.tsx | 371 ++++++++++-------- .../app/$libraryId/Explorer/File/Thumb.tsx | 43 +- .../$libraryId/Explorer/Inspector/index.tsx | 24 +- .../app/$libraryId/Explorer/View/GridView.tsx | 59 +-- .../app/$libraryId/Explorer/View/ListView.tsx | 84 ++-- .../Explorer/View/RenamableItemText.tsx | 54 +++ .../app/$libraryId/Explorer/View/index.tsx | 28 +- interface/app/$libraryId/Explorer/util.ts | 32 +- interface/app/$libraryId/KeyManager/index.tsx | 107 ++--- .../$libraryId/Layout/Sidebar/Contents.tsx | 18 +- .../Layout/Sidebar/LibrarySection.tsx | 60 +-- interface/app/$libraryId/index.tsx | 1 + interface/app/$libraryId/location/$id.tsx | 6 +- interface/app/$libraryId/node/$id.tsx | 48 +++ .../app/$libraryId/overview/Categories.tsx | 2 +- .../$libraryId/overview/CategoryButton.tsx | 8 +- .../app/$libraryId/overview/Statistics.tsx | 26 +- interface/app/$libraryId/search.tsx | 3 +- interface/app/$libraryId/settings/Sidebar.tsx | 9 +- .../$libraryId/settings/client/appearance.tsx | 38 +- .../$libraryId/settings/client/general.tsx | 48 ++- .../$libraryId/settings/client/privacy.tsx | 1 + .../$libraryId/settings/library/general.tsx | 22 +- interface/components/index.ts | 2 + interface/hooks/useExplorerItemData.ts | 3 +- interface/hooks/useExplorerTopBarOptions.tsx | 16 +- packages/assets/icons/Keys.png | Bin 0 -> 62119 bytes packages/assets/icons/index.ts | 2 + packages/client/src/core.ts | 7 +- packages/client/src/utils/explorerItem.ts | 33 ++ packages/client/src/utils/formatBytes.ts | 12 +- packages/client/src/utils/index.ts | 5 +- packages/ui/src/Switch.tsx | 4 +- packages/ui/src/Tooltip.tsx | 10 +- 51 files changed, 880 insertions(+), 552 deletions(-) create mode 100644 core/prisma/migrations/20230621173906_size_in_bytes_bytes/migration.sql create mode 100644 interface/app/$libraryId/Explorer/View/RenamableItemText.tsx create mode 100644 interface/app/$libraryId/node/$id.tsx create mode 100644 packages/assets/icons/Keys.png create mode 100644 packages/client/src/utils/explorerItem.ts diff --git a/.cspell/project_words.txt b/.cspell/project_words.txt index 7a59fba9b..5a12f695b 100644 --- a/.cspell/project_words.txt +++ b/.cspell/project_words.txt @@ -41,6 +41,7 @@ poonen rauch ravikant Recents +Renamable richelsen rspc rspcws diff --git a/apps/mobile/src/components/explorer/FileItem.tsx b/apps/mobile/src/components/explorer/FileItem.tsx index a36b09885..136ef8e97 100644 --- a/apps/mobile/src/components/explorer/FileItem.tsx +++ b/apps/mobile/src/components/explorer/FileItem.tsx @@ -1,5 +1,5 @@ import { Text, View } from 'react-native'; -import { ExplorerItem, isObject } from '@sd/client'; +import { ExplorerItem, getItemFilePath } from '@sd/client'; import Layout from '~/constants/Layout'; import { tw, twStyle } from '~/lib/tailwind'; import { getExplorerStore } from '~/stores/explorerStore'; @@ -12,7 +12,7 @@ type FileItemProps = { const FileItem = ({ data }: FileItemProps) => { const gridItemSize = Layout.window.width / getExplorerStore().gridNumColumns; - const filePath = isObject(data) ? data.item.file_paths[0] : data.item; + const filePath = getItemFilePath(data); return ( { - const filePath = isObject(data) ? data.item.file_paths[0] : data.item; + const filePath = getItemFilePath(data); return ( type KindType = keyof typeof icons | 'Unknown'; function getExplorerItemData(data: ExplorerItem) { - const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; - - const filePath = isObject(data) ? data.item.file_paths[0] : data.item; + const objectData = getItemObject(data); + const filePath = getItemFilePath(data); return { casId: filePath?.cas_id || null, diff --git a/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx b/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx index ac5c1e457..42ee337f2 100644 --- a/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx +++ b/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx @@ -1,6 +1,13 @@ import React from 'react'; import { Alert, Pressable, View, ViewStyle } from 'react-native'; -import { ExplorerItem, ObjectKind, isObject, isPath, useLibraryQuery } from '@sd/client'; +import { + ExplorerItem, + ObjectKind, + getItemFilePath, + getItemObject, + isPath, + useLibraryQuery +} from '@sd/client'; import { InfoPill, PlaceholderPill } from '~/components/primitive/InfoPill'; import { tw, twStyle } from '~/lib/tailwind'; @@ -10,8 +17,8 @@ type Props = { }; const InfoTagPills = ({ data, style }: Props) => { - const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; - const filePath = isObject(data) ? data.item.file_paths[0] : data.item; + const objectData = getItemObject(data); + const filePath = getItemFilePath(data); const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], { enabled: Boolean(objectData) diff --git a/apps/mobile/src/components/modal/inspector/ActionsModal.tsx b/apps/mobile/src/components/modal/inspector/ActionsModal.tsx index f07d31897..7d4f2ce47 100644 --- a/apps/mobile/src/components/modal/inspector/ActionsModal.tsx +++ b/apps/mobile/src/components/modal/inspector/ActionsModal.tsx @@ -1,3 +1,4 @@ +import byteSize from 'byte-size'; import dayjs from 'dayjs'; import { Copy, @@ -12,7 +13,7 @@ import { } from 'phosphor-react-native'; import { PropsWithChildren, useRef } from 'react'; import { Pressable, Text, View, ViewStyle } from 'react-native'; -import { formatBytes, isObject } from '@sd/client'; +import { bytesToNumber, getItemFilePath, getItemObject } from '@sd/client'; import FileThumb from '~/components/explorer/FileThumb'; import FavoriteButton from '~/components/explorer/sections/FavoriteButton'; import InfoTagPills from '~/components/explorer/sections/InfoTagPills'; @@ -60,8 +61,8 @@ export const ActionsModal = () => { const { modalRef, data } = useActionsModalStore(); - const filePath = data ? (isObject(data) ? data.item.file_paths[0] : data.item) : null; - const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; + const objectData = data && getItemObject(data); + const filePath = data && getItemFilePath(data); return ( <> @@ -84,7 +85,12 @@ export const ActionsModal = () => { - {formatBytes(Number(filePath?.size_in_bytes || 0))}, + {filePath?.size_in_bytes_bytes + ? byteSize( + bytesToNumber(filePath.size_in_bytes_bytes) + ).toString() + : 0} + , {' '} diff --git a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx index ca752b9e4..3f00b6b0d 100644 --- a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx +++ b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx @@ -1,3 +1,4 @@ +import byteSize from 'byte-size'; import dayjs from 'dayjs'; import { Barcode, @@ -10,7 +11,13 @@ import { } from 'phosphor-react-native'; import { forwardRef } from 'react'; import { Pressable, Text, View } from 'react-native'; -import { ExplorerItem, formatBytes, isObject, useLibraryQuery } from '@sd/client'; +import { + ExplorerItem, + bytesToNumber, + getItemFilePath, + getItemObject, + useLibraryQuery +} from '@sd/client'; import FileThumb from '~/components/explorer/FileThumb'; import InfoTagPills from '~/components/explorer/sections/InfoTagPills'; import { Modal, ModalRef, ModalScrollView } from '~/components/layout/Modal'; @@ -52,8 +59,8 @@ const FileInfoModal = forwardRef((props, ref) => { const item = data?.item; - const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; - const filePathData = data ? (isObject(data) ? data.item.file_paths[0] : data.item) : null; + const objectData = data && getItemObject(data); + const filePathData = data && getItemFilePath(data); const fullObjectData = useLibraryQuery(['files.get', { id: objectData?.id || -1 }], { enabled: objectData?.id !== undefined @@ -90,7 +97,13 @@ const FileInfoModal = forwardRef((props, ref) => { {/* Duration */} {fullObjectData.data?.media_data?.duration_seconds && ( diff --git a/core/prisma/migrations/20230621173906_size_in_bytes_bytes/migration.sql b/core/prisma/migrations/20230621173906_size_in_bytes_bytes/migration.sql new file mode 100644 index 000000000..f8c123b3e --- /dev/null +++ b/core/prisma/migrations/20230621173906_size_in_bytes_bytes/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "file_path" ADD COLUMN "size_in_bytes_bytes" BLOB; diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 53550deb8..fc90fb598 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -129,7 +129,8 @@ model FilePath { name String? extension String? - size_in_bytes String? + size_in_bytes String? // deprecated + size_in_bytes_bytes Bytes? inode Bytes? // This is actually an unsigned 64 bit integer, but we don't have this type in SQLite device Bytes? // This is actually an unsigned 64 bit integer, but we don't have this type in SQLite diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 4cd02c746..e08b6bf16 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -17,7 +17,7 @@ use specta::Type; use super::{utils::library, Ctx, R}; -#[derive(Serialize, Deserialize, Type, Debug)] +#[derive(Serialize, Type, Debug)] #[serde(tag = "type")] pub enum ExplorerContext { Location(location::Data), @@ -25,7 +25,7 @@ pub enum ExplorerContext { // Space(object_in_space::Data), } -#[derive(Serialize, Deserialize, Type, Debug)] +#[derive(Serialize, Type, Debug)] #[serde(tag = "type")] pub enum ExplorerItem { Path { @@ -41,9 +41,14 @@ pub enum ExplorerItem { thumbnail_key: Option>, item: object_with_file_paths::Data, }, + Location { + has_local_thumbnail: bool, + thumbnail_key: Option>, + item: location::Data, + }, } -#[derive(Serialize, Deserialize, Type, Debug)] +#[derive(Serialize, Type, Debug)] pub struct ExplorerData { pub context: ExplorerContext, pub items: Vec, @@ -103,7 +108,9 @@ pub(crate) fn mount() -> AlphaRouter { .procedure("update", { R.with2(library()) .mutation(|(_, library), args: LocationUpdateArgs| async move { - args.update(&library).await.map_err(Into::into) + let ret = args.update(&library).await.map_err(Into::into); + invalidate_query!(library, "locations.list"); + ret }) }) .procedure("delete", { diff --git a/core/src/api/nodes.rs b/core/src/api/nodes.rs index 347ed92ec..e034a0cf8 100644 --- a/core/src/api/nodes.rs +++ b/core/src/api/nodes.rs @@ -1,34 +1,72 @@ +use crate::prisma::{location, node}; use rspc::{alpha::AlphaRouter, ErrorCode}; + use serde::Deserialize; use specta::Type; use tracing::error; -use crate::api::R; - -use super::Ctx; +use super::{locations::ExplorerItem, utils::library, Ctx, R}; pub(crate) fn mount() -> AlphaRouter { - R.router().procedure("changeNodeName", { - #[derive(Deserialize, Type)] - pub struct ChangeNodeNameArgs { - pub name: String, - } - // TODO: validate name isn't empty or too long + R.router() + .procedure("changeNodeName", { + #[derive(Deserialize, Type)] + pub struct ChangeNodeNameArgs { + pub name: String, + } + // TODO: validate name isn't empty or too long - R.mutation(|ctx, args: ChangeNodeNameArgs| async move { - ctx.config - .write(|mut config| { - config.name = args.name; - }) - .await - .map_err(|err| { - error!("Failed to write config: {}", err); - rspc::Error::new( - ErrorCode::InternalServerError, - "error updating config".into(), - ) - }) - .map(|_| ()) + R.mutation(|ctx, args: ChangeNodeNameArgs| async move { + ctx.config + .write(|mut config| { + config.name = args.name; + }) + .await + .map_err(|err| { + error!("Failed to write config: {}", err); + rspc::Error::new( + ErrorCode::InternalServerError, + "error updating config".into(), + ) + }) + .map(|_| ()) + }) + }) + // TODO: add pagination!! and maybe ordering etc + .procedure("listLocations", { + R.with2(library()) + .query(|(ctx, library), _node_id: Option| async move { + // 1. grab currently active node + let node_config = ctx.config.get().await; + let node_pub_id = node_config.id.as_bytes().to_vec(); + // 2. get node from database + let node = library + .db + .node() + .find_unique(node::pub_id::equals(node_pub_id)) + .exec() + .await?; + + if let Some(node) = node { + // query for locations with that node id + let locations: Vec = library + .db + .location() + .find_many(vec![location::node_id::equals(Some(node.id))]) + .exec() + .await? + .into_iter() + .map(|location| ExplorerItem::Location { + has_local_thumbnail: false, + thumbnail_key: None, + item: location, + }) + .collect(); + + return Ok(locations); + } + + Ok(vec![]) + }) }) - }) } diff --git a/core/src/api/search.rs b/core/src/api/search.rs index f51b9aaa6..859b1835d 100644 --- a/core/src/api/search.rs +++ b/core/src/api/search.rs @@ -80,7 +80,7 @@ impl FilePathSearchOrdering { use file_path::*; match self { Self::Name(_) => name::order(dir), - Self::SizeInBytes(_) => size_in_bytes::order(dir), + Self::SizeInBytes(_) => size_in_bytes_bytes::order(dir), Self::DateCreated(_) => date_created::order(dir), Self::DateModified(_) => date_modified::order(dir), Self::DateIndexed(_) => date_indexed::order(dir), diff --git a/core/src/library/config.rs b/core/src/library/config.rs index 6f7efb5c4..4d128d991 100644 --- a/core/src/library/config.rs +++ b/core/src/library/config.rs @@ -1,5 +1,6 @@ use std::{path::PathBuf, sync::Arc}; +use prisma_client_rust::not; use sd_p2p::{spacetunnel::Identity, PeerId}; use sd_prisma::prisma::node; use serde::{Deserialize, Serialize}; @@ -8,7 +9,7 @@ use specta::Type; use uuid::Uuid; use crate::{ - prisma::{indexer_rule, PrismaClient}, + prisma::{file_path, indexer_rule, PrismaClient}, util::{ db::uuid_to_bytes, migrator::{Migrate, MigratorError}, @@ -61,7 +62,7 @@ impl LibraryConfig { #[async_trait::async_trait] impl Migrate for LibraryConfig { - const CURRENT_VERSION: u32 = 4; + const CURRENT_VERSION: u32 = 5; type Ctx = (Uuid, PeerId, Arc); @@ -135,6 +136,37 @@ impl Migrate for LibraryConfig { config.insert("node_id".into(), Value::String(node_id.to_string())); } 4 => {} // -_- + 5 => loop { + let paths = db + .file_path() + .find_many(vec![not![file_path::size_in_bytes::equals(None)]]) + .take(500) + .select(file_path::select!({ id size_in_bytes })) + .exec() + .await?; + + if paths.is_empty() { + break; + } + + db._batch(paths.into_iter().map(|path| { + db.file_path().update( + file_path::id::equals(path.id), + vec![ + file_path::size_in_bytes_bytes::set(Some( + path.size_in_bytes + .unwrap() + .parse::() + .unwrap() + .to_be_bytes() + .to_vec(), + )), + file_path::size_in_bytes::set(None), + ], + ) + })) + .await?; + }, v => unreachable!("Missing migration for library version {}", v), } diff --git a/core/src/location/file_path_helper/mod.rs b/core/src/location/file_path_helper/mod.rs index 9d11d4838..8be917898 100644 --- a/core/src/location/file_path_helper/mod.rs +++ b/core/src/location/file_path_helper/mod.rs @@ -184,8 +184,8 @@ pub async fn create_file_path( (name::NAME, json!(name)), (extension::NAME, json!(extension)), ( - size_in_bytes::NAME, - json!(metadata.size_in_bytes.to_string()), + size_in_bytes_bytes::NAME, + json!(metadata.size_in_bytes.to_be_bytes().to_vec()), ), (inode::NAME, json!(metadata.inode.to_le_bytes())), (device::NAME, json!(metadata.device.to_le_bytes())), @@ -217,7 +217,7 @@ pub async fn create_file_path( device::set(Some(metadata.device.to_le_bytes().into())), cas_id::set(cas_id), is_dir::set(Some(is_dir)), - size_in_bytes::set(Some(metadata.size_in_bytes.to_string())), + size_in_bytes_bytes::set(Some(metadata.size_in_bytes.to_be_bytes().to_vec())), date_created::set(Some(metadata.created_at.into())), date_modified::set(Some(metadata.modified_at.into())), ] diff --git a/core/src/location/indexer/mod.rs b/core/src/location/indexer/mod.rs index bc470d899..3e49d4113 100644 --- a/core/src/location/indexer/mod.rs +++ b/core/src/location/indexer/mod.rs @@ -13,6 +13,7 @@ use std::{ time::Duration, }; +use chrono::Utc; use rspc::ErrorCode; use sd_prisma::prisma_sync; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -179,10 +180,12 @@ async fn execute_indexer_save_step( ), ( ( - size_in_bytes::NAME, - json!(entry.metadata.size_in_bytes.to_string()), + size_in_bytes_bytes::NAME, + json!(entry.metadata.size_in_bytes.to_be_bytes().to_vec()), ), - size_in_bytes::set(Some(entry.metadata.size_in_bytes.to_string())), + size_in_bytes_bytes::set(Some( + entry.metadata.size_in_bytes.to_be_bytes().to_vec(), + )), ), ( (inode::NAME, json!(entry.metadata.inode.to_le_bytes())), @@ -200,6 +203,10 @@ async fn execute_indexer_save_step( (date_modified::NAME, json!(entry.metadata.modified_at)), date_modified::set(Some(entry.metadata.modified_at.into())), ), + ( + (date_indexed::NAME, json!(Utc::now())), + date_indexed::set(Some(Utc::now().into())), + ), ] .into_iter() .unzip(); diff --git a/core/src/location/manager/watcher/utils.rs b/core/src/location/manager/watcher/utils.rs index 7c976cce8..b9199fa6c 100644 --- a/core/src/location/manager/watcher/utils.rs +++ b/core/src/location/manager/watcher/utils.rs @@ -376,8 +376,11 @@ async fn inner_update_file( cas_id::set(Some(old_cas_id.clone())), ), ( - (size_in_bytes::NAME, json!(fs_metadata.len().to_string())), - size_in_bytes::set(Some(fs_metadata.len().to_string())), + ( + size_in_bytes_bytes::NAME, + json!(fs_metadata.len().to_be_bytes().to_vec()), + ), + size_in_bytes_bytes::set(Some(fs_metadata.len().to_be_bytes().to_vec())), ), { let date = DateTime::::from(fs_metadata.modified_or_now()).into(); diff --git a/interface/app/$libraryId/Explorer/File/ContextMenu.tsx b/interface/app/$libraryId/Explorer/File/ContextMenu.tsx index f75e8eeae..185d270d5 100644 --- a/interface/app/$libraryId/Explorer/File/ContextMenu.tsx +++ b/interface/app/$libraryId/Explorer/File/ContextMenu.tsx @@ -10,14 +10,20 @@ import { Trash, TrashSimple } from 'phosphor-react'; -import { ExplorerItem, isObject, useLibraryContext, useLibraryMutation } from '@sd/client'; +import { + ExplorerItem, + getItemFilePath, + getItemObject, + useLibraryContext, + useLibraryMutation +} from '@sd/client'; import { ContextMenu, dialogManager } from '@sd/ui'; import { getExplorerStore, useExplorerStore, useOperatingSystem } from '~/hooks'; import { usePlatform } from '~/util/Platform'; import AssignTagMenuItems from '../AssignTagMenuItems'; import { OpenInNativeExplorer } from '../ContextMenu'; import { useExplorerViewContext } from '../ViewContext'; -import { getItemFilePath, useExplorerSearchParams } from '../util'; +import { useExplorerSearchParams } from '../util'; import OpenWith from './ContextMenu/OpenWith'; // import DecryptDialog from './DecryptDialog'; import DeleteDialog from './DeleteDialog'; @@ -33,7 +39,7 @@ export default ({ data }: Props) => { const explorerView = useExplorerViewContext(); const explorerStore = useExplorerStore(); const [params] = useExplorerSearchParams(); - const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; + const objectData = data ? getItemObject(data) : null; // const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false; // const mountedKeys = useLibraryQuery(['keys.listMounted']); diff --git a/interface/app/$libraryId/Explorer/File/RenameTextBox.tsx b/interface/app/$libraryId/Explorer/File/RenameTextBox.tsx index 58f752425..2ed37932e 100644 --- a/interface/app/$libraryId/Explorer/File/RenameTextBox.tsx +++ b/interface/app/$libraryId/Explorer/File/RenameTextBox.tsx @@ -1,66 +1,209 @@ +/* eslint-disable react-hooks/exhaustive-deps */ import clsx from 'clsx'; -import { HTMLAttributes, useEffect, useRef, useState } from 'react'; +import { ComponentProps, forwardRef, useEffect, useRef, useState } from 'react'; import { useKey } from 'rooks'; -import { FilePath, useLibraryMutation, useRspcLibraryContext } from '@sd/client'; +import { useLibraryMutation, useRspcLibraryContext } from '@sd/client'; import { showAlertDialog } from '~/components'; -import useClickOutside from '~/hooks/useClickOutside'; -import { useOperatingSystem } from '~/hooks/useOperatingSystem'; +import { useOperatingSystem } from '~/hooks'; import { useExplorerViewContext } from '../ViewContext'; -interface Props extends HTMLAttributes { - filePathData: FilePath; +type Props = ComponentProps<'div'> & { + itemId: number; + locationId: number | null; + text: string | null; activeClassName?: string; disabled?: boolean; -} + renameHandler: (name: string) => Promise; +}; -export default ({ filePathData, className, activeClassName, disabled, ...props }: Props) => { - const explorerView = useExplorerViewContext(); - const os = useOperatingSystem(); +export const RenameTextBoxBase = forwardRef( + ({ className, activeClassName, disabled, ...props }, _ref) => { + const explorerView = useExplorerViewContext(); + const os = useOperatingSystem(); + + const [allowRename, setAllowRename] = useState(false); + const [renamable, setRenamable] = useState(false); + + const funnyRef = useRef(null); + const ref = typeof _ref === 'function' ? { current: funnyRef.current } : _ref; + + // Highlight file name up to extension or + // fully if it's a directory or has no extension + function highlightText() { + if (ref?.current) { + const range = document.createRange(); + const node = ref.current.firstChild; + if (!node) return; + + range.setStart(node, 0); + range.setEnd(node, props?.text?.length || 0); + + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + } + + // Blur field + function blur() { + if (ref?.current) { + ref.current.blur(); + setAllowRename(false); + } + } + + // Reset to original file name + function reset() { + if (ref?.current) { + ref.current.innerText = props.text || ''; + } + } + + async function handleRename() { + if (!ref?.current) return; + + const newName = ref?.current.innerText.trim(); + if (!newName) return reset(); + + if (!props.locationId) return; + + const oldName = props.text; + + if (!oldName || !props.locationId || newName === oldName) return; + + await props.renameHandler(newName); + } + + // Handle keydown events + function handleKeyDown(e: React.KeyboardEvent) { + switch (e.key) { + case 'Tab': + e.preventDefault(); + blur(); + break; + case 'Escape': + reset(); + blur(); + break; + case 'z': + if (os === 'macOS' ? e.metaKey : e.ctrlKey) { + reset(); + highlightText(); + } + break; + } + } + + // Focus and highlight when renaming is allowed + useEffect(() => { + if (allowRename) { + explorerView.setIsRenaming(true); + setTimeout(() => { + if (ref?.current) { + ref.current.focus(); + highlightText(); + } + }); + } + }, [allowRename]); + + // Handle renaming when triggered from outside + useEffect(() => { + if (!disabled) { + if (explorerView.isRenaming && !allowRename) setAllowRename(true); + else if (!explorerView.isRenaming && allowRename) setAllowRename(false); + } + }, [explorerView.isRenaming]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref?.current && !ref.current.contains(event.target as Node)) { + blur(); + } + } + + document.addEventListener('mousedown', handleClickOutside, true); + return () => { + document.removeEventListener('mousedown', handleClickOutside, true); + }; + }, [ref]); + + // Rename or blur on Enter key + useKey('Enter', (e) => { + if (allowRename) { + e.preventDefault(); + blur(); + } else if (!disabled) setAllowRename(true); + }); + + return ( +
e.stopPropagation()} + onMouseDown={(e) => e.button === 0 && setRenamable(!disabled)} + onMouseUp={(e) => { + if (e.button === 0) { + if (renamable) { + setAllowRename(true); + } + setRenamable(false); + } + }} + onBlur={async () => { + await handleRename(); + setAllowRename(false); + explorerView.setIsRenaming(false); + }} + onKeyDown={handleKeyDown} + {...props} + > + {props.text} +
+ ); + } +); + +export const RenamePathTextBox = ( + props: Omit & { isDir: boolean; extension?: string | null } +) => { const rspc = useRspcLibraryContext(); - const ref = useRef(null); - const [allowRename, setAllowRename] = useState(false); - const [renamable, setRenamable] = useState(false); - const renameFile = useLibraryMutation(['files.renameFile'], { onError: () => reset(), onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) }); - const fileName = `${filePathData?.name}${ - filePathData?.extension && `.${filePathData.extension}` - }`; - // Reset to original file name function reset() { - if (ref.current) { - ref.current.innerText = fileName; + if (ref?.current) { + ref.current.innerText = props.text || ''; } } + const fileName = + props.isDir || !props.extension ? props.text : props.text + '.' + props.extension; + // Handle renaming - async function rename() { - if (!ref.current) return; - - const newName = ref.current.innerText.trim(); - if (!newName) return reset(); - - if (!filePathData) return; - - const oldName = - filePathData.is_dir || !filePathData.extension - ? filePathData.name - : filePathData.name + '.' + filePathData.extension; - - if (!oldName || !filePathData.location_id || newName === oldName) return; - + async function rename(newName: string) { + if (!props.locationId || newName === fileName) return; try { await renameFile.mutateAsync({ - location_id: filePathData.location_id, + location_id: props.locationId, kind: { One: { - from_file_path_id: filePathData.id, + from_file_path_id: props.itemId, to: newName } } @@ -73,126 +216,44 @@ export default ({ filePathData, className, activeClassName, disabled, ...props } } } - // Highlight file name up to extension or - // fully if it's a directory or has no extension - function highlightFileName() { - if (ref.current) { - const range = document.createRange(); - const node = ref.current.firstChild; - if (!node) return; + return ; +}; - range.setStart(node, 0); - range.setEnd(node, filePathData?.name?.length || 0); +export const RenameLocationTextBox = (props: Omit) => { + const rspc = useRspcLibraryContext(); + const ref = useRef(null); - const sel = window.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(range); - } - } - - // Blur field - function blur() { - if (ref.current) { - ref.current.blur(); - setAllowRename(false); - } - } - - // Handle keydown events - function handleKeyDown(e: React.KeyboardEvent) { - switch (e.key) { - case 'Tab': - e.preventDefault(); - blur(); - break; - case 'Escape': - reset(); - blur(); - break; - case 'z': - if (os === 'macOS' ? e.metaKey : e.ctrlKey) { - reset(); - highlightFileName(); - } - break; - } - } - - // Focus and highlight when renaming is allowed - useEffect(() => { - if (allowRename) { - explorerView.setIsRenaming(true); - setTimeout(() => { - if (ref.current) { - ref.current.focus(); - highlightFileName(); - } - }); - } - }, [allowRename]); - - // Handle renaming when triggered from outside - useEffect(() => { - if (!disabled) { - if (explorerView.isRenaming && !allowRename) setAllowRename(true); - else if (!explorerView.isRenaming && allowRename) setAllowRename(false); - } - }, [explorerView.isRenaming]); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (ref.current && !ref.current.contains(event.target as Node)) { - blur(); - } - } - - document.addEventListener('mousedown', handleClickOutside, true); - return () => { - document.removeEventListener('mousedown', handleClickOutside, true); - }; - }, [ref]); - - // Rename or blur on Enter key - useKey('Enter', (e) => { - if (allowRename) { - e.preventDefault(); - blur(); - } else if (!disabled) setAllowRename(true); + const renameLocation = useLibraryMutation(['locations.update'], { + onError: () => reset(), + onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) }); - return ( -
e.stopPropagation()} - onMouseDown={(e) => e.button === 0 && setRenamable(!disabled)} - onMouseUp={(e) => { - if (e.button === 0) { - if (renamable) { - setAllowRename(true); - } - setRenamable(false); - } - }} - onBlur={async () => { - await rename(); - setAllowRename(false); - explorerView.setIsRenaming(false); - }} - onKeyDown={handleKeyDown} - {...props} - > - {fileName} -
- ); + // Reset to original file name + function reset() { + if (ref?.current) { + ref.current.innerText = props.text || ''; + } + } + + // Handle renaming + async function rename(newName: string) { + if (!props.locationId) return; + try { + await renameLocation.mutateAsync({ + id: props.locationId, + name: newName, + generate_preview_media: null, + sync_preview_media: null, + hidden: null, + indexer_rules_ids: [] + }); + } catch (e) { + showAlertDialog({ + title: 'Error', + value: String(e) + }); + } + } + + return ; }; diff --git a/interface/app/$libraryId/Explorer/File/Thumb.tsx b/interface/app/$libraryId/Explorer/File/Thumb.tsx index 0597f4f42..23ccb9fbb 100644 --- a/interface/app/$libraryId/Explorer/File/Thumb.tsx +++ b/interface/app/$libraryId/Explorer/File/Thumb.tsx @@ -1,7 +1,7 @@ import { getIcon, iconNames } from '@sd/assets/util'; import clsx from 'clsx'; import { ImgHTMLAttributes, memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { ExplorerItem, useLibraryContext } from '@sd/client'; +import { ExplorerItem, getItemLocation, useLibraryContext } from '@sd/client'; import { PDFViewer } from '~/components'; import { getExplorerStore, @@ -52,13 +52,13 @@ const Thumbnail = memo( videoBarsSize ? size && size.height >= size.width ? { - borderLeftWidth: videoBarsSize, - borderRightWidth: videoBarsSize - } + borderLeftWidth: videoBarsSize, + borderRightWidth: videoBarsSize + } : { - borderTopWidth: videoBarsSize, - borderBottomWidth: videoBarsSize - } + borderTopWidth: videoBarsSize, + borderBottomWidth: videoBarsSize + } : {} } onLoad={props.onLoad} @@ -76,11 +76,11 @@ const Thumbnail = memo( props.cover ? {} : size - ? { + ? { marginTop: Math.floor(size.height / 2) - 2, marginLeft: Math.floor(size.width / 2) - 2 - } - : { display: 'none' } + } + : { display: 'none' } } className={clsx( props.cover @@ -101,7 +101,8 @@ const Thumbnail = memo( enum ThumbType { Icon, Original, - Thumbnail + Thumbnail, + Location } export interface ThumbProps { @@ -117,6 +118,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) { const isDark = useIsDark(); const platform = usePlatform(); const itemData = useExplorerItemData(props.data); + const locationData = getItemLocation(props.data); const { library } = useLibraryContext(); const [src, setSrc] = useState(null); const [loaded, setLoaded] = useState(false); @@ -134,10 +136,12 @@ function FileThumb({ size, cover, ...props }: ThumbProps) { setThumbType(ThumbType.Original); } else if (itemData.hasLocalThumbnail) { setThumbType(ThumbType.Thumbnail); + } else if (locationData) { + setThumbType(ThumbType.Location); } else { setThumbType(ThumbType.Icon); } - }, [props.loadOriginal, itemData]); + }, [props.loadOriginal, locationData, itemData]); useEffect(() => { const { @@ -172,6 +176,9 @@ function FileThumb({ size, cover, ...props }: ThumbProps) { setThumbType(ThumbType.Icon); } break; + case ThumbType.Location: + setSrc(getIcon('Folder', isDark, extension, true)); + break; default: if (isDir !== null) setSrc(getIcon(kind, isDark, extension, isDir)); break; @@ -208,9 +215,9 @@ function FileThumb({ size, cover, ...props }: ThumbProps) { className={clsx( 'relative flex shrink-0 items-center justify-center', size && - kind !== 'Video' && - thumbType !== ThumbType.Icon && - 'border-2 border-transparent', + kind !== 'Video' && + thumbType !== ThumbType.Icon && + 'border-2 border-transparent', size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'], props.className )} @@ -312,9 +319,9 @@ function FileThumb({ size, cover, ...props }: ThumbProps) { 'shadow shadow-black/30' ], size && - (kind === 'Video' - ? 'border-x-0 border-black' - : size > 60 && 'border-2 border-app-line'), + (kind === 'Video' + ? 'border-x-0 border-black' + : size > 60 && 'border-2 border-app-line'), props.className )} crossOrigin={ThumbType.Original && 'anonymous'} // Here it is ok, because it is not a react attr diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index 05bbc6d0b..ea5c66d61 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -1,5 +1,6 @@ // import types from '../../constants/file-types.json'; import { Image, Image_Light } from '@sd/assets/icons'; +import byteSize from 'byte-size'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { Barcode, CircleWavyCheck, Clock, Cube, Hash, Link, Lock, Snowflake } from 'phosphor-react'; @@ -9,7 +10,9 @@ import { Location, ObjectKind, Tag, - formatBytes, + bytesToNumber, + getItemFilePath, + getItemObject, isPath, useLibraryQuery } from '@sd/client'; @@ -17,7 +20,6 @@ import { Button, Divider, DropdownMenu, Tooltip, tw } from '@sd/ui'; import { useExplorerStore, useIsDark } from '~/hooks'; import AssignTagMenuItems from '../AssignTagMenuItems'; import FileThumb from '../File/Thumb'; -import { getItemFilePath, getItemObject } from '../util'; import FavoriteButton from './FavoriteButton'; import Note from './Note'; @@ -151,13 +153,17 @@ export const Inspector = ({ data, context, showThumbnail = true, ...props }: Pro - - - Size - - {formatBytes(Number(filePathData?.size_in_bytes || 0))} - - + {filePathData?.size_in_bytes_bytes && ( + + + Size + + {byteSize( + bytesToNumber(filePathData.size_in_bytes_bytes) + ).toString()} + + + )} {fullObjectData.data?.media_data?.duration_seconds && ( diff --git a/interface/app/$libraryId/Explorer/View/GridView.tsx b/interface/app/$libraryId/Explorer/View/GridView.tsx index 7dd6c0a68..9f395fe64 100644 --- a/interface/app/$libraryId/Explorer/View/GridView.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView.tsx @@ -1,13 +1,13 @@ +import byteSize from 'byte-size'; import clsx from 'clsx'; import { memo } from 'react'; -import { ExplorerItem, formatBytes } from '@sd/client'; +import { ExplorerItem, bytesToNumber, getItemFilePath, getItemLocation } from '@sd/client'; import GridList from '~/components/GridList'; -import { useExplorerStore } from '~/hooks/useExplorerStore'; +import { useExplorerStore } from '~/hooks'; import { ViewItem } from '.'; -import RenameTextBox from '../File/RenameTextBox'; import FileThumb from '../File/Thumb'; import { useExplorerViewContext } from '../ViewContext'; -import { getItemFilePath } from '../util'; +import RenamableItemText from './RenamableItemText'; interface GridViewItemProps { data: ExplorerItem; @@ -16,10 +16,17 @@ interface GridViewItemProps { } const GridViewItem = memo(({ data, selected, index, ...props }: GridViewItemProps) => { - const filePathData = data ? getItemFilePath(data) : null; + const filePathData = getItemFilePath(data); + const location = getItemLocation(data); const explorerStore = useExplorerStore(); const explorerView = useExplorerViewContext(); + const showSize = + !filePathData?.is_dir && + !location && + explorerStore.showBytesInGridView && + (!explorerView.isRenaming || (explorerView.isRenaming && !selected)); + return (
@@ -27,30 +34,16 @@ const GridViewItem = memo(({ data, selected, index, ...props }: GridViewItemProp
- {filePathData && ( - + {showSize && filePathData?.size_in_bytes_bytes && ( + + > + {byteSize(bytesToNumber(filePathData.size_in_bytes_bytes)).toString()} + )} - {explorerStore.showBytesInGridView && - (!explorerView.isRenaming || (explorerView.isRenaming && !selected)) && ( - - {formatBytes(Number(filePathData?.size_in_bytes || 0))} - - )}
); @@ -81,19 +74,7 @@ export default () => { preventContextMenuSelection={!explorerView.contextMenu} > {({ index, item: Item }) => { - if (!explorerView.items) { - return ( - -
-
- {explorerStore.showBytesInGridView && ( -
- )} - - ); - } - - const item = explorerView.items[index]; + const item = explorerView.items?.[index]; if (!item) return null; const isSelected = Array.isArray(explorerView.selected) diff --git a/interface/app/$libraryId/Explorer/View/ListView.tsx b/interface/app/$libraryId/Explorer/View/ListView.tsx index 96b6112d7..ba6f1dd61 100644 --- a/interface/app/$libraryId/Explorer/View/ListView.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView.tsx @@ -15,19 +15,28 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync'; import { useBoundingclientrect, useKey } from 'rooks'; import useResizeObserver from 'use-resize-observer'; -import { ExplorerItem, FilePath, ObjectKind, isObject, isPath } from '@sd/client'; +import { + ExplorerItem, + FilePath, + ObjectKind, + bytesToNumber, + getExplorerItemData, + getItemFilePath, + getItemLocation, + getItemObject, + isPath +} from '@sd/client'; import { FilePathSearchOrderingKeys, getExplorerStore, - useExplorerStore -} from '~/hooks/useExplorerStore'; -import { useScrolled } from '~/hooks/useScrolled'; + useExplorerStore, + useScrolled +} from '~/hooks'; import { ViewItem } from '.'; -import RenameTextBox from '../File/RenameTextBox'; import FileThumb from '../File/Thumb'; import { InfoPill } from '../Inspector'; import { useExplorerViewContext } from '../ViewContext'; -import { getExplorerItemData, getItemFilePath } from '../util'; +import RenamableItemText from './RenamableItemText'; interface ListViewItemProps { row: Row; @@ -105,7 +114,6 @@ export default () => { const { width: tableWidth = 0 } = useResizeObserver({ ref: tableRef }); const { width: headerWidth = 0 } = useResizeObserver({ ref: tableHeaderRef }); - const getObjectData = (data: ExplorerItem) => (isObject(data) ? data.item : data.item.object); const getFileName = (path: FilePath) => `${path.name}${path.extension && `.${path.extension}`}`; const columns = useMemo[]>( @@ -116,12 +124,14 @@ export default () => { minSize: 200, meta: { className: '!overflow-visible !text-ink' }, accessorFn: (file) => { + const locationData = getItemLocation(file); const filePathData = getItemFilePath(file); - return filePathData && getFileName(filePathData); + return locationData + ? locationData.name + : filePathData && getFileName(filePathData); }, cell: (cell) => { const file = cell.row.original; - const filePathData = getItemFilePath(file); const selectedId = Array.isArray(explorerView.selected) ? explorerView.selected[0] @@ -134,17 +144,16 @@ export default () => {
- {filePathData && ( - 1) - } - activeClassName="absolute z-50 top-0.5 left-[58px] max-w-[calc(100%-60px)]" - /> - )} + 1) + } + />
); } @@ -156,7 +165,7 @@ export default () => { accessorFn: (file) => { return isPath(file) && file.item.is_dir ? 'Folder' - : ObjectKind[getObjectData(file)?.kind || 0]; + : ObjectKind[getItemObject(file)?.kind || 0]; }, cell: (cell) => { const file = cell.row.original; @@ -164,7 +173,7 @@ export default () => { {isPath(file) && file.item.is_dir ? 'Folder' - : ObjectKind[getObjectData(file)?.kind || 0]} + : ObjectKind[getItemObject(file)?.kind || 0]} ); } @@ -173,18 +182,47 @@ export default () => { id: 'sizeInBytes', header: 'Size', size: 100, - accessorFn: (file) => byteSize(Number(getItemFilePath(file)?.size_in_bytes || 0)) + accessorFn: (file) => { + const file_path = getItemFilePath(file); + if (!file_path || !file_path.size_in_bytes_bytes) return; + + return byteSize(bytesToNumber(file_path.size_in_bytes_bytes)); + } }, { id: 'dateCreated', header: 'Date Created', accessorFn: (file) => dayjs(file.item.date_created).format('MMM Do YYYY') }, + { + id: 'dateModified', + header: 'Date Modified', + accessorFn: (file) => + dayjs(getItemFilePath(file)?.date_modified).format('MMM Do YYYY') + }, + { + id: 'dateIndexed', + header: 'Date Indexed', + accessorFn: (file) => + dayjs(getItemFilePath(file)?.date_indexed).format('MMM Do YYYY') + }, + { + id: 'dateAccessed', + header: 'Date Accessed', + accessorFn: (file) => + dayjs(getItemObject(file)?.date_accessed).format('MMM Do YYYY') + }, { header: 'Content ID', enableSorting: false, size: 180, accessorFn: (file) => getExplorerItemData(file).casId + }, + { + header: 'Object ID', + enableSorting: false, + size: 180, + accessorFn: (file) => getItemObject(file)?.pub_id } ], [explorerView.selected] diff --git a/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx b/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx new file mode 100644 index 000000000..d1c4aafa5 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx @@ -0,0 +1,54 @@ +/* eslint-disable no-case-declarations */ +import clsx from 'clsx'; +import { ExplorerItem, getItemFilePath, getItemLocation } from '@sd/client'; +import { useExplorerStore } from '~/hooks'; +import { RenameLocationTextBox, RenamePathTextBox } from '../File/RenameTextBox'; + +export default function RenamableItemText(props: { + item: ExplorerItem; + selected: boolean; + disabled?: boolean; + allowHighlight?: boolean; +}) { + const { item, selected, disabled, allowHighlight } = props; + const explorerStore = useExplorerStore(); + + const sharedProps = { + className: clsx( + 'text-center font-medium text-ink', + selected && allowHighlight !== false && 'bg-accent text-white dark:text-ink' + ), + style: { maxHeight: explorerStore.gridItemSize / 3 }, + activeClassName: '!text-ink', + disabled: !selected || disabled + }; + + switch (item.type) { + case 'Path': + case 'Object': + const filePathData = getItemFilePath(item); + if (!filePathData) break; + return ( + + ); + case 'Location': + const locationData = getItemLocation(item); + if (!locationData) break; + return ( + + ); + } + return
; +} diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index e202ea016..a170b066d 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -10,14 +10,17 @@ import { } from 'react'; import { createSearchParams, useNavigate } from 'react-router-dom'; import { useKey } from 'rooks'; -import { ExplorerItem, isPath, useLibraryContext, useLibraryMutation } from '@sd/client'; -import { ContextMenu } from '@sd/ui'; import { - ExplorerLayoutMode, - getExplorerStore, - useExplorerConfigStore, - useExplorerStore -} from '~/hooks'; + ExplorerItem, + getExplorerItemData, + getItemFilePath, + getItemLocation, + isPath, + useLibraryContext, + useLibraryMutation +} from '@sd/client'; +import { ContextMenu } from '@sd/ui'; +import { ExplorerLayoutMode, getExplorerStore, useExplorerConfigStore } from '~/hooks'; import { usePlatform } from '~/util/Platform'; import { ExplorerViewContext, @@ -26,7 +29,6 @@ import { ViewContext, useExplorerViewContext } from '../ViewContext'; -import { getExplorerItemData, getItemFilePath } from '../util'; import GridView from './GridView'; import ListView from './ListView'; import MediaView from './MediaView'; @@ -43,11 +45,19 @@ export const ViewItem = ({ data, children, ...props }: ViewItemProps) => { const { openFilePath } = usePlatform(); const updateAccessTime = useLibraryMutation('files.updateAccessTime'); const filePath = getItemFilePath(data); + const location = getItemLocation(data); const explorerConfig = useExplorerConfigStore(); const onDoubleClick = () => { - if (isPath(data) && data.item.is_dir) { + if (location) { + navigate({ + pathname: `/${library.uuid}/location/${location.id}`, + search: createSearchParams({ + path: `/` + }).toString() + }); + } else if (isPath(data) && data.item.is_dir) { navigate({ pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`, search: createSearchParams({ diff --git a/interface/app/$libraryId/Explorer/util.ts b/interface/app/$libraryId/Explorer/util.ts index 945582f3b..16456e293 100644 --- a/interface/app/$libraryId/Explorer/util.ts +++ b/interface/app/$libraryId/Explorer/util.ts @@ -1,13 +1,6 @@ import { useMemo } from 'react'; import { z } from 'zod'; -import { - ExplorerItem, - FilePathSearchOrdering, - ObjectKind, - ObjectKindKey, - isObject, - isPath -} from '@sd/client'; +import { FilePathSearchOrdering } from '@sd/client'; import { useExplorerStore, useZodSearchParams } from '~/hooks'; export function useExplorerOrder(): FilePathSearchOrdering | undefined { @@ -31,29 +24,6 @@ export function useExplorerOrder(): FilePathSearchOrdering | undefined { return ordering; } -export function getItemObject(data: ExplorerItem) { - return isObject(data) ? data.item : data.item.object; -} - -export function getItemFilePath(data: ExplorerItem) { - return isObject(data) ? data.item.file_paths[0] : data.item; -} - -export function getExplorerItemData(data: ExplorerItem) { - const filePath = getItemFilePath(data); - const objectData = getItemObject(data); - - return { - kind: (ObjectKind[objectData?.kind ?? 0] as ObjectKindKey) || null, - casId: filePath?.cas_id || null, - isDir: isPath(data) && data.item.is_dir, - extension: filePath?.extension || null, - locationId: filePath?.location_id || null, - hasLocalThumbnail: data.has_local_thumbnail, // this will be overwritten if new thumbnail is generated - thumbnailKey: data.thumbnail_key - }; -} - export const SEARCH_PARAMS = z.object({ path: z.string().optional(), take: z.coerce.number().default(100) diff --git a/interface/app/$libraryId/KeyManager/index.tsx b/interface/app/$libraryId/KeyManager/index.tsx index 4b339dedc..08df1b393 100644 --- a/interface/app/$libraryId/KeyManager/index.tsx +++ b/interface/app/$libraryId/KeyManager/index.tsx @@ -1,85 +1,26 @@ -// import { Gear, Lock, MagnifyingGlass, X } from 'phosphor-react'; -// import { useLibraryContext, useLibraryMutation, useLibraryQuery } from '@sd/client'; -// import { Button, Tabs } from '@sd/ui'; -// import KeyList from './List'; -// import KeyMounter from './Mounter'; -// import NotSetup from './NotSetup'; -// import NotUnlocked from './NotUnlocked'; +/* eslint-disable tailwindcss/classnames-order */ +import { Keys } from '@sd/assets/icons'; +import { Button, Tooltip } from '@sd/ui'; -// export function KeyManager() { -// const isUnlocked = useLibraryQuery(['keys.isUnlocked']); -// const isSetup = useLibraryQuery(['keys.isSetup']); +export function KeyManager() { + // const isUnlocked = useLibraryQuery(['keys.isUnlocked']); + // const isSetup = useLibraryQuery(['keys.isSetup']); -// if (!isSetup?.data) return ; -// if (!isUnlocked?.data) return ; -// else return ; -// } - -// const Unlocked = () => { -// const { library } = useLibraryContext(); -// const isUnlocked = useLibraryQuery(['keys.isUnlocked']); - -// const unmountAll = useLibraryMutation('keys.unmountAll'); -// const clearMasterPassword = useLibraryMutation('keys.clearMasterPassword'); - -// return ( -//
-// -//
-// -// {/* */} -// {/* -// Mount -// -// -// Keys -// */} -// -//
-// -// -// -// -//
-// -// -// -// -// -// -// -//
-// ); -// }; - -// const Keys = () => { -// return ( -//
-//
-//
-//
-// -//
-//
-//
-//
-// ); -// }; + return ( +
+
+ + Key Manager + + Create encryption keys, mount and unmount your keys to see files decrypted on + the fly. + + + + +
+
+ ); +} diff --git a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx index 2a4a0c011..0f1445317 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx @@ -25,26 +25,14 @@ export default () => { Overview - {/* - - Spaces - */} - + {/* Spacedrop - {/* - - Media - */} - + Imports - - {/* */} - {/* */} - {/* Sync */} - {/* */} + */}
{library && }
}> diff --git a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx index 8773be041..ee8ab9358 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx @@ -9,6 +9,7 @@ import { useLibraryQuery, useOnlineLocations } from '@sd/client'; +import { Button, Tooltip } from '@sd/ui'; import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton'; import { Folder } from '~/components/Folder'; import { SubtleButton } from '~/components/SubtleButton'; @@ -27,9 +28,11 @@ type TriggeredContextItem = tagId: number; }; +const SEE_MORE_LOCATIONS_COUNT = 5; + export const LibrarySection = () => { const node = useBridgeQuery(['nodeState']); - const locations = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); const tags = useLibraryQuery(['tags.list'], { keepPreviousData: true }); const onlineLocations = useOnlineLocations(); const isPairingEnabled = useFeatureFlag('p2pPairing'); @@ -37,6 +40,13 @@ export const LibrarySection = () => { null ); + const [seeMoreLocations, setSeeMoreLocations] = useState(false); + + const locations = locationsQuery.data?.slice( + 0, + seeMoreLocations ? undefined : SEE_MORE_LOCATIONS_COUNT + ); + useEffect(() => { const outsideClick = () => { document.addEventListener('click', () => { @@ -63,27 +73,25 @@ export const LibrarySection = () => { ) } > - {/* - - Jamie's MBP - - - - spacephone - - - - titan - - {(locations.data?.length || 0) < 4 && ( - - )} */} - - - {node.data?.name} - +
{ } > - {locations.data?.map((location) => { + {locations?.map((location) => { const online = onlineLocations?.some((l) => arraysEqual(location.pub_id, l)); return ( @@ -128,7 +136,15 @@ export const LibrarySection = () => { ); })} - {(locations.data?.length || 0) < 4 && } + {locationsQuery.data?.[SEE_MORE_LOCATIONS_COUNT - 1] && ( +
setSeeMoreLocations(!seeMoreLocations)} + className="mb-1 ml-2 mt-0.5 cursor-pointer text-center text-tiny font-semibold text-ink-faint/50 transition hover:text-accent" + > + See {seeMoreLocations ? 'less' : 'more'} +
+ )} +
{!!tags.data?.length && (
import('./location/$id') }, + { path: 'node/:id', lazy: () => import('./node/$id') }, { path: 'tag/:id', lazy: () => import('./tag/$id') }, { path: 'search', lazy: () => import('./search') } ]; diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx index 405ab42fe..552f4e5e9 100644 --- a/interface/app/$libraryId/location/$id.tsx +++ b/interface/app/$libraryId/location/$id.tsx @@ -7,7 +7,7 @@ import { useLibrarySubscription, useRspcLibraryContext } from '@sd/client'; -import { Folder } from '~/components/Folder'; +import { Folder } from '~/components'; import { getExplorerStore, useExplorerStore, @@ -59,7 +59,9 @@ export const Component = () => { - {path ? getLastSectionOfPath(path) : location.data?.name} + {path && path?.length > 1 + ? getLastSectionOfPath(path) + : location.data?.name} {location.data && ( diff --git a/interface/app/$libraryId/node/$id.tsx b/interface/app/$libraryId/node/$id.tsx new file mode 100644 index 000000000..8a18fa063 --- /dev/null +++ b/interface/app/$libraryId/node/$id.tsx @@ -0,0 +1,48 @@ +import { Node } from '@sd/assets/icons'; +import { z } from 'zod'; +import { useBridgeQuery, useLibraryQuery } from '@sd/client'; +import { useExplorerTopBarOptions, useZodRouteParams } from '~/hooks'; +import Explorer from '../Explorer'; +import { useExplorerSearchParams } from '../Explorer/util'; +import { TopBarPortal } from '../TopBar/Portal'; +import TopBarOptions from '../TopBar/TopBarOptions'; + +const PARAMS = z.object({ + id: z.string() +}); + +export const Component = () => { + // const [{ path }] = useExplorerSearchParams(); + const { id: node_id } = useZodRouteParams(PARAMS); + + const locations = useLibraryQuery(['nodes.listLocations', node_id]); + + const nodeState = useBridgeQuery(['nodeState']); + + const { explorerViewOptions, explorerControlOptions, explorerToolOptions } = + useExplorerTopBarOptions(); + + return ( + <> + + + + + {nodeState.data?.name || 'Node'} + + +
+ } + right={ + + } + /> + + {locations.data && } + + ); +}; diff --git a/interface/app/$libraryId/overview/Categories.tsx b/interface/app/$libraryId/overview/Categories.tsx index 0175c13f1..46122dc2d 100644 --- a/interface/app/$libraryId/overview/Categories.tsx +++ b/interface/app/$libraryId/overview/Categories.tsx @@ -69,7 +69,7 @@ export const Categories = (props: { selected: Category; onSelectedChanged(c: Cat scroll > 0 ? 'cursor-pointer bg-app/50 opacity-100 hover:opacity-95' : 'pointer-events-none', - 'sticky left-[15px] z-40 mt-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-app-line bg-app p-2 opacity-0 backdrop-blur-md transition-all duration-200' + 'sticky left-[15px] z-40 -ml-4 mt-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-app-line bg-app p-2 opacity-0 backdrop-blur-md transition-all duration-200' )} > diff --git a/interface/app/$libraryId/overview/CategoryButton.tsx b/interface/app/$libraryId/overview/CategoryButton.tsx index 0eba99e0a..ad4f4e5c8 100644 --- a/interface/app/$libraryId/overview/CategoryButton.tsx +++ b/interface/app/$libraryId/overview/CategoryButton.tsx @@ -6,15 +6,17 @@ interface CategoryButtonProps { icon: string; selected?: boolean; onClick?: () => void; + disabled?: boolean; } -export default ({ category, icon, items, selected, onClick }: CategoryButtonProps) => { +export default ({ category, icon, items, selected, onClick, disabled }: CategoryButtonProps) => { return (
diff --git a/interface/app/$libraryId/overview/Statistics.tsx b/interface/app/$libraryId/overview/Statistics.tsx index ef61ac188..bada5a4d7 100644 --- a/interface/app/$libraryId/overview/Statistics.tsx +++ b/interface/app/$libraryId/overview/Statistics.tsx @@ -1,8 +1,10 @@ import byteSize from 'byte-size'; import clsx from 'clsx'; +import { Info } from 'phosphor-react'; import Skeleton from 'react-loading-skeleton'; import 'react-loading-skeleton/dist/skeleton.css'; import { Statistics, useLibraryContext, useLibraryQuery } from '@sd/client'; +import { Tooltip } from '@sd/ui'; import { useCounter } from '~/hooks'; import { usePlatform } from '~/util/Platform'; @@ -10,6 +12,7 @@ interface StatItemProps { title: string; bytes: bigint; isLoading: boolean; + info?: string; } const StatItemNames: Partial> = { @@ -18,6 +21,13 @@ const StatItemNames: Partial> = { library_db_size: 'Index size', total_bytes_free: 'Free space' }; +const StatDescriptions: Partial> = { + total_bytes_capacity: + 'The total capacity of all nodes connected to the library. May show incorrect values during alpha.', + preview_media_bytes: 'The total size of all preview media files, such as thumbnails.', + library_db_size: 'The size of the library database.', + total_bytes_free: 'Free space available on all nodes connected to the library.' +}; const EMPTY_STATISTICS = { id: 0, @@ -49,11 +59,22 @@ const StatItem = (props: StatItemProps) => { return (
- {title} + + {title} + {props.info && ( + + + + )} + + {isLoading && (
@@ -97,6 +118,7 @@ export default () => { title={StatItemNames[key as keyof Statistics]!} bytes={BigInt(value)} isLoading={platform.demoMode ? false : stats.isLoading} + info={StatDescriptions[key as keyof Statistics]} /> ); })} diff --git a/interface/app/$libraryId/search.tsx b/interface/app/$libraryId/search.tsx index b1b703237..11a5912db 100644 --- a/interface/app/$libraryId/search.tsx +++ b/interface/app/$libraryId/search.tsx @@ -1,7 +1,7 @@ import { MagnifyingGlass } from 'phosphor-react'; import { Suspense, memo, useDeferredValue, useEffect, useMemo } from 'react'; import { z } from 'zod'; -import { useLibraryQuery } from '@sd/client'; +import { getExplorerItemData, useLibraryQuery } from '@sd/client'; import { SortOrder, getExplorerStore, @@ -10,7 +10,6 @@ import { useZodSearchParams } from '~/hooks'; import Explorer from './Explorer'; -import { getExplorerItemData } from './Explorer/util'; import { TopBarPortal } from './TopBar/Portal'; import TopBarOptions from './TopBar/TopBarOptions'; diff --git a/interface/app/$libraryId/settings/Sidebar.tsx b/interface/app/$libraryId/settings/Sidebar.tsx index 02607b592..197324343 100644 --- a/interface/app/$libraryId/settings/Sidebar.tsx +++ b/interface/app/$libraryId/settings/Sidebar.tsx @@ -1,5 +1,6 @@ import { Books, + Cloud, FlyingSaucer, GearSix, HardDrive, @@ -70,10 +71,10 @@ export default () => { General - + {/* Nodes - + */} Locations @@ -82,6 +83,10 @@ export default () => { Tags + + + Clouds + Keys diff --git a/interface/app/$libraryId/settings/client/appearance.tsx b/interface/app/$libraryId/settings/client/appearance.tsx index 8cba6f060..fa64f0ec4 100644 --- a/interface/app/$libraryId/settings/client/appearance.tsx +++ b/interface/app/$libraryId/settings/client/appearance.tsx @@ -165,7 +165,7 @@ export const Component = () => { })}
- {themeStore.theme === 'dark' && ( + {/* {themeStore.theme === 'dark' && (
@@ -183,25 +183,27 @@ export const Component = () => {
- )} + )} */} - - - +
+ + + - - - + + + +
); diff --git a/interface/app/$libraryId/settings/client/general.tsx b/interface/app/$libraryId/settings/client/general.tsx index 5fd2efafd..45488ab99 100644 --- a/interface/app/$libraryId/settings/client/general.tsx +++ b/interface/app/$libraryId/settings/client/general.tsx @@ -1,6 +1,7 @@ +import { Laptop, Node } from '@sd/assets/icons'; import { Database } from 'phosphor-react'; import { getDebugState, useBridgeQuery, useDebugState } from '@sd/client'; -import { Card, Input, Switch, tw } from '@sd/ui'; +import { Button, Card, Input, Label, Switch, tw } from '@sd/ui'; import { usePlatform } from '~/util/Platform'; import { Heading } from '../Layout'; import Setting from '../Setting'; @@ -29,8 +30,10 @@ export const Component = () => {
-
-
+
+
+ +
Node Name { onChange={() => { /* TODO */ }} - disabled />
@@ -49,18 +51,12 @@ export const Component = () => { onChange={() => { /* TODO */ }} - disabled />
-
- - - Run daemon when app closed - -
-
-
+ {/*
{ if (node.data && platform?.openLink) { platform.openLink(node.data.data_path); @@ -72,10 +68,29 @@ export const Component = () => { Data Folder {node.data?.data_path} +
*/} + +
+ Data Folder +
+ + +
- - - + {/*
+ + +
*/} +
+
+ + + Run Spacedrive in the background when app closed +
@@ -86,6 +101,7 @@ export const Component = () => { description="Enable extra debugging features within the app." > (getDebugState().enabled = !debugState.enabled)} /> diff --git a/interface/app/$libraryId/settings/client/privacy.tsx b/interface/app/$libraryId/settings/client/privacy.tsx index cc9a21695..5b41dd9d0 100644 --- a/interface/app/$libraryId/settings/client/privacy.tsx +++ b/interface/app/$libraryId/settings/client/privacy.tsx @@ -18,6 +18,7 @@ export const Component = () => { checked={shareTelemetry} onClick={() => (telemetryStore.shareTelemetry = !shareTelemetry)} className="m-2 ml-4" + size="md" /> diff --git a/interface/app/$libraryId/settings/library/general.tsx b/interface/app/$libraryId/settings/library/general.tsx index 8bf59fe63..c85e33acb 100644 --- a/interface/app/$libraryId/settings/library/general.tsx +++ b/interface/app/$libraryId/settings/library/general.tsx @@ -1,5 +1,5 @@ import { MaybeUndefined, useBridgeMutation, useLibraryContext } from '@sd/client'; -import { Button, Input, dialogManager } from '@sd/ui'; +import { Button, Input, Switch, Tooltip, dialogManager } from '@sd/ui'; import { useZodForm, z } from '@sd/ui/src/forms'; import { useDebouncedFormWatch } from '~/hooks'; import { Heading } from '../Layout'; @@ -61,23 +61,27 @@ export const Component = () => {
- {/*
- + + +
-
*/} + - {/* +
- + + +
-
*/} +
{ @@ -83,13 +83,13 @@ export const useExplorerTopBarOptions = () => { const { client } = useRspcLibraryContext(); const explorerToolOptions: ToolOption[] = [ - // { - // toolTipLabel: 'Key Manager', - // icon: , - // popOverComponent: , - // individual: true, - // showAtResolution: 'xl:flex' - // }, + { + toolTipLabel: 'Key Manager', + icon: , + popOverComponent: , + individual: true, + showAtResolution: 'xl:flex' + }, { toolTipLabel: 'Tag Assign Mode', icon: ( diff --git a/packages/assets/icons/Keys.png b/packages/assets/icons/Keys.png new file mode 100644 index 0000000000000000000000000000000000000000..64c311aae3abb9ae920722c01413ae6a4932ea51 GIT binary patch literal 62119 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 literal 0 HcmV?d00001 diff --git a/packages/assets/icons/index.ts b/packages/assets/icons/index.ts index a5b79ac3b..248a1b578 100644 --- a/packages/assets/icons/index.ts +++ b/packages/assets/icons/index.ts @@ -42,6 +42,7 @@ import Heart from './Heart.png'; import HeartFlat from './HeartFlat.png'; import Image from './Image.png'; import Image_Light from './Image_Light.png'; +import Keys from './Keys.png'; import Laptop from './Laptop.png'; import Mesh from './Mesh.png'; import Mesh_Light from './Mesh_Light.png'; @@ -108,6 +109,7 @@ export { HeartFlat, Image, Image_Light, + Keys, Laptop, Mesh, Mesh_Light, diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index f51be8f71..878de7629 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -18,6 +18,7 @@ export type Procedures = { { key: "locations.indexer_rules.listForLocation", input: LibraryArgs, result: IndexerRule[] } | { key: "locations.list", input: LibraryArgs, result: { 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; node_id: number | null; node: Node | null }[] } | { key: "nodeState", input: never, result: NodeState } | + { key: "nodes.listLocations", input: LibraryArgs, result: ExplorerItem[] } | { key: "search.objects", input: LibraryArgs, result: SearchData } | { key: "search.paths", input: LibraryArgs, result: SearchData } | { key: "sync.messages", input: LibraryArgs, result: CRDTOperation[] } | @@ -93,7 +94,7 @@ export type DiskType = "SSD" | "HDD" | "Removable" export type EditLibraryArgs = { id: string; name: string | null; description: MaybeUndefined } -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 } +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 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 } @@ -103,7 +104,7 @@ export type FileDeleterJobInit = { location_id: number; file_path_ids: number[] export type FileEraserJobInit = { location_id: number; file_path_ids: number[]; passes: string } -export type FilePath = { 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; 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 } +export type FilePath = { 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 } export type FilePathFilterArgs = { locationId?: number | null; search?: string | null; extension?: string | null; createdAt?: OptionalRange; path?: string | null; object?: ObjectFilterArgs | null } @@ -111,7 +112,7 @@ export type FilePathSearchArgs = { take?: number | null; order?: FilePathSearchO export type FilePathSearchOrdering = { name: SortOrder } | { sizeInBytes: SortOrder } | { dateCreated: SortOrder } | { dateModified: SortOrder } | { dateIndexed: SortOrder } | { object: 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; 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 } +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 } export type FromPattern = { pattern: string; replace_all: boolean } diff --git a/packages/client/src/utils/explorerItem.ts b/packages/client/src/utils/explorerItem.ts new file mode 100644 index 000000000..c76ce4bae --- /dev/null +++ b/packages/client/src/utils/explorerItem.ts @@ -0,0 +1,33 @@ +import { ExplorerItem } from '../core'; +import { ObjectKind, ObjectKindKey } from './objectKind'; + +export function getItemObject(data: ExplorerItem) { + return data.type === 'Object' ? data.item : data.type === 'Path' ? data.item.object : null; +} + +export function getItemFilePath(data: ExplorerItem) { + return data.type === 'Path' + ? data.item + : data.type === 'Object' + ? data.item.file_paths[0] + : null; +} + +export function getItemLocation(data: ExplorerItem) { + return data.type === 'Location' ? data.item : null; +} + +export function getExplorerItemData(data: ExplorerItem) { + const filePath = getItemFilePath(data); + const objectData = getItemObject(data); + + return { + kind: (ObjectKind[objectData?.kind ?? 0] as ObjectKindKey) || null, + casId: filePath?.cas_id || null, + isDir: getItemFilePath(data)?.is_dir || false, + extension: filePath?.extension || null, + locationId: filePath?.location_id || null, + hasLocalThumbnail: data.has_local_thumbnail, // this will be overwritten if new thumbnail is generated + thumbnailKey: data.thumbnail_key + }; +} diff --git a/packages/client/src/utils/formatBytes.ts b/packages/client/src/utils/formatBytes.ts index 73e25d6e5..bbaad5262 100644 --- a/packages/client/src/utils/formatBytes.ts +++ b/packages/client/src/utils/formatBytes.ts @@ -1,11 +1,3 @@ -export function formatBytes(bytes: number, decimals = 2) { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +export function bytesToNumber(bytes: number[]) { + return bytes.reduce((acc, curr, i) => acc + curr * Math.pow(256, bytes.length - i - 1), 0); } diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts index 16d5b3b5b..6c050937d 100644 --- a/packages/client/src/utils/index.ts +++ b/packages/client/src/utils/index.ts @@ -2,16 +2,13 @@ import { ExplorerItem } from '../core'; export * from './objectKind'; export * from './formatBytes'; +export * from './explorerItem'; // export * from './keys'; export function isPath(item: ExplorerItem): item is Extract { return item.type === 'Path'; } -export function isObject(item: ExplorerItem): item is Extract { - return item.type === 'Object'; -} - export function arraysEqual(a: T[], b: T[]) { if (a === b) return true; if (a == null || b == null) return false; diff --git a/packages/ui/src/Switch.tsx b/packages/ui/src/Switch.tsx index df40ddc3d..245f634ec 100644 --- a/packages/ui/src/Switch.tsx +++ b/packages/ui/src/Switch.tsx @@ -23,7 +23,7 @@ const switchStyles = cva( } }, defaultVariants: { - size: 'lg' + size: 'md' } } ); @@ -38,7 +38,7 @@ const thumbStyles = cva( } }, defaultVariants: { - size: 'lg' + size: 'md' } } ); diff --git a/packages/ui/src/Tooltip.tsx b/packages/ui/src/Tooltip.tsx index 475c9879a..043cc7f8c 100644 --- a/packages/ui/src/Tooltip.tsx +++ b/packages/ui/src/Tooltip.tsx @@ -1,17 +1,20 @@ import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import clsx from 'clsx'; import { PropsWithChildren } from 'react'; export interface TooltipProps { label: string; position?: 'top' | 'right' | 'bottom' | 'left'; className?: string; + tooltipClassName?: string; } export const Tooltip = ({ children, label, position = 'bottom', - className + className, + tooltipClassName }: PropsWithChildren) => { return ( @@ -22,7 +25,10 @@ export const Tooltip = ({ {label}