[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>
|
@ -37,6 +37,7 @@ narkhede
|
|||
naveen
|
||||
neha
|
||||
noco
|
||||
Normalised
|
||||
OSSC
|
||||
poonen
|
||||
rauch
|
||||
|
|
24
Cargo.lock
generated
|
@ -2004,18 +2004,6 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deps-generator"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_metadata 0.18.1",
|
||||
"clap",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.6.1"
|
||||
|
@ -7588,6 +7576,18 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sd-deps-generator"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_metadata 0.18.1",
|
||||
"clap",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sd-desktop"
|
||||
version = "0.1.4"
|
||||
|
|
|
@ -27,9 +27,13 @@ const FileThumbWrapper = ({ children, size = 1 }: PropsWithChildren<{ size: numb
|
|||
function useExplorerItemData(explorerItem: ExplorerItem) {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const firstThumbnail =
|
||||
explorerItem.type === 'Label'
|
||||
? explorerItem.thumbnails?.[0]
|
||||
: 'thumbnail' in explorerItem && explorerItem.thumbnail;
|
||||
|
||||
const newThumbnail = !!(
|
||||
explorerItem.thumbnail_key &&
|
||||
explorerStore.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key))
|
||||
firstThumbnail && explorerStore.newThumbnails.has(flattenThumbnailKey(firstThumbnail))
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
|
|
|
@ -70,7 +70,7 @@ const OverviewStats = () => {
|
|||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
{Object.entries(libraryStatistics).map(([key, bytesRaw]) => {
|
||||
if (!displayableStatItems.includes(key)) return null;
|
||||
let bytes = BigInt(bytesRaw);
|
||||
let bytes = BigInt(bytesRaw?.total_bytes_free ?? 0);
|
||||
if (key === 'total_bytes_free') {
|
||||
bytes = BigInt(sizeInfo.freeSpace);
|
||||
} else if (key === 'total_bytes_capacity') {
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
use crate::{invalidate_query, library::Library};
|
||||
use crate::{invalidate_query, library::Library, object::media::thumbnail::get_indexed_thumb_key};
|
||||
|
||||
use sd_prisma::prisma::{label, label_on_object, object};
|
||||
use sd_prisma::prisma::{label, label_on_object, object, SortOrder};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use rspc::alpha::AlphaRouter;
|
||||
|
||||
use super::{utils::library, Ctx, R};
|
||||
use super::{locations::ExplorerItem, utils::library, Ctx, R};
|
||||
|
||||
label::include!((take: i64) => label_with_objects {
|
||||
label_objects(vec![]).take(take): select {
|
||||
object: select {
|
||||
id
|
||||
file_paths(vec![]).take(1)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
||||
R.router()
|
||||
|
@ -15,6 +24,45 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
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", {
|
||||
R.with2(library())
|
||||
.query(|(_, library), object_id: i32| async move {
|
||||
|
|
|
@ -1,31 +1,50 @@
|
|||
use crate::{
|
||||
library::{Library, LibraryConfig, LibraryName},
|
||||
invalidate_query,
|
||||
library::{update_library_statistics, Library, LibraryConfig, LibraryName},
|
||||
location::{scan_location, LocationCreateArgs},
|
||||
util::MaybeUndefined,
|
||||
volume::get_volumes,
|
||||
Node,
|
||||
};
|
||||
|
||||
use futures::StreamExt;
|
||||
use sd_cache::{Model, Normalise, NormalisedResult, NormalisedResults};
|
||||
use sd_file_ext::kind::ObjectKind;
|
||||
use sd_p2p::spacetunnel::RemoteIdentity;
|
||||
use sd_prisma::prisma::{indexer_rule, statistics};
|
||||
use sd_prisma::prisma::{indexer_rule, object, statistics};
|
||||
use tokio_stream::wrappers::IntervalStream;
|
||||
|
||||
use std::{convert::identity, sync::Arc};
|
||||
use std::{
|
||||
collections::{hash_map::Entry, HashMap},
|
||||
convert::identity,
|
||||
pin::pin,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use async_channel as chan;
|
||||
use directories::UserDirs;
|
||||
use futures_concurrency::future::Join;
|
||||
use futures_concurrency::{future::Join, stream::Merge};
|
||||
use once_cell::sync::Lazy;
|
||||
use rspc::{alpha::AlphaRouter, ErrorCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tokio::spawn;
|
||||
use strum::IntoEnumIterator;
|
||||
use tokio::{
|
||||
spawn,
|
||||
sync::Mutex,
|
||||
time::{interval, Instant},
|
||||
};
|
||||
use tracing::{debug, error};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
utils::{get_size, library},
|
||||
Ctx, R,
|
||||
};
|
||||
use super::{utils::library, Ctx, R};
|
||||
|
||||
const ONE_MINUTE: Duration = Duration::from_secs(60);
|
||||
const TWO_MINUTES: Duration = Duration::from_secs(60 * 2);
|
||||
const FIVE_MINUTES: Duration = Duration::from_secs(60 * 5);
|
||||
|
||||
static STATISTICS_UPDATERS: Lazy<Mutex<HashMap<Uuid, chan::Sender<Instant>>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
// TODO(@Oscar): Replace with `specta::json`
|
||||
#[derive(Serialize, Type)]
|
||||
|
@ -80,65 +99,69 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
})
|
||||
})
|
||||
.procedure("statistics", {
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
pub struct StatisticsResponse {
|
||||
statistics: Option<statistics::Data>,
|
||||
}
|
||||
R.with2(library())
|
||||
.query(|(node, library), _: ()| async move {
|
||||
// TODO: get from database if library is offline
|
||||
// let _statistics = library
|
||||
// .db
|
||||
// .statistics()
|
||||
// .find_unique(statistics::id::equals(library.node_local_id))
|
||||
// .exec()
|
||||
// .await?;
|
||||
|
||||
let volumes = get_volumes().await;
|
||||
// save_volume(&library).await?;
|
||||
|
||||
let mut total_capacity: u64 = 0;
|
||||
let mut available_capacity: u64 = 0;
|
||||
for volume in volumes {
|
||||
total_capacity += volume.total_capacity;
|
||||
available_capacity += volume.available_capacity;
|
||||
}
|
||||
|
||||
let library_db_size = get_size(
|
||||
node.config
|
||||
.data_directory()
|
||||
.join("libraries")
|
||||
.join(&format!("{}.db", library.id)),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let thumbnail_folder_size =
|
||||
get_size(node.config.data_directory().join("thumbnails"))
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
use statistics::*;
|
||||
let params = vec![
|
||||
id::set(1), // Each library is a database so only one of these ever exists
|
||||
date_captured::set(Utc::now().into()),
|
||||
total_object_count::set(0),
|
||||
library_db_size::set(library_db_size.to_string()),
|
||||
total_bytes_used::set(0.to_string()),
|
||||
total_bytes_capacity::set(total_capacity.to_string()),
|
||||
total_unique_bytes::set(0.to_string()),
|
||||
total_bytes_free::set(available_capacity.to_string()),
|
||||
preview_media_bytes::set(thumbnail_folder_size.to_string()),
|
||||
];
|
||||
|
||||
Ok(library
|
||||
let statistics = library
|
||||
.db
|
||||
.statistics()
|
||||
.upsert(
|
||||
statistics::id::equals(1), // Each library is a database so only one of these ever exists
|
||||
statistics::create(params.clone()),
|
||||
params,
|
||||
)
|
||||
.find_unique(statistics::id::equals(1))
|
||||
.exec()
|
||||
.await?)
|
||||
.await?;
|
||||
|
||||
match STATISTICS_UPDATERS.lock().await.entry(library.id) {
|
||||
Entry::Occupied(entry) => {
|
||||
if entry.get().send(Instant::now()).await.is_err() {
|
||||
error!("Failed to send statistics update request");
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
let (tx, rx) = chan::bounded(1);
|
||||
entry.insert(tx);
|
||||
|
||||
spawn(update_statistics_loop(node, library, rx));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StatisticsResponse { statistics })
|
||||
})
|
||||
})
|
||||
.procedure("kindStatistics", {
|
||||
#[derive(Serialize, Deserialize, Type, Default)]
|
||||
pub struct KindStatistic {
|
||||
kind: i32,
|
||||
name: String,
|
||||
count: i32,
|
||||
total_bytes: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Type, Default)]
|
||||
pub struct KindStatistics {
|
||||
statistics: Vec<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", {
|
||||
#[derive(Deserialize, Type, Default)]
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,39 +28,37 @@ use serde::{Deserialize, Serialize};
|
|||
use specta::Type;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use super::{utils::library, Ctx, R};
|
||||
use super::{labels::label_with_objects, utils::library, Ctx, R};
|
||||
|
||||
// it includes the shard hex formatted as ([["f02", "cab34a76fbf3469f"]])
|
||||
// Will be None if no thumbnail exists
|
||||
pub type ThumbnailKey = Vec<String>;
|
||||
|
||||
#[derive(Serialize, Type, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ExplorerItem {
|
||||
Path {
|
||||
// has_local_thumbnail is true only if there is local existence of a thumbnail
|
||||
has_local_thumbnail: bool,
|
||||
// thumbnail_key is present if there is a cas_id
|
||||
// it includes the shard hex formatted as (["f0", "cab34a76fbf3469f"])
|
||||
thumbnail_key: Option<Vec<String>>,
|
||||
thumbnail: Option<ThumbnailKey>,
|
||||
item: file_path_with_object::Data,
|
||||
},
|
||||
Object {
|
||||
has_local_thumbnail: bool,
|
||||
thumbnail_key: Option<Vec<String>>,
|
||||
thumbnail: Option<ThumbnailKey>,
|
||||
item: object_with_file_paths::Data,
|
||||
},
|
||||
Location {
|
||||
has_local_thumbnail: bool,
|
||||
thumbnail_key: Option<Vec<String>>,
|
||||
item: location::Data,
|
||||
},
|
||||
NonIndexedPath {
|
||||
has_local_thumbnail: bool,
|
||||
thumbnail_key: Option<Vec<String>>,
|
||||
thumbnail: Option<ThumbnailKey>,
|
||||
item: NonIndexedPathItem,
|
||||
},
|
||||
SpacedropPeer {
|
||||
has_local_thumbnail: bool,
|
||||
thumbnail_key: Option<Vec<String>>,
|
||||
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.
|
||||
|
@ -79,6 +77,7 @@ impl ExplorerItem {
|
|||
ExplorerItem::Location { .. } => "Location",
|
||||
ExplorerItem::NonIndexedPath { .. } => "NonIndexedPath",
|
||||
ExplorerItem::SpacedropPeer { .. } => "SpacedropPeer",
|
||||
ExplorerItem::Label { .. } => "Label",
|
||||
};
|
||||
match self {
|
||||
ExplorerItem::Path { item, .. } => format!("{ty}:{}", item.id),
|
||||
|
@ -86,6 +85,7 @@ impl ExplorerItem {
|
|||
ExplorerItem::Location { item, .. } => format!("{ty}:{}", item.id),
|
||||
ExplorerItem::NonIndexedPath { item, .. } => format!("{ty}:{}", item.path),
|
||||
ExplorerItem::SpacedropPeer { item, .. } => format!("{ty}:{}", item.name), // TODO: Use a proper primary key
|
||||
ExplorerItem::Label { item, .. } => format!("{ty}:{}", item.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
use crate::{
|
||||
invalidate_query,
|
||||
job::JobProgressEvent,
|
||||
node::config::{NodeConfig, NodePreferences},
|
||||
node::{
|
||||
config::{NodeConfig, NodePreferences},
|
||||
get_hardware_model_name, HardwareModel,
|
||||
},
|
||||
Node,
|
||||
};
|
||||
|
||||
use sd_cache::patch_typedef;
|
||||
use sd_p2p::P2PStatus;
|
||||
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
|
||||
use itertools::Itertools;
|
||||
|
@ -118,6 +120,7 @@ struct NodeState {
|
|||
config: SanitisedNodeConfig,
|
||||
data_path: String,
|
||||
p2p: P2PStatus,
|
||||
device_model: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn mount() -> Arc<Router> {
|
||||
|
@ -139,6 +142,10 @@ pub(crate) fn mount() -> Arc<Router> {
|
|||
})
|
||||
.procedure("nodeState", {
|
||||
R.query(|node, _: ()| async move {
|
||||
let device_model = get_hardware_model_name()
|
||||
.unwrap_or(HardwareModel::Other)
|
||||
.to_string();
|
||||
|
||||
Ok(NodeState {
|
||||
config: node.config.get().await.into(),
|
||||
// We are taking the assumption here that this value is only used on the frontend for display purposes
|
||||
|
@ -149,6 +156,7 @@ pub(crate) fn mount() -> Arc<Router> {
|
|||
.expect("Found non-UTF-8 path")
|
||||
.to_string(),
|
||||
p2p: node.p2p.manager.status(),
|
||||
device_model: Some(device_model),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -149,11 +149,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
.exec()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|location| ExplorerItem::Location {
|
||||
has_local_thumbnail: false,
|
||||
thumbnail_key: None,
|
||||
item: location,
|
||||
})
|
||||
.map(|location| ExplorerItem::Location { item: location })
|
||||
.collect::<Vec<_>>())
|
||||
})
|
||||
})
|
||||
|
|
|
@ -255,10 +255,10 @@ pub fn mount() -> AlphaRouter<Ctx> {
|
|||
};
|
||||
|
||||
items.push(ExplorerItem::Path {
|
||||
has_local_thumbnail: thumbnail_exists_locally,
|
||||
thumbnail_key: file_path
|
||||
thumbnail: file_path
|
||||
.cas_id
|
||||
.as_ref()
|
||||
.filter(|_| thumbnail_exists_locally)
|
||||
.map(|i| get_indexed_thumb_key(i, library.id)),
|
||||
item: file_path,
|
||||
})
|
||||
|
@ -377,8 +377,9 @@ pub fn mount() -> AlphaRouter<Ctx> {
|
|||
};
|
||||
|
||||
items.push(ExplorerItem::Object {
|
||||
has_local_thumbnail: thumbnail_exists_locally,
|
||||
thumbnail_key: cas_id.map(|i| get_indexed_thumb_key(i, library.id)),
|
||||
thumbnail: cas_id
|
||||
.filter(|_| thumbnail_exists_locally)
|
||||
.map(|cas_id| get_indexed_thumb_key(cas_id, library.id)),
|
||||
item: object,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// use crate::library::Category;
|
||||
|
||||
use sd_prisma::prisma::{self, object, tag_on_object};
|
||||
use sd_prisma::prisma::{self, label_on_object, object, tag_on_object};
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use prisma_client_rust::{not, or, OrderByQuery, PaginatedQuery, WhereQuery};
|
||||
|
@ -113,6 +113,7 @@ pub enum ObjectFilterArgs {
|
|||
Hidden(ObjectHiddenFilter),
|
||||
Kind(InOrNotIn<i32>),
|
||||
Tags(InOrNotIn<i32>),
|
||||
Labels(InOrNotIn<i32>),
|
||||
DateAccessed(Range<chrono::DateTime<FixedOffset>>),
|
||||
}
|
||||
|
||||
|
@ -130,6 +131,13 @@ impl ObjectFilterArgs {
|
|||
)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default(),
|
||||
Self::Labels(v) => v
|
||||
.into_param(
|
||||
|v| labels::some(vec![label_on_object::label_id::in_vec(v)]),
|
||||
|v| labels::none(vec![label_on_object::label_id::in_vec(v)]),
|
||||
)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default(),
|
||||
Self::Kind(v) => v
|
||||
.into_param(kind::in_vec, kind::not_in_vec)
|
||||
.map(|v| vec![v])
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,16 +1,16 @@
|
|||
// pub(crate) mod cat;
|
||||
mod actors;
|
||||
mod config;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod library;
|
||||
mod manager;
|
||||
mod name;
|
||||
mod statistics;
|
||||
|
||||
// pub use cat::*;
|
||||
pub use actors::*;
|
||||
pub use config::*;
|
||||
pub use library::*;
|
||||
pub use manager::*;
|
||||
pub use name::*;
|
||||
pub use statistics::*;
|
||||
|
||||
pub type LibraryId = uuid::Uuid;
|
||||
|
|
66
core/src/library/statistics.rs
Normal 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)
|
||||
}
|
|
@ -231,8 +231,7 @@ pub async fn walk(
|
|||
};
|
||||
|
||||
tx.send(Ok(ExplorerItem::NonIndexedPath {
|
||||
has_local_thumbnail: thumbnail_key.is_some(),
|
||||
thumbnail_key,
|
||||
thumbnail: thumbnail_key,
|
||||
item: NonIndexedPathItem {
|
||||
hidden: path_is_hidden(Path::new(&entry_path), &entry.metadata),
|
||||
path: entry_path,
|
||||
|
@ -281,16 +280,11 @@ pub async fn walk(
|
|||
|
||||
for (directory, name, metadata) in directories {
|
||||
if let Some(location) = locations.remove(&directory) {
|
||||
tx.send(Ok(ExplorerItem::Location {
|
||||
has_local_thumbnail: false,
|
||||
thumbnail_key: None,
|
||||
item: location,
|
||||
}))
|
||||
.await?;
|
||||
tx.send(Ok(ExplorerItem::Location { item: location }))
|
||||
.await?;
|
||||
} else {
|
||||
tx.send(Ok(ExplorerItem::NonIndexedPath {
|
||||
has_local_thumbnail: false,
|
||||
thumbnail_key: None,
|
||||
thumbnail: None,
|
||||
item: NonIndexedPathItem {
|
||||
hidden: path_is_hidden(Path::new(&directory), &metadata),
|
||||
path: directory,
|
||||
|
|
71
core/src/node/hardware.rs
Normal 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",
|
||||
))
|
||||
}
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
pub mod config;
|
||||
mod hardware;
|
||||
mod platform;
|
||||
|
||||
pub use hardware::*;
|
||||
pub use platform::*;
|
||||
|
|
|
@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use prisma_client_rust::or;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, trace, warn};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use super::{process_identifier_file_paths, FileIdentifierJobError, CHUNK_SIZE};
|
||||
|
||||
|
@ -28,7 +28,7 @@ pub async fn shallow(
|
|||
) -> Result<(), JobError> {
|
||||
let Library { db, .. } = library;
|
||||
|
||||
debug!("Identifying orphan File Paths...");
|
||||
warn!("Identifying orphan File Paths...");
|
||||
|
||||
let location_id = location.id;
|
||||
let location_path = maybe_missing(&location.path, "location.path").map(Path::new)?;
|
||||
|
@ -66,7 +66,7 @@ pub async fn shallow(
|
|||
}
|
||||
|
||||
let task_count = (orphan_count as f64 / CHUNK_SIZE as f64).ceil() as usize;
|
||||
debug!(
|
||||
warn!(
|
||||
"Found {} orphan Paths. Will execute {} tasks...",
|
||||
orphan_count, task_count
|
||||
);
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
use crate::{
|
||||
node::config,
|
||||
node::{config, get_hardware_model_name, HardwareModel},
|
||||
p2p::{OperatingSystem, SPACEDRIVE_APP_ID},
|
||||
};
|
||||
|
||||
use sd_p2p::{
|
||||
spacetunnel::RemoteIdentity, Manager, ManagerConfig, ManagerError, PeerStatus, Service,
|
||||
};
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
net::SocketAddr,
|
||||
|
@ -95,6 +94,7 @@ impl P2PManager {
|
|||
PeerMetadata {
|
||||
name: config.name.clone(),
|
||||
operating_system: Some(OperatingSystem::get_os()),
|
||||
device_model: Some(get_hardware_model_name().unwrap_or(HardwareModel::Other)),
|
||||
version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::node::Platform;
|
||||
use crate::node::{HardwareModel, Platform};
|
||||
|
||||
use sd_p2p::Metadata;
|
||||
|
||||
|
@ -11,6 +11,7 @@ use specta::Type;
|
|||
pub struct PeerMetadata {
|
||||
pub name: String,
|
||||
pub operating_system: Option<OperatingSystem>,
|
||||
pub device_model: Option<HardwareModel>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
|
@ -24,6 +25,9 @@ impl Metadata for PeerMetadata {
|
|||
if let Some(version) = self.version {
|
||||
map.insert("version".to_owned(), version);
|
||||
}
|
||||
if let Some(device_model) = self.device_model {
|
||||
map.insert("device_model".to_owned(), device_model.to_string());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
|
@ -43,6 +47,11 @@ impl Metadata for PeerMetadata {
|
|||
.get("os")
|
||||
.map(|os| os.parse().map_err(|_| "Unable to parse 'OperationSystem'!"))
|
||||
.transpose()?,
|
||||
device_model: Some(HardwareModel::from_display_name(
|
||||
data.get("device_model")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("Other"),
|
||||
)),
|
||||
version: data.get("version").map(|v| v.to_owned()),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "deps-generator"
|
||||
name = "sd-deps-generator"
|
||||
version = "0.0.0"
|
||||
authors = ["Jake Robinson <jake@spacedrive.com>"]
|
||||
description = "A tool to compile all Spacedrive dependencies and their respective licenses"
|
||||
|
|
|
@ -121,6 +121,7 @@ extension_category_enum! {
|
|||
Aptx = [0x4B, 0xBF, 0x4B, 0xBF],
|
||||
Adts = [0xFF, 0xF1],
|
||||
Ast = [0x53, 0x54, 0x52, 0x4D],
|
||||
Mid = [0x4D, 0x54, 0x68, 0x64],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use specta::Type;
|
||||
use strum_macros::{Display, EnumIter};
|
||||
// Note: The order of this enum should never change, and always be kept in sync with `packages/client/src/utils/objectKind.ts`
|
||||
#[repr(i32)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[derive(Debug, Clone, Display, Copy, EnumIter, Type, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub enum ObjectKind {
|
||||
/// A file that can not be identified by the indexer
|
||||
Unknown = 0,
|
||||
|
@ -56,4 +57,6 @@ pub enum ObjectKind {
|
|||
Dotfile = 24,
|
||||
/// Screenshot
|
||||
Screenshot = 25,
|
||||
/// Label
|
||||
Label = 26,
|
||||
}
|
||||
|
|
|
@ -159,8 +159,8 @@ const Path = ({ path, onClick, disabled }: PathProps) => {
|
|||
ref={setDroppableRef}
|
||||
className={clsx(
|
||||
'group flex items-center gap-1 rounded px-1 py-0.5',
|
||||
isDroppable && [isDark ? 'bg-app-lightBox' : 'bg-app-darkerBox'],
|
||||
!disabled && [isDark ? 'hover:bg-app-lightBox' : 'hover:bg-app-darkerBox'],
|
||||
isDroppable && [isDark ? 'bg-app-button/70' : 'bg-app-darkerBox'],
|
||||
!disabled && [isDark ? 'hover:bg-app-button/70' : 'hover:bg-app-darkerBox'],
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -33,7 +33,7 @@ export const EmptyNotice = (props: {
|
|||
: emptyNoticeIcon(props.icon as Icon)
|
||||
: emptyNoticeIcon()}
|
||||
|
||||
<p className="mt-5 text-sm font-medium">
|
||||
<p className="mt-5">
|
||||
{props.message !== undefined ? props.message : 'This list is empty'}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -61,6 +61,10 @@ export const useViewItemDoubleClick = () => {
|
|||
const paths =
|
||||
selectedItem.type === 'Path'
|
||||
? [selectedItem.item]
|
||||
: selectedItem.type === 'Label'
|
||||
? selectedItem.item.label_objects.flatMap(
|
||||
(o) => o.object.file_paths
|
||||
)
|
||||
: selectedItem.item.file_paths;
|
||||
|
||||
for (const filePath of paths) {
|
||||
|
|
|
@ -119,7 +119,6 @@ export const useExplorerDroppable = ({
|
|||
allowedType = ['Path', 'NonIndexedPath', 'Object'];
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Tag': {
|
||||
allowedType = ['Path', 'Object'];
|
||||
break;
|
||||
|
|
|
@ -10,14 +10,14 @@ export function useExplorerSearchParams() {
|
|||
}
|
||||
|
||||
export function useExplorerItemData(explorerItem: ExplorerItem) {
|
||||
const newThumbnail = useSelector(
|
||||
explorerStore,
|
||||
(s) =>
|
||||
!!(
|
||||
explorerItem.thumbnail_key &&
|
||||
s.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key))
|
||||
)
|
||||
);
|
||||
const newThumbnail = useSelector(explorerStore, (s) => {
|
||||
const firstThumbnail =
|
||||
explorerItem.type === 'Label'
|
||||
? explorerItem.thumbnails?.[0]
|
||||
: 'thumbnail' in explorerItem && explorerItem.thumbnail;
|
||||
|
||||
return !!(firstThumbnail && s.newThumbnails.has(flattenThumbnailKey(firstThumbnail)));
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
const itemData = getExplorerItemData(explorerItem);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -5,9 +5,9 @@ import { Button, ButtonLink, Popover, Tooltip, usePopover } from '@sd/ui';
|
|||
import { useKeysMatcher, useLocale, useShortcut } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import DebugPopover from './DebugPopover';
|
||||
import DebugPopover from '../DebugPopover';
|
||||
import { IsRunningJob, JobManager } from '../JobManager';
|
||||
import FeedbackButton from './FeedbackButton';
|
||||
import { IsRunningJob, JobManager } from './JobManager';
|
||||
|
||||
export default () => {
|
||||
const { library } = useClientContext();
|
|
@ -4,7 +4,7 @@ import { useClientContext } from '@sd/client';
|
|||
import { dialogManager, Dropdown, DropdownMenu } from '@sd/ui';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import CreateDialog from '../../settings/node/libraries/CreateDialog';
|
||||
import CreateDialog from '../../../settings/node/libraries/CreateDialog';
|
||||
|
||||
export default () => {
|
||||
const { library, libraries, currentLibraryId } = useClientContext();
|
|
@ -46,7 +46,7 @@ const Link = forwardRef<
|
|||
}}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'ring-0', // Remove ugly outline ring on Chrome Windows & Linux
|
||||
'relative ring-0', // ring-0 to remove ugly outline ring on Chrome Windows & Linux
|
||||
styles({ active: isActive, transparent: os === 'macOS' }),
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className
|
|
@ -3,7 +3,7 @@ import { MacTrafficLights } from '~/components/TrafficLights';
|
|||
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { macOnly } from './helpers';
|
||||
import { macOnly } from '../helpers';
|
||||
|
||||
export default () => {
|
||||
const { platform } = usePlatform();
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,56 +1,35 @@
|
|||
import clsx from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
import { MacTrafficLights } from '~/components';
|
||||
import { useOperatingSystem, useShowControls } from '~/hooks';
|
||||
import { useWindowState } from '~/hooks/useWindowState';
|
||||
import { LibraryContextProvider, useClientContext } from '@sd/client';
|
||||
|
||||
import Contents from './Contents';
|
||||
import Footer from './Footer';
|
||||
import LibrariesDropdown from './LibrariesDropdown';
|
||||
|
||||
export default () => {
|
||||
const os = useOperatingSystem();
|
||||
const showControls = useShowControls();
|
||||
const windowState = useWindowState();
|
||||
|
||||
//prevent sidebar scrolling with keyboard
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const arrows = ['ArrowUp', 'ArrowDown'];
|
||||
if (arrows.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
import Debug from './sections/Debug';
|
||||
// sections
|
||||
import Devices from './sections/Devices';
|
||||
import Library from './sections/Library';
|
||||
import Local from './sections/Local';
|
||||
import Locations from './sections/Locations';
|
||||
import SavedSearches from './sections/SavedSearches';
|
||||
import Tags from './sections/Tags';
|
||||
import SidebarLayout from './SidebarLayout';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { library } = useClientContext();
|
||||
return (
|
||||
<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]'
|
||||
<SidebarLayout>
|
||||
{library && (
|
||||
<LibraryContextProvider library={library}>
|
||||
<Library />
|
||||
</LibraryContextProvider>
|
||||
)}
|
||||
>
|
||||
{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'
|
||||
)}
|
||||
/>
|
||||
<Debug />
|
||||
<Local />
|
||||
{library && (
|
||||
<LibraryContextProvider library={library}>
|
||||
<SavedSearches />
|
||||
<Devices />
|
||||
<Locations />
|
||||
<Tags />
|
||||
</LibraryContextProvider>
|
||||
)}
|
||||
<LibrariesDropdown />
|
||||
<Contents />
|
||||
<Footer />
|
||||
</div>
|
||||
{/* <Tools /> */}
|
||||
</SidebarLayout>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -4,10 +4,10 @@ import { Button, Tooltip } from '@sd/ui';
|
|||
import { Icon, SubtleButton } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import SidebarLink from '../Link';
|
||||
import Section from '../Section';
|
||||
import SidebarLink from '../../SidebarLayout/Link';
|
||||
import Section from '../../SidebarLayout/Section';
|
||||
|
||||
export const Devices = () => {
|
||||
export default function DevicesSection() {
|
||||
const { data: node } = useBridgeQuery(['nodeState']);
|
||||
const isPairingEnabled = useFeatureFlag('p2pPairing');
|
||||
|
||||
|
@ -38,4 +38,4 @@ export const Devices = () => {
|
|||
</Tooltip>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -6,11 +6,11 @@ import { Button, toast, tw } from '@sd/ui';
|
|||
import { Icon, IconName } from '~/components';
|
||||
import { useHomeDir } from '~/hooks/useHomeDir';
|
||||
|
||||
import { useExplorerDroppable } from '../../Explorer/useExplorerDroppable';
|
||||
import { useExplorerSearchParams } from '../../Explorer/util';
|
||||
import SidebarLink from './Link';
|
||||
import Section from './Section';
|
||||
import { SeeMore } from './SeeMore';
|
||||
import { useExplorerDroppable } from '../../../../Explorer/useExplorerDroppable';
|
||||
import { useExplorerSearchParams } from '../../../../Explorer/util';
|
||||
import SidebarLink from '../../SidebarLayout/Link';
|
||||
import Section from '../../SidebarLayout/Section';
|
||||
import { SeeMore } from '../../SidebarLayout/SeeMore';
|
||||
|
||||
const Name = tw.span`truncate`;
|
||||
|
||||
|
@ -29,7 +29,7 @@ const SidebarIcon = ({ name }: { name: IconName }) => {
|
|||
return <Icon name={name} size={20} className="mr-1" />;
|
||||
};
|
||||
|
||||
export const EphemeralSection = () => {
|
||||
export default function LocalSection() {
|
||||
const locationsQuery = useLibraryQuery(['locations.list']);
|
||||
useNodes(locationsQuery.data?.nodes);
|
||||
const locations = useCache(locationsQuery.data?.items);
|
||||
|
@ -137,7 +137,7 @@ export const EphemeralSection = () => {
|
|||
</SeeMore>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const EphemeralLocation = ({
|
||||
children,
|
|
@ -13,12 +13,12 @@ import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util';
|
|||
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
|
||||
import { Icon, SubtleButton } from '~/components';
|
||||
|
||||
import SidebarLink from '../Link';
|
||||
import Section from '../Section';
|
||||
import { SeeMore } from '../SeeMore';
|
||||
import SidebarLink from '../../SidebarLayout/Link';
|
||||
import Section from '../../SidebarLayout/Section';
|
||||
import { SeeMore } from '../../SidebarLayout/SeeMore';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export const Locations = () => {
|
||||
export default function Locations() {
|
||||
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
useNodes(locationsQuery.data?.nodes);
|
||||
const locations = useCache(locationsQuery.data?.items);
|
||||
|
@ -45,7 +45,7 @@ export const Locations = () => {
|
|||
<AddLocationButton className="mt-1" />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const Location = ({ location, online }: { location: LocationType; online: boolean }) => {
|
||||
const locationId = useMatch('/:libraryId/location/:locationId')?.params.locationId;
|
|
@ -6,11 +6,11 @@ import { Button } from '@sd/ui';
|
|||
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
|
||||
import { Folder } from '~/components';
|
||||
|
||||
import SidebarLink from '../Link';
|
||||
import Section from '../Section';
|
||||
import { SeeMore } from '../SeeMore';
|
||||
import SidebarLink from '../../SidebarLayout/Link';
|
||||
import Section from '../../SidebarLayout/Section';
|
||||
import { SeeMore } from '../../SidebarLayout/SeeMore';
|
||||
|
||||
export const SavedSearches = () => {
|
||||
export default function SavedSearches() {
|
||||
const savedSearches = useLibraryQuery(['search.saved.list']);
|
||||
|
||||
const path = useResolvedPath('saved-search/:id');
|
||||
|
@ -58,7 +58,7 @@ export const SavedSearches = () => {
|
|||
</SeeMore>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const SavedSearch = ({ search, onDelete }: { search: SavedSearch; onDelete(): void }) => {
|
||||
const searchId = useMatch('/:libraryId/saved-search/:searchId')?.params.searchId;
|
|
@ -4,12 +4,12 @@ import { useCache, useLibraryQuery, useNodes, type Tag } from '@sd/client';
|
|||
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
|
||||
import { SubtleButton } from '~/components';
|
||||
|
||||
import SidebarLink from '../Link';
|
||||
import Section from '../Section';
|
||||
import { SeeMore } from '../SeeMore';
|
||||
import SidebarLink from '../../SidebarLayout/Link';
|
||||
import Section from '../../SidebarLayout/Section';
|
||||
import { SeeMore } from '../../SidebarLayout/SeeMore';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export const Tags = () => {
|
||||
export default function TagsSection() {
|
||||
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
|
||||
useNodes(result.data?.nodes);
|
||||
const tags = useCache(result.data?.items);
|
||||
|
@ -32,7 +32,7 @@ export const Tags = () => {
|
|||
</SeeMore>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const Tag = ({ tag }: { tag: Tag }) => {
|
||||
const tagId = useMatch('/:libraryId/tag/:tagId')?.params.tagId;
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx';
|
||||
import { Suspense, useEffect, useMemo, useRef } from 'react';
|
||||
import { Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ClientContextProvider,
|
||||
initPlausible,
|
||||
|
@ -31,6 +31,7 @@ import { DndContext } from './DndContext';
|
|||
import Sidebar from './Sidebar';
|
||||
|
||||
const Layout = () => {
|
||||
console.log(useLocation());
|
||||
const { libraries, library } = useClientContext();
|
||||
const os = useOperatingSystem();
|
||||
const showControls = useShowControls();
|
||||
|
|
92
interface/app/$libraryId/favorites.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -9,9 +9,9 @@ import settingsRoutes from './settings';
|
|||
const pageRoutes: RouteObject = {
|
||||
lazy: () => import('./PageLayout'),
|
||||
children: [
|
||||
{ path: 'people', lazy: () => import('./people') },
|
||||
{ path: 'media', lazy: () => import('./media') },
|
||||
{ path: 'spaces', lazy: () => import('./spaces') },
|
||||
{ path: 'overview', lazy: () => import('./overview') },
|
||||
// { path: 'labels', lazy: () => import('./labels') },
|
||||
// { path: 'spaces', lazy: () => import('./spaces') },
|
||||
{ path: 'debug', children: debugRoutes }
|
||||
]
|
||||
};
|
||||
|
@ -19,6 +19,10 @@ const pageRoutes: RouteObject = {
|
|||
// Routes that render the explorer and don't need padding and stuff
|
||||
// provided by PageLayout
|
||||
const explorerRoutes: RouteObject[] = [
|
||||
{ path: 'recents', lazy: () => import('./recents') },
|
||||
{ path: 'favorites', lazy: () => import('./favorites') },
|
||||
{ path: 'labels', lazy: () => import('./labels') },
|
||||
{ path: 'search', lazy: () => import('./search') },
|
||||
{ path: 'ephemeral/:id', lazy: () => import('./ephemeral') },
|
||||
{ path: 'location/:id', lazy: () => import('./location/$id') },
|
||||
{ path: 'node/:id', lazy: () => import('./node/$id') },
|
||||
|
|
94
interface/app/$libraryId/labels.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -37,8 +37,8 @@ import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
|||
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
import { useExplorerSearchParams } from '../Explorer/util';
|
||||
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
|
||||
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
|
||||
import SearchBar from '../Search/SearchBar';
|
||||
import { SearchContextProvider, SearchOptions, useSearch } from '../search';
|
||||
import SearchBar from '../search/SearchBar';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
|
||||
import LocationOptions from './LocationOptions';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useDebugState, useDiscoveredPeers, useFeatureFlag, useFeatureFlags } from '@sd/client';
|
||||
import { useDiscoveredPeers } from '@sd/client';
|
||||
import { Icon } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
import { useRouteTitle } from '~/hooks/useRouteTitle';
|
||||
|
@ -35,9 +35,9 @@ export const Component = () => {
|
|||
|
||||
const explorer = useExplorer({
|
||||
items: peers.map((peer) => ({
|
||||
type: 'SpacedropPeer',
|
||||
type: 'SpacedropPeer' as const,
|
||||
has_local_thumbnail: false,
|
||||
thumbnail_key: null,
|
||||
thumbnail: null,
|
||||
item: {
|
||||
...peer,
|
||||
pub_id: []
|
||||
|
|
95
interface/app/$libraryId/overview/FileKindStats.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
46
interface/app/$libraryId/overview/Layout/Section.tsx
Normal 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;
|
115
interface/app/$libraryId/overview/LibraryStats.tsx
Normal 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;
|
48
interface/app/$libraryId/overview/LocationCard.tsx
Normal 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;
|
42
interface/app/$libraryId/overview/NewCard.tsx
Normal 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;
|
94
interface/app/$libraryId/overview/StatCard.tsx
Normal 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;
|
216
interface/app/$libraryId/overview/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
92
interface/app/$libraryId/recents.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -19,8 +19,8 @@ import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Ex
|
|||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
|
||||
import SearchOptions, { SearchContextProvider, useSearch, useSearchContext } from '../Search';
|
||||
import SearchBar from '../Search/SearchBar';
|
||||
import { SearchContextProvider, SearchOptions, useSearch, useSearchContext } from '../search';
|
||||
import SearchBar from '../search/SearchBar';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
||||
export const Component = () => {
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
import { CircleDashed, Cube, Folder, Icon, SelectionSlash, Textbox } from '@phosphor-icons/react';
|
||||
import {
|
||||
CircleDashed,
|
||||
Cube,
|
||||
Folder,
|
||||
Icon,
|
||||
SelectionSlash,
|
||||
Tag,
|
||||
Textbox
|
||||
} from '@phosphor-icons/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
InOrNotIn,
|
||||
|
@ -574,6 +582,39 @@ export const filterRegistry = [
|
|||
];
|
||||
},
|
||||
Render: ({ filter, search }) => <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
|
||||
//
|
|
@ -4,8 +4,8 @@ import { Input, ModifierKeys, Shortcut } from '@sd/ui';
|
|||
import { useOperatingSystem } from '~/hooks';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
|
||||
import { useSearchContext } from '../Search';
|
||||
import { useSearchStore } from '../Search/store';
|
||||
import { useSearchContext } from './context';
|
||||
import { useSearchStore } from './store';
|
||||
|
||||
export default () => {
|
||||
const search = useSearchContext();
|
|
@ -87,7 +87,10 @@ export const SearchOptionSubMenu = (
|
|||
|
||||
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 isDark = useIsDark();
|
||||
return (
|
||||
|
@ -132,8 +135,6 @@ const SearchOptions = ({ allowExit, children }: { allowExit?: boolean } & PropsW
|
|||
);
|
||||
};
|
||||
|
||||
export default SearchOptions;
|
||||
|
||||
const SearchResults = memo(
|
||||
({ searchQuery, search }: { searchQuery: string; search: UseSearch }) => {
|
||||
const { allFiltersKeys } = search;
|
|
@ -1,7 +1,5 @@
|
|||
import { createContext, PropsWithChildren, useContext } from 'react';
|
||||
|
||||
import { filterRegistry } from './Filters';
|
||||
import { useRegisterSearchFilterOptions } from './store';
|
||||
import { UseSearch } from './useSearch';
|
||||
|
||||
const SearchContext = createContext<UseSearch | null>(null);
|
134
interface/app/$libraryId/search/index.tsx
Normal 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;
|
||||
}
|
|
@ -20,8 +20,8 @@ import { tw } from '@sd/ui';
|
|||
import { useLocale, useOperatingSystem } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import Icon from '../Layout/Sidebar/Icon';
|
||||
import SidebarLink from '../Layout/Sidebar/Link';
|
||||
import Icon from '../Layout/Sidebar/SidebarLayout/Icon';
|
||||
import SidebarLink from '../Layout/Sidebar/SidebarLayout/Link';
|
||||
import { NavigationButtons } from '../TopBar/NavigationButtons';
|
||||
|
||||
const Heading = tw.div`mb-1 ml-1 text-xs font-semibold text-gray-400`;
|
||||
|
@ -41,7 +41,7 @@ export default () => {
|
|||
{platform === 'tauri' ? (
|
||||
<div
|
||||
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 />
|
||||
</div>
|
||||
|
@ -56,10 +56,10 @@ export default () => {
|
|||
<Icon component={GearSix} />
|
||||
{t('general')}
|
||||
</SidebarLink>
|
||||
<SidebarLink to="client/usage">
|
||||
{/* <SidebarLink to="client/usage">
|
||||
<Icon component={ChartBar} />
|
||||
{t('usage')}
|
||||
</SidebarLink>
|
||||
</SidebarLink> */}
|
||||
<SidebarLink to="client/account">
|
||||
<Icon component={User} />
|
||||
{t('account')}
|
||||
|
|
|
@ -27,12 +27,11 @@ export const Component = () => {
|
|||
)}
|
||||
</>
|
||||
}
|
||||
title={t('your_account')}
|
||||
description={t('your_account_description')}
|
||||
title="Spacedrive Cloud"
|
||||
description="Spacedrive is always local first, but we will offer our own optional cloud services in the future. For now, authentication is only used for the Feedback feature, otherwise it is not required."
|
||||
/>
|
||||
<div className="flex flex-col justify-between gap-5 lg:flex-row">
|
||||
<Profile authStore={authStore} email={me.data?.email} />
|
||||
<Cloud />
|
||||
</div>
|
||||
{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() {
|
||||
const locations = useBridgeQuery(['cloud.locations.list'], { retry: false });
|
||||
|
||||
|
|
|
@ -22,10 +22,11 @@ export const Component = () => {
|
|||
const discoveredPeers = useDiscoveredPeers();
|
||||
const info = useMemo(() => {
|
||||
if (locations.data && discoveredPeers) {
|
||||
const tb_capacity = byteSize(stats.data?.total_bytes_capacity);
|
||||
const free_space = byteSize(stats.data?.total_bytes_free);
|
||||
const library_db_size = byteSize(stats.data?.library_db_size);
|
||||
const preview_media = byteSize(stats.data?.preview_media_bytes);
|
||||
const statistics = stats.data?.statistics;
|
||||
const tb_capacity = byteSize(statistics?.total_bytes_capacity);
|
||||
const free_space = byteSize(statistics?.total_bytes_free);
|
||||
const library_db_size = byteSize(statistics?.library_db_size);
|
||||
const preview_media = byteSize(statistics?.preview_media_bytes);
|
||||
const data: {
|
||||
icon: keyof typeof iconNames;
|
||||
title?: string;
|
||||
|
|
|
@ -9,8 +9,8 @@ import {
|
|||
useZodForm
|
||||
} from '@sd/client';
|
||||
import { Button, Card, Form, InputField, Label, Tooltip, z } from '@sd/ui';
|
||||
import { SearchContextProvider, useSearch } from '~/app/$libraryId/Search';
|
||||
import { AppliedFilters } from '~/app/$libraryId/Search/AppliedFilters';
|
||||
import { SearchContextProvider, useSearch } from '~/app/$libraryId/search';
|
||||
import { AppliedFilters } from '~/app/$libraryId/search/AppliedFilters';
|
||||
import { Heading } from '~/app/$libraryId/settings/Layout';
|
||||
import { useDebouncedFormWatch, useLocale } from '~/hooks';
|
||||
|
||||
|
|
|
@ -11,8 +11,8 @@ import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Expl
|
|||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
|
||||
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
|
||||
import SearchBar from '../Search/SearchBar';
|
||||
import { SearchContextProvider, SearchOptions, useSearch } from '../search';
|
||||
import SearchBar from '../search/SearchBar';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
||||
export function Component() {
|
||||
|
|
|
@ -383,6 +383,10 @@ body {
|
|||
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1);
|
||||
}
|
||||
|
||||
.icon-with-shadow {
|
||||
filter: url(#svg-shadow-filter);
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(-1deg); }
|
||||
50% { transform: rotate(1deg); }
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { getIcon, iconNames } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { ImgHTMLAttributes } from 'react';
|
||||
import { useIsDark } from '~/hooks';
|
||||
|
||||
|
@ -18,6 +19,7 @@ export const Icon = ({ name, size, theme, ...props }: Props) => {
|
|||
width={size}
|
||||
height={size}
|
||||
{...props}
|
||||
className={clsx('pointer-events-none', props.className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
14
interface/util/hardware.ts
Normal 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';
|
||||
}
|
||||
}
|
BIN
packages/assets/icons/CollectionSparkle.png
Normal file
After Width: | Height: | Size: 43 KiB |
BIN
packages/assets/icons/Document_xmp.png
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
packages/assets/icons/Folder-tag-xmp.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
packages/assets/icons/Location.png
Normal file
After Width: | Height: | Size: 80 KiB |
BIN
packages/assets/icons/LocationManaged.png
Normal file
After Width: | Height: | Size: 79 KiB |
BIN
packages/assets/icons/LocationReplica.png
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
packages/assets/icons/Search.png
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
packages/assets/icons/SearchAlt.png
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
packages/assets/icons/SilverBox.png
Normal file
After Width: | Height: | Size: 44 KiB |
|
@ -29,6 +29,7 @@ import Code20 from './Code-20.png';
|
|||
import Collection_Light from './Collection_Light.png';
|
||||
import Collection20 from './Collection-20.png';
|
||||
import Collection from './Collection.png';
|
||||
import CollectionSparkle from './CollectionSparkle.png';
|
||||
import Config20 from './Config-20.png';
|
||||
import Database_Light from './Database_Light.png';
|
||||
import Database20 from './Database-20.png';
|
||||
|
@ -42,6 +43,7 @@ import Document_pdf_Light from './Document_pdf_Light.png';
|
|||
import Document_pdf from './Document_pdf.png';
|
||||
import Document_xls_Light from './Document_xls_Light.png';
|
||||
import Document_xls from './Document_xls.png';
|
||||
import Document_xmp from './Document_xmp.png';
|
||||
import Document20 from './Document-20.png';
|
||||
import Document from './Document.png';
|
||||
import Dotfile20 from './Dotfile-20.png';
|
||||
|
@ -82,6 +84,7 @@ import Executable from './Executable.png';
|
|||
import Face_Light from './Face_Light.png';
|
||||
import Folder_Light from './Folder_Light.png';
|
||||
import Folder20 from './Folder-20.png';
|
||||
import Foldertagxmp from './Folder-tag-xmp.png';
|
||||
import Folder from './Folder.png';
|
||||
import FolderGrey_Light from './FolderGrey_Light.png';
|
||||
import FolderGrey from './FolderGrey.png';
|
||||
|
@ -113,6 +116,9 @@ import Laptop from './Laptop.png';
|
|||
import Link_Light from './Link_Light.png';
|
||||
import Link20 from './Link-20.png';
|
||||
import Link from './Link.png';
|
||||
import Location from './Location.png';
|
||||
import LocationManaged from './LocationManaged.png';
|
||||
import LocationReplica from './LocationReplica.png';
|
||||
import Lock_Light from './Lock_Light.png';
|
||||
import Lock from './Lock.png';
|
||||
import Mega from './Mega.png';
|
||||
|
@ -142,8 +148,11 @@ import Screenshot from './Screenshot.png';
|
|||
import ScreenshotAlt from './ScreenshotAlt.png';
|
||||
import SD_Light from './SD_Light.png';
|
||||
import SD from './SD.png';
|
||||
import Search from './Search.png';
|
||||
import SearchAlt from './SearchAlt.png';
|
||||
import Server_Light from './Server_Light.png';
|
||||
import Server from './Server.png';
|
||||
import SilverBox from './SilverBox.png';
|
||||
import Spacedrop_Light from './Spacedrop_Light.png';
|
||||
import Spacedrop1 from './Spacedrop-1.png';
|
||||
import Spacedrop from './Spacedrop.png';
|
||||
|
@ -200,6 +209,7 @@ export {
|
|||
Code20,
|
||||
Collection20,
|
||||
Collection,
|
||||
CollectionSparkle,
|
||||
Collection_Light,
|
||||
Config20,
|
||||
DAV,
|
||||
|
@ -216,6 +226,7 @@ export {
|
|||
Document_pdf_Light,
|
||||
Document_xls,
|
||||
Document_xls_Light,
|
||||
Document_xmp,
|
||||
Dotfile20,
|
||||
DriveAmazonS3,
|
||||
DriveAmazonS3_Light,
|
||||
|
@ -253,6 +264,7 @@ export {
|
|||
Executable_old,
|
||||
Face_Light,
|
||||
Folder20,
|
||||
Foldertagxmp,
|
||||
Folder,
|
||||
FolderGrey,
|
||||
FolderGrey_Light,
|
||||
|
@ -285,6 +297,9 @@ export {
|
|||
Link20,
|
||||
Link,
|
||||
Link_Light,
|
||||
Location,
|
||||
LocationManaged,
|
||||
LocationReplica,
|
||||
Lock,
|
||||
Lock_Light,
|
||||
Mega,
|
||||
|
@ -314,8 +329,11 @@ export {
|
|||
Screenshot,
|
||||
ScreenshotAlt,
|
||||
Screenshot_Light,
|
||||
Search,
|
||||
SearchAlt,
|
||||
Server,
|
||||
Server_Light,
|
||||
SilverBox,
|
||||
Spacedrop1,
|
||||
Spacedrop,
|
||||
Spacedrop_Light,
|
||||
|
|
|
@ -18,12 +18,15 @@ export type Procedures = {
|
|||
{ key: "invalidation.test-invalidate", input: never, result: number } |
|
||||
{ key: "jobs.isActive", input: LibraryArgs<null>, result: boolean } |
|
||||
{ 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.getForObject", input: LibraryArgs<number>, result: Label[] } |
|
||||
{ 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.listWithThumbnails", input: LibraryArgs<string>, result: ExplorerItem[] } |
|
||||
{ key: "library.kindStatistics", input: LibraryArgs<null>, result: KindStatistics } |
|
||||
{ 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.getWithRules", input: LibraryArgs<number>, result: { item: Reference<LocationWithIndexerRule>; nodes: CacheNode[] } | null } |
|
||||
{ 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 ExplorerItem = { type: "Path"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: FilePathWithObject } | { type: "Object"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: Location } | { type: "NonIndexedPath"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: NonIndexedPathItem } | { type: "SpacedropPeer"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: PeerMetadata }
|
||||
export type ExplorerItem = { type: "Path"; thumbnail: string[] | null; item: FilePathWithObject } | { type: "Object"; thumbnail: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; item: Location } | { type: "NonIndexedPath"; thumbnail: string[] | null; item: NonIndexedPathItem } | { type: "SpacedropPeer"; item: PeerMetadata } | { type: "Label"; thumbnails: string[][]; item: LabelWithObjects }
|
||||
|
||||
export type ExplorerLayout = "grid" | "list" | "media"
|
||||
|
||||
|
@ -314,6 +317,8 @@ export type GenerateThumbsForLocationArgs = { id: number; path: string; regenera
|
|||
|
||||
export type GetAll = { backups: Backup[]; directory: string }
|
||||
|
||||
export type HardwareModel = "Other" | "MacStudio" | "MacBookAir" | "MacBookPro" | "MacBook" | "MacMini" | "MacPro" | "IMac" | "IMacPro" | "IPad" | "IPhone"
|
||||
|
||||
export type IdentifyUniqueFilesArgs = { id: number; path: string }
|
||||
|
||||
export type ImageMetadata = { resolution: Resolution; date_taken: MediaDate | null; location: MediaLocation | null; camera_data: CameraData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null }
|
||||
|
@ -340,14 +345,20 @@ export type JobGroup = { id: string; action: string | null; status: JobStatus; c
|
|||
|
||||
export type JobProgressEvent = { id: string; library_id: string; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string }
|
||||
|
||||
export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: { [key in string]: JsonValue } | null; is_background: boolean; errors_text: string[]; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string }
|
||||
export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: { [key in string]: JsonValue } | null; errors_text: string[]; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string }
|
||||
|
||||
export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused" | "CompletedWithErrors"
|
||||
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue }
|
||||
|
||||
export type KindStatistic = { kind: number; name: string; count: number; total_bytes: string }
|
||||
|
||||
export type KindStatistics = { statistics: KindStatistic[] }
|
||||
|
||||
export type Label = { id: number; pub_id: number[]; name: string; date_created: string; date_modified: string }
|
||||
|
||||
export type LabelWithObjects = { id: number; pub_id: number[]; name: string; date_created: string; date_modified: string; label_objects: { object: { id: number; file_paths: FilePath[] } }[] }
|
||||
|
||||
/**
|
||||
* Can wrap a query argument to require it to contain a `library_id` and provide helpers for working with libraries.
|
||||
*/
|
||||
|
@ -429,7 +440,7 @@ id: string;
|
|||
/**
|
||||
* name is the display name of the current node. This is set by the user and is shown in the UI. // TODO: Length validation so it can fit in DNS record
|
||||
*/
|
||||
name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences; image_labeler_version: string | null }) & { data_path: string; p2p: P2PStatus }
|
||||
name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences; image_labeler_version: string | null }) & { data_path: string; p2p: P2PStatus; device_model: string | null }
|
||||
|
||||
export type NonIndexedPathItem = { path: string; name: string; extension: string; kind: number; is_dir: boolean; date_created: string; date_modified: string; size_in_bytes_bytes: number[]; hidden: boolean }
|
||||
|
||||
|
@ -466,7 +477,7 @@ export type Object = { id: number; pub_id: number[]; kind: number | null; key_id
|
|||
|
||||
export type ObjectCursor = "none" | { dateAccessed: CursorOrderItem<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"
|
||||
|
||||
|
@ -501,7 +512,7 @@ export type PairingDecision = { decision: "accept"; libraryId: string } | { deci
|
|||
|
||||
export type PairingStatus = { type: "EstablishingConnection" } | { type: "PairingRequested" } | { type: "LibraryAlreadyExists" } | { type: "PairingDecisionRequest" } | { type: "PairingInProgress"; data: { library_name: string; library_description: string | null } } | { type: "InitialSyncProgress"; data: number } | { type: "PairingComplete"; data: string } | { type: "PairingRejected" }
|
||||
|
||||
export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; version: string | null }
|
||||
export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; device_model: HardwareModel | null; version: string | null }
|
||||
|
||||
export type PlusCode = string
|
||||
|
||||
|
@ -557,6 +568,8 @@ export type SpacedropArgs = { identity: RemoteIdentity; file_path: string[] }
|
|||
|
||||
export type Statistics = { id: number; date_captured: string; total_object_count: number; library_db_size: string; total_bytes_used: string; total_bytes_capacity: string; total_unique_bytes: string; total_bytes_free: string; preview_media_bytes: string }
|
||||
|
||||
export type StatisticsResponse = { statistics: Statistics | null }
|
||||
|
||||
export type SystemLocations = { desktop: string | null; documents: string | null; downloads: string | null; pictures: string | null; music: string | null; videos: string | null }
|
||||
|
||||
export type Tag = { id: number; pub_id: number[]; name: string | null; color: string | null; is_hidden: boolean | null; date_created: string | null; date_modified: string | null }
|
||||
|
|
130
packages/client/src/lib/explorerItem.ts
Normal 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
|
||||
};
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
export * from './objectKind';
|
||||
export * from './explorerItem';
|
||||
export * from './byte-size';
|
||||
export * from './passwordStrength';
|
||||
export * from './valtio';
|
||||
|
|
|
@ -26,7 +26,8 @@ export enum ObjectKindEnum {
|
|||
Book,
|
||||
Config,
|
||||
Dotfile,
|
||||
Screenshot
|
||||
Screenshot,
|
||||
Label
|
||||
}
|
||||
|
||||
export type ObjectKindKey = keyof typeof ObjectKindEnum;
|
|
@ -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]);
|
||||
};
|
|
@ -1,11 +1,98 @@
|
|||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ExplorerItem, LibraryConfigWrapped } from '../core';
|
||||
import type { Object } from '..';
|
||||
import type { ExplorerItem, FilePath, NonIndexedPathItem } from '../core';
|
||||
import { LibraryConfigWrapped } from '../core';
|
||||
|
||||
export * from './objectKind';
|
||||
export * from './explorerItem';
|
||||
export * from './jobs';
|
||||
// export * from './keys';
|
||||
|
||||
export const useItemsAsObjects = (items: ExplorerItem[]) => {
|
||||
return useMemo(() => {
|
||||
const array: Object[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case 'Path': {
|
||||
if (!item.item.object) return [];
|
||||
array.push(item.item.object);
|
||||
break;
|
||||
}
|
||||
case 'Object': {
|
||||
array.push(item.item);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return array;
|
||||
}, [items]);
|
||||
};
|
||||
|
||||
export const useItemsAsFilePaths = (items: ExplorerItem[]) => {
|
||||
return useMemo(() => {
|
||||
const array: FilePath[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case 'Path': {
|
||||
array.push(item.item);
|
||||
break;
|
||||
}
|
||||
case 'Object': {
|
||||
// this isn't good but it's the current behaviour
|
||||
const filePath = item.item.file_paths[0];
|
||||
if (filePath) array.push(filePath);
|
||||
else return [];
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return array;
|
||||
}, [items]);
|
||||
};
|
||||
|
||||
export const useItemsAsEphemeralPaths = (items: ExplorerItem[]) => {
|
||||
return useMemo(() => {
|
||||
return items
|
||||
.filter((item) => item.type === 'NonIndexedPath')
|
||||
.map((item) => item.item as NonIndexedPathItem);
|
||||
}, [items]);
|
||||
};
|
||||
|
||||
export function getItemObject(data: ExplorerItem) {
|
||||
return data.type === 'Object' ? data.item : data.type === 'Path' ? data.item.object : null;
|
||||
}
|
||||
|
||||
export function getItemFilePath(data: ExplorerItem) {
|
||||
if (data.type === 'Path' || data.type === 'NonIndexedPath') return data.item;
|
||||
return (data.type === 'Object' && data.item.file_paths[0]) || null;
|
||||
}
|
||||
|
||||
export function getEphemeralPath(data: ExplorerItem) {
|
||||
return data.type === 'NonIndexedPath' ? data.item : null;
|
||||
}
|
||||
|
||||
export function getIndexedItemFilePath(data: ExplorerItem) {
|
||||
return data.type === 'Path'
|
||||
? data.item
|
||||
: data.type === 'Object'
|
||||
? data.item.file_paths[0] ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getItemLocation(data: ExplorerItem) {
|
||||
return data.type === 'Location' ? data.item : null;
|
||||
}
|
||||
export function getItemSpacedropPeer(data: ExplorerItem) {
|
||||
return data.type === 'SpacedropPeer' ? data.item : null;
|
||||
}
|
||||
|
||||
export function isPath(item: ExplorerItem): item is Extract<ExplorerItem, { type: 'Path' }> {
|
||||
return item.type === 'Path';
|
||||
|
|