[ENG-1511] Library Screens (#1903)

* init

* changes

* Now updating statistics once a minute

* More robust statistics updater

* Concurrency is hard

* improvements to stats

* refactor

* adjust setting back/forward padding so it matches top bar

* refactor sidebar

* rename

* setting up screens

* some changes

* Co-authored-by: Brendan Allan <Brendonovich@users.noreply.github.com>

* yes

* yes2

* refactored explorerItem.ts

* important explorer code shouldn't be thrown away in a util moment

* support for multiple thumbnails in ExplorerItem

* clippy

* move debug

* yes

* label filters

* ts

* comment out unconnected stuff

* added .mid for midi files

---------

Co-authored-by: Ericson Fogo Soares <ericson.ds999@gmail.com>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
This commit is contained in:
Jamie Pine 2024-01-16 04:15:03 -08:00 committed by GitHub
parent 5cebca67bc
commit fdf31fc3a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
103 changed files with 2281 additions and 631 deletions

View file

@ -37,6 +37,7 @@ narkhede
naveen naveen
neha neha
noco noco
Normalised
OSSC OSSC
poonen poonen
rauch rauch

24
Cargo.lock generated
View file

@ -2004,18 +2004,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "deps-generator"
version = "0.0.0"
dependencies = [
"anyhow",
"cargo_metadata 0.18.1",
"clap",
"reqwest",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.6.1" version = "0.6.1"
@ -7588,6 +7576,18 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "sd-deps-generator"
version = "0.0.0"
dependencies = [
"anyhow",
"cargo_metadata 0.18.1",
"clap",
"reqwest",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "sd-desktop" name = "sd-desktop"
version = "0.1.4" version = "0.1.4"

View file

@ -27,9 +27,13 @@ const FileThumbWrapper = ({ children, size = 1 }: PropsWithChildren<{ size: numb
function useExplorerItemData(explorerItem: ExplorerItem) { function useExplorerItemData(explorerItem: ExplorerItem) {
const explorerStore = useExplorerStore(); const explorerStore = useExplorerStore();
const firstThumbnail =
explorerItem.type === 'Label'
? explorerItem.thumbnails?.[0]
: 'thumbnail' in explorerItem && explorerItem.thumbnail;
const newThumbnail = !!( const newThumbnail = !!(
explorerItem.thumbnail_key && firstThumbnail && explorerStore.newThumbnails.has(flattenThumbnailKey(firstThumbnail))
explorerStore.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key))
); );
return useMemo(() => { return useMemo(() => {

View file

@ -70,7 +70,7 @@ const OverviewStats = () => {
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
{Object.entries(libraryStatistics).map(([key, bytesRaw]) => { {Object.entries(libraryStatistics).map(([key, bytesRaw]) => {
if (!displayableStatItems.includes(key)) return null; if (!displayableStatItems.includes(key)) return null;
let bytes = BigInt(bytesRaw); let bytes = BigInt(bytesRaw?.total_bytes_free ?? 0);
if (key === 'total_bytes_free') { if (key === 'total_bytes_free') {
bytes = BigInt(sizeInfo.freeSpace); bytes = BigInt(sizeInfo.freeSpace);
} else if (key === 'total_bytes_capacity') { } else if (key === 'total_bytes_capacity') {

View file

@ -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 std::collections::BTreeMap;
use rspc::alpha::AlphaRouter; 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<Ctx> { pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router() R.router()
@ -15,6 +24,45 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
Ok(library.db.label().find_many(vec![]).exec().await?) 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<Vec<String>>
.collect::<Vec<_>>(), // Collect into Vec<Vec<Vec<String>>>
})
.collect::<Vec<_>>())
})
})
.procedure("count", {
R.with2(library()).query(|(_, library), _: ()| async move {
Ok(library.db.label().count(vec![]).exec().await? as i32)
})
})
.procedure("getForObject", { .procedure("getForObject", {
R.with2(library()) R.with2(library())
.query(|(_, library), object_id: i32| async move { .query(|(_, library), object_id: i32| async move {

View file

@ -1,31 +1,50 @@
use crate::{ use crate::{
library::{Library, LibraryConfig, LibraryName}, invalidate_query,
library::{update_library_statistics, Library, LibraryConfig, LibraryName},
location::{scan_location, LocationCreateArgs}, location::{scan_location, LocationCreateArgs},
util::MaybeUndefined, util::MaybeUndefined,
volume::get_volumes,
Node, Node,
}; };
use futures::StreamExt;
use sd_cache::{Model, Normalise, NormalisedResult, NormalisedResults}; use sd_cache::{Model, Normalise, NormalisedResult, NormalisedResults};
use sd_file_ext::kind::ObjectKind;
use sd_p2p::spacetunnel::RemoteIdentity; 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 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 rspc::{alpha::AlphaRouter, ErrorCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use tokio::spawn; use strum::IntoEnumIterator;
use tokio::{
spawn,
sync::Mutex,
time::{interval, Instant},
};
use tracing::{debug, error}; use tracing::{debug, error};
use uuid::Uuid; use uuid::Uuid;
use super::{ use super::{utils::library, Ctx, R};
utils::{get_size, 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<Mutex<HashMap<Uuid, chan::Sender<Instant>>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
// TODO(@Oscar): Replace with `specta::json` // TODO(@Oscar): Replace with `specta::json`
#[derive(Serialize, Type)] #[derive(Serialize, Type)]
@ -80,65 +99,69 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}) })
}) })
.procedure("statistics", { .procedure("statistics", {
#[derive(Serialize, Deserialize, Type)]
pub struct StatisticsResponse {
statistics: Option<statistics::Data>,
}
R.with2(library()) R.with2(library())
.query(|(node, library), _: ()| async move { .query(|(node, library), _: ()| async move {
// TODO: get from database if library is offline let statistics = library
// 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
.db .db
.statistics() .statistics()
.upsert( .find_unique(statistics::id::equals(1))
statistics::id::equals(1), // Each library is a database so only one of these ever exists
statistics::create(params.clone()),
params,
)
.exec() .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<KindStatistic>,
}
R.with2(library()).query(|(_, library), _: ()| async move {
let mut statistics: Vec<KindStatistic> = 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", { .procedure("create", {
#[derive(Deserialize, Type, Default)] #[derive(Deserialize, Type, Default)]
pub struct DefaultLocations { pub struct DefaultLocations {
@ -358,3 +381,44 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}), }),
) )
} }
async fn update_statistics_loop(
node: Arc<Node>,
library: Arc<Library>,
last_requested_rx: chan::Receiver<Instant>,
) {
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;
}
}
}
}
}

View file

@ -28,39 +28,37 @@ use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use tracing::{debug, error}; 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<String>;
#[derive(Serialize, Type, Debug)] #[derive(Serialize, Type, Debug)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum ExplorerItem { pub enum ExplorerItem {
Path { Path {
// has_local_thumbnail is true only if there is local existence of a thumbnail thumbnail: Option<ThumbnailKey>,
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<Vec<String>>,
item: file_path_with_object::Data, item: file_path_with_object::Data,
}, },
Object { Object {
has_local_thumbnail: bool, thumbnail: Option<ThumbnailKey>,
thumbnail_key: Option<Vec<String>>,
item: object_with_file_paths::Data, item: object_with_file_paths::Data,
}, },
Location { Location {
has_local_thumbnail: bool,
thumbnail_key: Option<Vec<String>>,
item: location::Data, item: location::Data,
}, },
NonIndexedPath { NonIndexedPath {
has_local_thumbnail: bool, thumbnail: Option<ThumbnailKey>,
thumbnail_key: Option<Vec<String>>,
item: NonIndexedPathItem, item: NonIndexedPathItem,
}, },
SpacedropPeer { SpacedropPeer {
has_local_thumbnail: bool,
thumbnail_key: Option<Vec<String>>,
item: PeerMetadata, item: PeerMetadata,
}, },
Label {
thumbnails: Vec<ThumbnailKey>,
item: label_with_objects::Data,
},
} }
// TODO: Really this shouldn't be a `Model` but it's easy for now. // TODO: Really this shouldn't be a `Model` but it's easy for now.
@ -79,6 +77,7 @@ impl ExplorerItem {
ExplorerItem::Location { .. } => "Location", ExplorerItem::Location { .. } => "Location",
ExplorerItem::NonIndexedPath { .. } => "NonIndexedPath", ExplorerItem::NonIndexedPath { .. } => "NonIndexedPath",
ExplorerItem::SpacedropPeer { .. } => "SpacedropPeer", ExplorerItem::SpacedropPeer { .. } => "SpacedropPeer",
ExplorerItem::Label { .. } => "Label",
}; };
match self { match self {
ExplorerItem::Path { item, .. } => format!("{ty}:{}", item.id), ExplorerItem::Path { item, .. } => format!("{ty}:{}", item.id),
@ -86,6 +85,7 @@ impl ExplorerItem {
ExplorerItem::Location { item, .. } => format!("{ty}:{}", item.id), ExplorerItem::Location { item, .. } => format!("{ty}:{}", item.id),
ExplorerItem::NonIndexedPath { item, .. } => format!("{ty}:{}", item.path), ExplorerItem::NonIndexedPath { item, .. } => format!("{ty}:{}", item.path),
ExplorerItem::SpacedropPeer { item, .. } => format!("{ty}:{}", item.name), // TODO: Use a proper primary key ExplorerItem::SpacedropPeer { item, .. } => format!("{ty}:{}", item.name), // TODO: Use a proper primary key
ExplorerItem::Label { item, .. } => format!("{ty}:{}", item.name),
} }
} }
} }

View file

@ -1,13 +1,15 @@
use crate::{ use crate::{
invalidate_query, invalidate_query,
job::JobProgressEvent, job::JobProgressEvent,
node::config::{NodeConfig, NodePreferences}, node::{
config::{NodeConfig, NodePreferences},
get_hardware_model_name, HardwareModel,
},
Node, Node,
}; };
use sd_cache::patch_typedef; use sd_cache::patch_typedef;
use sd_p2p::P2PStatus; use sd_p2p::P2PStatus;
use std::sync::{atomic::Ordering, Arc}; use std::sync::{atomic::Ordering, Arc};
use itertools::Itertools; use itertools::Itertools;
@ -118,6 +120,7 @@ struct NodeState {
config: SanitisedNodeConfig, config: SanitisedNodeConfig,
data_path: String, data_path: String,
p2p: P2PStatus, p2p: P2PStatus,
device_model: Option<String>,
} }
pub(crate) fn mount() -> Arc<Router> { pub(crate) fn mount() -> Arc<Router> {
@ -139,6 +142,10 @@ pub(crate) fn mount() -> Arc<Router> {
}) })
.procedure("nodeState", { .procedure("nodeState", {
R.query(|node, _: ()| async move { R.query(|node, _: ()| async move {
let device_model = get_hardware_model_name()
.unwrap_or(HardwareModel::Other)
.to_string();
Ok(NodeState { Ok(NodeState {
config: node.config.get().await.into(), config: node.config.get().await.into(),
// We are taking the assumption here that this value is only used on the frontend for display purposes // 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<Router> {
.expect("Found non-UTF-8 path") .expect("Found non-UTF-8 path")
.to_string(), .to_string(),
p2p: node.p2p.manager.status(), p2p: node.p2p.manager.status(),
device_model: Some(device_model),
}) })
}) })
}) })

View file

@ -149,11 +149,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.exec() .exec()
.await? .await?
.into_iter() .into_iter()
.map(|location| ExplorerItem::Location { .map(|location| ExplorerItem::Location { item: location })
has_local_thumbnail: false,
thumbnail_key: None,
item: location,
})
.collect::<Vec<_>>()) .collect::<Vec<_>>())
}) })
}) })

