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 000000000..64c311aae Binary files /dev/null and b/packages/assets/icons/Keys.png differ 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}