diff --git a/.cspell/project_words.txt b/.cspell/project_words.txt index 964f537d6..796630ea8 100644 --- a/.cspell/project_words.txt +++ b/.cspell/project_words.txt @@ -37,6 +37,7 @@ narkhede naveen neha noco +Normalised OSSC poonen rauch diff --git a/Cargo.lock b/Cargo.lock index 3e432e41a..104d11b71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,18 +2004,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "deps-generator" -version = "0.0.0" -dependencies = [ - "anyhow", - "cargo_metadata 0.18.1", - "clap", - "reqwest", - "serde", - "serde_json", -] - [[package]] name = "der" version = "0.6.1" @@ -7588,6 +7576,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sd-deps-generator" +version = "0.0.0" +dependencies = [ + "anyhow", + "cargo_metadata 0.18.1", + "clap", + "reqwest", + "serde", + "serde_json", +] + [[package]] name = "sd-desktop" version = "0.1.4" diff --git a/apps/mobile/src/components/explorer/FileThumb.tsx b/apps/mobile/src/components/explorer/FileThumb.tsx index a8b9e65f1..81c1a08b5 100644 --- a/apps/mobile/src/components/explorer/FileThumb.tsx +++ b/apps/mobile/src/components/explorer/FileThumb.tsx @@ -27,9 +27,13 @@ const FileThumbWrapper = ({ children, size = 1 }: PropsWithChildren<{ size: numb function useExplorerItemData(explorerItem: ExplorerItem) { const explorerStore = useExplorerStore(); + const firstThumbnail = + explorerItem.type === 'Label' + ? explorerItem.thumbnails?.[0] + : 'thumbnail' in explorerItem && explorerItem.thumbnail; + const newThumbnail = !!( - explorerItem.thumbnail_key && - explorerStore.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key)) + firstThumbnail && explorerStore.newThumbnails.has(flattenThumbnailKey(firstThumbnail)) ); return useMemo(() => { diff --git a/apps/mobile/src/components/overview/OverviewStats.tsx b/apps/mobile/src/components/overview/OverviewStats.tsx index 4a052d129..0337435c4 100644 --- a/apps/mobile/src/components/overview/OverviewStats.tsx +++ b/apps/mobile/src/components/overview/OverviewStats.tsx @@ -70,7 +70,7 @@ const OverviewStats = () => { {Object.entries(libraryStatistics).map(([key, bytesRaw]) => { if (!displayableStatItems.includes(key)) return null; - let bytes = BigInt(bytesRaw); + let bytes = BigInt(bytesRaw?.total_bytes_free ?? 0); if (key === 'total_bytes_free') { bytes = BigInt(sizeInfo.freeSpace); } else if (key === 'total_bytes_capacity') { diff --git a/core/src/api/labels.rs b/core/src/api/labels.rs index fb31fd5a0..6b2817b62 100644 --- a/core/src/api/labels.rs +++ b/core/src/api/labels.rs @@ -1,12 +1,21 @@ -use crate::{invalidate_query, library::Library}; +use crate::{invalidate_query, library::Library, object::media::thumbnail::get_indexed_thumb_key}; -use sd_prisma::prisma::{label, label_on_object, object}; +use sd_prisma::prisma::{label, label_on_object, object, SortOrder}; use std::collections::BTreeMap; use rspc::alpha::AlphaRouter; -use super::{utils::library, Ctx, R}; +use super::{locations::ExplorerItem, utils::library, Ctx, R}; + +label::include!((take: i64) => label_with_objects { + label_objects(vec![]).take(take): select { + object: select { + id + file_paths(vec![]).take(1) + } + } +}); pub(crate) fn mount() -> AlphaRouter { R.router() @@ -15,6 +24,45 @@ pub(crate) fn mount() -> AlphaRouter { Ok(library.db.label().find_many(vec![]).exec().await?) }) }) + // + .procedure("listWithThumbnails", { + R.with2(library()) + .query(|(_, library), cursor: label::name::Type| async move { + Ok(library + .db + .label() + .find_many(vec![label::name::gt(cursor)]) + .order_by(label::name::order(SortOrder::Asc)) + .include(label_with_objects::include(4)) + .exec() + .await? + .into_iter() + .map(|label| ExplorerItem::Label { + item: label.clone(), + // map the first 4 objects to thumbnails + thumbnails: label + .label_objects + .into_iter() + .take(10) + .filter_map(|label_object| { + label_object.object.file_paths.into_iter().next() + }) + .filter_map(|file_path_data| { + file_path_data + .cas_id + .as_ref() + .map(|cas_id| get_indexed_thumb_key(cas_id, library.id)) + }) // Filter out None values and transform each element to Vec> + .collect::>(), // Collect into Vec>> + }) + .collect::>()) + }) + }) + .procedure("count", { + R.with2(library()).query(|(_, library), _: ()| async move { + Ok(library.db.label().count(vec![]).exec().await? as i32) + }) + }) .procedure("getForObject", { R.with2(library()) .query(|(_, library), object_id: i32| async move { diff --git a/core/src/api/libraries.rs b/core/src/api/libraries.rs index 7064dcf33..f255ce292 100644 --- a/core/src/api/libraries.rs +++ b/core/src/api/libraries.rs @@ -1,31 +1,50 @@ use crate::{ - library::{Library, LibraryConfig, LibraryName}, + invalidate_query, + library::{update_library_statistics, Library, LibraryConfig, LibraryName}, location::{scan_location, LocationCreateArgs}, util::MaybeUndefined, - volume::get_volumes, Node, }; +use futures::StreamExt; use sd_cache::{Model, Normalise, NormalisedResult, NormalisedResults}; +use sd_file_ext::kind::ObjectKind; use sd_p2p::spacetunnel::RemoteIdentity; -use sd_prisma::prisma::{indexer_rule, statistics}; +use sd_prisma::prisma::{indexer_rule, object, statistics}; +use tokio_stream::wrappers::IntervalStream; -use std::{convert::identity, sync::Arc}; +use std::{ + collections::{hash_map::Entry, HashMap}, + convert::identity, + pin::pin, + sync::Arc, + time::Duration, +}; -use chrono::Utc; +use async_channel as chan; use directories::UserDirs; -use futures_concurrency::future::Join; +use futures_concurrency::{future::Join, stream::Merge}; +use once_cell::sync::Lazy; use rspc::{alpha::AlphaRouter, ErrorCode}; use serde::{Deserialize, Serialize}; use specta::Type; -use tokio::spawn; +use strum::IntoEnumIterator; +use tokio::{ + spawn, + sync::Mutex, + time::{interval, Instant}, +}; use tracing::{debug, error}; use uuid::Uuid; -use super::{ - utils::{get_size, library}, - Ctx, R, -}; +use super::{utils::library, Ctx, R}; + +const ONE_MINUTE: Duration = Duration::from_secs(60); +const TWO_MINUTES: Duration = Duration::from_secs(60 * 2); +const FIVE_MINUTES: Duration = Duration::from_secs(60 * 5); + +static STATISTICS_UPDATERS: Lazy>>> = + Lazy::new(|| Mutex::new(HashMap::new())); // TODO(@Oscar): Replace with `specta::json` #[derive(Serialize, Type)] @@ -80,65 +99,69 @@ pub(crate) fn mount() -> AlphaRouter { }) }) .procedure("statistics", { + #[derive(Serialize, Deserialize, Type)] + pub struct StatisticsResponse { + statistics: Option, + } R.with2(library()) .query(|(node, library), _: ()| async move { - // TODO: get from database if library is offline - // let _statistics = library - // .db - // .statistics() - // .find_unique(statistics::id::equals(library.node_local_id)) - // .exec() - // .await?; - - let volumes = get_volumes().await; - // save_volume(&library).await?; - - let mut total_capacity: u64 = 0; - let mut available_capacity: u64 = 0; - for volume in volumes { - total_capacity += volume.total_capacity; - available_capacity += volume.available_capacity; - } - - let library_db_size = get_size( - node.config - .data_directory() - .join("libraries") - .join(&format!("{}.db", library.id)), - ) - .await - .unwrap_or(0); - - let thumbnail_folder_size = - get_size(node.config.data_directory().join("thumbnails")) - .await - .unwrap_or(0); - - use statistics::*; - let params = vec![ - id::set(1), // Each library is a database so only one of these ever exists - date_captured::set(Utc::now().into()), - total_object_count::set(0), - library_db_size::set(library_db_size.to_string()), - total_bytes_used::set(0.to_string()), - total_bytes_capacity::set(total_capacity.to_string()), - total_unique_bytes::set(0.to_string()), - total_bytes_free::set(available_capacity.to_string()), - preview_media_bytes::set(thumbnail_folder_size.to_string()), - ]; - - Ok(library + let statistics = library .db .statistics() - .upsert( - statistics::id::equals(1), // Each library is a database so only one of these ever exists - statistics::create(params.clone()), - params, - ) + .find_unique(statistics::id::equals(1)) .exec() - .await?) + .await?; + + match STATISTICS_UPDATERS.lock().await.entry(library.id) { + Entry::Occupied(entry) => { + if entry.get().send(Instant::now()).await.is_err() { + error!("Failed to send statistics update request"); + } + } + Entry::Vacant(entry) => { + let (tx, rx) = chan::bounded(1); + entry.insert(tx); + + spawn(update_statistics_loop(node, library, rx)); + } + } + + Ok(StatisticsResponse { statistics }) }) }) + .procedure("kindStatistics", { + #[derive(Serialize, Deserialize, Type, Default)] + pub struct KindStatistic { + kind: i32, + name: String, + count: i32, + total_bytes: String, + } + #[derive(Serialize, Deserialize, Type, Default)] + pub struct KindStatistics { + statistics: Vec, + } + R.with2(library()).query(|(_, library), _: ()| async move { + let mut statistics: Vec = vec![]; + for kind in ObjectKind::iter() { + let count = library + .db + .object() + .count(vec![object::kind::equals(Some(kind as i32))]) + .exec() + .await?; + + statistics.push(KindStatistic { + kind: kind as i32, + name: kind.to_string(), + count: count as i32, + total_bytes: "0".to_string(), + }); + } + + Ok(KindStatistics { statistics }) + }) + }) .procedure("create", { #[derive(Deserialize, Type, Default)] pub struct DefaultLocations { @@ -358,3 +381,44 @@ pub(crate) fn mount() -> AlphaRouter { }), ) } + +async fn update_statistics_loop( + node: Arc, + library: Arc, + last_requested_rx: chan::Receiver, +) { + let mut last_received_at = Instant::now(); + + let tick = interval(ONE_MINUTE); + + enum Message { + Tick, + Requested(Instant), + } + + let mut msg_stream = pin!(( + IntervalStream::new(tick).map(|_| Message::Tick), + last_requested_rx.map(Message::Requested) + ) + .merge()); + + while let Some(msg) = msg_stream.next().await { + match msg { + Message::Tick => { + if last_received_at.elapsed() < FIVE_MINUTES { + if let Err(e) = update_library_statistics(&node, &library).await { + error!("Failed to update library statistics: {e:#?}"); + } else { + invalidate_query!(&library, "library.statistics"); + } + } + } + Message::Requested(instant) => { + if instant - last_received_at > TWO_MINUTES { + debug!("Updating last received at"); + last_received_at = instant; + } + } + } + } +} diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 14acbb0ea..5c71d4aec 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -28,39 +28,37 @@ use serde::{Deserialize, Serialize}; use specta::Type; use tracing::{debug, error}; -use super::{utils::library, Ctx, R}; +use super::{labels::label_with_objects, utils::library, Ctx, R}; + +// it includes the shard hex formatted as ([["f02", "cab34a76fbf3469f"]]) +// Will be None if no thumbnail exists +pub type ThumbnailKey = Vec; #[derive(Serialize, Type, Debug)] #[serde(tag = "type")] pub enum ExplorerItem { Path { - // has_local_thumbnail is true only if there is local existence of a thumbnail - has_local_thumbnail: bool, - // thumbnail_key is present if there is a cas_id - // it includes the shard hex formatted as (["f0", "cab34a76fbf3469f"]) - thumbnail_key: Option>, + thumbnail: Option, item: file_path_with_object::Data, }, Object { - has_local_thumbnail: bool, - thumbnail_key: Option>, + thumbnail: Option, item: object_with_file_paths::Data, }, Location { - has_local_thumbnail: bool, - thumbnail_key: Option>, item: location::Data, }, NonIndexedPath { - has_local_thumbnail: bool, - thumbnail_key: Option>, + thumbnail: Option, item: NonIndexedPathItem, }, SpacedropPeer { - has_local_thumbnail: bool, - thumbnail_key: Option>, item: PeerMetadata, }, + Label { + thumbnails: Vec, + item: label_with_objects::Data, + }, } // TODO: Really this shouldn't be a `Model` but it's easy for now. @@ -79,6 +77,7 @@ impl ExplorerItem { ExplorerItem::Location { .. } => "Location", ExplorerItem::NonIndexedPath { .. } => "NonIndexedPath", ExplorerItem::SpacedropPeer { .. } => "SpacedropPeer", + ExplorerItem::Label { .. } => "Label", }; match self { ExplorerItem::Path { item, .. } => format!("{ty}:{}", item.id), @@ -86,6 +85,7 @@ impl ExplorerItem { ExplorerItem::Location { item, .. } => format!("{ty}:{}", item.id), ExplorerItem::NonIndexedPath { item, .. } => format!("{ty}:{}", item.path), ExplorerItem::SpacedropPeer { item, .. } => format!("{ty}:{}", item.name), // TODO: Use a proper primary key + ExplorerItem::Label { item, .. } => format!("{ty}:{}", item.name), } } } diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index ed255c19b..32e452eb3 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -1,13 +1,15 @@ use crate::{ invalidate_query, job::JobProgressEvent, - node::config::{NodeConfig, NodePreferences}, + node::{ + config::{NodeConfig, NodePreferences}, + get_hardware_model_name, HardwareModel, + }, Node, }; use sd_cache::patch_typedef; use sd_p2p::P2PStatus; - use std::sync::{atomic::Ordering, Arc}; use itertools::Itertools; @@ -118,6 +120,7 @@ struct NodeState { config: SanitisedNodeConfig, data_path: String, p2p: P2PStatus, + device_model: Option, } pub(crate) fn mount() -> Arc { @@ -139,6 +142,10 @@ pub(crate) fn mount() -> Arc { }) .procedure("nodeState", { R.query(|node, _: ()| async move { + let device_model = get_hardware_model_name() + .unwrap_or(HardwareModel::Other) + .to_string(); + Ok(NodeState { config: node.config.get().await.into(), // We are taking the assumption here that this value is only used on the frontend for display purposes @@ -149,6 +156,7 @@ pub(crate) fn mount() -> Arc { .expect("Found non-UTF-8 path") .to_string(), p2p: node.p2p.manager.status(), + device_model: Some(device_model), }) }) }) diff --git a/core/src/api/nodes.rs b/core/src/api/nodes.rs index 1736d4caf..b4d7d999c 100644 --- a/core/src/api/nodes.rs +++ b/core/src/api/nodes.rs @@ -149,11 +149,7 @@ pub(crate) fn mount() -> AlphaRouter { .exec() .await? .into_iter() - .map(|location| ExplorerItem::Location { - has_local_thumbnail: false, - thumbnail_key: None, - item: location, - }) + .map(|location| ExplorerItem::Location { item: location }) .collect::>()) }) }) diff --git a/core/src/api/search/mod.rs b/core/src/api/search/mod.rs index 5d54844eb..097d672a0 100644 --- a/core/src/api/search/mod.rs +++ b/core/src/api/search/mod.rs @@ -255,10 +255,10 @@ pub fn mount() -> AlphaRouter { }; items.push(ExplorerItem::Path { - has_local_thumbnail: thumbnail_exists_locally, - thumbnail_key: file_path + thumbnail: file_path .cas_id .as_ref() + .filter(|_| thumbnail_exists_locally) .map(|i| get_indexed_thumb_key(i, library.id)), item: file_path, }) @@ -377,8 +377,9 @@ pub fn mount() -> AlphaRouter { }; items.push(ExplorerItem::Object { - has_local_thumbnail: thumbnail_exists_locally, - thumbnail_key: cas_id.map(|i| get_indexed_thumb_key(i, library.id)), + thumbnail: cas_id + .filter(|_| thumbnail_exists_locally) + .map(|cas_id| get_indexed_thumb_key(cas_id, library.id)), item: object, }); } diff --git a/core/src/api/search/object.rs b/core/src/api/search/object.rs index bf7b68720..247634e2d 100644 --- a/core/src/api/search/object.rs +++ b/core/src/api/search/object.rs @@ -1,6 +1,6 @@ // use crate::library::Category; -use sd_prisma::prisma::{self, object, tag_on_object}; +use sd_prisma::prisma::{self, label_on_object, object, tag_on_object}; use chrono::{DateTime, FixedOffset}; use prisma_client_rust::{not, or, OrderByQuery, PaginatedQuery, WhereQuery}; @@ -113,6 +113,7 @@ pub enum ObjectFilterArgs { Hidden(ObjectHiddenFilter), Kind(InOrNotIn), Tags(InOrNotIn), + Labels(InOrNotIn), DateAccessed(Range>), } @@ -130,6 +131,13 @@ impl ObjectFilterArgs { ) .map(|v| vec![v]) .unwrap_or_default(), + Self::Labels(v) => v + .into_param( + |v| labels::some(vec![label_on_object::label_id::in_vec(v)]), + |v| labels::none(vec![label_on_object::label_id::in_vec(v)]), + ) + .map(|v| vec![v]) + .unwrap_or_default(), Self::Kind(v) => v .into_param(kind::in_vec, kind::not_in_vec) .map(|v| vec![v]) diff --git a/core/src/library/cat.rs b/core/src/library/cat.rs deleted file mode 100644 index 7c573589e..000000000 --- a/core/src/library/cat.rs +++ /dev/null @@ -1,80 +0,0 @@ -use sd_file_ext::kind::ObjectKind; -use sd_prisma::prisma::object; - -use prisma_client_rust::not; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::vec; - -use strum_macros::{EnumString, EnumVariantNames}; - -/// Meow -#[derive( - Serialize, - Deserialize, - Type, - Debug, - PartialEq, - Eq, - PartialOrd, - Ord, - EnumVariantNames, - EnumString, - Clone, - Copy, -)] -pub enum Category { - Recents, - Favorites, - Albums, - Photos, - Videos, - Movies, - Music, - Documents, - Downloads, - Encrypted, - Projects, - Applications, - Archives, - Databases, - Games, - Books, - Contacts, - Trash, - Screenshots, -} - -impl Category { - // this should really be done without unimplemented! and on another type but ehh - fn to_object_kind(self) -> ObjectKind { - match self { - Category::Photos => ObjectKind::Image, - Category::Videos => ObjectKind::Video, - Category::Music => ObjectKind::Audio, - Category::Books => ObjectKind::Book, - Category::Encrypted => ObjectKind::Encrypted, - Category::Databases => ObjectKind::Database, - Category::Archives => ObjectKind::Archive, - Category::Applications => ObjectKind::Executable, - Category::Screenshots => ObjectKind::Screenshot, - _ => unimplemented!("Category::to_object_kind() for {:?}", self), - } - } - - pub fn to_where_param(self) -> object::WhereParam { - match self { - Category::Recents => not![object::date_accessed::equals(None)], - Category::Favorites => object::favorite::equals(Some(true)), - Category::Photos - | Category::Videos - | Category::Music - | Category::Encrypted - | Category::Databases - | Category::Archives - | Category::Applications - | Category::Books => object::kind::equals(Some(self.to_object_kind() as i32)), - _ => object::id::equals(-1), - } - } -} diff --git a/core/src/library/mod.rs b/core/src/library/mod.rs index 01284ff22..944f40410 100644 --- a/core/src/library/mod.rs +++ b/core/src/library/mod.rs @@ -1,16 +1,16 @@ -// pub(crate) mod cat; mod actors; mod config; #[allow(clippy::module_inception)] mod library; mod manager; mod name; +mod statistics; -// pub use cat::*; pub use actors::*; pub use config::*; pub use library::*; pub use manager::*; pub use name::*; +pub use statistics::*; pub type LibraryId = uuid::Uuid; diff --git a/core/src/library/statistics.rs b/core/src/library/statistics.rs new file mode 100644 index 000000000..804c35f77 --- /dev/null +++ b/core/src/library/statistics.rs @@ -0,0 +1,66 @@ +use crate::{api::utils::get_size, library::Library, volume::get_volumes, Node}; + +use sd_prisma::prisma::statistics; + +use chrono::Utc; +use tracing::info; + +use super::LibraryManagerError; + +pub async fn update_library_statistics( + node: &Node, + library: &Library, +) -> Result { + let volumes = get_volumes().await; + + let mut total_capacity: u64 = 0; + let mut available_capacity: u64 = 0; + for volume in volumes { + total_capacity += volume.total_capacity; + available_capacity += volume.available_capacity; + } + + let total_bytes_used = total_capacity - available_capacity; + + let library_db_size = get_size( + node.config + .data_directory() + .join("libraries") + .join(&format!("{}.db", library.id)), + ) + .await + .unwrap_or(0); + + let thumbnail_folder_size = get_size(node.config.data_directory().join("thumbnails")) + .await + .unwrap_or(0); + + use statistics::*; + let params = vec![ + id::set(1), // Each library is a database so only one of these ever exists + date_captured::set(Utc::now().into()), + total_object_count::set(0), + library_db_size::set(library_db_size.to_string()), + total_bytes_used::set(total_bytes_used.to_string()), + total_bytes_capacity::set(total_capacity.to_string()), + total_unique_bytes::set(0.to_string()), + total_bytes_free::set(available_capacity.to_string()), + preview_media_bytes::set(thumbnail_folder_size.to_string()), + ]; + + let stats = library + .db + .statistics() + .upsert( + // Each library is a database so only one of these ever exists + statistics::id::equals(1), + statistics::create(params.clone()), + params, + ) + .exec() + .await?; + + info!("Updated library statistics: {:?}", stats); + + Ok(stats) +} diff --git a/core/src/location/non_indexed.rs b/core/src/location/non_indexed.rs index 30c3fb394..a3c0d48ce 100644 --- a/core/src/location/non_indexed.rs +++ b/core/src/location/non_indexed.rs @@ -231,8 +231,7 @@ pub async fn walk( }; tx.send(Ok(ExplorerItem::NonIndexedPath { - has_local_thumbnail: thumbnail_key.is_some(), - thumbnail_key, + thumbnail: thumbnail_key, item: NonIndexedPathItem { hidden: path_is_hidden(Path::new(&entry_path), &entry.metadata), path: entry_path, @@ -281,16 +280,11 @@ pub async fn walk( for (directory, name, metadata) in directories { if let Some(location) = locations.remove(&directory) { - tx.send(Ok(ExplorerItem::Location { - has_local_thumbnail: false, - thumbnail_key: None, - item: location, - })) - .await?; + tx.send(Ok(ExplorerItem::Location { item: location })) + .await?; } else { tx.send(Ok(ExplorerItem::NonIndexedPath { - has_local_thumbnail: false, - thumbnail_key: None, + thumbnail: None, item: NonIndexedPathItem { hidden: path_is_hidden(Path::new(&directory), &metadata), path: directory, diff --git a/core/src/node/hardware.rs b/core/src/node/hardware.rs new file mode 100644 index 000000000..ae00a4d95 --- /dev/null +++ b/core/src/node/hardware.rs @@ -0,0 +1,71 @@ +use std::io::Error; +use std::process::Command; +use std::str; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use strum_macros::{Display, EnumIter}; + +#[repr(i32)] +#[derive(Debug, Clone, Display, Copy, EnumIter, Type, Serialize, Deserialize, Eq, PartialEq)] +pub enum HardwareModel { + Other, + MacStudio, + MacBookAir, + MacBookPro, + MacBook, + MacMini, + MacPro, + IMac, + IMacPro, + IPad, + IPhone, +} + +impl HardwareModel { + pub fn from_display_name(name: &str) -> Self { + use strum::IntoEnumIterator; + HardwareModel::iter() + .find(|&model| { + model.to_string().to_lowercase().replace(' ', "") + == name.to_lowercase().replace(' ', "") + }) + .unwrap_or(HardwareModel::Other) + } +} + +pub fn get_hardware_model_name() -> Result { + #[cfg(target_os = "macos")] + { + let output = Command::new("system_profiler") + .arg("SPHardwareDataType") + .output()?; + + if output.status.success() { + let output_str = std::str::from_utf8(&output.stdout).unwrap_or_default(); + let hardware_model = output_str + .lines() + .find(|line| line.to_lowercase().contains("model name")) + .and_then(|line| line.split_once(':')) + .map(|(_, model_name)| HardwareModel::from_display_name(model_name.trim())) + .unwrap_or(HardwareModel::Other); + + Ok(hardware_model) + } else { + Err(Error::new( + std::io::ErrorKind::Other, + format!( + "Failed to get hardware model name: {}", + String::from_utf8_lossy(&output.stderr) + ), + )) + } + } + #[cfg(not(target_os = "macos"))] + { + Err(Error::new( + std::io::ErrorKind::Unsupported, + "Unsupported operating system", + )) + } +} diff --git a/core/src/node/mod.rs b/core/src/node/mod.rs index 4ef44157d..dbc77094b 100644 --- a/core/src/node/mod.rs +++ b/core/src/node/mod.rs @@ -1,4 +1,6 @@ pub mod config; +mod hardware; mod platform; +pub use hardware::*; pub use platform::*; diff --git a/core/src/object/file_identifier/shallow.rs b/core/src/object/file_identifier/shallow.rs index 4f28a8216..4acbbd454 100644 --- a/core/src/object/file_identifier/shallow.rs +++ b/core/src/object/file_identifier/shallow.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use prisma_client_rust::or; use serde::{Deserialize, Serialize}; -use tracing::{debug, trace, warn}; +use tracing::{trace, warn}; use super::{process_identifier_file_paths, FileIdentifierJobError, CHUNK_SIZE}; @@ -28,7 +28,7 @@ pub async fn shallow( ) -> Result<(), JobError> { let Library { db, .. } = library; - debug!("Identifying orphan File Paths..."); + warn!("Identifying orphan File Paths..."); let location_id = location.id; let location_path = maybe_missing(&location.path, "location.path").map(Path::new)?; @@ -66,7 +66,7 @@ pub async fn shallow( } let task_count = (orphan_count as f64 / CHUNK_SIZE as f64).ceil() as usize; - debug!( + warn!( "Found {} orphan Paths. Will execute {} tasks...", orphan_count, task_count ); diff --git a/core/src/p2p/p2p_manager.rs b/core/src/p2p/p2p_manager.rs index be1ab9ef6..48eb41000 100644 --- a/core/src/p2p/p2p_manager.rs +++ b/core/src/p2p/p2p_manager.rs @@ -1,12 +1,11 @@ use crate::{ - node::config, + node::{config, get_hardware_model_name, HardwareModel}, p2p::{OperatingSystem, SPACEDRIVE_APP_ID}, }; use sd_p2p::{ spacetunnel::RemoteIdentity, Manager, ManagerConfig, ManagerError, PeerStatus, Service, }; - use std::{ collections::{HashMap, HashSet}, net::SocketAddr, @@ -95,6 +94,7 @@ impl P2PManager { PeerMetadata { name: config.name.clone(), operating_system: Some(OperatingSystem::get_os()), + device_model: Some(get_hardware_model_name().unwrap_or(HardwareModel::Other)), version: Some(env!("CARGO_PKG_VERSION").to_string()), } }); diff --git a/core/src/p2p/peer_metadata.rs b/core/src/p2p/peer_metadata.rs index 51885f469..10c397ee2 100644 --- a/core/src/p2p/peer_metadata.rs +++ b/core/src/p2p/peer_metadata.rs @@ -1,4 +1,4 @@ -use crate::node::Platform; +use crate::node::{HardwareModel, Platform}; use sd_p2p::Metadata; @@ -11,6 +11,7 @@ use specta::Type; pub struct PeerMetadata { pub name: String, pub operating_system: Option, + pub device_model: Option, pub version: Option, } @@ -24,6 +25,9 @@ impl Metadata for PeerMetadata { if let Some(version) = self.version { map.insert("version".to_owned(), version); } + if let Some(device_model) = self.device_model { + map.insert("device_model".to_owned(), device_model.to_string()); + } map } @@ -43,6 +47,11 @@ impl Metadata for PeerMetadata { .get("os") .map(|os| os.parse().map_err(|_| "Unable to parse 'OperationSystem'!")) .transpose()?, + device_model: Some(HardwareModel::from_display_name( + data.get("device_model") + .map(|s| s.as_str()) + .unwrap_or("Other"), + )), version: data.get("version").map(|v| v.to_owned()), }) } diff --git a/crates/deps-generator/Cargo.toml b/crates/deps-generator/Cargo.toml index 68e2627e0..2c58af519 100644 --- a/crates/deps-generator/Cargo.toml +++ b/crates/deps-generator/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "deps-generator" +name = "sd-deps-generator" version = "0.0.0" authors = ["Jake Robinson "] description = "A tool to compile all Spacedrive dependencies and their respective licenses" diff --git a/crates/file-ext/src/extensions.rs b/crates/file-ext/src/extensions.rs index 5cef0f1b5..bb904a6c4 100644 --- a/crates/file-ext/src/extensions.rs +++ b/crates/file-ext/src/extensions.rs @@ -121,6 +121,7 @@ extension_category_enum! { Aptx = [0x4B, 0xBF, 0x4B, 0xBF], Adts = [0xFF, 0xF1], Ast = [0x53, 0x54, 0x52, 0x4D], + Mid = [0x4D, 0x54, 0x68, 0x64], } } diff --git a/crates/file-ext/src/kind.rs b/crates/file-ext/src/kind.rs index b8e9d6487..058e206a4 100644 --- a/crates/file-ext/src/kind.rs +++ b/crates/file-ext/src/kind.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; - +use specta::Type; +use strum_macros::{Display, EnumIter}; // Note: The order of this enum should never change, and always be kept in sync with `packages/client/src/utils/objectKind.ts` #[repr(i32)] -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Clone, Display, Copy, EnumIter, Type, Serialize, Deserialize, Eq, PartialEq)] pub enum ObjectKind { /// A file that can not be identified by the indexer Unknown = 0, @@ -56,4 +57,6 @@ pub enum ObjectKind { Dotfile = 24, /// Screenshot Screenshot = 25, + /// Label + Label = 26, } diff --git a/interface/app/$libraryId/Explorer/ExplorerPath.tsx b/interface/app/$libraryId/Explorer/ExplorerPath.tsx index fe4418dd7..dbecaac3b 100644 --- a/interface/app/$libraryId/Explorer/ExplorerPath.tsx +++ b/interface/app/$libraryId/Explorer/ExplorerPath.tsx @@ -159,8 +159,8 @@ const Path = ({ path, onClick, disabled }: PathProps) => { ref={setDroppableRef} className={clsx( 'group flex items-center gap-1 rounded px-1 py-0.5', - isDroppable && [isDark ? 'bg-app-lightBox' : 'bg-app-darkerBox'], - !disabled && [isDark ? 'hover:bg-app-lightBox' : 'hover:bg-app-darkerBox'], + isDroppable && [isDark ? 'bg-app-button/70' : 'bg-app-darkerBox'], + !disabled && [isDark ? 'hover:bg-app-button/70' : 'hover:bg-app-darkerBox'], className )} disabled={disabled} diff --git a/interface/app/$libraryId/Explorer/View/EmptyNotice.tsx b/interface/app/$libraryId/Explorer/View/EmptyNotice.tsx index a0beb65a1..8d272cb11 100644 --- a/interface/app/$libraryId/Explorer/View/EmptyNotice.tsx +++ b/interface/app/$libraryId/Explorer/View/EmptyNotice.tsx @@ -33,7 +33,7 @@ export const EmptyNotice = (props: { : emptyNoticeIcon(props.icon as Icon) : emptyNoticeIcon()} -

+

{props.message !== undefined ? props.message : 'This list is empty'}

diff --git a/interface/app/$libraryId/Explorer/View/ViewItem.tsx b/interface/app/$libraryId/Explorer/View/ViewItem.tsx index 4a4410eef..3c14987d8 100644 --- a/interface/app/$libraryId/Explorer/View/ViewItem.tsx +++ b/interface/app/$libraryId/Explorer/View/ViewItem.tsx @@ -61,6 +61,10 @@ export const useViewItemDoubleClick = () => { const paths = selectedItem.type === 'Path' ? [selectedItem.item] + : selectedItem.type === 'Label' + ? selectedItem.item.label_objects.flatMap( + (o) => o.object.file_paths + ) : selectedItem.item.file_paths; for (const filePath of paths) { diff --git a/interface/app/$libraryId/Explorer/useExplorerDroppable.tsx b/interface/app/$libraryId/Explorer/useExplorerDroppable.tsx index aca229d36..665eb4d82 100644 --- a/interface/app/$libraryId/Explorer/useExplorerDroppable.tsx +++ b/interface/app/$libraryId/Explorer/useExplorerDroppable.tsx @@ -119,7 +119,6 @@ export const useExplorerDroppable = ({ allowedType = ['Path', 'NonIndexedPath', 'Object']; break; } - case 'Tag': { allowedType = ['Path', 'Object']; break; diff --git a/interface/app/$libraryId/Explorer/util.ts b/interface/app/$libraryId/Explorer/util.ts index d2c5c7827..33fddfe3e 100644 --- a/interface/app/$libraryId/Explorer/util.ts +++ b/interface/app/$libraryId/Explorer/util.ts @@ -10,14 +10,14 @@ export function useExplorerSearchParams() { } export function useExplorerItemData(explorerItem: ExplorerItem) { - const newThumbnail = useSelector( - explorerStore, - (s) => - !!( - explorerItem.thumbnail_key && - s.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key)) - ) - ); + const newThumbnail = useSelector(explorerStore, (s) => { + const firstThumbnail = + explorerItem.type === 'Label' + ? explorerItem.thumbnails?.[0] + : 'thumbnail' in explorerItem && explorerItem.thumbnail; + + return !!(firstThumbnail && s.newThumbnails.has(flattenThumbnailKey(firstThumbnail))); + }); return useMemo(() => { const itemData = getExplorerItemData(explorerItem); diff --git a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx deleted file mode 100644 index 25d70522b..000000000 --- a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { ArrowsClockwise, Cloud, Database, Factory } from '@phosphor-icons/react'; -import { LibraryContextProvider, useClientContext, useFeatureFlag } from '@sd/client'; - -import { EphemeralSection } from './EphemeralSection'; -import Icon from './Icon'; -import { LibrarySection } from './LibrarySection'; -import SidebarLink from './Link'; -import Section from './Section'; - -export default () => { - const { library } = useClientContext(); - - const debugRoutes = useFeatureFlag('debugRoutes'); - - return ( -
- {/* - - Spacedrop - */} - {/* - {/* - - Imports - */} - {debugRoutes && ( -
-
- - - Sync - - - - Cloud - - - - Cache - - - - Actors - -
-
- )} - - {library && ( - - - - )} - {/*
}> - - - Duplicates - - - - Find a File - - - - Cache Cleaner - - - - Media Encoder - -
*/} -
-
- ); -}; diff --git a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx deleted file mode 100644 index ca41374d8..000000000 --- a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Devices } from './Devices'; -import { Locations } from './Locations'; -import { SavedSearches } from './SavedSearches'; -import { Tags } from './Tags'; - -export const LibrarySection = () => { - return ( - <> - - - - - - ); -}; diff --git a/interface/app/$libraryId/Layout/Sidebar/FeedbackButton.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/FeedbackButton.tsx similarity index 100% rename from interface/app/$libraryId/Layout/Sidebar/FeedbackButton.tsx rename to interface/app/$libraryId/Layout/Sidebar/SidebarLayout/FeedbackButton.tsx diff --git a/interface/app/$libraryId/Layout/Sidebar/Footer.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx similarity index 95% rename from interface/app/$libraryId/Layout/Sidebar/Footer.tsx rename to interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx index 58ce8c4fc..daddcb6c7 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Footer.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx @@ -5,9 +5,9 @@ import { Button, ButtonLink, Popover, Tooltip, usePopover } from '@sd/ui'; import { useKeysMatcher, useLocale, useShortcut } from '~/hooks'; import { usePlatform } from '~/util/Platform'; -import DebugPopover from './DebugPopover'; +import DebugPopover from '../DebugPopover'; +import { IsRunningJob, JobManager } from '../JobManager'; import FeedbackButton from './FeedbackButton'; -import { IsRunningJob, JobManager } from './JobManager'; export default () => { const { library } = useClientContext(); diff --git a/interface/app/$libraryId/Layout/Sidebar/Icon.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Icon.tsx similarity index 100% rename from interface/app/$libraryId/Layout/Sidebar/Icon.tsx rename to interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Icon.tsx diff --git a/interface/app/$libraryId/Layout/Sidebar/LibrariesDropdown.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx similarity index 96% rename from interface/app/$libraryId/Layout/Sidebar/LibrariesDropdown.tsx rename to interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx index 14c149b68..a4eb3cb3b 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LibrariesDropdown.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx @@ -4,7 +4,7 @@ import { useClientContext } from '@sd/client'; import { dialogManager, Dropdown, DropdownMenu } from '@sd/ui'; import { useLocale } from '~/hooks'; -import CreateDialog from '../../settings/node/libraries/CreateDialog'; +import CreateDialog from '../../../settings/node/libraries/CreateDialog'; export default () => { const { library, libraries, currentLibraryId } = useClientContext(); diff --git a/interface/app/$libraryId/Layout/Sidebar/Link.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Link.tsx similarity index 94% rename from interface/app/$libraryId/Layout/Sidebar/Link.tsx rename to interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Link.tsx index 64624f118..af0d38751 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Link.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Link.tsx @@ -46,7 +46,7 @@ const Link = forwardRef< }} className={({ isActive }) => clsx( - 'ring-0', // Remove ugly outline ring on Chrome Windows & Linux + 'relative ring-0', // ring-0 to remove ugly outline ring on Chrome Windows & Linux styles({ active: isActive, transparent: os === 'macOS' }), disabled && 'pointer-events-none opacity-50', className diff --git a/interface/app/$libraryId/Layout/Sidebar/Section.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Section.tsx similarity index 100% rename from interface/app/$libraryId/Layout/Sidebar/Section.tsx rename to interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Section.tsx diff --git a/interface/app/$libraryId/Layout/Sidebar/SeeMore.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/SeeMore.tsx similarity index 100% rename from interface/app/$libraryId/Layout/Sidebar/SeeMore.tsx rename to interface/app/$libraryId/Layout/Sidebar/SidebarLayout/SeeMore.tsx diff --git a/interface/app/$libraryId/Layout/Sidebar/WindowControls.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/WindowControls.tsx similarity index 95% rename from interface/app/$libraryId/Layout/Sidebar/WindowControls.tsx rename to interface/app/$libraryId/Layout/Sidebar/SidebarLayout/WindowControls.tsx index ab4f3f2a1..31aa69696 100644 --- a/interface/app/$libraryId/Layout/Sidebar/WindowControls.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/WindowControls.tsx @@ -3,7 +3,7 @@ import { MacTrafficLights } from '~/components/TrafficLights'; import { useOperatingSystem } from '~/hooks/useOperatingSystem'; import { usePlatform } from '~/util/Platform'; -import { macOnly } from './helpers'; +import { macOnly } from '../helpers'; export default () => { const { platform } = usePlatform(); diff --git a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/index.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/index.tsx new file mode 100644 index 000000000..44e539980 --- /dev/null +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/index.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import { PropsWithChildren, useEffect } from 'react'; +import { MacTrafficLights } from '~/components'; +import { useOperatingSystem, useShowControls } from '~/hooks'; +import { useWindowState } from '~/hooks/useWindowState'; + +import Footer from './Footer'; +import LibrariesDropdown from './LibrariesDropdown'; + +export default (props: PropsWithChildren) => { + const os = useOperatingSystem(); + const showControls = useShowControls(); + const windowState = useWindowState(); + + //prevent sidebar scrolling with keyboard + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const arrows = ['ArrowUp', 'ArrowDown']; + if (arrows.includes(e.key)) { + e.preventDefault(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + return ( +
+ {showControls.isEnabled && } + + {os === 'macOS' && ( +
+ )} + +
+ {props.children} +
+
+
+
+ ); +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/index.tsx b/interface/app/$libraryId/Layout/Sidebar/index.tsx index 3a68a1b61..9a8b1068a 100644 --- a/interface/app/$libraryId/Layout/Sidebar/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/index.tsx @@ -1,56 +1,35 @@ -import clsx from 'clsx'; -import { useEffect } from 'react'; -import { MacTrafficLights } from '~/components'; -import { useOperatingSystem, useShowControls } from '~/hooks'; -import { useWindowState } from '~/hooks/useWindowState'; +import { LibraryContextProvider, useClientContext } from '@sd/client'; -import Contents from './Contents'; -import Footer from './Footer'; -import LibrariesDropdown from './LibrariesDropdown'; - -export default () => { - const os = useOperatingSystem(); - const showControls = useShowControls(); - const windowState = useWindowState(); - - //prevent sidebar scrolling with keyboard - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const arrows = ['ArrowUp', 'ArrowDown']; - if (arrows.includes(e.key)) { - e.preventDefault(); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, []); +import Debug from './sections/Debug'; +// sections +import Devices from './sections/Devices'; +import Library from './sections/Library'; +import Local from './sections/Local'; +import Locations from './sections/Locations'; +import SavedSearches from './sections/SavedSearches'; +import Tags from './sections/Tags'; +import SidebarLayout from './SidebarLayout'; +export default function Sidebar() { + const { library } = useClientContext(); return ( -
+ {library && ( + + + )} - > - {showControls.isEnabled && } - - {os === 'macOS' && ( -
+ + + {library && ( + + + + + + )} - - -
-
+ {/* */} + ); -}; +} diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Debug/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Debug/index.tsx new file mode 100644 index 000000000..83804b9e5 --- /dev/null +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Debug/index.tsx @@ -0,0 +1,35 @@ +import { ArrowsClockwise, Cloud, Database, Factory } from '@phosphor-icons/react'; +import { useFeatureFlag } from '@sd/client'; + +import Icon from '../../SidebarLayout/Icon'; +import SidebarLink from '../../SidebarLayout/Link'; +import Section from '../../SidebarLayout/Section'; + +export default function DebugSection() { + const debugRoutes = useFeatureFlag('debugRoutes'); + + if (!debugRoutes) return <>; + + return ( +
+
+ + + Sync + + + + Cloud + + + + Cache + + + + Actors + +
+
+ ); +} diff --git a/interface/app/$libraryId/Layout/Sidebar/Devices/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Devices/index.tsx similarity index 86% rename from interface/app/$libraryId/Layout/Sidebar/Devices/index.tsx rename to interface/app/$libraryId/Layout/Sidebar/sections/Devices/index.tsx index 6b7101f5a..73ecc162b 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Devices/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Devices/index.tsx @@ -4,10 +4,10 @@ import { Button, Tooltip } from '@sd/ui'; import { Icon, SubtleButton } from '~/components'; import { useLocale } from '~/hooks'; -import SidebarLink from '../Link'; -import Section from '../Section'; +import SidebarLink from '../../SidebarLayout/Link'; +import Section from '../../SidebarLayout/Section'; -export const Devices = () => { +export default function DevicesSection() { const { data: node } = useBridgeQuery(['nodeState']); const isPairingEnabled = useFeatureFlag('p2pPairing'); @@ -38,4 +38,4 @@ export const Devices = () => { ); -}; +} diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Library/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Library/index.tsx new file mode 100644 index 000000000..b7af4ec91 --- /dev/null +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Library/index.tsx @@ -0,0 +1,35 @@ +import { Clock, Heart, Planet, Tag } from '@phosphor-icons/react'; +import { useLibraryQuery } from '@sd/client'; + +import Icon from '../../SidebarLayout/Icon'; +import SidebarLink from '../../SidebarLayout/Link'; + +export const COUNT_STYLE = `absolute right-1 min-w-[20px] top-1 flex h-[19px] px-1 items-center justify-center rounded-full border border-app-button/40 text-[9px]`; + +export default function LibrarySection() { + const labelCount = useLibraryQuery(['labels.count']); + + return ( +
+ + + Overview + + + + Recents + {/*
34
*/} +
+ + + Favorites + {/*
2
*/} +
+ + + Labels +
{labelCount.data || 0}
+
+
+ ); +} diff --git a/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx similarity index 92% rename from interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx rename to interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx index a3fc9577a..5e014fe0e 100644 --- a/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx @@ -6,11 +6,11 @@ import { Button, toast, tw } from '@sd/ui'; import { Icon, IconName } from '~/components'; import { useHomeDir } from '~/hooks/useHomeDir'; -import { useExplorerDroppable } from '../../Explorer/useExplorerDroppable'; -import { useExplorerSearchParams } from '../../Explorer/util'; -import SidebarLink from './Link'; -import Section from './Section'; -import { SeeMore } from './SeeMore'; +import { useExplorerDroppable } from '../../../../Explorer/useExplorerDroppable'; +import { useExplorerSearchParams } from '../../../../Explorer/util'; +import SidebarLink from '../../SidebarLayout/Link'; +import Section from '../../SidebarLayout/Section'; +import { SeeMore } from '../../SidebarLayout/SeeMore'; const Name = tw.span`truncate`; @@ -29,7 +29,7 @@ const SidebarIcon = ({ name }: { name: IconName }) => { return ; }; -export const EphemeralSection = () => { +export default function LocalSection() { const locationsQuery = useLibraryQuery(['locations.list']); useNodes(locationsQuery.data?.nodes); const locations = useCache(locationsQuery.data?.items); @@ -137,7 +137,7 @@ export const EphemeralSection = () => { ); -}; +} const EphemeralLocation = ({ children, diff --git a/interface/app/$libraryId/Layout/Sidebar/Locations/ContextMenu.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Locations/ContextMenu.tsx similarity index 100% rename from interface/app/$libraryId/Layout/Sidebar/Locations/ContextMenu.tsx rename to interface/app/$libraryId/Layout/Sidebar/sections/Locations/ContextMenu.tsx diff --git a/interface/app/$libraryId/Layout/Sidebar/Locations/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx similarity index 92% rename from interface/app/$libraryId/Layout/Sidebar/Locations/index.tsx rename to interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx index bf0645e4b..39fa4c837 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Locations/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx @@ -13,12 +13,12 @@ import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util'; import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton'; import { Icon, SubtleButton } from '~/components'; -import SidebarLink from '../Link'; -import Section from '../Section'; -import { SeeMore } from '../SeeMore'; +import SidebarLink from '../../SidebarLayout/Link'; +import Section from '../../SidebarLayout/Section'; +import { SeeMore } from '../../SidebarLayout/SeeMore'; import { ContextMenu } from './ContextMenu'; -export const Locations = () => { +export default function Locations() { const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); useNodes(locationsQuery.data?.nodes); const locations = useCache(locationsQuery.data?.items); @@ -45,7 +45,7 @@ export const Locations = () => { ); -}; +} const Location = ({ location, online }: { location: LocationType; online: boolean }) => { const locationId = useMatch('/:libraryId/location/:locationId')?.params.locationId; diff --git a/interface/app/$libraryId/Layout/Sidebar/SavedSearches/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/SavedSearches/index.tsx similarity index 93% rename from interface/app/$libraryId/Layout/Sidebar/SavedSearches/index.tsx rename to interface/app/$libraryId/Layout/Sidebar/sections/SavedSearches/index.tsx index 5b85ca75e..3a0aa9076 100644 --- a/interface/app/$libraryId/Layout/Sidebar/SavedSearches/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/SavedSearches/index.tsx @@ -6,11 +6,11 @@ import { Button } from '@sd/ui'; import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable'; import { Folder } from '~/components'; -import SidebarLink from '../Link'; -import Section from '../Section'; -import { SeeMore } from '../SeeMore'; +import SidebarLink from '../../SidebarLayout/Link'; +import Section from '../../SidebarLayout/Section'; +import { SeeMore } from '../../SidebarLayout/SeeMore'; -export const SavedSearches = () => { +export default function SavedSearches() { const savedSearches = useLibraryQuery(['search.saved.list']); const path = useResolvedPath('saved-search/:id'); @@ -58,7 +58,7 @@ export const SavedSearches = () => { ); -}; +} const SavedSearch = ({ search, onDelete }: { search: SavedSearch; onDelete(): void }) => { const searchId = useMatch('/:libraryId/saved-search/:searchId')?.params.searchId; diff --git a/interface/app/$libraryId/Layout/Sidebar/Tags/ContextMenu.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Tags/ContextMenu.tsx similarity index 100% rename from interface/app/$libraryId/Layout/Sidebar/Tags/ContextMenu.tsx rename to interface/app/$libraryId/Layout/Sidebar/sections/Tags/ContextMenu.tsx diff --git a/interface/app/$libraryId/Layout/Sidebar/Tags/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx similarity index 89% rename from interface/app/$libraryId/Layout/Sidebar/Tags/index.tsx rename to interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx index d45d35ca2..96553fad3 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Tags/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx @@ -4,12 +4,12 @@ import { useCache, useLibraryQuery, useNodes, type Tag } from '@sd/client'; import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable'; import { SubtleButton } from '~/components'; -import SidebarLink from '../Link'; -import Section from '../Section'; -import { SeeMore } from '../SeeMore'; +import SidebarLink from '../../SidebarLayout/Link'; +import Section from '../../SidebarLayout/Section'; +import { SeeMore } from '../../SidebarLayout/SeeMore'; import { ContextMenu } from './ContextMenu'; -export const Tags = () => { +export default function TagsSection() { const result = useLibraryQuery(['tags.list'], { keepPreviousData: true }); useNodes(result.data?.nodes); const tags = useCache(result.data?.items); @@ -32,7 +32,7 @@ export const Tags = () => { ); -}; +} const Tag = ({ tag }: { tag: Tag }) => { const tagId = useMatch('/:libraryId/tag/:tagId')?.params.tagId; diff --git a/interface/app/$libraryId/Layout/index.tsx b/interface/app/$libraryId/Layout/index.tsx index 3e64be336..4512e1914 100644 --- a/interface/app/$libraryId/Layout/index.tsx +++ b/interface/app/$libraryId/Layout/index.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { Suspense, useEffect, useMemo, useRef } from 'react'; -import { Navigate, Outlet, useNavigate } from 'react-router-dom'; +import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { ClientContextProvider, initPlausible, @@ -31,6 +31,7 @@ import { DndContext } from './DndContext'; import Sidebar from './Sidebar'; const Layout = () => { + console.log(useLocation()); const { libraries, library } = useClientContext(); const os = useOperatingSystem(); const showControls = useShowControls(); diff --git a/interface/app/$libraryId/favorites.tsx b/interface/app/$libraryId/favorites.tsx new file mode 100644 index 000000000..2df0e2257 --- /dev/null +++ b/interface/app/$libraryId/favorites.tsx @@ -0,0 +1,92 @@ +import { useMemo } from 'react'; +import { ObjectFilterArgs, ObjectKindEnum, ObjectOrder, SearchFilterArgs } from '@sd/client'; +import { Icon } from '~/components'; +import { useRouteTitle } from '~/hooks'; + +import Explorer from './Explorer'; +import { ExplorerContextProvider } from './Explorer/Context'; +import { useObjectsExplorerQuery } from './Explorer/queries/useObjectsExplorerQuery'; +import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explorer/store'; +import { DefaultTopBarOptions } from './Explorer/TopBarOptions'; +import { useExplorer, useExplorerSettings } from './Explorer/useExplorer'; +import { EmptyNotice } from './Explorer/View/EmptyNotice'; +import { SearchContextProvider, SearchOptions, useSearch } from './search'; +import SearchBar from './search/SearchBar'; +import { TopBarPortal } from './TopBar/Portal'; + +export function Component() { + useRouteTitle('Favorites'); + + const explorerSettings = useExplorerSettings({ + settings: useMemo(() => { + return createDefaultExplorerSettings({ order: null }); + }, []), + orderingKeys: objectOrderingKeysSchema + }); + + const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + + const fixedFilters = useMemo( + () => [ + // { object: { favorite: true } }, + ...(explorerSettingsSnapshot.layoutMode === 'media' + ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] + : []) + ], + [explorerSettingsSnapshot.layoutMode] + ); + + const search = useSearch({ + fixedFilters + }); + + const objects = useObjectsExplorerQuery({ + arg: { + take: 100, + filters: [ + ...search.allFilters, + // TODO: Add filter to search options + { object: { favorite: true } } + ] + }, + explorerSettings + }); + + const explorer = useExplorer({ + ...objects, + isFetchingNextPage: objects.query.isFetchingNextPage, + settings: explorerSettings + }); + + return ( + + + } + left={ +
+ Favorites +
+ } + right={} + > + {search.open && ( + <> +
+ + + )} +
+
+ + } + message="No favorite items" + /> + } + /> +
+ ); +} diff --git a/interface/app/$libraryId/index.tsx b/interface/app/$libraryId/index.tsx index 1caebd1aa..5b0d5e293 100644 --- a/interface/app/$libraryId/index.tsx +++ b/interface/app/$libraryId/index.tsx @@ -9,9 +9,9 @@ import settingsRoutes from './settings'; const pageRoutes: RouteObject = { lazy: () => import('./PageLayout'), children: [ - { path: 'people', lazy: () => import('./people') }, - { path: 'media', lazy: () => import('./media') }, - { path: 'spaces', lazy: () => import('./spaces') }, + { path: 'overview', lazy: () => import('./overview') }, + // { path: 'labels', lazy: () => import('./labels') }, + // { path: 'spaces', lazy: () => import('./spaces') }, { path: 'debug', children: debugRoutes } ] }; @@ -19,6 +19,10 @@ const pageRoutes: RouteObject = { // Routes that render the explorer and don't need padding and stuff // provided by PageLayout const explorerRoutes: RouteObject[] = [ + { path: 'recents', lazy: () => import('./recents') }, + { path: 'favorites', lazy: () => import('./favorites') }, + { path: 'labels', lazy: () => import('./labels') }, + { path: 'search', lazy: () => import('./search') }, { path: 'ephemeral/:id', lazy: () => import('./ephemeral') }, { path: 'location/:id', lazy: () => import('./location/$id') }, { path: 'node/:id', lazy: () => import('./node/$id') }, diff --git a/interface/app/$libraryId/labels.tsx b/interface/app/$libraryId/labels.tsx new file mode 100644 index 000000000..27f7d659e --- /dev/null +++ b/interface/app/$libraryId/labels.tsx @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { + ObjectFilterArgs, + ObjectKindEnum, + ObjectOrder, + SearchFilterArgs, + useLibraryQuery +} from '@sd/client'; +import { Icon } from '~/components'; +import { useRouteTitle } from '~/hooks'; + +import Explorer from './Explorer'; +import { ExplorerContextProvider } from './Explorer/Context'; +import { useObjectsExplorerQuery } from './Explorer/queries/useObjectsExplorerQuery'; +import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explorer/store'; +import { DefaultTopBarOptions } from './Explorer/TopBarOptions'; +import { useExplorer, useExplorerSettings } from './Explorer/useExplorer'; +import { EmptyNotice } from './Explorer/View/EmptyNotice'; +import { SearchContextProvider, SearchOptions, useSearch } from './search'; +import SearchBar from './search/SearchBar'; +import { TopBarPortal } from './TopBar/Portal'; + +export function Component() { + useRouteTitle('Labels'); + + const labels = useLibraryQuery(['labels.listWithThumbnails', '']); + + const explorerSettings = useExplorerSettings({ + settings: useMemo(() => { + return createDefaultExplorerSettings({ order: null }); + }, []), + orderingKeys: objectOrderingKeysSchema + }); + + // const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + + // const fixedFilters = useMemo( + // () => [ + // ...(explorerSettingsSnapshot.layoutMode === 'media' + // ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] + // : []) + // ], + // [explorerSettingsSnapshot.layoutMode] + // ); + + const search = useSearch({}); + + // const objects = useObjectsExplorerQuery({ + // arg: { + // take: 100, + // filters: [...search.allFilters, { object: { tags: { in: [3] } } }] + // }, + // explorerSettings + // }); + + const explorer = useExplorer({ + items: labels.data || null, + settings: explorerSettings, + showPathBar: false, + layouts: { media: false } + }); + + return ( + + + } + left={ +
+ Labels +
+ } + right={} + > + {search.open && ( + <> +
+ + + )} +
+
+ + } + message="No labels" + /> + } + /> +
+ ); +} diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx index cb96dff32..01cd44388 100644 --- a/interface/app/$libraryId/location/$id.tsx +++ b/interface/app/$libraryId/location/$id.tsx @@ -37,8 +37,8 @@ import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; import { useExplorerSearchParams } from '../Explorer/util'; import { EmptyNotice } from '../Explorer/View/EmptyNotice'; -import SearchOptions, { SearchContextProvider, useSearch } from '../Search'; -import SearchBar from '../Search/SearchBar'; +import { SearchContextProvider, SearchOptions, useSearch } from '../search'; +import SearchBar from '../search/SearchBar'; import { TopBarPortal } from '../TopBar/Portal'; import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions'; import LocationOptions from './LocationOptions'; diff --git a/interface/app/$libraryId/network.tsx b/interface/app/$libraryId/network.tsx index be64bf116..984c1c39d 100644 --- a/interface/app/$libraryId/network.tsx +++ b/interface/app/$libraryId/network.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useDebugState, useDiscoveredPeers, useFeatureFlag, useFeatureFlags } from '@sd/client'; +import { useDiscoveredPeers } from '@sd/client'; import { Icon } from '~/components'; import { useLocale } from '~/hooks'; import { useRouteTitle } from '~/hooks/useRouteTitle'; @@ -35,9 +35,9 @@ export const Component = () => { const explorer = useExplorer({ items: peers.map((peer) => ({ - type: 'SpacedropPeer', + type: 'SpacedropPeer' as const, has_local_thumbnail: false, - thumbnail_key: null, + thumbnail: null, item: { ...peer, pub_id: [] diff --git a/interface/app/$libraryId/overview/FileKindStats.tsx b/interface/app/$libraryId/overview/FileKindStats.tsx new file mode 100644 index 000000000..0e376abac --- /dev/null +++ b/interface/app/$libraryId/overview/FileKindStats.tsx @@ -0,0 +1,95 @@ +import clsx from 'clsx'; +import { motion } from 'framer-motion'; +import { useRef } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { formatNumber, SearchFilterArgs, useLibraryQuery } from '@sd/client'; +import { Icon } from '~/components'; + +export default () => { + const ref = useRef(null); + + const kinds = useLibraryQuery(['library.kindStatistics']); + + return ( + <> + {/* This is awful, will replace icons accordingly and memo etc */} + {kinds.data?.statistics + ?.sort((a, b) => b.count - a.count) + .filter((i) => i.kind !== 0) + .map(({ kind, name, count }) => { + let icon = name; + switch (name) { + case 'Code': + icon = 'Terminal'; + break; + case 'Unknown': + icon = 'Undefined'; + break; + } + return ( + + {}} + /> + + ); + })} + + ); +}; + +interface KindItemProps { + kind: number; + name: string; + items: number; + icon: string; + selected?: boolean; + onClick?: () => void; + disabled?: boolean; +} + +const KindItem = ({ kind, name, icon, items, selected, onClick, disabled }: KindItemProps) => { + return ( + +
+ +
+

{name}

+ {items !== undefined && ( +

+ {formatNumber(items)} Item{(items > 1 || items === 0) && 's'} +

+ )} +
+
+ + ); +}; diff --git a/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx b/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx new file mode 100644 index 000000000..1ff0a3092 --- /dev/null +++ b/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx @@ -0,0 +1,93 @@ +import { ArrowLeft, ArrowRight } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useDraggable } from 'react-use-draggable-scroll'; +import { tw } from '@sd/ui'; + +const ArrowButton = tw.div`absolute top-1/2 z-40 flex h-8 w-8 shrink-0 -translate-y-1/2 items-center p-2 cursor-pointer justify-center rounded-full border border-app-line bg-app/50 hover:opacity-95 backdrop-blur-md transition-all duration-200`; + +const HorizontalScroll = ({ children }: { children: ReactNode }) => { + const ref = useRef(null); + const { events } = useDraggable(ref as React.MutableRefObject); + const [lastItemVisible, setLastItemVisible] = useState(false); + const [scroll, setScroll] = useState(0); + // If the content is overflowing, we need to show the arrows + const [isContentOverflow, setIsContentOverflow] = useState(false); + + const updateScrollState = () => { + const element = ref.current; + if (element) { + setScroll(element.scrollLeft); + setLastItemVisible(element.scrollWidth - element.clientWidth === element.scrollLeft); + setIsContentOverflow(element.scrollWidth > element.clientWidth); + } + }; + + useEffect(() => { + const element = ref.current; + if (element) { + element.addEventListener('scroll', updateScrollState); + } + return () => { + if (element) { + element.removeEventListener('scroll', updateScrollState); + } + }; + }, [ref]); + + useLayoutEffect(() => { + updateScrollState(); + }, []); + + const handleArrowOnClick = (direction: 'right' | 'left') => { + const element = ref.current; + if (!element) return; + + const scrollAmount = element.clientWidth; + + element.scrollTo({ + left: + direction === 'left' + ? element.scrollLeft + scrollAmount + : element.scrollLeft - scrollAmount, + behavior: 'smooth' + }); + }; + + const maskImage = `linear-gradient(90deg, transparent 0.1%, rgba(0, 0, 0, 1) ${ + scroll > 0 ? '10%' : '0%' + }, rgba(0, 0, 0, 1) ${lastItemVisible ? '95%' : '85%'}, transparent 99%)`; + + return ( +
+ handleArrowOnClick('right')} + className={clsx('left-3', scroll === 0 && 'pointer-events-none opacity-0')} + > + + +
+ {children} +
+ + {isContentOverflow && ( + handleArrowOnClick('left')} + className={clsx('right-3', lastItemVisible && 'pointer-events-none opacity-0')} + > + + + )} +
+ ); +}; + +export default HorizontalScroll; diff --git a/interface/app/$libraryId/overview/Layout/Section.tsx b/interface/app/$libraryId/overview/Layout/Section.tsx new file mode 100644 index 000000000..aee43edf9 --- /dev/null +++ b/interface/app/$libraryId/overview/Layout/Section.tsx @@ -0,0 +1,46 @@ +import { CaretDown, CaretUp } from '@phosphor-icons/react'; +import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg'; +import clsx from 'clsx'; +import { Button } from '@sd/ui'; + +import HorizontalScroll from './HorizontalScroll'; + +const COUNT_STYLE = `min-w-[20px] flex h-[20px] px-1 items-center justify-center rounded-full border border-app-button/40 text-[9px]`; + +const BUTTON_STYLE = `!p-[5px] opacity-0 transition-opacity group-hover:opacity-100`; + +const OverviewSection = ({ + children, + title, + className, + count +}: React.HTMLAttributes & { title?: string; count?: number }) => { + return ( +
+ {title && ( +
+
{title}
+ {count &&
{count}
} +
+
+ {/* + */} + {/* */} +
+
+ )} + {/* {title &&
} */} + + {children} +
+
+ ); +}; + +export default OverviewSection; diff --git a/interface/app/$libraryId/overview/LibraryStats.tsx b/interface/app/$libraryId/overview/LibraryStats.tsx new file mode 100644 index 000000000..810efb029 --- /dev/null +++ b/interface/app/$libraryId/overview/LibraryStats.tsx @@ -0,0 +1,115 @@ +import { Info } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import { useEffect, useState } from 'react'; +import { byteSize, Statistics, useLibraryContext, useLibraryQuery } from '@sd/client'; +import { Tooltip } from '@sd/ui'; +import { useCounter } from '~/hooks'; + +interface StatItemProps { + title: string; + bytes: bigint; + isLoading: boolean; + info?: string; +} + +const StatItemNames: Partial> = { + total_bytes_capacity: 'Total capacity', + preview_media_bytes: 'Preview media', + library_db_size: 'Index size', + total_bytes_free: 'Free space', + total_bytes_used: 'Total used 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.', + total_bytes_used: 'Total space used on all nodes connected to the library.' +}; + +const displayableStatItems = Object.keys(StatItemNames) as unknown as keyof typeof StatItemNames; + +let mounted = false; + +const StatItem = (props: StatItemProps) => { + const { title, bytes, isLoading } = props; + + // This is important to the counter working. + // On first render of the counter this will potentially be `false` which means the counter should the count up. + // but in a `useEffect` `mounted` will be set to `true` so that subsequent renders of the counter will not run the count up. + // The acts as a cache of the value of `mounted` on the first render of this `StateItem`. + const [isMounted] = useState(mounted); + + const size = byteSize(bytes); + const count = useCounter({ + name: title, + end: size.value, + duration: isMounted ? 0 : 1, + saveState: false + }); + + return ( +
+ + {title} + {props.info && ( + + + + )} + + + +
+ {count} + {size.unit} +
+
+
+ ); +}; + +const LibraryStats = () => { + const { library } = useLibraryContext(); + + const stats = useLibraryQuery(['library.statistics']); + + useEffect(() => { + if (!stats.isLoading) mounted = true; + }); + + return ( +
+
+ {Object.entries(stats?.data?.statistics || []).map(([key, value]) => { + if (!displayableStatItems.includes(key)) return null; + return ( + + ); + })} +
+
+ ); +}; + +export default LibraryStats; diff --git a/interface/app/$libraryId/overview/LocationCard.tsx b/interface/app/$libraryId/overview/LocationCard.tsx new file mode 100644 index 000000000..aea89c630 --- /dev/null +++ b/interface/app/$libraryId/overview/LocationCard.tsx @@ -0,0 +1,48 @@ +import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg'; +import { useEffect, useMemo, useState } from 'react'; +import { byteSize } from '@sd/client'; +import { Button, Card, CircularProgress, tw } from '@sd/ui'; +import { Icon } from '~/components'; +import { useIsDark } from '~/hooks'; + +type LocationCardProps = { + name: string; + icon: string; + totalSpace: string | number[]; + color: string; + connectionType: 'lan' | 'p2p' | 'cloud' | null; +}; + +const Pill = tw.div`px-1.5 py-[1px] rounded text-tiny font-medium text-ink-dull bg-app-box border border-app-line`; + +const LocationCard = ({ icon, name, connectionType, ...stats }: LocationCardProps) => { + const { totalSpace } = useMemo(() => { + return { + totalSpace: byteSize(stats.totalSpace) + }; + }, [stats]); + + return ( + +
+
+ + {name} + + {totalSpace.value} + {totalSpace.unit} + +
+
+
+ {connectionType || 'Local'} +
+ +
+ + ); +}; + +export default LocationCard; diff --git a/interface/app/$libraryId/overview/NewCard.tsx b/interface/app/$libraryId/overview/NewCard.tsx new file mode 100644 index 000000000..45e73d6e5 --- /dev/null +++ b/interface/app/$libraryId/overview/NewCard.tsx @@ -0,0 +1,42 @@ +// import { X } from '@phosphor-icons/react'; +import { Button } from '@sd/ui'; +import { Icon, IconName } from '~/components'; + +interface NewCardProps { + icons: IconName[]; + text: string; + buttonText?: string; +} + +const maskImage = `linear-gradient(90deg, transparent 0.1%, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 35%, transparent 99%)`; + +const NewCard = ({ icons, text, buttonText }: NewCardProps) => { + return ( +
+
+
+ {icons.map((iconName, index) => ( +
+ +
+ ))} +
+ {/* */} +
+ {text} + +
+ ); +}; + +export default NewCard; diff --git a/interface/app/$libraryId/overview/StatCard.tsx b/interface/app/$libraryId/overview/StatCard.tsx new file mode 100644 index 000000000..08875a905 --- /dev/null +++ b/interface/app/$libraryId/overview/StatCard.tsx @@ -0,0 +1,94 @@ +import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg'; +import { useEffect, useMemo, useState } from 'react'; +import { byteSize } from '@sd/client'; +import { Button, Card, CircularProgress, tw } from '@sd/ui'; +import { Icon } from '~/components'; +import { useIsDark } from '~/hooks'; + +type StatCardProps = { + name: string; + icon: string; + totalSpace: string | number[]; + freeSpace?: string | number[]; + color: string; + connectionType: 'lan' | 'p2p' | 'cloud' | null; +}; + +const Pill = tw.div`px-1.5 py-[1px] rounded text-tiny font-medium text-ink-dull bg-app-box border border-app-line`; + +const StatCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => { + const [mounted, setMounted] = useState(false); + + const isDark = useIsDark(); + + const { totalSpace, freeSpace, remainingSpace } = useMemo(() => { + return { + totalSpace: byteSize(stats.totalSpace), + freeSpace: byteSize(stats.freeSpace), + remainingSpace: byteSize( + stats.freeSpace + ? Number(stats.totalSpace) - Number(stats.freeSpace) + : stats.totalSpace + ) + }; + }, [stats]); + + useEffect(() => { + setMounted(true); + }, []); + + const progress = useMemo(() => { + if (!mounted) return 0; + return Math.floor( + ((Number(totalSpace.original) - Number(freeSpace.original)) / + Number(totalSpace.original)) * + 100 + ); + }, [totalSpace, freeSpace, mounted]); + + return ( + +
+ {!!stats.freeSpace && ( + 90 ? '#E14444' : '#2599FF'} + fillColor="transparent" + trackStrokeColor={isDark ? '#252631' : '#efefef'} + strokeLinecap="square" + className="flex items-center justify-center" + transition="stroke-dashoffset 1s ease 0s, stroke 1s ease" + > +
+ {remainingSpace.value} + + {remainingSpace.unit} + +
+
+ )} +
+ + {name} + + {freeSpace.value} + {freeSpace.unit} free of {totalSpace.value} + {totalSpace.unit} + +
+
+
+ {connectionType || 'Local'} +
+ {/* */} +
+ + ); +}; + +export default StatCard; diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx new file mode 100644 index 000000000..f74c36481 --- /dev/null +++ b/interface/app/$libraryId/overview/index.tsx @@ -0,0 +1,216 @@ +import { ArrowClockwise, Broadcast, Key, Laptop, SlidersHorizontal } from '@phosphor-icons/react'; +import { DriveAmazonS3, DriveDropbox, Mobile, Server, Tablet } from '@sd/assets/icons'; +import { + useBridgeQuery, + useCache, + useLibraryQuery, + useNodes, + useOnlineLocations +} from '@sd/client'; +import { useRouteTitle } from '~/hooks/useRouteTitle'; +import { hardwareModelToIcon } from '~/util/hardware'; + +import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; +import { SearchContextProvider, useSearch } from '../search'; +import SearchBar from '../search/SearchBar'; +import { TopBarPortal } from '../TopBar/Portal'; +import TopBarOptions, { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions'; +import FileKindStatistics from './FileKindStats'; +import OverviewSection from './Layout/Section'; +import LibraryStatistics from './LibraryStats'; +import NewCard from './NewCard'; +import StatisticItem from './StatCard'; + +export const Component = () => { + useRouteTitle('Overview'); + + const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + useNodes(locationsQuery.data?.nodes); + const locations = useCache(locationsQuery.data?.items) ?? []; + const onlineLocations = useOnlineLocations(); + + const { data: node } = useBridgeQuery(['nodeState']); + + const search = useSearch({ + open: true + }); + + const stats = useLibraryQuery(['library.statistics']); + + return ( + +
+ + Library Overview +
+ } + center={} + // right={ + // {}, + // icon: , + // individual: true, + // showAtResolution: 'sm:flex' + // }, + // { + // toolTipLabel: 'Key Manager', + // onClick: () => {}, + // icon: , + // individual: true, + // showAtResolution: 'sm:flex' + // }, + // { + // toolTipLabel: 'Overview Display Settings', + // onClick: () => {}, + // icon: , + // individual: true, + // showAtResolution: 'sm:flex' + // } + // ] + // ]} + // /> + // } + /> +
+ + + + + + + + {node && ( + + )} + {/* + + + + */} + + {/**/} + + + + {locations?.map((item) => ( + + ))} + {!locations?.length && ( + + )} + + + + {/* + */} + + + + + {/* +
+ {locations.map((location) => ( +
+ + + {location.name} + +
+ ))} +
+
*/} +
+
+ + ); +}; diff --git a/interface/app/$libraryId/recents.tsx b/interface/app/$libraryId/recents.tsx new file mode 100644 index 000000000..758f3f75b --- /dev/null +++ b/interface/app/$libraryId/recents.tsx @@ -0,0 +1,92 @@ +import { useMemo } from 'react'; +import { ObjectFilterArgs, ObjectKindEnum, ObjectOrder, SearchFilterArgs } from '@sd/client'; +import { Icon } from '~/components'; +import { useRouteTitle } from '~/hooks'; + +import Explorer from './Explorer'; +import { ExplorerContextProvider } from './Explorer/Context'; +import { useObjectsExplorerQuery } from './Explorer/queries/useObjectsExplorerQuery'; +import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explorer/store'; +import { DefaultTopBarOptions } from './Explorer/TopBarOptions'; +import { useExplorer, useExplorerSettings } from './Explorer/useExplorer'; +import { EmptyNotice } from './Explorer/View/EmptyNotice'; +import { SearchContextProvider, SearchOptions, useSearch } from './search'; +import SearchBar from './search/SearchBar'; +import { TopBarPortal } from './TopBar/Portal'; + +export function Component() { + useRouteTitle('Recents'); + + const explorerSettings = useExplorerSettings({ + settings: useMemo(() => { + return createDefaultExplorerSettings({ order: null }); + }, []), + orderingKeys: objectOrderingKeysSchema + }); + + const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + + const fixedFilters = useMemo( + () => [ + // { object: { dateAccessed: { from: new Date(0).toISOString() } } }, + ...(explorerSettingsSnapshot.layoutMode === 'media' + ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] + : []) + ], + [explorerSettingsSnapshot.layoutMode] + ); + + const search = useSearch({ + fixedFilters + }); + + const objects = useObjectsExplorerQuery({ + arg: { + take: 100, + filters: [ + ...search.allFilters, + // TODO: Add filter to search options + { object: { dateAccessed: { from: new Date(0).toISOString() } } } + ] + }, + explorerSettings + }); + + const explorer = useExplorer({ + ...objects, + isFetchingNextPage: objects.query.isFetchingNextPage, + settings: explorerSettings + }); + + return ( + + + } + left={ +
+ Recents +
+ } + right={} + > + {search.open && ( + <> +
+ + + )} +
+
+ + } + message="Recents are created when you open a file." + /> + } + /> +
+ ); +} diff --git a/interface/app/$libraryId/saved-search/$id.tsx b/interface/app/$libraryId/saved-search/$id.tsx index 74c12d7ab..412b08137 100644 --- a/interface/app/$libraryId/saved-search/$id.tsx +++ b/interface/app/$libraryId/saved-search/$id.tsx @@ -19,8 +19,8 @@ import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Ex import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { EmptyNotice } from '../Explorer/View/EmptyNotice'; -import SearchOptions, { SearchContextProvider, useSearch, useSearchContext } from '../Search'; -import SearchBar from '../Search/SearchBar'; +import { SearchContextProvider, SearchOptions, useSearch, useSearchContext } from '../search'; +import SearchBar from '../search/SearchBar'; import { TopBarPortal } from '../TopBar/Portal'; export const Component = () => { diff --git a/interface/app/$libraryId/Search/AppliedFilters.tsx b/interface/app/$libraryId/search/AppliedFilters.tsx similarity index 100% rename from interface/app/$libraryId/Search/AppliedFilters.tsx rename to interface/app/$libraryId/search/AppliedFilters.tsx diff --git a/interface/app/$libraryId/Search/Filters.tsx b/interface/app/$libraryId/search/Filters.tsx similarity index 94% rename from interface/app/$libraryId/Search/Filters.tsx rename to interface/app/$libraryId/search/Filters.tsx index af107f13f..96dbead99 100644 --- a/interface/app/$libraryId/Search/Filters.tsx +++ b/interface/app/$libraryId/search/Filters.tsx @@ -1,4 +1,12 @@ -import { CircleDashed, Cube, Folder, Icon, SelectionSlash, Textbox } from '@phosphor-icons/react'; +import { + CircleDashed, + Cube, + Folder, + Icon, + SelectionSlash, + Tag, + Textbox +} from '@phosphor-icons/react'; import { useState } from 'react'; import { InOrNotIn, @@ -574,6 +582,39 @@ export const filterRegistry = [ ]; }, Render: ({ filter, search }) => + }), + createInOrNotInFilter({ + name: 'Label', + icon: Tag, + extract: (arg) => { + if ('object' in arg && 'labels' in arg.object) return arg.object.labels; + }, + create: (labels) => ({ object: { labels } }), + argsToOptions(values, options) { + return values + .map((value) => { + const option = options.get(this.name)?.find((o) => o.value === value); + + if (!option) return; + + return { + ...option, + type: this.name + }; + }) + .filter(Boolean) as any; + }, + useOptions: () => { + const query = useLibraryQuery(['labels.list'], { keepPreviousData: true }); + + return (query.data ?? []).map((tag) => ({ + name: tag.name!, + value: tag.id + })); + }, + Render: ({ filter, options, search }) => ( + + ) }) // idk how to handle this rn since include_descendants is part of 'path' now // diff --git a/interface/app/$libraryId/Search/SearchBar.tsx b/interface/app/$libraryId/search/SearchBar.tsx similarity index 96% rename from interface/app/$libraryId/Search/SearchBar.tsx rename to interface/app/$libraryId/search/SearchBar.tsx index 63d07846e..c4f7bae57 100644 --- a/interface/app/$libraryId/Search/SearchBar.tsx +++ b/interface/app/$libraryId/search/SearchBar.tsx @@ -4,8 +4,8 @@ import { Input, ModifierKeys, Shortcut } from '@sd/ui'; import { useOperatingSystem } from '~/hooks'; import { keybindForOs } from '~/util/keybinds'; -import { useSearchContext } from '../Search'; -import { useSearchStore } from '../Search/store'; +import { useSearchContext } from './context'; +import { useSearchStore } from './store'; export default () => { const search = useSearchContext(); diff --git a/interface/app/$libraryId/Search/index.tsx b/interface/app/$libraryId/search/SearchOptions.tsx similarity index 98% rename from interface/app/$libraryId/Search/index.tsx rename to interface/app/$libraryId/search/SearchOptions.tsx index a644e7d68..07278c872 100644 --- a/interface/app/$libraryId/Search/index.tsx +++ b/interface/app/$libraryId/search/SearchOptions.tsx @@ -87,7 +87,10 @@ export const SearchOptionSubMenu = ( export const Separator = () => ; -const SearchOptions = ({ allowExit, children }: { allowExit?: boolean } & PropsWithChildren) => { +export const SearchOptions = ({ + allowExit, + children +}: { allowExit?: boolean } & PropsWithChildren) => { const search = useSearchContext(); const isDark = useIsDark(); return ( @@ -132,8 +135,6 @@ const SearchOptions = ({ allowExit, children }: { allowExit?: boolean } & PropsW ); }; -export default SearchOptions; - const SearchResults = memo( ({ searchQuery, search }: { searchQuery: string; search: UseSearch }) => { const { allFiltersKeys } = search; diff --git a/interface/app/$libraryId/Search/context.tsx b/interface/app/$libraryId/search/context.tsx similarity index 84% rename from interface/app/$libraryId/Search/context.tsx rename to interface/app/$libraryId/search/context.tsx index 424eded44..0184dccf4 100644 --- a/interface/app/$libraryId/Search/context.tsx +++ b/interface/app/$libraryId/search/context.tsx @@ -1,7 +1,5 @@ import { createContext, PropsWithChildren, useContext } from 'react'; -import { filterRegistry } from './Filters'; -import { useRegisterSearchFilterOptions } from './store'; import { UseSearch } from './useSearch'; const SearchContext = createContext(null); diff --git a/interface/app/$libraryId/search/index.tsx b/interface/app/$libraryId/search/index.tsx new file mode 100644 index 000000000..6139ad0a3 --- /dev/null +++ b/interface/app/$libraryId/search/index.tsx @@ -0,0 +1,134 @@ +import { useEffect, useMemo } from 'react'; +import { useSearchParams as useRawSearchParams } from 'react-router-dom'; +import { ObjectKindEnum, ObjectOrder } from '@sd/client'; +import { Icon } from '~/components'; +import { useRouteTitle } from '~/hooks'; + +import { SearchContextProvider, SearchOptions, useSearch } from '.'; +import Explorer from '../Explorer'; +import { ExplorerContextProvider } from '../Explorer/Context'; +import { useObjectsExplorerQuery } from '../Explorer/queries/useObjectsExplorerQuery'; +import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store'; +import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; +import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; +import { EmptyNotice } from '../Explorer/View/EmptyNotice'; +import { TopBarPortal } from '../TopBar/Portal'; +import SearchBar from './SearchBar'; + +export * from './context'; +export * from './SearchOptions'; +export * from './useSearch'; + +export function Component() { + useRouteTitle('Search'); + + const explorerSettings = useExplorerSettings({ + settings: useMemo(() => { + return createDefaultExplorerSettings({ order: null }); + }, []), + orderingKeys: objectOrderingKeysSchema + }); + + const search = useSearchWithFilters(explorerSettings); + + const objects = useObjectsExplorerQuery({ + arg: { + take: 100, + filters: search.allFilters + }, + explorerSettings + }); + + const explorer = useExplorer({ + ...objects, + isFetchingNextPage: objects.query.isFetchingNextPage, + settings: explorerSettings + }); + + return ( + + + } + left={ +
+ Search +
+ } + right={} + > + {search.open && ( + <> +
+ + + )} +
+
+ + } + message="No recent items" + /> + } + /> +
+ ); +} + +function useSearchWithFilters(explorerSettings: UseExplorerSettings) { + const [searchParams, setSearchParams] = useRawSearchParams(); + const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot(); + + const fixedFilters = useMemo( + () => [ + ...(explorerSettingsSnapshot.layoutMode === 'media' + ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] + : []) + ], + [explorerSettingsSnapshot.layoutMode] + ); + + const filtersParam = searchParams.get('filters'); + const dynamicFilters = useMemo(() => JSON.parse(filtersParam ?? '[]'), [filtersParam]); + + const searchQueryParam = searchParams.get('search'); + + const search = useSearch({ + open: !!searchQueryParam || dynamicFilters.length > 0 || undefined, + search: searchParams.get('search') ?? undefined, + fixedFilters, + dynamicFilters + }); + + useEffect(() => { + setSearchParams( + (p) => { + if (search.dynamicFilters.length > 0) + p.set('filters', JSON.stringify(search.dynamicFilters)); + else p.delete('filters'); + + return p; + }, + { replace: true } + ); + }, [search.dynamicFilters, setSearchParams]); + + const searchQuery = search.search; + + useEffect(() => { + setSearchParams( + (p) => { + if (searchQuery !== '') p.set('search', searchQuery); + else p.delete('search'); + + return p; + }, + { replace: true } + ); + }, [searchQuery, setSearchParams]); + + return search; +} diff --git a/interface/app/$libraryId/Search/store.tsx b/interface/app/$libraryId/search/store.tsx similarity index 100% rename from interface/app/$libraryId/Search/store.tsx rename to interface/app/$libraryId/search/store.tsx diff --git a/interface/app/$libraryId/Search/useSearch.ts b/interface/app/$libraryId/search/useSearch.ts similarity index 100% rename from interface/app/$libraryId/Search/useSearch.ts rename to interface/app/$libraryId/search/useSearch.ts diff --git a/interface/app/$libraryId/Search/util.tsx b/interface/app/$libraryId/search/util.tsx similarity index 100% rename from interface/app/$libraryId/Search/util.tsx rename to interface/app/$libraryId/search/util.tsx diff --git a/interface/app/$libraryId/settings/Sidebar.tsx b/interface/app/$libraryId/settings/Sidebar.tsx index 90a5ee66c..69b455d0b 100644 --- a/interface/app/$libraryId/settings/Sidebar.tsx +++ b/interface/app/$libraryId/settings/Sidebar.tsx @@ -20,8 +20,8 @@ import { tw } from '@sd/ui'; import { useLocale, useOperatingSystem } from '~/hooks'; import { usePlatform } from '~/util/Platform'; -import Icon from '../Layout/Sidebar/Icon'; -import SidebarLink from '../Layout/Sidebar/Link'; +import Icon from '../Layout/Sidebar/SidebarLayout/Icon'; +import SidebarLink from '../Layout/Sidebar/SidebarLayout/Link'; import { NavigationButtons } from '../TopBar/NavigationButtons'; const Heading = tw.div`mb-1 ml-1 text-xs font-semibold text-gray-400`; @@ -41,7 +41,7 @@ export default () => { {platform === 'tauri' ? (
@@ -56,10 +56,10 @@ export default () => { {t('general')} - + {/* {t('usage')} - + */} {t('account')} diff --git a/interface/app/$libraryId/settings/client/account.tsx b/interface/app/$libraryId/settings/client/account.tsx index 500870dec..0deddaeb6 100644 --- a/interface/app/$libraryId/settings/client/account.tsx +++ b/interface/app/$libraryId/settings/client/account.tsx @@ -27,12 +27,11 @@ export const Component = () => { )} } - title={t('your_account')} - description={t('your_account_description')} + title="Spacedrive Cloud" + description="Spacedrive is always local first, but we will offer our own optional cloud services in the future. For now, authentication is only used for the Feedback feature, otherwise it is not required." />
-
{useFeatureFlag('hostedLocations') && } @@ -67,41 +66,6 @@ const Profile = ({ email, authStore }: { email?: string; authStore: { status: st ); }; -const services: { service: string; icon: keyof typeof iconNames }[] = [ - { service: 'S3', icon: 'AmazonS3' }, - { service: 'Dropbox', icon: 'Dropbox' }, - { service: 'DAV', icon: 'DAV' }, - { service: 'Mega', icon: 'Mega' }, - { service: 'Onedrive', icon: 'OneDrive' }, - { service: 'Google Drive', icon: 'GoogleDrive' } -]; -const Cloud = () => { - return ( - -

Cloud services

-
- {services.map((s, index) => ( - -
-

- Coming soon -

-
- -

{s.service}

-
- ))} -
-
- ); -}; - function HostedLocationsPlayground() { const locations = useBridgeQuery(['cloud.locations.list'], { retry: false }); diff --git a/interface/app/$libraryId/settings/client/usage.tsx b/interface/app/$libraryId/settings/client/usage.tsx index e8b27eb65..d54155837 100644 --- a/interface/app/$libraryId/settings/client/usage.tsx +++ b/interface/app/$libraryId/settings/client/usage.tsx @@ -22,10 +22,11 @@ export const Component = () => { const discoveredPeers = useDiscoveredPeers(); const info = useMemo(() => { if (locations.data && discoveredPeers) { - const tb_capacity = byteSize(stats.data?.total_bytes_capacity); - const free_space = byteSize(stats.data?.total_bytes_free); - const library_db_size = byteSize(stats.data?.library_db_size); - const preview_media = byteSize(stats.data?.preview_media_bytes); + const statistics = stats.data?.statistics; + const tb_capacity = byteSize(statistics?.total_bytes_capacity); + const free_space = byteSize(statistics?.total_bytes_free); + const library_db_size = byteSize(statistics?.library_db_size); + const preview_media = byteSize(statistics?.preview_media_bytes); const data: { icon: keyof typeof iconNames; title?: string; diff --git a/interface/app/$libraryId/settings/library/saved-searches/index.tsx b/interface/app/$libraryId/settings/library/saved-searches/index.tsx index 365f42a6d..34759770a 100644 --- a/interface/app/$libraryId/settings/library/saved-searches/index.tsx +++ b/interface/app/$libraryId/settings/library/saved-searches/index.tsx @@ -9,8 +9,8 @@ import { useZodForm } from '@sd/client'; import { Button, Card, Form, InputField, Label, Tooltip, z } from '@sd/ui'; -import { SearchContextProvider, useSearch } from '~/app/$libraryId/Search'; -import { AppliedFilters } from '~/app/$libraryId/Search/AppliedFilters'; +import { SearchContextProvider, useSearch } from '~/app/$libraryId/search'; +import { AppliedFilters } from '~/app/$libraryId/search/AppliedFilters'; import { Heading } from '~/app/$libraryId/settings/Layout'; import { useDebouncedFormWatch, useLocale } from '~/hooks'; diff --git a/interface/app/$libraryId/tag/$id.tsx b/interface/app/$libraryId/tag/$id.tsx index 9d5580123..e0108ac7b 100644 --- a/interface/app/$libraryId/tag/$id.tsx +++ b/interface/app/$libraryId/tag/$id.tsx @@ -11,8 +11,8 @@ import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Expl import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { EmptyNotice } from '../Explorer/View/EmptyNotice'; -import SearchOptions, { SearchContextProvider, useSearch } from '../Search'; -import SearchBar from '../Search/SearchBar'; +import { SearchContextProvider, SearchOptions, useSearch } from '../search'; +import SearchBar from '../search/SearchBar'; import { TopBarPortal } from '../TopBar/Portal'; export function Component() { diff --git a/interface/app/$libraryId/tag/Component.1.tsx b/interface/app/$libraryId/tag/Component.1.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/interface/app/$libraryId/tag/Component.tsx b/interface/app/$libraryId/tag/Component.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/interface/app/style.scss b/interface/app/style.scss index a926842f9..4426d04b7 100644 --- a/interface/app/style.scss +++ b/interface/app/style.scss @@ -383,6 +383,10 @@ body { transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1); } +.icon-with-shadow { + filter: url(#svg-shadow-filter); +} + @keyframes wiggle { 0%, 100% { transform: rotate(-1deg); } 50% { transform: rotate(1deg); } diff --git a/interface/components/Icon.tsx b/interface/components/Icon.tsx index 838d3a843..d0963de0c 100644 --- a/interface/components/Icon.tsx +++ b/interface/components/Icon.tsx @@ -1,4 +1,5 @@ import { getIcon, iconNames } from '@sd/assets/util'; +import clsx from 'clsx'; import { ImgHTMLAttributes } from 'react'; import { useIsDark } from '~/hooks'; @@ -18,6 +19,7 @@ export const Icon = ({ name, size, theme, ...props }: Props) => { width={size} height={size} {...props} + className={clsx('pointer-events-none', props.className)} /> ); }; diff --git a/interface/util/hardware.ts b/interface/util/hardware.ts new file mode 100644 index 000000000..3d06e3cb2 --- /dev/null +++ b/interface/util/hardware.ts @@ -0,0 +1,14 @@ +import * as Icons from '@sd/assets/icons'; +import { getIcon } from '@sd/assets/util'; +import { HardwareModel } from '@sd/client'; + +export function hardwareModelToIcon(hardwareModel: HardwareModel) { + switch (hardwareModel) { + case 'MacBookPro': + return 'Laptop'; + case 'MacStudio': + return 'SilverBox'; + default: + return 'Laptop'; + } +} diff --git a/packages/assets/icons/CollectionSparkle.png b/packages/assets/icons/CollectionSparkle.png new file mode 100644 index 000000000..3b426a6cd Binary files /dev/null and b/packages/assets/icons/CollectionSparkle.png differ diff --git a/packages/assets/icons/Document_xmp.png b/packages/assets/icons/Document_xmp.png new file mode 100644 index 000000000..e4741706b Binary files /dev/null and b/packages/assets/icons/Document_xmp.png differ diff --git a/packages/assets/icons/Folder-tag-xmp.png b/packages/assets/icons/Folder-tag-xmp.png new file mode 100644 index 000000000..a069be06d Binary files /dev/null and b/packages/assets/icons/Folder-tag-xmp.png differ diff --git a/packages/assets/icons/Location.png b/packages/assets/icons/Location.png new file mode 100644 index 000000000..ec61a0d98 Binary files /dev/null and b/packages/assets/icons/Location.png differ diff --git a/packages/assets/icons/LocationManaged.png b/packages/assets/icons/LocationManaged.png new file mode 100644 index 000000000..88000e36b Binary files /dev/null and b/packages/assets/icons/LocationManaged.png differ diff --git a/packages/assets/icons/LocationReplica.png b/packages/assets/icons/LocationReplica.png new file mode 100644 index 000000000..ac6651762 Binary files /dev/null and b/packages/assets/icons/LocationReplica.png differ diff --git a/packages/assets/icons/Search.png b/packages/assets/icons/Search.png new file mode 100644 index 000000000..4cccd8dab Binary files /dev/null and b/packages/assets/icons/Search.png differ diff --git a/packages/assets/icons/SearchAlt.png b/packages/assets/icons/SearchAlt.png new file mode 100644 index 000000000..2a5de7f17 Binary files /dev/null and b/packages/assets/icons/SearchAlt.png differ diff --git a/packages/assets/icons/SilverBox.png b/packages/assets/icons/SilverBox.png new file mode 100644 index 000000000..4a34d7d7a Binary files /dev/null and b/packages/assets/icons/SilverBox.png differ diff --git a/packages/assets/icons/index.ts b/packages/assets/icons/index.ts index 47226dcc6..2331a3668 100644 --- a/packages/assets/icons/index.ts +++ b/packages/assets/icons/index.ts @@ -29,6 +29,7 @@ import Code20 from './Code-20.png'; import Collection_Light from './Collection_Light.png'; import Collection20 from './Collection-20.png'; import Collection from './Collection.png'; +import CollectionSparkle from './CollectionSparkle.png'; import Config20 from './Config-20.png'; import Database_Light from './Database_Light.png'; import Database20 from './Database-20.png'; @@ -42,6 +43,7 @@ import Document_pdf_Light from './Document_pdf_Light.png'; import Document_pdf from './Document_pdf.png'; import Document_xls_Light from './Document_xls_Light.png'; import Document_xls from './Document_xls.png'; +import Document_xmp from './Document_xmp.png'; import Document20 from './Document-20.png'; import Document from './Document.png'; import Dotfile20 from './Dotfile-20.png'; @@ -82,6 +84,7 @@ import Executable from './Executable.png'; import Face_Light from './Face_Light.png'; import Folder_Light from './Folder_Light.png'; import Folder20 from './Folder-20.png'; +import Foldertagxmp from './Folder-tag-xmp.png'; import Folder from './Folder.png'; import FolderGrey_Light from './FolderGrey_Light.png'; import FolderGrey from './FolderGrey.png'; @@ -113,6 +116,9 @@ import Laptop from './Laptop.png'; import Link_Light from './Link_Light.png'; import Link20 from './Link-20.png'; import Link from './Link.png'; +import Location from './Location.png'; +import LocationManaged from './LocationManaged.png'; +import LocationReplica from './LocationReplica.png'; import Lock_Light from './Lock_Light.png'; import Lock from './Lock.png'; import Mega from './Mega.png'; @@ -142,8 +148,11 @@ import Screenshot from './Screenshot.png'; import ScreenshotAlt from './ScreenshotAlt.png'; import SD_Light from './SD_Light.png'; import SD from './SD.png'; +import Search from './Search.png'; +import SearchAlt from './SearchAlt.png'; import Server_Light from './Server_Light.png'; import Server from './Server.png'; +import SilverBox from './SilverBox.png'; import Spacedrop_Light from './Spacedrop_Light.png'; import Spacedrop1 from './Spacedrop-1.png'; import Spacedrop from './Spacedrop.png'; @@ -200,6 +209,7 @@ export { Code20, Collection20, Collection, + CollectionSparkle, Collection_Light, Config20, DAV, @@ -216,6 +226,7 @@ export { Document_pdf_Light, Document_xls, Document_xls_Light, + Document_xmp, Dotfile20, DriveAmazonS3, DriveAmazonS3_Light, @@ -253,6 +264,7 @@ export { Executable_old, Face_Light, Folder20, + Foldertagxmp, Folder, FolderGrey, FolderGrey_Light, @@ -285,6 +297,9 @@ export { Link20, Link, Link_Light, + Location, + LocationManaged, + LocationReplica, Lock, Lock_Light, Mega, @@ -314,8 +329,11 @@ export { Screenshot, ScreenshotAlt, Screenshot_Light, + Search, + SearchAlt, Server, Server_Light, + SilverBox, Spacedrop1, Spacedrop, Spacedrop_Light, diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 06711775d..73593ea34 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -18,12 +18,15 @@ export type Procedures = { { key: "invalidation.test-invalidate", input: never, result: number } | { key: "jobs.isActive", input: LibraryArgs, result: boolean } | { key: "jobs.reports", input: LibraryArgs, result: JobGroup[] } | + { key: "labels.count", input: LibraryArgs, result: number } | { key: "labels.get", input: LibraryArgs, result: { id: number; pub_id: number[]; name: string; date_created: string; date_modified: string } | null } | { key: "labels.getForObject", input: LibraryArgs, result: Label[] } | { key: "labels.getWithObjects", input: LibraryArgs, result: { [key in number]: { date_created: string; object: { id: number } }[] } } | { key: "labels.list", input: LibraryArgs, result: Label[] } | + { key: "labels.listWithThumbnails", input: LibraryArgs, result: ExplorerItem[] } | + { key: "library.kindStatistics", input: LibraryArgs, result: KindStatistics } | { key: "library.list", input: never, result: NormalisedResults } | - { key: "library.statistics", input: LibraryArgs, result: Statistics } | + { key: "library.statistics", input: LibraryArgs, result: StatisticsResponse } | { key: "locations.get", input: LibraryArgs, result: { item: Reference; nodes: CacheNode[] } | null } | { key: "locations.getWithRules", input: LibraryArgs, result: { item: Reference; nodes: CacheNode[] } | null } | { key: "locations.indexer_rules.get", input: LibraryArgs, result: NormalisedResult } | @@ -227,7 +230,7 @@ export type Error = { code: ErrorCode; message: string } */ export type ErrorCode = "BadRequest" | "Unauthorized" | "Forbidden" | "NotFound" | "Timeout" | "Conflict" | "PreconditionFailed" | "PayloadTooLarge" | "MethodNotSupported" | "ClientClosedRequest" | "InternalServerError" -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 } | { type: "NonIndexedPath"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: NonIndexedPathItem } | { type: "SpacedropPeer"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: PeerMetadata } +export type ExplorerItem = { type: "Path"; thumbnail: string[] | null; item: FilePathWithObject } | { type: "Object"; thumbnail: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; item: Location } | { type: "NonIndexedPath"; thumbnail: string[] | null; item: NonIndexedPathItem } | { type: "SpacedropPeer"; item: PeerMetadata } | { type: "Label"; thumbnails: string[][]; item: LabelWithObjects } export type ExplorerLayout = "grid" | "list" | "media" @@ -314,6 +317,8 @@ export type GenerateThumbsForLocationArgs = { id: number; path: string; regenera export type GetAll = { backups: Backup[]; directory: string } +export type HardwareModel = "Other" | "MacStudio" | "MacBookAir" | "MacBookPro" | "MacBook" | "MacMini" | "MacPro" | "IMac" | "IMacPro" | "IPad" | "IPhone" + export type IdentifyUniqueFilesArgs = { id: number; path: string } export type ImageMetadata = { resolution: Resolution; date_taken: MediaDate | null; location: MediaLocation | null; camera_data: CameraData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } @@ -340,14 +345,20 @@ export type JobGroup = { id: string; action: string | null; status: JobStatus; c export type JobProgressEvent = { id: string; library_id: string; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string } -export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: { [key in string]: JsonValue } | null; is_background: boolean; errors_text: string[]; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string } +export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: { [key in string]: JsonValue } | null; errors_text: string[]; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string } export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused" | "CompletedWithErrors" export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue } +export type KindStatistic = { kind: number; name: string; count: number; total_bytes: string } + +export type KindStatistics = { statistics: KindStatistic[] } + export type Label = { id: number; pub_id: number[]; name: string; date_created: string; date_modified: string } +export type LabelWithObjects = { id: number; pub_id: number[]; name: string; date_created: string; date_modified: string; label_objects: { object: { id: number; file_paths: FilePath[] } }[] } + /** * Can wrap a query argument to require it to contain a `library_id` and provide helpers for working with libraries. */ @@ -429,7 +440,7 @@ id: string; /** * name is the display name of the current node. This is set by the user and is shown in the UI. // TODO: Length validation so it can fit in DNS record */ -name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences; image_labeler_version: string | null }) & { data_path: string; p2p: P2PStatus } +name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences; image_labeler_version: string | null }) & { data_path: string; p2p: P2PStatus; device_model: string | null } export type NonIndexedPathItem = { path: string; name: string; extension: string; kind: number; is_dir: boolean; date_created: string; date_modified: string; size_in_bytes_bytes: number[]; hidden: boolean } @@ -466,7 +477,7 @@ export type Object = { id: number; pub_id: number[]; kind: number | null; key_id export type ObjectCursor = "none" | { dateAccessed: CursorOrderItem } | { kind: CursorOrderItem } -export type ObjectFilterArgs = { favorite: boolean } | { hidden: ObjectHiddenFilter } | { kind: InOrNotIn } | { tags: InOrNotIn } | { dateAccessed: Range } +export type ObjectFilterArgs = { favorite: boolean } | { hidden: ObjectHiddenFilter } | { kind: InOrNotIn } | { tags: InOrNotIn } | { labels: InOrNotIn } | { dateAccessed: Range } export type ObjectHiddenFilter = "exclude" | "include" @@ -501,7 +512,7 @@ export type PairingDecision = { decision: "accept"; libraryId: string } | { deci export type PairingStatus = { type: "EstablishingConnection" } | { type: "PairingRequested" } | { type: "LibraryAlreadyExists" } | { type: "PairingDecisionRequest" } | { type: "PairingInProgress"; data: { library_name: string; library_description: string | null } } | { type: "InitialSyncProgress"; data: number } | { type: "PairingComplete"; data: string } | { type: "PairingRejected" } -export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; version: string | null } +export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; device_model: HardwareModel | null; version: string | null } export type PlusCode = string @@ -557,6 +568,8 @@ export type SpacedropArgs = { identity: RemoteIdentity; file_path: string[] } export type Statistics = { id: number; date_captured: string; total_object_count: number; library_db_size: string; total_bytes_used: string; total_bytes_capacity: string; total_unique_bytes: string; total_bytes_free: string; preview_media_bytes: string } +export type StatisticsResponse = { statistics: Statistics | null } + export type SystemLocations = { desktop: string | null; documents: string | null; downloads: string | null; pictures: string | null; music: string | null; videos: string | null } export type Tag = { id: number; pub_id: number[]; name: string | null; color: string | null; is_hidden: boolean | null; date_created: string | null; date_modified: string | null } diff --git a/packages/client/src/lib/explorerItem.ts b/packages/client/src/lib/explorerItem.ts new file mode 100644 index 000000000..4d7064bef --- /dev/null +++ b/packages/client/src/lib/explorerItem.ts @@ -0,0 +1,130 @@ +import { byteSize } from '.'; +import { getItemFilePath, getItemLocation, getItemObject, type ObjectKindKey } from '..'; +import type { ExplorerItem } from '../core'; +import { ObjectKind } from './objectKind'; + +// ItemData is a single data structure understood by the Explorer, we map all ExplorerItems to this structure in this file +// we use `null` instead of `?` optional values intentionally +export interface ItemData { + name: string | null; + fullName: string | null; + size: ReturnType; + kind: ObjectKindKey; + isDir: boolean; + casId: string | null; + extension: string | null; + locationId: number | null; + dateIndexed: string | null; + dateCreated: string | null; + dateModified: string | null; + dateAccessed: string | null; + thumbnailKey: string[]; // default behavior is to render a single thumbnail + thumbnailKeys?: string[][]; // if set, we can render multiple thumbnails + hasLocalThumbnail: boolean; // this is overwritten when new thumbnails are generated + customIcon: string | null; +} + +// this function maps an ExplorerItem to an ItemData +export function getExplorerItemData(data?: ExplorerItem | null): ItemData { + const itemData = getDefaultItemData(); + if (!data) return itemData; + + // a typesafe switch statement for all the different types of ExplorerItems + switch (data.type) { + // the getItemObject and getItemFilePath type-guards mean we can handle the following types in one case + case 'Object': + case 'NonIndexedPath': + case 'Path': { + // handle object + const object = getItemObject(data); + + if (object?.kind) itemData.kind = ObjectKind[object?.kind] ?? 'Unknown'; + // Objects only have dateCreated and dateAccessed + itemData.dateCreated = object?.date_created ?? null; + itemData.dateAccessed = object?.date_accessed ?? null; + // handle thumbnail based on provided key + // This could be better, but for now we're mapping the backend property to two different local properties (thumbnailKey, thumbnailKeys) for backward compatibility + if (data.thumbnail) { + itemData.thumbnailKey = data.thumbnail; + itemData.thumbnailKeys = [data.thumbnail]; + } + + itemData.hasLocalThumbnail = !!data.thumbnail; + // handle file path + const filePath = getItemFilePath(data); + if (filePath) { + itemData.name = filePath.name; + itemData.fullName = getFullName(filePath.name, filePath.extension); + itemData.size = byteSize(filePath.size_in_bytes_bytes); + itemData.isDir = filePath.is_dir ?? false; + itemData.extension = filePath.extension?.toLocaleLowerCase() ?? null; + // + if ('cas_id' in filePath) itemData.casId = filePath.cas_id; + if ('location_id' in filePath) itemData.locationId = filePath.location_id; + if ('date_indexed' in filePath) itemData.dateIndexed = filePath.date_indexed; + if ('date_modified' in filePath) itemData.dateModified = filePath.date_modified; + } + break; + } + // the following types do not have a file_path or an object associated, and must be handled from scratch + case 'Location': { + const location = getItemLocation(data); + if (location) { + if (location.total_capacity != null && location.available_capacity != null) + itemData.size = byteSize(location.total_capacity - location.available_capacity); + + itemData.name = location.name; + itemData.fullName = location.name; + itemData.kind = ObjectKind[ObjectKind.Folder] ?? 'Unknown'; + itemData.isDir = true; + itemData.locationId = location.id; + } + break; + } + case 'SpacedropPeer': { + itemData.name = data.item.name; + itemData.customIcon = 'Laptop'; + break; + } + case 'Label': { + itemData.name = data.item.name; + itemData.customIcon = 'Tag'; + itemData.thumbnailKey = data.thumbnails[0] ?? []; + itemData.thumbnailKeys = data.thumbnails; + itemData.hasLocalThumbnail = !!data.thumbnails; + itemData.kind = 'Label'; + + console.log({ itemData }); + break; + } + } + + return itemData; +} + +export function getFullName( + filePathName: string | null, + filePathExtension?: string | null +): string { + return `${filePathName}${filePathExtension ? `.${filePathExtension}` : ''}`; +} + +function getDefaultItemData(kind: ObjectKindKey = 'Unknown'): ItemData { + return { + name: null, + fullName: null, + size: byteSize(0), + kind: 'Unknown', + isDir: false, + casId: null, + extension: null, + locationId: null, + dateIndexed: null, + dateCreated: null, + dateModified: null, + dateAccessed: null, + thumbnailKey: [], + hasLocalThumbnail: false, + customIcon: null + }; +} diff --git a/packages/client/src/lib/index.ts b/packages/client/src/lib/index.ts index 904729832..ecb706ba7 100644 --- a/packages/client/src/lib/index.ts +++ b/packages/client/src/lib/index.ts @@ -1,3 +1,5 @@ +export * from './objectKind'; +export * from './explorerItem'; export * from './byte-size'; export * from './passwordStrength'; export * from './valtio'; diff --git a/packages/client/src/utils/objectKind.ts b/packages/client/src/lib/objectKind.ts similarity index 97% rename from packages/client/src/utils/objectKind.ts rename to packages/client/src/lib/objectKind.ts index 3d513a10b..c0c992553 100644 --- a/packages/client/src/utils/objectKind.ts +++ b/packages/client/src/lib/objectKind.ts @@ -26,7 +26,8 @@ export enum ObjectKindEnum { Book, Config, Dotfile, - Screenshot + Screenshot, + Label } export type ObjectKindKey = keyof typeof ObjectKindEnum; diff --git a/packages/client/src/utils/explorerItem.ts b/packages/client/src/utils/explorerItem.ts deleted file mode 100644 index eb2ab9d85..000000000 --- a/packages/client/src/utils/explorerItem.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { useMemo } from 'react'; - -import type { Object } from '..'; -import type { ExplorerItem, FilePath, NonIndexedPathItem } from '../core'; -import { byteSize } from '../lib'; -import { ObjectKind } 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) { - if (data.type === 'Path' || data.type === 'NonIndexedPath') return data.item; - return (data.type === 'Object' && data.item.file_paths[0]) || null; -} - -export function getEphemeralPath(data: ExplorerItem) { - return data.type === 'NonIndexedPath' ? data.item : null; -} - -export function getIndexedItemFilePath(data: ExplorerItem) { - return data.type === 'Path' - ? data.item - : data.type === 'Object' - ? data.item.file_paths[0] ?? null - : null; -} - -export function getItemLocation(data: ExplorerItem) { - return data.type === 'Location' ? data.item : null; -} -export function getItemSpacedropPeer(data: ExplorerItem) { - return data.type === 'SpacedropPeer' ? data.item : null; -} - -export function getExplorerItemData(data?: null | ExplorerItem) { - const itemObj = data ? getItemObject(data) : null; - - const kind = (itemObj?.kind ? ObjectKind[itemObj.kind] : null) ?? 'Unknown'; - - const itemData = { - name: null as string | null, - fullName: null as string | null, - size: byteSize(0), - kind, - isDir: false, - casId: null as string | null, - extension: null as string | null, - locationId: null as number | null, - dateIndexed: null as string | null, - dateCreated: - data?.item && 'date_created' in data.item - ? data.item.date_created - : itemObj?.date_created ?? null, - dateModified: null as string | null, - dateAccessed: itemObj?.date_accessed ?? null, - thumbnailKey: data?.thumbnail_key ?? [], - hasLocalThumbnail: data?.has_local_thumbnail ?? false, // this will be overwritten if new thumbnail is generated - customIcon: null as string | null - }; - - if (!data) return itemData; - - const filePath = getItemFilePath(data); - const location = getItemLocation(data); - if (filePath) { - itemData.name = filePath.name; - itemData.fullName = `${filePath.name}${filePath.extension ? `.${filePath.extension}` : ''}`; - itemData.size = byteSize(filePath.size_in_bytes_bytes); - itemData.isDir = filePath.is_dir ?? false; - itemData.extension = filePath.extension?.toLocaleLowerCase() ?? null; - if ('kind' in filePath) itemData.kind = ObjectKind[filePath.kind] ?? 'Unknown'; - if ('cas_id' in filePath) itemData.casId = filePath.cas_id; - if ('location_id' in filePath) itemData.locationId = filePath.location_id; - if ('date_indexed' in filePath) itemData.dateIndexed = filePath.date_indexed; - if ('date_modified' in filePath) itemData.dateModified = filePath.date_modified; - } else if (location) { - if (location.total_capacity != null && location.available_capacity != null) - itemData.size = byteSize(location.total_capacity - location.available_capacity); - - itemData.name = location.name; - itemData.fullName = location.name; - itemData.kind = ObjectKind[ObjectKind.Folder] ?? 'Unknown'; - itemData.isDir = true; - itemData.locationId = location.id; - itemData.dateIndexed = location.date_created; - } else if (data.type === 'SpacedropPeer') { - itemData.name = data.item.name; - itemData.customIcon = 'Laptop'; - } - - if (data.type == 'Path' && itemData.isDir) itemData.kind = 'Folder'; - - return itemData; -} - -export const useItemsAsObjects = (items: ExplorerItem[]) => { - return useMemo(() => { - const array: Object[] = []; - - for (const item of items) { - switch (item.type) { - case 'Path': { - if (!item.item.object) return []; - array.push(item.item.object); - break; - } - case 'Object': { - array.push(item.item); - break; - } - default: - return []; - } - } - - return array; - }, [items]); -}; - -export const useItemsAsFilePaths = (items: ExplorerItem[]) => { - return useMemo(() => { - const array: FilePath[] = []; - - for (const item of items) { - switch (item.type) { - case 'Path': { - array.push(item.item); - break; - } - case 'Object': { - // this isn't good but it's the current behaviour - const filePath = item.item.file_paths[0]; - if (filePath) array.push(filePath); - else return []; - - break; - } - default: - return []; - } - } - - return array; - }, [items]); -}; - -export const useItemsAsEphemeralPaths = (items: ExplorerItem[]) => { - return useMemo(() => { - return items - .filter((item) => item.type === 'NonIndexedPath') - .map((item) => item.item as NonIndexedPathItem); - }, [items]); -}; diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts index d5a8f2503..7420e22e8 100644 --- a/packages/client/src/utils/index.ts +++ b/packages/client/src/utils/index.ts @@ -1,11 +1,98 @@ import { QueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; -import { ExplorerItem, LibraryConfigWrapped } from '../core'; +import type { Object } from '..'; +import type { ExplorerItem, FilePath, NonIndexedPathItem } from '../core'; +import { LibraryConfigWrapped } from '../core'; -export * from './objectKind'; -export * from './explorerItem'; export * from './jobs'; -// export * from './keys'; + +export const useItemsAsObjects = (items: ExplorerItem[]) => { + return useMemo(() => { + const array: Object[] = []; + + for (const item of items) { + switch (item.type) { + case 'Path': { + if (!item.item.object) return []; + array.push(item.item.object); + break; + } + case 'Object': { + array.push(item.item); + break; + } + default: + return []; + } + } + + return array; + }, [items]); +}; + +export const useItemsAsFilePaths = (items: ExplorerItem[]) => { + return useMemo(() => { + const array: FilePath[] = []; + + for (const item of items) { + switch (item.type) { + case 'Path': { + array.push(item.item); + break; + } + case 'Object': { + // this isn't good but it's the current behaviour + const filePath = item.item.file_paths[0]; + if (filePath) array.push(filePath); + else return []; + + break; + } + default: + return []; + } + } + + return array; + }, [items]); +}; + +export const useItemsAsEphemeralPaths = (items: ExplorerItem[]) => { + return useMemo(() => { + return items + .filter((item) => item.type === 'NonIndexedPath') + .map((item) => item.item as NonIndexedPathItem); + }, [items]); +}; + +export function getItemObject(data: ExplorerItem) { + return data.type === 'Object' ? data.item : data.type === 'Path' ? data.item.object : null; +} + +export function getItemFilePath(data: ExplorerItem) { + if (data.type === 'Path' || data.type === 'NonIndexedPath') return data.item; + return (data.type === 'Object' && data.item.file_paths[0]) || null; +} + +export function getEphemeralPath(data: ExplorerItem) { + return data.type === 'NonIndexedPath' ? data.item : null; +} + +export function getIndexedItemFilePath(data: ExplorerItem) { + return data.type === 'Path' + ? data.item + : data.type === 'Object' + ? data.item.file_paths[0] ?? null + : null; +} + +export function getItemLocation(data: ExplorerItem) { + return data.type === 'Location' ? data.item : null; +} +export function getItemSpacedropPeer(data: ExplorerItem) { + return data.type === 'SpacedropPeer' ? data.item : null; +} export function isPath(item: ExplorerItem): item is Extract { return item.type === 'Path'; diff --git a/packages/ui/src/CircularProgress.tsx b/packages/ui/src/CircularProgress.tsx new file mode 100644 index 000000000..3015a16ec --- /dev/null +++ b/packages/ui/src/CircularProgress.tsx @@ -0,0 +1,145 @@ +// adapted from https://github.com/martyan/react-customizable-progressbar/ +import clsx from 'clsx'; +import React, { FunctionComponent, useEffect, useState } from 'react'; + +export type CircularProgressProps = { + radius: number; + progress: number; + steps?: number; + cut?: number; + rotate?: number; + strokeWidth?: number; + strokeColor?: string; + fillColor?: string; + strokeLinecap?: 'round' | 'inherit' | 'butt' | 'square'; + transition?: string; + pointerRadius?: number; + pointerStrokeWidth?: number; + pointerStrokeColor?: string; + pointerFillColor?: string; + trackStrokeColor?: string; + trackStrokeWidth?: number; + trackStrokeLinecap?: 'round' | 'inherit' | 'butt' | 'square'; + trackTransition?: string; + counterClockwise?: boolean; + inverse?: boolean; + initialAnimation?: boolean; + initialAnimationDelay?: number; + className?: string; + children?: React.ReactNode; +}; + +export const CircularProgress: FunctionComponent = ({ + radius, + progress, + steps = 100, + cut = 0, + rotate = -90, + strokeWidth = 20, + strokeColor = 'indianred', + fillColor = 'none', + strokeLinecap = 'round', + transition = '.3s ease', + pointerRadius = 0, + pointerStrokeWidth = 20, + pointerStrokeColor = 'indianred', + pointerFillColor = 'white', + trackStrokeColor = '#e6e6e6', + trackStrokeWidth = 20, + trackStrokeLinecap = 'round', + trackTransition = '.3s ease', + counterClockwise = false, + inverse = false, + initialAnimation = false, + initialAnimationDelay = 0, + className = '', + children +}) => { + const [animationInitialized, setAnimationInitialized] = useState(false); + + useEffect(() => { + if (initialAnimation) { + const timeout = setTimeout(() => setAnimationInitialized(true), initialAnimationDelay); + return () => clearTimeout(timeout); + } + }, [initialAnimation, initialAnimationDelay]); + + const getProgress = () => (initialAnimation && !animationInitialized ? 0 : progress); + + const circumference = radius * 2 * Math.PI; + + const strokeDasharray = `${circumference} ${circumference}`; + const strokeDashoffset = ((100 - getProgress()) / 100) * circumference; + + // The space needed for the strokeWidth on all sides + const fullStrokeWidth = strokeWidth * 2; + + // Adjust the svgSize to account for the space needed for the strokeWidth + const svgSize = radius * 2 + fullStrokeWidth; + const viewBox = `0 0 ${svgSize} ${svgSize}`; + + // Adjust the cx and cy to be the actual center of the SVG + const center = radius + strokeWidth; // The center is radius + strokeWidth + + return ( +
+ + {trackStrokeWidth > 0 && ( + + )} + {strokeWidth > 0 && ( + + )} + {pointerRadius > 0 && ( + + )} + + {children} +
+ ); +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9d18b5bc9..8da882ee8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -19,6 +19,7 @@ export * from './utils'; export * from './Tooltip'; export * from './Slider'; export * from './Divider'; +export * from './CircularProgress'; export * from './Shortcut'; export * from './ProgressBar'; export * from './keys'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5847de0cd..22dab1761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6676,7 +6676,7 @@ packages: magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.3.3) typescript: 5.3.3 - vite: 5.0.10(@types/node@18.17.19) + vite: 5.0.10(less@4.2.0) /@jridgewell/gen-mapping@0.3.3: resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} @@ -9957,7 +9957,7 @@ packages: magic-string: 0.30.5 rollup: 3.29.4 typescript: 5.3.3 - vite: 5.0.10(@types/node@18.17.19) + vite: 5.0.10(less@4.2.0) transitivePeerDependencies: - encoding - supports-color @@ -10310,7 +10310,7 @@ packages: react: 18.2.0 react-docgen: 6.0.4 react-dom: 18.2.0(react@18.2.0) - vite: 5.0.10(@types/node@18.17.19) + vite: 5.0.10(less@4.2.0) transitivePeerDependencies: - '@preact/preset-vite' - encoding @@ -11712,7 +11712,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2) magic-string: 0.27.0 react-refresh: 0.14.0 - vite: 5.0.10(@types/node@18.17.19) + vite: 5.0.10(less@4.2.0) transitivePeerDependencies: - supports-color @@ -24830,6 +24830,7 @@ packages: rollup: 4.9.2 optionalDependencies: fsevents: 2.3.3 + dev: true /vite@5.0.10(less@4.2.0): resolution: {integrity: sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==} @@ -24865,7 +24866,6 @@ packages: rollup: 4.9.2 optionalDependencies: fsevents: 2.3.3 - dev: true /vite@5.0.10(sass@1.69.5): resolution: {integrity: sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==}