View file

@ -255,10 +255,10 @@ pub fn mount() -> AlphaRouter<Ctx> {
}; };
items.push(ExplorerItem::Path { items.push(ExplorerItem::Path {
has_local_thumbnail: thumbnail_exists_locally, thumbnail: file_path
thumbnail_key: file_path
.cas_id .cas_id
.as_ref() .as_ref()
.filter(|_| thumbnail_exists_locally)
.map(|i| get_indexed_thumb_key(i, library.id)), .map(|i| get_indexed_thumb_key(i, library.id)),
item: file_path, item: file_path,
}) })
@ -377,8 +377,9 @@ pub fn mount() -> AlphaRouter<Ctx> {
}; };
items.push(ExplorerItem::Object { items.push(ExplorerItem::Object {
has_local_thumbnail: thumbnail_exists_locally, thumbnail: cas_id
thumbnail_key: cas_id.map(|i| get_indexed_thumb_key(i, library.id)), .filter(|_| thumbnail_exists_locally)
.map(|cas_id| get_indexed_thumb_key(cas_id, library.id)),
item: object, item: object,
}); });
} }

View file

@ -1,6 +1,6 @@
// use crate::library::Category; // 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 chrono::{DateTime, FixedOffset};
use prisma_client_rust::{not, or, OrderByQuery, PaginatedQuery, WhereQuery}; use prisma_client_rust::{not, or, OrderByQuery, PaginatedQuery, WhereQuery};
@ -113,6 +113,7 @@ pub enum ObjectFilterArgs {
Hidden(ObjectHiddenFilter), Hidden(ObjectHiddenFilter),
Kind(InOrNotIn<i32>), Kind(InOrNotIn<i32>),
Tags(InOrNotIn<i32>), Tags(InOrNotIn<i32>),
Labels(InOrNotIn<i32>),
DateAccessed(Range<chrono::DateTime<FixedOffset>>), DateAccessed(Range<chrono::DateTime<FixedOffset>>),
} }
@ -130,6 +131,13 @@ impl ObjectFilterArgs {
) )
.map(|v| vec![v]) .map(|v| vec![v])
.unwrap_or_default(), .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 Self::Kind(v) => v
.into_param(kind::in_vec, kind::not_in_vec) .into_param(kind::in_vec, kind::not_in_vec)
.map(|v| vec![v]) .map(|v| vec![v])

View file

@ -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),
}
}
}

View file

@ -1,16 +1,16 @@
// pub(crate) mod cat;
mod actors; mod actors;
mod config; mod config;
#[allow(clippy::module_inception)] #[allow(clippy::module_inception)]
mod library; mod library;
mod manager; mod manager;
mod name; mod name;
mod statistics;
// pub use cat::*;
pub use actors::*; pub use actors::*;
pub use config::*; pub use config::*;
pub use library::*; pub use library::*;
pub use manager::*; pub use manager::*;
pub use name::*; pub use name::*;
pub use statistics::*;
pub type LibraryId = uuid::Uuid; pub type LibraryId = uuid::Uuid;

View file

@ -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<statistics::Data, LibraryManagerError> {
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)
}

View file

@ -231,8 +231,7 @@ pub async fn walk(
}; };
tx.send(Ok(ExplorerItem::NonIndexedPath { tx.send(Ok(ExplorerItem::NonIndexedPath {
has_local_thumbnail: thumbnail_key.is_some(), thumbnail: thumbnail_key,
thumbnail_key,
item: NonIndexedPathItem { item: NonIndexedPathItem {
hidden: path_is_hidden(Path::new(&entry_path), &entry.metadata), hidden: path_is_hidden(Path::new(&entry_path), &entry.metadata),
path: entry_path, path: entry_path,
@ -281,16 +280,11 @@ pub async fn walk(
for (directory, name, metadata) in directories { for (directory, name, metadata) in directories {
if let Some(location) = locations.remove(&directory) { if let Some(location) = locations.remove(&directory) {
tx.send(Ok(ExplorerItem::Location { tx.send(Ok(ExplorerItem::Location { item: location }))
has_local_thumbnail: false, .await?;
thumbnail_key: None,
item: location,
}))
.await?;
} else { } else {
tx.send(Ok(ExplorerItem::NonIndexedPath { tx.send(Ok(ExplorerItem::NonIndexedPath {
has_local_thumbnail: false, thumbnail: None,
thumbnail_key: None,
item: NonIndexedPathItem { item: NonIndexedPathItem {
hidden: path_is_hidden(Path::new(&directory), &metadata), hidden: path_is_hidden(Path::new(&directory), &metadata),
path: directory, path: directory,

71
core/src/node/hardware.rs Normal file
View file

@ -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<HardwareModel, Error> {
#[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",
))
}
}

View file

@ -1,4 +1,6 @@
pub mod config; pub mod config;
mod hardware;
mod platform; mod platform;
pub use hardware::*;
pub use platform::*; pub use platform::*;

View file

@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
use prisma_client_rust::or; use prisma_client_rust::or;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, trace, warn}; use tracing::{trace, warn};
use super::{process_identifier_file_paths, FileIdentifierJobError, CHUNK_SIZE}; use super::{process_identifier_file_paths, FileIdentifierJobError, CHUNK_SIZE};
@ -28,7 +28,7 @@ pub async fn shallow(
) -> Result<(), JobError> { ) -> Result<(), JobError> {
let Library { db, .. } = library; let Library { db, .. } = library;
debug!("Identifying orphan File Paths..."); warn!("Identifying orphan File Paths...");
let location_id = location.id; let location_id = location.id;
let location_path = maybe_missing(&location.path, "location.path").map(Path::new)?; 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; let task_count = (orphan_count as f64 / CHUNK_SIZE as f64).ceil() as usize;
debug!( warn!(
"Found {} orphan Paths. Will execute {} tasks...", "Found {} orphan Paths. Will execute {} tasks...",
orphan_count, task_count orphan_count, task_count
); );

View file

@ -1,12 +1,11 @@
use crate::{ use crate::{
node::config, node::{config, get_hardware_model_name, HardwareModel},
p2p::{OperatingSystem, SPACEDRIVE_APP_ID}, p2p::{OperatingSystem, SPACEDRIVE_APP_ID},
}; };
use sd_p2p::{ use sd_p2p::{
spacetunnel::RemoteIdentity, Manager, ManagerConfig, ManagerError, PeerStatus, Service, spacetunnel::RemoteIdentity, Manager, ManagerConfig, ManagerError, PeerStatus, Service,
}; };
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
net::SocketAddr, net::SocketAddr,
@ -95,6 +94,7 @@ impl P2PManager {
PeerMetadata { PeerMetadata {
name: config.name.clone(), name: config.name.clone(),
operating_system: Some(OperatingSystem::get_os()), 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()), version: Some(env!("CARGO_PKG_VERSION").to_string()),
} }
}); });

View file

@ -1,4 +1,4 @@
use crate::node::Platform; use crate::node::{HardwareModel, Platform};
use sd_p2p::Metadata; use sd_p2p::Metadata;
@ -11,6 +11,7 @@ use specta::Type;
pub struct PeerMetadata { pub struct PeerMetadata {
pub name: String, pub name: String,
pub operating_system: Option<OperatingSystem>, pub operating_system: Option<OperatingSystem>,
pub device_model: Option<HardwareModel>,
pub version: Option<String>, pub version: Option<String>,
} }
@ -24,6 +25,9 @@ impl Metadata for PeerMetadata {
if let Some(version) = self.version { if let Some(version) = self.version {
map.insert("version".to_owned(), 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 map
} }
@ -43,6 +47,11 @@ impl Metadata for PeerMetadata {
.get("os") .get("os")
.map(|os| os.parse().map_err(|_| "Unable to parse 'OperationSystem'!")) .map(|os| os.parse().map_err(|_| "Unable to parse 'OperationSystem'!"))
.transpose()?, .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()), version: data.get("version").map(|v| v.to_owned()),
}) })
} }

View file

@ -1,5 +1,5 @@
[package] [package]
name = "deps-generator" name = "sd-deps-generator"
version = "0.0.0" version = "0.0.0"
authors = ["Jake Robinson <jake@spacedrive.com>"] authors = ["Jake Robinson <jake@spacedrive.com>"]
description = "A tool to compile all Spacedrive dependencies and their respective licenses" description = "A tool to compile all Spacedrive dependencies and their respective licenses"

View file

@ -121,6 +121,7 @@ extension_category_enum! {
Aptx = [0x4B, 0xBF, 0x4B, 0xBF], Aptx = [0x4B, 0xBF, 0x4B, 0xBF],
Adts = [0xFF, 0xF1], Adts = [0xFF, 0xF1],
Ast = [0x53, 0x54, 0x52, 0x4D], Ast = [0x53, 0x54, 0x52, 0x4D],
Mid = [0x4D, 0x54, 0x68, 0x64],
} }
} }

View file

@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize}; 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` // Note: The order of this enum should never change, and always be kept in sync with `packages/client/src/utils/objectKind.ts`
#[repr(i32)] #[repr(i32)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] #[derive(Debug, Clone, Display, Copy, EnumIter, Type, Serialize, Deserialize, Eq, PartialEq)]
pub enum ObjectKind { pub enum ObjectKind {
/// A file that can not be identified by the indexer /// A file that can not be identified by the indexer
Unknown = 0, Unknown = 0,
@ -56,4 +57,6 @@ pub enum ObjectKind {
Dotfile = 24, Dotfile = 24,
/// Screenshot /// Screenshot
Screenshot = 25, Screenshot = 25,
/// Label
Label = 26,
} }

View file

@ -159,8 +159,8 @@ const Path = ({ path, onClick, disabled }: PathProps) => {
ref={setDroppableRef} ref={setDroppableRef}
className={clsx( className={clsx(
'group flex items-center gap-1 rounded px-1 py-0.5', 'group flex items-center gap-1 rounded px-1 py-0.5',
isDroppable && [isDark ? 'bg-app-lightBox' : 'bg-app-darkerBox'], isDroppable && [isDark ? 'bg-app-button/70' : 'bg-app-darkerBox'],
!disabled && [isDark ? 'hover:bg-app-lightBox' : 'hover:bg-app-darkerBox'], !disabled && [isDark ? 'hover:bg-app-button/70' : 'hover:bg-app-darkerBox'],
className className
)} )}
disabled={disabled} disabled={disabled}

View file

@ -33,7 +33,7 @@ export const EmptyNotice = (props: {
: emptyNoticeIcon(props.icon as Icon) : emptyNoticeIcon(props.icon as Icon)
: emptyNoticeIcon()} : emptyNoticeIcon()}
<p className="mt-5 text-sm font-medium"> <p className="mt-5">
{props.message !== undefined ? props.message : 'This list is empty'} {props.message !== undefined ? props.message : 'This list is empty'}
</p> </p>
</div> </div>

View file

@ -61,6 +61,10 @@ export const useViewItemDoubleClick = () => {
const paths = const paths =
selectedItem.type === 'Path' selectedItem.type === 'Path'
? [selectedItem.item] ? [selectedItem.item]
: selectedItem.type === 'Label'
? selectedItem.item.label_objects.flatMap(
(o) => o.object.file_paths
)
: selectedItem.item.file_paths; : selectedItem.item.file_paths;
for (const filePath of paths) { for (const filePath of paths) {

View file

@ -119,7 +119,6 @@ export const useExplorerDroppable = ({
allowedType = ['Path', 'NonIndexedPath', 'Object']; allowedType = ['Path', 'NonIndexedPath', 'Object'];
break; break;
} }
case 'Tag': { case 'Tag': {
allowedType = ['Path', 'Object']; allowedType = ['Path', 'Object'];
break; break;

View file

@ -10,14 +10,14 @@ export function useExplorerSearchParams() {
} }
export function useExplorerItemData(explorerItem: ExplorerItem) { export function useExplorerItemData(explorerItem: ExplorerItem) {
const newThumbnail = useSelector( const newThumbnail = useSelector(explorerStore, (s) => {
explorerStore, const firstThumbnail =
(s) => explorerItem.type === 'Label'
!!( ? explorerItem.thumbnails?.[0]
explorerItem.thumbnail_key && : 'thumbnail' in explorerItem && explorerItem.thumbnail;
s.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key))
) return !!(firstThumbnail && s.newThumbnails.has(flattenThumbnailKey(firstThumbnail)));
); });
return useMemo(() => { return useMemo(() => {
const itemData = getExplorerItemData(explorerItem); const itemData = getExplorerItemData(explorerItem);

View file

@ -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 (
<div className="no-scrollbar mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
{/* <SidebarLink to="spacedrop">
<Icon component={Broadcast} />
Spacedrop
</SidebarLink> */}
{/*
{/* <SidebarLink to="imports">
<Icon component={ArchiveBox} />
Imports
</SidebarLink> */}
{debugRoutes && (
<Section name="Debug">
<div className="space-y-0.5">
<SidebarLink to="debug/sync">
<Icon component={ArrowsClockwise} />
Sync
</SidebarLink>
<SidebarLink to="debug/cloud">
<Icon component={Cloud} />
Cloud
</SidebarLink>
<SidebarLink to="debug/cache">
<Icon component={Database} />
Cache
</SidebarLink>
<SidebarLink to="debug/actors">
<Icon component={Factory} />
Actors
</SidebarLink>
</div>
</Section>
)}
<EphemeralSection />
{library && (
<LibraryContextProvider library={library}>
<LibrarySection />
</LibraryContextProvider>
)}
{/* <Section name="Tools" actionArea={<SubtleButton />}>
<SidebarLink disabled to="duplicate-finder">
<Icon component={CopySimple} />
Duplicates
</SidebarLink>
<SidebarLink disabled to="lost-and-found">
<Icon component={Crosshair} />
Find a File
</SidebarLink>
<SidebarLink disabled to="cache-cleaner">
<Icon component={Eraser} />
Cache Cleaner
</SidebarLink>
<SidebarLink disabled to="media-encoder">
<Icon component={FilmStrip} />
Media Encoder
</SidebarLink>
</Section> */}
<div className="grow" />
</div>
);
};

View file

@ -1,15 +0,0 @@
import { Devices } from './Devices';
import { Locations } from './Locations';
import { SavedSearches } from './SavedSearches';
import { Tags } from './Tags';
export const LibrarySection = () => {
return (
<>
<SavedSearches />
<Devices />
<Locations />
<Tags />
</>
);
};

View file

@ -5,9 +5,9 @@ import { Button, ButtonLink, Popover, Tooltip, usePopover } from '@sd/ui';
import { useKeysMatcher, useLocale, useShortcut } from '~/hooks'; import { useKeysMatcher, useLocale, useShortcut } from '~/hooks';
import { usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
import DebugPopover from './DebugPopover'; import DebugPopover from '../DebugPopover';
import { IsRunningJob, JobManager } from '../JobManager';
import FeedbackButton from './FeedbackButton'; import FeedbackButton from './FeedbackButton';
import { IsRunningJob, JobManager } from './JobManager';
export default () => { export default () => {
const { library } = useClientContext(); const { library } = useClientContext();

View file

@ -4,7 +4,7 @@ import { useClientContext } from '@sd/client';
import { dialogManager, Dropdown, DropdownMenu } from '@sd/ui'; import { dialogManager, Dropdown, DropdownMenu } from '@sd/ui';
import { useLocale } from '~/hooks'; import { useLocale } from '~/hooks';
import CreateDialog from '../../settings/node/libraries/CreateDialog'; import CreateDialog from '../../../settings/node/libraries/CreateDialog';
export default () => { export default () => {
const { library, libraries, currentLibraryId } = useClientContext(); const { library, libraries, currentLibraryId } = useClientContext();

View file

@ -46,7 +46,7 @@ const Link = forwardRef<
}} }}
className={({ isActive }) => className={({ isActive }) =>
clsx( 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' }), styles({ active: isActive, transparent: os === 'macOS' }),
disabled && 'pointer-events-none opacity-50', disabled && 'pointer-events-none opacity-50',
className className

View file

@ -3,7 +3,7 @@ import { MacTrafficLights } from '~/components/TrafficLights';
import { useOperatingSystem } from '~/hooks/useOperatingSystem'; import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import { usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
import { macOnly } from './helpers'; import { macOnly } from '../helpers';
export default () => { export default () => {
const { platform } = usePlatform(); const { platform } = usePlatform();

View file

@ -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 (
<div
className={clsx(
'relative flex min-h-full w-44 shrink-0 grow-0 flex-col gap-2.5 border-r border-sidebar-divider bg-sidebar px-2.5 pb-2 transition-[padding-top] ease-linear motion-reduce:transition-none',
os === 'macOS' && windowState.isFullScreen
? '-mt-2 pt-[8.75px] duration-100'
: 'pt-2.5 duration-75',
os === 'macOS' || showControls.transparentBg
? 'bg-opacity-[0.65]'
: 'bg-opacity-[1]'
)}
>
{showControls.isEnabled && <MacTrafficLights className="z-50 mb-1" />}
{os === 'macOS' && (
<div
data-tauri-drag-region
className={clsx(
'w-full shrink-0 transition-[height] ease-linear motion-reduce:transition-none',
windowState.isFullScreen ? 'h-0 duration-100' : 'h-4 duration-75'
)}
/>
)}
<LibrariesDropdown />
<div className="no-scrollbar mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
{props.children}
<div className="grow" />
</div>
<Footer />
</div>
);
};

View file

@ -1,56 +1,35 @@
import clsx from 'clsx'; import { LibraryContextProvider, useClientContext } from '@sd/client';
import { useEffect } from 'react';
import { MacTrafficLights } from '~/components';
import { useOperatingSystem, useShowControls } from '~/hooks';
import { useWindowState } from '~/hooks/useWindowState';
import Contents from './Contents'; import Debug from './sections/Debug';
import Footer from './Footer'; // sections
import LibrariesDropdown from './LibrariesDropdown'; import Devices from './sections/Devices';
import Library from './sections/Library';
export default () => { import Local from './sections/Local';
const os = useOperatingSystem(); import Locations from './sections/Locations';
const showControls = useShowControls(); import SavedSearches from './sections/SavedSearches';
const windowState = useWindowState(); import Tags from './sections/Tags';
import SidebarLayout from './SidebarLayout';
//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);
}, []);
export default function Sidebar() {
const { library } = useClientContext();
return ( return (
<div <SidebarLayout>
className={clsx( {library && (
'relative flex min-h-full w-44 shrink-0 grow-0 flex-col gap-2.5 border-r border-sidebar-divider bg-sidebar px-2.5 pb-2 transition-[padding-top] ease-linear motion-reduce:transition-none', <LibraryContextProvider library={library}>
os === 'macOS' && windowState.isFullScreen <Library />
? '-mt-2 pt-[8.75px] duration-100' </LibraryContextProvider>
: 'pt-2.5 duration-75',
os === 'macOS' || showControls.transparentBg
? 'bg-opacity-[0.65]'
: 'bg-opacity-[1]'
)} )}
> <Debug />
{showControls.isEnabled && <MacTrafficLights className="z-50 mb-1" />} <Local />
{library && (
{os === 'macOS' && ( <LibraryContextProvider library={library}>
<div <SavedSearches />
data-tauri-drag-region <Devices />
className={clsx( <Locations />
'w-full shrink-0 transition-[height] ease-linear motion-reduce:transition-none', <Tags />
windowState.isFullScreen ? 'h-0 duration-100' : 'h-4 duration-75' </LibraryContextProvider>
)}
/>
)} )}
<LibrariesDropdown /> {/* <Tools /> */}
<Contents /> </SidebarLayout>
<Footer />
</div>
); );
}; }

View file

@ -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 (
<Section name="Debug">
<div className="space-y-0.5">
<SidebarLink to="debug/sync">
<Icon component={ArrowsClockwise} />
Sync
</SidebarLink>
<SidebarLink to="debug/cloud">
<Icon component={Cloud} />
Cloud
</SidebarLink>
<SidebarLink to="debug/cache">
<Icon component={Database} />
Cache
</SidebarLink>
<SidebarLink to="debug/actors">
<Icon component={Factory} />
Actors
</SidebarLink>
</div>
</Section>
);
}

View file

@ -4,10 +4,10 @@ import { Button, Tooltip } from '@sd/ui';
import { Icon, SubtleButton } from '~/components'; import { Icon, SubtleButton } from '~/components';
import { useLocale } from '~/hooks'; import { useLocale } from '~/hooks';
import SidebarLink from '../Link'; import SidebarLink from '../../SidebarLayout/Link';
import Section from '../Section'; import Section from '../../SidebarLayout/Section';
export const Devices = () => { export default function DevicesSection() {
const { data: node } = useBridgeQuery(['nodeState']); const { data: node } = useBridgeQuery(['nodeState']);
const isPairingEnabled = useFeatureFlag('p2pPairing'); const isPairingEnabled = useFeatureFlag('p2pPairing');
@ -38,4 +38,4 @@ export const Devices = () => {
</Tooltip> </Tooltip>
</Section> </Section>
); );
}; }

View file

@ -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 (
<div className="space-y-0.5">
<SidebarLink to="overview">
<Icon component={Planet} />
Overview
</SidebarLink>
<SidebarLink to="recents">
<Icon component={Clock} />
Recents
{/* <div className={COUNT_STYLE}>34</div> */}
</SidebarLink>
<SidebarLink to="favorites">
<Icon component={Heart} />
Favorites
{/* <div className={COUNT_STYLE}>2</div> */}
</SidebarLink>
<SidebarLink to="labels">
<Icon component={Tag} />
Labels
<div className={COUNT_STYLE}>{labelCount.data || 0}</div>
</SidebarLink>
</div>
);
}

View file

@ -6,11 +6,11 @@ import { Button, toast, tw } from '@sd/ui';
import { Icon, IconName } from '~/components'; import { Icon, IconName } from '~/components';
import { useHomeDir } from '~/hooks/useHomeDir'; import { useHomeDir } from '~/hooks/useHomeDir';
import { useExplorerDroppable } from '../../Explorer/useExplorerDroppable'; import { useExplorerDroppable } from '../../../../Explorer/useExplorerDroppable';
import { useExplorerSearchParams } from '../../Explorer/util'; import { useExplorerSearchParams } from '../../../../Explorer/util';
import SidebarLink from './Link'; import SidebarLink from '../../SidebarLayout/Link';
import Section from './Section'; import Section from '../../SidebarLayout/Section';
import { SeeMore } from './SeeMore'; import { SeeMore } from '../../SidebarLayout/SeeMore';
const Name = tw.span`truncate`; const Name = tw.span`truncate`;
@ -29,7 +29,7 @@ const SidebarIcon = ({ name }: { name: IconName }) => {
return <Icon name={name} size={20} className="mr-1" />; return <Icon name={name} size={20} className="mr-1" />;
}; };
export const EphemeralSection = () => { export default function LocalSection() {
const locationsQuery = useLibraryQuery(['locations.list']); const locationsQuery = useLibraryQuery(['locations.list']);
useNodes(locationsQuery.data?.nodes); useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items); const locations = useCache(locationsQuery.data?.items);
@ -137,7 +137,7 @@ export const EphemeralSection = () => {
</SeeMore> </SeeMore>
</Section> </Section>
); );
}; }
const EphemeralLocation = ({ const EphemeralLocation = ({
children, children,

View file

@ -13,12 +13,12 @@ import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util';
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton'; import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
import { Icon, SubtleButton } from '~/components'; import { Icon, SubtleButton } from '~/components';
import SidebarLink from '../Link'; import SidebarLink from '../../SidebarLayout/Link';
import Section from '../Section'; import Section from '../../SidebarLayout/Section';
import { SeeMore } from '../SeeMore'; import { SeeMore } from '../../SidebarLayout/SeeMore';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
export const Locations = () => { export default function Locations() {
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes); useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items); const locations = useCache(locationsQuery.data?.items);
@ -45,7 +45,7 @@ export const Locations = () => {
<AddLocationButton className="mt-1" /> <AddLocationButton className="mt-1" />
</Section> </Section>
); );
}; }
const Location = ({ location, online }: { location: LocationType; online: boolean }) => { const Location = ({ location, online }: { location: LocationType; online: boolean }) => {
const locationId = useMatch('/:libraryId/location/:locationId')?.params.locationId; const locationId = useMatch('/:libraryId/location/:locationId')?.params.locationId;

View file

@ -6,11 +6,11 @@ import { Button } from '@sd/ui';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable'; import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
import { Folder } from '~/components'; import { Folder } from '~/components';
import SidebarLink from '../Link'; import SidebarLink from '../../SidebarLayout/Link';
import Section from '../Section'; import Section from '../../SidebarLayout/Section';
import { SeeMore } from '../SeeMore'; import { SeeMore } from '../../SidebarLayout/SeeMore';
export const SavedSearches = () => { export default function SavedSearches() {
const savedSearches = useLibraryQuery(['search.saved.list']); const savedSearches = useLibraryQuery(['search.saved.list']);
const path = useResolvedPath('saved-search/:id'); const path = useResolvedPath('saved-search/:id');
@ -58,7 +58,7 @@ export const SavedSearches = () => {
</SeeMore> </SeeMore>
</Section> </Section>
); );
}; }
const SavedSearch = ({ search, onDelete }: { search: SavedSearch; onDelete(): void }) => { const SavedSearch = ({ search, onDelete }: { search: SavedSearch; onDelete(): void }) => {
const searchId = useMatch('/:libraryId/saved-search/:searchId')?.params.searchId; const searchId = useMatch('/:libraryId/saved-search/:searchId')?.params.searchId;

View file

@ -4,12 +4,12 @@ import { useCache, useLibraryQuery, useNodes, type Tag } from '@sd/client';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable'; import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
import { SubtleButton } from '~/components'; import { SubtleButton } from '~/components';
import SidebarLink from '../Link'; import SidebarLink from '../../SidebarLayout/Link';
import Section from '../Section'; import Section from '../../SidebarLayout/Section';
import { SeeMore } from '../SeeMore'; import { SeeMore } from '../../SidebarLayout/SeeMore';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
export const Tags = () => { export default function TagsSection() {
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true }); const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
useNodes(result.data?.nodes); useNodes(result.data?.nodes);
const tags = useCache(result.data?.items); const tags = useCache(result.data?.items);
@ -32,7 +32,7 @@ export const Tags = () => {
</SeeMore> </SeeMore>
</Section> </Section>
); );
}; }
const Tag = ({ tag }: { tag: Tag }) => { const Tag = ({ tag }: { tag: Tag }) => {
const tagId = useMatch('/:libraryId/tag/:tagId')?.params.tagId; const tagId = useMatch('/:libraryId/tag/:tagId')?.params.tagId;

View file

@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Suspense, useEffect, useMemo, useRef } from 'react'; 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 { import {
ClientContextProvider, ClientContextProvider,
initPlausible, initPlausible,
@ -31,6 +31,7 @@ import { DndContext } from './DndContext';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
const Layout = () => { const Layout = () => {
console.log(useLocation());
const { libraries, library } = useClientContext(); const { libraries, library } = useClientContext();
const os = useOperatingSystem(); const os = useOperatingSystem();
const showControls = useShowControls(); const showControls = useShowControls();

View file

@ -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<ObjectOrder>({ order: null });
}, []),
orderingKeys: objectOrderingKeysSchema
});
const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
const fixedFilters = useMemo<SearchFilterArgs[]>(
() => [
// { 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 (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Favorites</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="Heart" size={128} />}
message="No favorite items"
/>
}
/>
</ExplorerContextProvider>
);
}

View file

@ -9,9 +9,9 @@ import settingsRoutes from './settings';
const pageRoutes: RouteObject = { const pageRoutes: RouteObject = {
lazy: () => import('./PageLayout'), lazy: () => import('./PageLayout'),
children: [ children: [
{ path: 'people', lazy: () => import('./people') }, { path: 'overview', lazy: () => import('./overview') },
{ path: 'media', lazy: () => import('./media') }, // { path: 'labels', lazy: () => import('./labels') },
{ path: 'spaces', lazy: () => import('./spaces') }, // { path: 'spaces', lazy: () => import('./spaces') },
{ path: 'debug', children: debugRoutes } { path: 'debug', children: debugRoutes }
] ]
}; };
@ -19,6 +19,10 @@ const pageRoutes: RouteObject = {
// Routes that render the explorer and don't need padding and stuff // Routes that render the explorer and don't need padding and stuff
// provided by PageLayout // provided by PageLayout
const explorerRoutes: RouteObject[] = [ 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: 'ephemeral/:id', lazy: () => import('./ephemeral') },
{ path: 'location/:id', lazy: () => import('./location/$id') }, { path: 'location/:id', lazy: () => import('./location/$id') },
{ path: 'node/:id', lazy: () => import('./node/$id') }, { path: 'node/:id', lazy: () => import('./node/$id') },

View file

@ -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<ObjectOrder>({ order: null });
}, []),
orderingKeys: objectOrderingKeysSchema
});
// const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
// const fixedFilters = useMemo<SearchFilterArgs[]>(
// () => [
// ...(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 (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Labels</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="CollectionSparkle" size={128} />}
message="No labels"
/>
}
/>
</ExplorerContextProvider>
);
}

View file

@ -37,8 +37,8 @@ import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer'; import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
import { useExplorerSearchParams } from '../Explorer/util'; import { useExplorerSearchParams } from '../Explorer/util';
import { EmptyNotice } from '../Explorer/View/EmptyNotice'; import { EmptyNotice } from '../Explorer/View/EmptyNotice';
import SearchOptions, { SearchContextProvider, useSearch } from '../Search'; import { SearchContextProvider, SearchOptions, useSearch } from '../search';
import SearchBar from '../Search/SearchBar'; import SearchBar from '../search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal'; import { TopBarPortal } from '../TopBar/Portal';
import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions'; import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
import LocationOptions from './LocationOptions'; import LocationOptions from './LocationOptions';

View file

@ -1,5 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useDebugState, useDiscoveredPeers, useFeatureFlag, useFeatureFlags } from '@sd/client'; import { useDiscoveredPeers } from '@sd/client';
import { Icon } from '~/components'; import { Icon } from '~/components';
import { useLocale } from '~/hooks'; import { useLocale } from '~/hooks';
import { useRouteTitle } from '~/hooks/useRouteTitle'; import { useRouteTitle } from '~/hooks/useRouteTitle';
@ -35,9 +35,9 @@ export const Component = () => {
const explorer = useExplorer({ const explorer = useExplorer({
items: peers.map((peer) => ({ items: peers.map((peer) => ({
type: 'SpacedropPeer', type: 'SpacedropPeer' as const,
has_local_thumbnail: false, has_local_thumbnail: false,
thumbnail_key: null, thumbnail: null,
item: { item: {
...peer, ...peer,
pub_id: [] pub_id: []

View file

@ -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<HTMLDivElement>(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 (
<motion.div
viewport={{
root: ref,
// WARNING: Edge breaks if the values are not postfixed with px or %
margin: '0% -120px 0% 0%'
}}
className={clsx('min-w-fit')}
key={kind}
>
<KindItem
kind={kind}
name={name}
icon={icon}
items={count}
onClick={() => {}}
/>
</motion.div>
);
})}
</>
);
};
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 (
<Link
to={{
pathname: '../search',
search: new URLSearchParams({
filters: JSON.stringify([
{ object: { kind: { in: [kind] } } }
] as SearchFilterArgs[])
}).toString()
}}
>
<div
onClick={onClick}
className={clsx(
'flex shrink-0 items-center rounded-lg py-1 text-sm outline-none focus:bg-app-selectedItem/50',
selected && 'bg-app-selectedItem',
disabled && 'cursor-not-allowed opacity-30'
)}
>
<Icon name={icon as any} className="mr-3 h-12 w-12" />
<div className="pr-5">
<h2 className="text-sm font-medium">{name}</h2>
{items !== undefined && (
<p className="text-xs text-ink-faint">
{formatNumber(items)} Item{(items > 1 || items === 0) && 's'}
</p>
)}
</div>
</div>
</Link>
);
};

View file

@ -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<HTMLDivElement>(null);
const { events } = useDraggable(ref as React.MutableRefObject<HTMLDivElement>);
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 (
<div className="relative mb-4 flex pl-6">
<ArrowButton
onClick={() => handleArrowOnClick('right')}
className={clsx('left-3', scroll === 0 && 'pointer-events-none opacity-0')}
>
<ArrowLeft weight="bold" className="h-4 w-4 text-ink" />
</ArrowButton>
<div
ref={ref}
{...events}
className="no-scrollbar flex gap-2 space-x-px overflow-x-scroll pl-1 pr-[60px]"
style={{
WebkitMaskImage: maskImage,
maskImage
}}
>
{children}
</div>
{isContentOverflow && (
<ArrowButton
onClick={() => handleArrowOnClick('left')}
className={clsx('right-3', lastItemVisible && 'pointer-events-none opacity-0')}
>
<ArrowRight weight="bold" className="h-4 w-4 text-ink" />
</ArrowButton>
)}
</div>
);
};
export default HorizontalScroll;

View file

@ -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<HTMLDivElement> & { title?: string; count?: number }) => {
return (
<div className={clsx('group w-full', className)}>
{title && (
<div className="mb-3 flex w-full items-center gap-3 px-7 ">
<div className="truncate font-bold">{title}</div>
{count && <div className={COUNT_STYLE}>{count}</div>}
<div className="grow" />
<div className="flex flex-row gap-1 text-sidebar-inkFaint opacity-0 transition-all duration-300 hover:!opacity-100 group-hover:opacity-30">
{/* <Button className={BUTTON_STYLE} size="icon" variant="subtle">
<CaretUp weight="fill" className="h-3 w-3 text-ink-faint " />
</Button>
<Button className={BUTTON_STYLE} size="icon" variant="subtle">
<CaretDown weight="fill" className="h-3 w-3 text-ink-faint " />
</Button> */}
{/* <Button className={BUTTON_STYLE} size="icon" variant="subtle">
<Ellipsis className="h-3 w-3 text-ink-faint " />
</Button> */}
</div>
</div>
)}
{/* {title && <div className="mx-7 mb-3 h-[1px] w-full bg-app-line/50" />} */}
<HorizontalScroll>{children}</HorizontalScroll>
<div className="my-2 h-[1px] w-full " />
</div>
);
};
export default OverviewSection;

View file

@ -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<Record<keyof Statistics, string>> = {
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<Record<keyof Statistics, string>> = {
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 (
<div
className={clsx(
'group/stat flex w-32 shrink-0 flex-col duration-75',
!bytes && 'hidden'
)}
>
<span className="whitespace-nowrap text-sm font-medium text-ink-faint">
{title}
{props.info && (
<Tooltip label={props.info}>
<Info
weight="fill"
className="-mt-0.5 ml-1 inline h-3 w-3 text-ink-faint opacity-0 transition-opacity group-hover/stat:opacity-70"
/>
</Tooltip>
)}
</span>
<span className="text-2xl">
<div
className={clsx({
hidden: isLoading
})}
>
<span className="font-black tabular-nums">{count}</span>
<span className="ml-1 text-[16px] font-medium text-ink-faint">{size.unit}</span>
</div>
</span>
</div>
);
};
const LibraryStats = () => {
const { library } = useLibraryContext();
const stats = useLibraryQuery(['library.statistics']);
useEffect(() => {
if (!stats.isLoading) mounted = true;
});
return (
<div className="flex w-full">
<div className="flex gap-3 overflow-hidden">
{Object.entries(stats?.data?.statistics || []).map(([key, value]) => {
if (!displayableStatItems.includes(key)) return null;
return (
<StatItem
key={`${library.uuid} ${key}`}
title={StatItemNames[key as keyof Statistics]!}
bytes={BigInt(value)}
isLoading={stats.isLoading}
info={StatDescriptions[key as keyof Statistics]}
/>
);
})}
</div>
</div>
);
};
export default LibraryStats;

View file

@ -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 (
<Card className="flex w-[280px] shrink-0 flex-col bg-app-box/50 !p-0 ">
<div className="flex flex-row items-center gap-5 p-4 px-6 ">
<div className="flex flex-col overflow-hidden">
<Icon className="-ml-1" name={icon as any} size={60} />
<span className="truncate font-medium">{name}</span>
<span className="mt-1 truncate text-tiny text-ink-faint">
{totalSpace.value}
{totalSpace.unit}
</span>
</div>
</div>
<div className="flex h-10 flex-row items-center gap-1.5 border-t border-app-line px-2">
<Pill className="uppercase">{connectionType || 'Local'}</Pill>
<div className="grow" />
<Button size="icon" variant="outline">
<Ellipsis className="h-3 w-3 opacity-50" />
</Button>
</div>
</Card>
);
};
export default LocationCard;

View file

@ -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 (
<div className="flex h-[170px] w-[280px] shrink-0 flex-col justify-between rounded border border-dashed border-app-line p-4">
<div className="flex flex-row items-start justify-between">
<div
className="flex flex-row"
style={{
WebkitMaskImage: maskImage,
maskImage
}}
>
{icons.map((iconName, index) => (
<div key={index}>
<Icon size={60} name={iconName} />
</div>
))}
</div>
{/* <Button size="icon" variant="outline">
<X weight="bold" className="h-3 w-3 opacity-50" />
</Button> */}
</div>
<span className="text-sm text-ink-dull">{text}</span>
<Button disabled={!buttonText} variant="outline">
{buttonText ? buttonText : 'Coming Soon'}
</Button>
</div>
);
};
export default NewCard;

View file

@ -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 (
<Card className="flex w-[280px] shrink-0 flex-col bg-app-box/50 !p-0 ">
<div className="flex flex-row items-center gap-5 p-4 px-6 ">
{!!stats.freeSpace && (
<CircularProgress
radius={40}
progress={progress}
strokeWidth={6}
trackStrokeWidth={6}
strokeColor={progress > 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"
>
<div className="absolute text-lg font-semibold">
{remainingSpace.value}
<span className="ml-0.5 text-tiny opacity-60">
{remainingSpace.unit}
</span>
</div>
</CircularProgress>
)}
<div className="flex flex-col overflow-hidden">
<Icon className="-ml-1" name={icon as any} size={60} />
<span className="truncate font-medium">{name}</span>
<span className="mt-1 truncate text-tiny text-ink-faint">
{freeSpace.value}
{freeSpace.unit} free of {totalSpace.value}
{totalSpace.unit}
</span>
</div>
</div>
<div className="flex h-10 flex-row items-center gap-1.5 border-t border-app-line px-2">
<Pill className="uppercase">{connectionType || 'Local'}</Pill>
<div className="grow" />
{/* <Button size="icon" variant="outline">
<Ellipsis className="h-3 w-3 opacity-50" />
</Button> */}
</div>
</Card>
);
};
export default StatCard;

View file

@ -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 (
<SearchContextProvider search={search}>
<div>
<TopBarPortal
left={
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">Library Overview</span>
</div>
}
center={<SearchBar />}
// right={
// <TopBarOptions
// options={[
// [
// {
// toolTipLabel: 'Spacedrop',
// onClick: () => {},
// icon: <Broadcast className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// },
// {
// toolTipLabel: 'Key Manager',
// onClick: () => {},
// icon: <Key className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// },
// {
// toolTipLabel: 'Overview Display Settings',
// onClick: () => {},
// icon: <SlidersHorizontal className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// }
// ]
// ]}
// />
// }
/>
<div className="mt-4 flex flex-col gap-3 pt-3">
<OverviewSection>
<LibraryStatistics />
</OverviewSection>
<OverviewSection>
<FileKindStatistics />
</OverviewSection>
<OverviewSection count={1} title="Devices">
{node && (
<StatisticItem
name={node.name}
icon={hardwareModelToIcon(node.device_model as any)}
totalSpace={stats.data?.statistics?.total_bytes_capacity || '0'}
freeSpace={stats.data?.statistics?.total_bytes_free || '0'}
color="#0362FF"
connectionType={null}
/>
)}
{/* <StatisticItem
name="Jamie's MacBook"
icon="Laptop"
total_space="4098046511104"
free_space="969004651119"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Jamie's iPhone"
icon="Mobile"
total_space="500046511104"
free_space="39006511104"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Titan NAS"
icon="Server"
total_space="60000046511104"
free_space="43000046511104"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Jamie's iPad"
icon="Tablet"
total_space="1074077906944"
free_space="121006553275"
color="#0362FF"
connection_type="lan"
/>
<StatisticItem
name="Jamie's Air"
icon="Laptop"
total_space="4098046511104"
free_space="969004651119"
color="#0362FF"
connection_type="p2p"
/> */}
<NewCard
icons={['Laptop', 'Server', 'SilverBox', 'Tablet']}
text="Spacedrive works best on all your devices."
// buttonText="Connect a device"
/>
{/**/}
</OverviewSection>
<OverviewSection count={locations.length} title="Locations">
{locations?.map((item) => (
<StatisticItem
key={item.id}
name={item.name || 'Unnamed Location'}
icon="Folder"
totalSpace={item.size_in_bytes || [0]}
color="#0362FF"
connectionType={null}
/>
))}
{!locations?.length && (
<NewCard
icons={['HDD', 'Folder', 'Globe', 'SD']}
text="Connect a local path, volume or network location to Spacedrive."
buttonText="Add a Location"
/>
)}
</OverviewSection>
<OverviewSection count={0} title="Cloud Drives">
{/* <StatisticItem
name="James Pine"
icon="DriveDropbox"
total_space="104877906944"
free_space="074877906944"
color="#0362FF"
connection_type="cloud"
/>
<StatisticItem
name="Spacedrive S3"
icon="DriveAmazonS3"
total_space="1074877906944"
free_space="704877906944"
color="#0362FF"
connection_type="cloud"
/> */}
<NewCard
icons={[
'DriveAmazonS3',
'DriveDropbox',
'DriveGoogleDrive',
'DriveOneDrive'
// 'DriveBox'
]}
text="Connect your cloud accounts to Spacedrive."
// buttonText="Connect a cloud"
/>
</OverviewSection>
{/* <OverviewSection title="Locations">
<div className="flex flex-row gap-2">
{locations.map((location) => (
<div
key={location.id}
className="flex w-[100px] flex-col items-center gap-2"
>
<Icon size={80} name="Folder" />
<span className="truncate text-xs font-medium">
{location.name}
</span>
</div>
))}
</div>
</OverviewSection> */}
</div>
</div>
</SearchContextProvider>
);
};

View file

@ -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<ObjectOrder>({ order: null });
}, []),
orderingKeys: objectOrderingKeysSchema
});
const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
const fixedFilters = useMemo<SearchFilterArgs[]>(
() => [
// { 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 (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Recents</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="Collection" size={128} />}
message="Recents are created when you open a file."
/>
}
/>
</ExplorerContextProvider>
);
}

View file

@ -19,8 +19,8 @@ import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Ex
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { EmptyNotice } from '../Explorer/View/EmptyNotice'; import { EmptyNotice } from '../Explorer/View/EmptyNotice';
import SearchOptions, { SearchContextProvider, useSearch, useSearchContext } from '../Search'; import { SearchContextProvider, SearchOptions, useSearch, useSearchContext } from '../search';
import SearchBar from '../Search/SearchBar'; import SearchBar from '../search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal'; import { TopBarPortal } from '../TopBar/Portal';
export const Component = () => { export const Component = () => {

View file

@ -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 { useState } from 'react';
import { import {
InOrNotIn, InOrNotIn,
@ -574,6 +582,39 @@ export const filterRegistry = [
]; ];
}, },
Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={search} /> Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={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 }) => (
<FilterOptionList filter={filter} options={options} search={search} />
)
}) })
// idk how to handle this rn since include_descendants is part of 'path' now // idk how to handle this rn since include_descendants is part of 'path' now
// //

View file

@ -4,8 +4,8 @@ import { Input, ModifierKeys, Shortcut } from '@sd/ui';
import { useOperatingSystem } from '~/hooks'; import { useOperatingSystem } from '~/hooks';
import { keybindForOs } from '~/util/keybinds'; import { keybindForOs } from '~/util/keybinds';
import { useSearchContext } from '../Search'; import { useSearchContext } from './context';
import { useSearchStore } from '../Search/store'; import { useSearchStore } from './store';
export default () => { export default () => {
const search = useSearchContext(); const search = useSearchContext();

View file

@ -87,7 +87,10 @@ export const SearchOptionSubMenu = (
export const Separator = () => <DropdownMenu.Separator className="!border-app-line" />; export const Separator = () => <DropdownMenu.Separator className="!border-app-line" />;
const SearchOptions = ({ allowExit, children }: { allowExit?: boolean } & PropsWithChildren) => { export const SearchOptions = ({
allowExit,
children
}: { allowExit?: boolean } & PropsWithChildren) => {
const search = useSearchContext(); const search = useSearchContext();
const isDark = useIsDark(); const isDark = useIsDark();
return ( return (
@ -132,8 +135,6 @@ const SearchOptions = ({ allowExit, children }: { allowExit?: boolean } & PropsW
); );
}; };
export default SearchOptions;
const SearchResults = memo( const SearchResults = memo(
({ searchQuery, search }: { searchQuery: string; search: UseSearch }) => { ({ searchQuery, search }: { searchQuery: string; search: UseSearch }) => {
const { allFiltersKeys } = search; const { allFiltersKeys } = search;

View file

@ -1,7 +1,5 @@
import { createContext, PropsWithChildren, useContext } from 'react'; import { createContext, PropsWithChildren, useContext } from 'react';
import { filterRegistry } from './Filters';
import { useRegisterSearchFilterOptions } from './store';
import { UseSearch } from './useSearch'; import { UseSearch } from './useSearch';
const SearchContext = createContext<UseSearch | null>(null); const SearchContext = createContext<UseSearch | null>(null);

View file

@ -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<ObjectOrder>({ 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 (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Search</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="Collection" size={128} />}
message="No recent items"
/>
}
/>
</ExplorerContextProvider>
);
}
function useSearchWithFilters(explorerSettings: UseExplorerSettings<ObjectOrder>) {
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;
}

View file

@ -20,8 +20,8 @@ import { tw } from '@sd/ui';
import { useLocale, useOperatingSystem } from '~/hooks'; import { useLocale, useOperatingSystem } from '~/hooks';
import { usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
import Icon from '../Layout/Sidebar/Icon'; import Icon from '../Layout/Sidebar/SidebarLayout/Icon';
import SidebarLink from '../Layout/Sidebar/Link'; import SidebarLink from '../Layout/Sidebar/SidebarLayout/Link';
import { NavigationButtons } from '../TopBar/NavigationButtons'; import { NavigationButtons } from '../TopBar/NavigationButtons';
const Heading = tw.div`mb-1 ml-1 text-xs font-semibold text-gray-400`; const Heading = tw.div`mb-1 ml-1 text-xs font-semibold text-gray-400`;
@ -41,7 +41,7 @@ export default () => {
{platform === 'tauri' ? ( {platform === 'tauri' ? (
<div <div
data-tauri-drag-region={os === 'macOS'} data-tauri-drag-region={os === 'macOS'}
className="mb-3 h-3 w-full p-3 pl-[14px] pt-[10px]" className="mb-3 h-3 w-full p-3 pl-[14px] pt-[11px]"
> >
<NavigationButtons /> <NavigationButtons />
</div> </div>
@ -56,10 +56,10 @@ export default () => {
<Icon component={GearSix} /> <Icon component={GearSix} />
{t('general')} {t('general')}
</SidebarLink> </SidebarLink>
<SidebarLink to="client/usage"> {/* <SidebarLink to="client/usage">
<Icon component={ChartBar} /> <Icon component={ChartBar} />
{t('usage')} {t('usage')}
</SidebarLink> </SidebarLink> */}
<SidebarLink to="client/account"> <SidebarLink to="client/account">
<Icon component={User} /> <Icon component={User} />
{t('account')} {t('account')}

View file

@ -27,12 +27,11 @@ export const Component = () => {
)} )}
</> </>
} }
title={t('your_account')} title="Spacedrive Cloud"
description={t('your_account_description')} 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."
/> />
<div className="flex flex-col justify-between gap-5 lg:flex-row"> <div className="flex flex-col justify-between gap-5 lg:flex-row">
<Profile authStore={authStore} email={me.data?.email} /> <Profile authStore={authStore} email={me.data?.email} />
<Cloud />
</div> </div>
{useFeatureFlag('hostedLocations') && <HostedLocationsPlayground />} {useFeatureFlag('hostedLocations') && <HostedLocationsPlayground />}
</> </>
@ -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 (
<Card className="flex w-full flex-col !p-6">
<h1 className="text-lg font-bold">Cloud services</h1>
<div className="mt-5 grid grid-cols-1 gap-2 lg:grid-cols-3">
{services.map((s, index) => (
<Card
key={index}
className="relative flex flex-col items-center justify-center gap-2 bg-app-input !p-4"
>
<div
className="z-5 absolute flex h-full w-full items-center justify-center rounded-md bg-app/50 backdrop-blur-[8px]"
key={index}
>
<p className="text-center text-[13px] font-medium text-ink-faint">
Coming soon
</p>
</div>
<Icon name={s.icon} size={50} />
<p className="text-[14px] font-medium text-ink">{s.service}</p>
</Card>
))}
</div>
</Card>
);
};
function HostedLocationsPlayground() { function HostedLocationsPlayground() {
const locations = useBridgeQuery(['cloud.locations.list'], { retry: false }); const locations = useBridgeQuery(['cloud.locations.list'], { retry: false });

View file

@ -22,10 +22,11 @@ export const Component = () => {
const discoveredPeers = useDiscoveredPeers(); const discoveredPeers = useDiscoveredPeers();
const info = useMemo(() => { const info = useMemo(() => {
if (locations.data && discoveredPeers) { if (locations.data && discoveredPeers) {
const tb_capacity = byteSize(stats.data?.total_bytes_capacity); const statistics = stats.data?.statistics;
const free_space = byteSize(stats.data?.total_bytes_free); const tb_capacity = byteSize(statistics?.total_bytes_capacity);
const library_db_size = byteSize(stats.data?.library_db_size); const free_space = byteSize(statistics?.total_bytes_free);
const preview_media = byteSize(stats.data?.preview_media_bytes); const library_db_size = byteSize(statistics?.library_db_size);
const preview_media = byteSize(statistics?.preview_media_bytes);
const data: { const data: {
icon: keyof typeof iconNames; icon: keyof typeof iconNames;
title?: string; title?: string;

View file

@ -9,8 +9,8 @@ import {
useZodForm useZodForm
} from '@sd/client'; } from '@sd/client';
import { Button, Card, Form, InputField, Label, Tooltip, z } from '@sd/ui'; import { Button, Card, Form, InputField, Label, Tooltip, z } from '@sd/ui';
import { SearchContextProvider, useSearch } from '~/app/$libraryId/Search'; import { SearchContextProvider, useSearch } from '~/app/$libraryId/search';
import { AppliedFilters } from '~/app/$libraryId/Search/AppliedFilters'; import { AppliedFilters } from '~/app/$libraryId/search/AppliedFilters';
import { Heading } from '~/app/$libraryId/settings/Layout'; import { Heading } from '~/app/$libraryId/settings/Layout';
import { useDebouncedFormWatch, useLocale } from '~/hooks'; import { useDebouncedFormWatch, useLocale } from '~/hooks';

View file

@ -11,8 +11,8 @@ import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Expl
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions'; import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer'; import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { EmptyNotice } from '../Explorer/View/EmptyNotice'; import { EmptyNotice } from '../Explorer/View/EmptyNotice';
import SearchOptions, { SearchContextProvider, useSearch } from '../Search'; import { SearchContextProvider, SearchOptions, useSearch } from '../search';
import SearchBar from '../Search/SearchBar'; import SearchBar from '../search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal'; import { TopBarPortal } from '../TopBar/Portal';
export function Component() { export function Component() {

View file

@ -383,6 +383,10 @@ body {
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1); transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1);
} }
.icon-with-shadow {
filter: url(#svg-shadow-filter);
}
@keyframes wiggle { @keyframes wiggle {
0%, 100% { transform: rotate(-1deg); } 0%, 100% { transform: rotate(-1deg); }
50% { transform: rotate(1deg); } 50% { transform: rotate(1deg); }

View file

@ -1,4 +1,5 @@
import { getIcon, iconNames } from '@sd/assets/util'; import { getIcon, iconNames } from '@sd/assets/util';
import clsx from 'clsx';
import { ImgHTMLAttributes } from 'react'; import { ImgHTMLAttributes } from 'react';
import { useIsDark } from '~/hooks'; import { useIsDark } from '~/hooks';
@ -18,6 +19,7 @@ export const Icon = ({ name, size, theme, ...props }: Props) => {
width={size} width={size}
height={size} height={size}
{...props} {...props}
className={clsx('pointer-events-none', props.className)}
/> />
); );
}; };

View file

@ -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';
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -29,6 +29,7 @@ import Code20 from './Code-20.png';
import Collection_Light from './Collection_Light.png'; import Collection_Light from './Collection_Light.png';
import Collection20 from './Collection-20.png'; import Collection20 from './Collection-20.png';
import Collection from './Collection.png'; import Collection from './Collection.png';
import CollectionSparkle from './CollectionSparkle.png';
import Config20 from './Config-20.png'; import Config20 from './Config-20.png';
import Database_Light from './Database_Light.png'; import Database_Light from './Database_Light.png';
import Database20 from './Database-20.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_pdf from './Document_pdf.png';
import Document_xls_Light from './Document_xls_Light.png'; import Document_xls_Light from './Document_xls_Light.png';
import Document_xls from './Document_xls.png'; import Document_xls from './Document_xls.png';
import Document_xmp from './Document_xmp.png';
import Document20 from './Document-20.png'; import Document20 from './Document-20.png';
import Document from './Document.png'; import Document from './Document.png';
import Dotfile20 from './Dotfile-20.png'; import Dotfile20 from './Dotfile-20.png';
@ -82,6 +84,7 @@ import Executable from './Executable.png';
import Face_Light from './Face_Light.png'; import Face_Light from './Face_Light.png';
import Folder_Light from './Folder_Light.png'; import Folder_Light from './Folder_Light.png';
import Folder20 from './Folder-20.png'; import Folder20 from './Folder-20.png';
import Foldertagxmp from './Folder-tag-xmp.png';
import Folder from './Folder.png'; import Folder from './Folder.png';
import FolderGrey_Light from './FolderGrey_Light.png'; import FolderGrey_Light from './FolderGrey_Light.png';
import FolderGrey from './FolderGrey.png'; import FolderGrey from './FolderGrey.png';
@ -113,6 +116,9 @@ import Laptop from './Laptop.png';
import Link_Light from './Link_Light.png'; import Link_Light from './Link_Light.png';
import Link20 from './Link-20.png'; import Link20 from './Link-20.png';
import Link from './Link.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_Light from './Lock_Light.png';
import Lock from './Lock.png'; import Lock from './Lock.png';
import Mega from './Mega.png'; import Mega from './Mega.png';
@ -142,8 +148,11 @@ import Screenshot from './Screenshot.png';
import ScreenshotAlt from './ScreenshotAlt.png'; import ScreenshotAlt from './ScreenshotAlt.png';
import SD_Light from './SD_Light.png'; import SD_Light from './SD_Light.png';
import SD from './SD.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_Light from './Server_Light.png';
import Server from './Server.png'; import Server from './Server.png';
import SilverBox from './SilverBox.png';
import Spacedrop_Light from './Spacedrop_Light.png'; import Spacedrop_Light from './Spacedrop_Light.png';
import Spacedrop1 from './Spacedrop-1.png'; import Spacedrop1 from './Spacedrop-1.png';
import Spacedrop from './Spacedrop.png'; import Spacedrop from './Spacedrop.png';
@ -200,6 +209,7 @@ export {
Code20, Code20,
Collection20, Collection20,
Collection, Collection,
CollectionSparkle,
Collection_Light, Collection_Light,
Config20, Config20,
DAV, DAV,
@ -216,6 +226,7 @@ export {
Document_pdf_Light, Document_pdf_Light,
Document_xls, Document_xls,
Document_xls_Light, Document_xls_Light,
Document_xmp,
Dotfile20, Dotfile20,
DriveAmazonS3, DriveAmazonS3,
DriveAmazonS3_Light, DriveAmazonS3_Light,
@ -253,6 +264,7 @@ export {
Executable_old, Executable_old,
Face_Light, Face_Light,
Folder20, Folder20,
Foldertagxmp,
Folder, Folder,
FolderGrey, FolderGrey,
FolderGrey_Light, FolderGrey_Light,
@ -285,6 +297,9 @@ export {
Link20, Link20,
Link, Link,
Link_Light, Link_Light,
Location,
LocationManaged,
LocationReplica,
Lock, Lock,
Lock_Light, Lock_Light,
Mega, Mega,
@ -314,8 +329,11 @@ export {
Screenshot, Screenshot,
ScreenshotAlt, ScreenshotAlt,
Screenshot_Light, Screenshot_Light,
Search,
SearchAlt,
Server, Server,
Server_Light, Server_Light,
SilverBox,
Spacedrop1, Spacedrop1,
Spacedrop, Spacedrop,
Spacedrop_Light, Spacedrop_Light,

View file

@ -18,12 +18,15 @@ export type Procedures = {
{ key: "invalidation.test-invalidate", input: never, result: number } | { key: "invalidation.test-invalidate", input: never, result: number } |
{ key: "jobs.isActive", input: LibraryArgs<null>, result: boolean } | { key: "jobs.isActive", input: LibraryArgs<null>, result: boolean } |
{ key: "jobs.reports", input: LibraryArgs<null>, result: JobGroup[] } | { key: "jobs.reports", input: LibraryArgs<null>, result: JobGroup[] } |
{ key: "labels.count", input: LibraryArgs<null>, result: number } |
{ key: "labels.get", input: LibraryArgs<number>, result: { id: number; pub_id: number[]; name: string; date_created: string; date_modified: string } | null } | { key: "labels.get", input: LibraryArgs<number>, result: { id: number; pub_id: number[]; name: string; date_created: string; date_modified: string } | null } |
{ key: "labels.getForObject", input: LibraryArgs<number>, result: Label[] } | { key: "labels.getForObject", input: LibraryArgs<number>, result: Label[] } |
{ key: "labels.getWithObjects", input: LibraryArgs<number[]>, result: { [key in number]: { date_created: string; object: { id: number } }[] } } | { key: "labels.getWithObjects", input: LibraryArgs<number[]>, result: { [key in number]: { date_created: string; object: { id: number } }[] } } |
{ key: "labels.list", input: LibraryArgs<null>, result: Label[] } | { key: "labels.list", input: LibraryArgs<null>, result: Label[] } |
{ key: "labels.listWithThumbnails", input: LibraryArgs<string>, result: ExplorerItem[] } |
{ key: "library.kindStatistics", input: LibraryArgs<null>, result: KindStatistics } |
{ key: "library.list", input: never, result: NormalisedResults<LibraryConfigWrapped> } | { key: "library.list", input: never, result: NormalisedResults<LibraryConfigWrapped> } |
{ key: "library.statistics", input: LibraryArgs<null>, result: Statistics } | { key: "library.statistics", input: LibraryArgs<null>, result: StatisticsResponse } |
{ key: "locations.get", input: LibraryArgs<number>, result: { item: Reference<Location>; nodes: CacheNode[] } | null } | { key: "locations.get", input: LibraryArgs<number>, result: { item: Reference<Location>; nodes: CacheNode[] } | null } |
{ key: "locations.getWithRules", input: LibraryArgs<number>, result: { item: Reference<LocationWithIndexerRule>; nodes: CacheNode[] } | null } | { key: "locations.getWithRules", input: LibraryArgs<number>, result: { item: Reference<LocationWithIndexerRule>; nodes: CacheNode[] } | null } |
{ key: "locations.indexer_rules.get", input: LibraryArgs<number>, result: NormalisedResult<IndexerRule> } | { key: "locations.indexer_rules.get", input: LibraryArgs<number>, result: NormalisedResult<IndexerRule> } |
@ -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 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" 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 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 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 } 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 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 JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused" | "CompletedWithErrors"
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue } 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 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. * 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 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 } 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<string> } | { kind: CursorOrderItem<number> } export type ObjectCursor = "none" | { dateAccessed: CursorOrderItem<string> } | { kind: CursorOrderItem<number> }
export type ObjectFilterArgs = { favorite: boolean } | { hidden: ObjectHiddenFilter } | { kind: InOrNotIn<number> } | { tags: InOrNotIn<number> } | { dateAccessed: Range<string> } export type ObjectFilterArgs = { favorite: boolean } | { hidden: ObjectHiddenFilter } | { kind: InOrNotIn<number> } | { tags: InOrNotIn<number> } | { labels: InOrNotIn<number> } | { dateAccessed: Range<string> }
export type ObjectHiddenFilter = "exclude" | "include" 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 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 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 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 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 } 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 }

View file

@ -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<typeof byteSize>;
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
};
}

View file

@ -1,3 +1,5 @@
export * from './objectKind';
export * from './explorerItem';
export * from './byte-size'; export * from './byte-size';
export * from './passwordStrength'; export * from './passwordStrength';
export * from './valtio'; export * from './valtio';

View file

@ -26,7 +26,8 @@ export enum ObjectKindEnum {
Book, Book,
Config, Config,
Dotfile, Dotfile,
Screenshot Screenshot,
Label
} }
export type ObjectKindKey = keyof typeof ObjectKindEnum; export type ObjectKindKey = keyof typeof ObjectKindEnum;

View file

@ -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]);
};

View file

@ -1,11 +1,98 @@
import { QueryClient } from '@tanstack/react-query'; 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 './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<ExplorerItem, { type: 'Path' }> { export function isPath(item: ExplorerItem): item is Extract<ExplorerItem, { type: 'Path' }> {
return item.type === 'Path'; return item.type === 'Path';

Some files were not shown because too many files have changed in this diff Show more