[ENG-1511] Library Screens (#1903)

* init

* changes

* Now updating statistics once a minute

* More robust statistics updater

* Concurrency is hard

* improvements to stats

* refactor

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

* refactor sidebar

* rename

* setting up screens

* some changes

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

* yes

* yes2

* refactored explorerItem.ts

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

* support for multiple thumbnails in ExplorerItem

* clippy

* move debug

* yes

* label filters

* ts

* comment out unconnected stuff

* added .mid for midi files

---------

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

View file

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

24
Cargo.lock generated
View file

@ -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"

View file

@ -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(() => {

View file

@ -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') {

View file

@ -1,12 +1,21 @@
use crate::{invalidate_query, library::Library};
use crate::{invalidate_query, library::Library, object::media::thumbnail::get_indexed_thumb_key};
use sd_prisma::prisma::{label, label_on_object, object};
use sd_prisma::prisma::{label, label_on_object, object, SortOrder};
use std::collections::BTreeMap;
use 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 {

View file

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

View file

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

View file

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

View file

@ -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<_>>())
})
})

View file

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

View file

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

View file

@ -1,80 +0,0 @@
use sd_file_ext::kind::ObjectKind;
use sd_prisma::prisma::object;
use prisma_client_rust::not;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::vec;
use strum_macros::{EnumString, EnumVariantNames};
/// Meow
#[derive(
Serialize,
Deserialize,
Type,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
EnumVariantNames,
EnumString,
Clone,
Copy,
)]
pub enum Category {
Recents,
Favorites,
Albums,
Photos,
Videos,
Movies,
Music,
Documents,
Downloads,
Encrypted,
Projects,
Applications,
Archives,
Databases,
Games,
Books,
Contacts,
Trash,
Screenshots,
}
impl Category {
// this should really be done without unimplemented! and on another type but ehh
fn to_object_kind(self) -> ObjectKind {
match self {
Category::Photos => ObjectKind::Image,
Category::Videos => ObjectKind::Video,
Category::Music => ObjectKind::Audio,
Category::Books => ObjectKind::Book,
Category::Encrypted => ObjectKind::Encrypted,
Category::Databases => ObjectKind::Database,
Category::Archives => ObjectKind::Archive,
Category::Applications => ObjectKind::Executable,
Category::Screenshots => ObjectKind::Screenshot,
_ => unimplemented!("Category::to_object_kind() for {:?}", self),
}
}
pub fn to_where_param(self) -> object::WhereParam {
match self {
Category::Recents => not![object::date_accessed::equals(None)],
Category::Favorites => object::favorite::equals(Some(true)),
Category::Photos
| Category::Videos
| Category::Music
| Category::Encrypted
| Category::Databases
| Category::Archives
| Category::Applications
| Category::Books => object::kind::equals(Some(self.to_object_kind() as i32)),
_ => object::id::equals(-1),
}
}
}

View file

@ -1,16 +1,16 @@
// pub(crate) mod cat;
mod actors;
mod 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;

View file

@ -0,0 +1,66 @@
use crate::{api::utils::get_size, library::Library, volume::get_volumes, Node};
use sd_prisma::prisma::statistics;
use chrono::Utc;
use tracing::info;
use super::LibraryManagerError;
pub async fn update_library_statistics(
node: &Node,
library: &Library,
) -> Result<statistics::Data, LibraryManagerError> {
let volumes = get_volumes().await;
let mut total_capacity: u64 = 0;
let mut available_capacity: u64 = 0;
for volume in volumes {
total_capacity += volume.total_capacity;
available_capacity += volume.available_capacity;
}
let total_bytes_used = total_capacity - available_capacity;
let library_db_size = get_size(
node.config
.data_directory()
.join("libraries")
.join(&format!("{}.db", library.id)),
)
.await
.unwrap_or(0);
let thumbnail_folder_size = get_size(node.config.data_directory().join("thumbnails"))
.await
.unwrap_or(0);
use statistics::*;
let params = vec![
id::set(1), // Each library is a database so only one of these ever exists
date_captured::set(Utc::now().into()),
total_object_count::set(0),
library_db_size::set(library_db_size.to_string()),
total_bytes_used::set(total_bytes_used.to_string()),
total_bytes_capacity::set(total_capacity.to_string()),
total_unique_bytes::set(0.to_string()),
total_bytes_free::set(available_capacity.to_string()),
preview_media_bytes::set(thumbnail_folder_size.to_string()),
];
let stats = library
.db
.statistics()
.upsert(
// Each library is a database so only one of these ever exists
statistics::id::equals(1),
statistics::create(params.clone()),
params,
)
.exec()
.await?;
info!("Updated library statistics: {:?}", stats);
Ok(stats)
}

View file

@ -231,8 +231,7 @@ pub async fn walk(
};
tx.send(Ok(ExplorerItem::NonIndexedPath {
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
View file

@ -0,0 +1,71 @@
use std::io::Error;
use std::process::Command;
use std::str;
use serde::{Deserialize, Serialize};
use specta::Type;
use strum_macros::{Display, EnumIter};
#[repr(i32)]
#[derive(Debug, Clone, Display, Copy, EnumIter, Type, Serialize, Deserialize, Eq, PartialEq)]
pub enum HardwareModel {
Other,
MacStudio,
MacBookAir,
MacBookPro,
MacBook,
MacMini,
MacPro,
IMac,
IMacPro,
IPad,
IPhone,
}
impl HardwareModel {
pub fn from_display_name(name: &str) -> Self {
use strum::IntoEnumIterator;
HardwareModel::iter()
.find(|&model| {
model.to_string().to_lowercase().replace(' ', "")
== name.to_lowercase().replace(' ', "")
})
.unwrap_or(HardwareModel::Other)
}
}
pub fn get_hardware_model_name() -> Result<HardwareModel, Error> {
#[cfg(target_os = "macos")]
{
let output = Command::new("system_profiler")
.arg("SPHardwareDataType")
.output()?;
if output.status.success() {
let output_str = std::str::from_utf8(&output.stdout).unwrap_or_default();
let hardware_model = output_str
.lines()
.find(|line| line.to_lowercase().contains("model name"))
.and_then(|line| line.split_once(':'))
.map(|(_, model_name)| HardwareModel::from_display_name(model_name.trim()))
.unwrap_or(HardwareModel::Other);
Ok(hardware_model)
} else {
Err(Error::new(
std::io::ErrorKind::Other,
format!(
"Failed to get hardware model name: {}",
String::from_utf8_lossy(&output.stderr)
),
))
}
}
#[cfg(not(target_os = "macos"))]
{
Err(Error::new(
std::io::ErrorKind::Unsupported,
"Unsupported operating system",
))
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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) {

View file

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

View file

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

View file

@ -1,75 +0,0 @@
import { ArrowsClockwise, Cloud, Database, Factory } from '@phosphor-icons/react';
import { LibraryContextProvider, useClientContext, useFeatureFlag } from '@sd/client';
import { EphemeralSection } from './EphemeralSection';
import Icon from './Icon';
import { LibrarySection } from './LibrarySection';
import SidebarLink from './Link';
import Section from './Section';
export default () => {
const { library } = useClientContext();
const debugRoutes = useFeatureFlag('debugRoutes');
return (
<div className="no-scrollbar mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
{/* <SidebarLink to="spacedrop">
<Icon component={Broadcast} />
Spacedrop
</SidebarLink> */}
{/*
{/* <SidebarLink to="imports">
<Icon component={ArchiveBox} />
Imports
</SidebarLink> */}
{debugRoutes && (
<Section name="Debug">
<div className="space-y-0.5">
<SidebarLink to="debug/sync">
<Icon component={ArrowsClockwise} />
Sync
</SidebarLink>
<SidebarLink to="debug/cloud">
<Icon component={Cloud} />
Cloud
</SidebarLink>
<SidebarLink to="debug/cache">
<Icon component={Database} />
Cache
</SidebarLink>
<SidebarLink to="debug/actors">
<Icon component={Factory} />
Actors
</SidebarLink>
</div>
</Section>
)}
<EphemeralSection />
{library && (
<LibraryContextProvider library={library}>
<LibrarySection />
</LibraryContextProvider>
)}
{/* <Section name="Tools" actionArea={<SubtleButton />}>
<SidebarLink disabled to="duplicate-finder">
<Icon component={CopySimple} />
Duplicates
</SidebarLink>
<SidebarLink disabled to="lost-and-found">
<Icon component={Crosshair} />
Find a File
</SidebarLink>
<SidebarLink disabled to="cache-cleaner">
<Icon component={Eraser} />
Cache Cleaner
</SidebarLink>
<SidebarLink disabled to="media-encoder">
<Icon component={FilmStrip} />
Media Encoder
</SidebarLink>
</Section> */}
<div className="grow" />
</div>
);
};

View file

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

View file

@ -5,9 +5,9 @@ import { Button, ButtonLink, Popover, Tooltip, usePopover } from '@sd/ui';
import { useKeysMatcher, useLocale, useShortcut } from '~/hooks';
import { 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();

View file

@ -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();

View file

@ -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

View file

@ -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();

View file

@ -0,0 +1,58 @@
import clsx from 'clsx';
import { PropsWithChildren, useEffect } from 'react';
import { MacTrafficLights } from '~/components';
import { useOperatingSystem, useShowControls } from '~/hooks';
import { useWindowState } from '~/hooks/useWindowState';
import Footer from './Footer';
import LibrariesDropdown from './LibrariesDropdown';
export default (props: PropsWithChildren) => {
const os = useOperatingSystem();
const showControls = useShowControls();
const windowState = useWindowState();
//prevent sidebar scrolling with keyboard
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const arrows = ['ArrowUp', 'ArrowDown'];
if (arrows.includes(e.key)) {
e.preventDefault();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<div
className={clsx(
'relative flex min-h-full w-44 shrink-0 grow-0 flex-col gap-2.5 border-r border-sidebar-divider bg-sidebar px-2.5 pb-2 transition-[padding-top] ease-linear motion-reduce:transition-none',
os === 'macOS' && windowState.isFullScreen
? '-mt-2 pt-[8.75px] duration-100'
: 'pt-2.5 duration-75',
os === 'macOS' || showControls.transparentBg
? 'bg-opacity-[0.65]'
: 'bg-opacity-[1]'
)}
>
{showControls.isEnabled && <MacTrafficLights className="z-50 mb-1" />}
{os === 'macOS' && (
<div
data-tauri-drag-region
className={clsx(
'w-full shrink-0 transition-[height] ease-linear motion-reduce:transition-none',
windowState.isFullScreen ? 'h-0 duration-100' : 'h-4 duration-75'
)}
/>
)}
<LibrariesDropdown />
<div className="no-scrollbar mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
{props.children}
<div className="grow" />
</div>
<Footer />
</div>
);
};

View file

@ -1,56 +1,35 @@
import clsx from 'clsx';
import { 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>
);
};
}

View file

@ -0,0 +1,35 @@
import { ArrowsClockwise, Cloud, Database, Factory } from '@phosphor-icons/react';
import { useFeatureFlag } from '@sd/client';
import Icon from '../../SidebarLayout/Icon';
import SidebarLink from '../../SidebarLayout/Link';
import Section from '../../SidebarLayout/Section';
export default function DebugSection() {
const debugRoutes = useFeatureFlag('debugRoutes');
if (!debugRoutes) return <></>;
return (
<Section name="Debug">
<div className="space-y-0.5">
<SidebarLink to="debug/sync">
<Icon component={ArrowsClockwise} />
Sync
</SidebarLink>
<SidebarLink to="debug/cloud">
<Icon component={Cloud} />
Cloud
</SidebarLink>
<SidebarLink to="debug/cache">
<Icon component={Database} />
Cache
</SidebarLink>
<SidebarLink to="debug/actors">
<Icon component={Factory} />
Actors
</SidebarLink>
</div>
</Section>
);
}

View file

@ -4,10 +4,10 @@ import { Button, Tooltip } from '@sd/ui';
import { Icon, SubtleButton } from '~/components';
import { 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>
);
};
}

View file

@ -0,0 +1,35 @@
import { Clock, Heart, Planet, Tag } from '@phosphor-icons/react';
import { useLibraryQuery } from '@sd/client';
import Icon from '../../SidebarLayout/Icon';
import SidebarLink from '../../SidebarLayout/Link';
export const COUNT_STYLE = `absolute right-1 min-w-[20px] top-1 flex h-[19px] px-1 items-center justify-center rounded-full border border-app-button/40 text-[9px]`;
export default function LibrarySection() {
const labelCount = useLibraryQuery(['labels.count']);
return (
<div className="space-y-0.5">
<SidebarLink to="overview">
<Icon component={Planet} />
Overview
</SidebarLink>
<SidebarLink to="recents">
<Icon component={Clock} />
Recents
{/* <div className={COUNT_STYLE}>34</div> */}
</SidebarLink>
<SidebarLink to="favorites">
<Icon component={Heart} />
Favorites
{/* <div className={COUNT_STYLE}>2</div> */}
</SidebarLink>
<SidebarLink to="labels">
<Icon component={Tag} />
Labels
<div className={COUNT_STYLE}>{labelCount.data || 0}</div>
</SidebarLink>
</div>
);
}

View file

@ -6,11 +6,11 @@ import { Button, toast, tw } from '@sd/ui';
import { Icon, IconName } from '~/components';
import { 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,

View file

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

View file

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

View file

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

View file

@ -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();

View file

@ -0,0 +1,92 @@
import { useMemo } from 'react';
import { ObjectFilterArgs, ObjectKindEnum, ObjectOrder, SearchFilterArgs } from '@sd/client';
import { Icon } from '~/components';
import { useRouteTitle } from '~/hooks';
import Explorer from './Explorer';
import { ExplorerContextProvider } from './Explorer/Context';
import { useObjectsExplorerQuery } from './Explorer/queries/useObjectsExplorerQuery';
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explorer/store';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
import { EmptyNotice } from './Explorer/View/EmptyNotice';
import { SearchContextProvider, SearchOptions, useSearch } from './search';
import SearchBar from './search/SearchBar';
import { TopBarPortal } from './TopBar/Portal';
export function Component() {
useRouteTitle('Favorites');
const explorerSettings = useExplorerSettings({
settings: useMemo(() => {
return createDefaultExplorerSettings<ObjectOrder>({ order: null });
}, []),
orderingKeys: objectOrderingKeysSchema
});
const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
const fixedFilters = useMemo<SearchFilterArgs[]>(
() => [
// { object: { favorite: true } },
...(explorerSettingsSnapshot.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [])
],
[explorerSettingsSnapshot.layoutMode]
);
const search = useSearch({
fixedFilters
});
const objects = useObjectsExplorerQuery({
arg: {
take: 100,
filters: [
...search.allFilters,
// TODO: Add filter to search options
{ object: { favorite: true } }
]
},
explorerSettings
});
const explorer = useExplorer({
...objects,
isFetchingNextPage: objects.query.isFetchingNextPage,
settings: explorerSettings
});
return (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Favorites</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="Heart" size={128} />}
message="No favorite items"
/>
}
/>
</ExplorerContextProvider>
);
}

View file

@ -9,9 +9,9 @@ import settingsRoutes from './settings';
const pageRoutes: RouteObject = {
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') },

View file

@ -0,0 +1,94 @@
import { useMemo } from 'react';
import {
ObjectFilterArgs,
ObjectKindEnum,
ObjectOrder,
SearchFilterArgs,
useLibraryQuery
} from '@sd/client';
import { Icon } from '~/components';
import { useRouteTitle } from '~/hooks';
import Explorer from './Explorer';
import { ExplorerContextProvider } from './Explorer/Context';
import { useObjectsExplorerQuery } from './Explorer/queries/useObjectsExplorerQuery';
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explorer/store';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
import { EmptyNotice } from './Explorer/View/EmptyNotice';
import { SearchContextProvider, SearchOptions, useSearch } from './search';
import SearchBar from './search/SearchBar';
import { TopBarPortal } from './TopBar/Portal';
export function Component() {
useRouteTitle('Labels');
const labels = useLibraryQuery(['labels.listWithThumbnails', '']);
const explorerSettings = useExplorerSettings({
settings: useMemo(() => {
return createDefaultExplorerSettings<ObjectOrder>({ order: null });
}, []),
orderingKeys: objectOrderingKeysSchema
});
// const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
// const fixedFilters = useMemo<SearchFilterArgs[]>(
// () => [
// ...(explorerSettingsSnapshot.layoutMode === 'media'
// ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
// : [])
// ],
// [explorerSettingsSnapshot.layoutMode]
// );
const search = useSearch({});
// const objects = useObjectsExplorerQuery({
// arg: {
// take: 100,
// filters: [...search.allFilters, { object: { tags: { in: [3] } } }]
// },
// explorerSettings
// });
const explorer = useExplorer({
items: labels.data || null,
settings: explorerSettings,
showPathBar: false,
layouts: { media: false }
});
return (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Labels</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="CollectionSparkle" size={128} />}
message="No labels"
/>
}
/>
</ExplorerContextProvider>
);
}

View file

@ -37,8 +37,8 @@ import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
import { 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';

View file

@ -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: []

View file

@ -0,0 +1,95 @@
import clsx from 'clsx';
import { motion } from 'framer-motion';
import { useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { formatNumber, SearchFilterArgs, useLibraryQuery } from '@sd/client';
import { Icon } from '~/components';
export default () => {
const ref = useRef<HTMLDivElement>(null);
const kinds = useLibraryQuery(['library.kindStatistics']);
return (
<>
{/* This is awful, will replace icons accordingly and memo etc */}
{kinds.data?.statistics
?.sort((a, b) => b.count - a.count)
.filter((i) => i.kind !== 0)
.map(({ kind, name, count }) => {
let icon = name;
switch (name) {
case 'Code':
icon = 'Terminal';
break;
case 'Unknown':
icon = 'Undefined';
break;
}
return (
<motion.div
viewport={{
root: ref,
// WARNING: Edge breaks if the values are not postfixed with px or %
margin: '0% -120px 0% 0%'
}}
className={clsx('min-w-fit')}
key={kind}
>
<KindItem
kind={kind}
name={name}
icon={icon}
items={count}
onClick={() => {}}
/>
</motion.div>
);
})}
</>
);
};
interface KindItemProps {
kind: number;
name: string;
items: number;
icon: string;
selected?: boolean;
onClick?: () => void;
disabled?: boolean;
}
const KindItem = ({ kind, name, icon, items, selected, onClick, disabled }: KindItemProps) => {
return (
<Link
to={{
pathname: '../search',
search: new URLSearchParams({
filters: JSON.stringify([
{ object: { kind: { in: [kind] } } }
] as SearchFilterArgs[])
}).toString()
}}
>
<div
onClick={onClick}
className={clsx(
'flex shrink-0 items-center rounded-lg py-1 text-sm outline-none focus:bg-app-selectedItem/50',
selected && 'bg-app-selectedItem',
disabled && 'cursor-not-allowed opacity-30'
)}
>
<Icon name={icon as any} className="mr-3 h-12 w-12" />
<div className="pr-5">
<h2 className="text-sm font-medium">{name}</h2>
{items !== undefined && (
<p className="text-xs text-ink-faint">
{formatNumber(items)} Item{(items > 1 || items === 0) && 's'}
</p>
)}
</div>
</div>
</Link>
);
};

View file

@ -0,0 +1,93 @@
import { ArrowLeft, ArrowRight } from '@phosphor-icons/react';
import clsx from 'clsx';
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useDraggable } from 'react-use-draggable-scroll';
import { tw } from '@sd/ui';
const ArrowButton = tw.div`absolute top-1/2 z-40 flex h-8 w-8 shrink-0 -translate-y-1/2 items-center p-2 cursor-pointer justify-center rounded-full border border-app-line bg-app/50 hover:opacity-95 backdrop-blur-md transition-all duration-200`;
const HorizontalScroll = ({ children }: { children: ReactNode }) => {
const ref = useRef<HTMLDivElement>(null);
const { events } = useDraggable(ref as React.MutableRefObject<HTMLDivElement>);
const [lastItemVisible, setLastItemVisible] = useState(false);
const [scroll, setScroll] = useState(0);
// If the content is overflowing, we need to show the arrows
const [isContentOverflow, setIsContentOverflow] = useState(false);
const updateScrollState = () => {
const element = ref.current;
if (element) {
setScroll(element.scrollLeft);
setLastItemVisible(element.scrollWidth - element.clientWidth === element.scrollLeft);
setIsContentOverflow(element.scrollWidth > element.clientWidth);
}
};
useEffect(() => {
const element = ref.current;
if (element) {
element.addEventListener('scroll', updateScrollState);
}
return () => {
if (element) {
element.removeEventListener('scroll', updateScrollState);
}
};
}, [ref]);
useLayoutEffect(() => {
updateScrollState();
}, []);
const handleArrowOnClick = (direction: 'right' | 'left') => {
const element = ref.current;
if (!element) return;
const scrollAmount = element.clientWidth;
element.scrollTo({
left:
direction === 'left'
? element.scrollLeft + scrollAmount
: element.scrollLeft - scrollAmount,
behavior: 'smooth'
});
};
const maskImage = `linear-gradient(90deg, transparent 0.1%, rgba(0, 0, 0, 1) ${
scroll > 0 ? '10%' : '0%'
}, rgba(0, 0, 0, 1) ${lastItemVisible ? '95%' : '85%'}, transparent 99%)`;
return (
<div className="relative mb-4 flex pl-6">
<ArrowButton
onClick={() => handleArrowOnClick('right')}
className={clsx('left-3', scroll === 0 && 'pointer-events-none opacity-0')}
>
<ArrowLeft weight="bold" className="h-4 w-4 text-ink" />
</ArrowButton>
<div
ref={ref}
{...events}
className="no-scrollbar flex gap-2 space-x-px overflow-x-scroll pl-1 pr-[60px]"
style={{
WebkitMaskImage: maskImage,
maskImage
}}
>
{children}
</div>
{isContentOverflow && (
<ArrowButton
onClick={() => handleArrowOnClick('left')}
className={clsx('right-3', lastItemVisible && 'pointer-events-none opacity-0')}
>
<ArrowRight weight="bold" className="h-4 w-4 text-ink" />
</ArrowButton>
)}
</div>
);
};
export default HorizontalScroll;

View file

@ -0,0 +1,46 @@
import { CaretDown, CaretUp } from '@phosphor-icons/react';
import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg';
import clsx from 'clsx';
import { Button } from '@sd/ui';
import HorizontalScroll from './HorizontalScroll';
const COUNT_STYLE = `min-w-[20px] flex h-[20px] px-1 items-center justify-center rounded-full border border-app-button/40 text-[9px]`;
const BUTTON_STYLE = `!p-[5px] opacity-0 transition-opacity group-hover:opacity-100`;
const OverviewSection = ({
children,
title,
className,
count
}: React.HTMLAttributes<HTMLDivElement> & { title?: string; count?: number }) => {
return (
<div className={clsx('group w-full', className)}>
{title && (
<div className="mb-3 flex w-full items-center gap-3 px-7 ">
<div className="truncate font-bold">{title}</div>
{count && <div className={COUNT_STYLE}>{count}</div>}
<div className="grow" />
<div className="flex flex-row gap-1 text-sidebar-inkFaint opacity-0 transition-all duration-300 hover:!opacity-100 group-hover:opacity-30">
{/* <Button className={BUTTON_STYLE} size="icon" variant="subtle">
<CaretUp weight="fill" className="h-3 w-3 text-ink-faint " />
</Button>
<Button className={BUTTON_STYLE} size="icon" variant="subtle">
<CaretDown weight="fill" className="h-3 w-3 text-ink-faint " />
</Button> */}
{/* <Button className={BUTTON_STYLE} size="icon" variant="subtle">
<Ellipsis className="h-3 w-3 text-ink-faint " />
</Button> */}
</div>
</div>
)}
{/* {title && <div className="mx-7 mb-3 h-[1px] w-full bg-app-line/50" />} */}
<HorizontalScroll>{children}</HorizontalScroll>
<div className="my-2 h-[1px] w-full " />
</div>
);
};
export default OverviewSection;

View file

@ -0,0 +1,115 @@
import { Info } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { byteSize, Statistics, useLibraryContext, useLibraryQuery } from '@sd/client';
import { Tooltip } from '@sd/ui';
import { useCounter } from '~/hooks';
interface StatItemProps {
title: string;
bytes: bigint;
isLoading: boolean;
info?: string;
}
const StatItemNames: Partial<Record<keyof Statistics, string>> = {
total_bytes_capacity: 'Total capacity',
preview_media_bytes: 'Preview media',
library_db_size: 'Index size',
total_bytes_free: 'Free space',
total_bytes_used: 'Total used space'
};
const StatDescriptions: Partial<Record<keyof Statistics, string>> = {
total_bytes_capacity:
'The total capacity of all nodes connected to the library. May show incorrect values during alpha.',
preview_media_bytes: 'The total size of all preview media files, such as thumbnails.',
library_db_size: 'The size of the library database.',
total_bytes_free: 'Free space available on all nodes connected to the library.',
total_bytes_used: 'Total space used on all nodes connected to the library.'
};
const displayableStatItems = Object.keys(StatItemNames) as unknown as keyof typeof StatItemNames;
let mounted = false;
const StatItem = (props: StatItemProps) => {
const { title, bytes, isLoading } = props;
// This is important to the counter working.
// On first render of the counter this will potentially be `false` which means the counter should the count up.
// but in a `useEffect` `mounted` will be set to `true` so that subsequent renders of the counter will not run the count up.
// The acts as a cache of the value of `mounted` on the first render of this `StateItem`.
const [isMounted] = useState(mounted);
const size = byteSize(bytes);
const count = useCounter({
name: title,
end: size.value,
duration: isMounted ? 0 : 1,
saveState: false
});
return (
<div
className={clsx(
'group/stat flex w-32 shrink-0 flex-col duration-75',
!bytes && 'hidden'
)}
>
<span className="whitespace-nowrap text-sm font-medium text-ink-faint">
{title}
{props.info && (
<Tooltip label={props.info}>
<Info
weight="fill"
className="-mt-0.5 ml-1 inline h-3 w-3 text-ink-faint opacity-0 transition-opacity group-hover/stat:opacity-70"
/>
</Tooltip>
)}
</span>
<span className="text-2xl">
<div
className={clsx({
hidden: isLoading
})}
>
<span className="font-black tabular-nums">{count}</span>
<span className="ml-1 text-[16px] font-medium text-ink-faint">{size.unit}</span>
</div>
</span>
</div>
);
};
const LibraryStats = () => {
const { library } = useLibraryContext();
const stats = useLibraryQuery(['library.statistics']);
useEffect(() => {
if (!stats.isLoading) mounted = true;
});
return (
<div className="flex w-full">
<div className="flex gap-3 overflow-hidden">
{Object.entries(stats?.data?.statistics || []).map(([key, value]) => {
if (!displayableStatItems.includes(key)) return null;
return (
<StatItem
key={`${library.uuid} ${key}`}
title={StatItemNames[key as keyof Statistics]!}
bytes={BigInt(value)}
isLoading={stats.isLoading}
info={StatDescriptions[key as keyof Statistics]}
/>
);
})}
</div>
</div>
);
};
export default LibraryStats;

View file

@ -0,0 +1,48 @@
import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg';
import { useEffect, useMemo, useState } from 'react';
import { byteSize } from '@sd/client';
import { Button, Card, CircularProgress, tw } from '@sd/ui';
import { Icon } from '~/components';
import { useIsDark } from '~/hooks';
type LocationCardProps = {
name: string;
icon: string;
totalSpace: string | number[];
color: string;
connectionType: 'lan' | 'p2p' | 'cloud' | null;
};
const Pill = tw.div`px-1.5 py-[1px] rounded text-tiny font-medium text-ink-dull bg-app-box border border-app-line`;
const LocationCard = ({ icon, name, connectionType, ...stats }: LocationCardProps) => {
const { totalSpace } = useMemo(() => {
return {
totalSpace: byteSize(stats.totalSpace)
};
}, [stats]);
return (
<Card className="flex w-[280px] shrink-0 flex-col bg-app-box/50 !p-0 ">
<div className="flex flex-row items-center gap-5 p-4 px-6 ">
<div className="flex flex-col overflow-hidden">
<Icon className="-ml-1" name={icon as any} size={60} />
<span className="truncate font-medium">{name}</span>
<span className="mt-1 truncate text-tiny text-ink-faint">
{totalSpace.value}
{totalSpace.unit}
</span>
</div>
</div>
<div className="flex h-10 flex-row items-center gap-1.5 border-t border-app-line px-2">
<Pill className="uppercase">{connectionType || 'Local'}</Pill>
<div className="grow" />
<Button size="icon" variant="outline">
<Ellipsis className="h-3 w-3 opacity-50" />
</Button>
</div>
</Card>
);
};
export default LocationCard;

View file

@ -0,0 +1,42 @@
// import { X } from '@phosphor-icons/react';
import { Button } from '@sd/ui';
import { Icon, IconName } from '~/components';
interface NewCardProps {
icons: IconName[];
text: string;
buttonText?: string;
}
const maskImage = `linear-gradient(90deg, transparent 0.1%, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 35%, transparent 99%)`;
const NewCard = ({ icons, text, buttonText }: NewCardProps) => {
return (
<div className="flex h-[170px] w-[280px] shrink-0 flex-col justify-between rounded border border-dashed border-app-line p-4">
<div className="flex flex-row items-start justify-between">
<div
className="flex flex-row"
style={{
WebkitMaskImage: maskImage,
maskImage
}}
>
{icons.map((iconName, index) => (
<div key={index}>
<Icon size={60} name={iconName} />
</div>
))}
</div>
{/* <Button size="icon" variant="outline">
<X weight="bold" className="h-3 w-3 opacity-50" />
</Button> */}
</div>
<span className="text-sm text-ink-dull">{text}</span>
<Button disabled={!buttonText} variant="outline">
{buttonText ? buttonText : 'Coming Soon'}
</Button>
</div>
);
};
export default NewCard;

View file

@ -0,0 +1,94 @@
import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg';
import { useEffect, useMemo, useState } from 'react';
import { byteSize } from '@sd/client';
import { Button, Card, CircularProgress, tw } from '@sd/ui';
import { Icon } from '~/components';
import { useIsDark } from '~/hooks';
type StatCardProps = {
name: string;
icon: string;
totalSpace: string | number[];
freeSpace?: string | number[];
color: string;
connectionType: 'lan' | 'p2p' | 'cloud' | null;
};
const Pill = tw.div`px-1.5 py-[1px] rounded text-tiny font-medium text-ink-dull bg-app-box border border-app-line`;
const StatCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => {
const [mounted, setMounted] = useState(false);
const isDark = useIsDark();
const { totalSpace, freeSpace, remainingSpace } = useMemo(() => {
return {
totalSpace: byteSize(stats.totalSpace),
freeSpace: byteSize(stats.freeSpace),
remainingSpace: byteSize(
stats.freeSpace
? Number(stats.totalSpace) - Number(stats.freeSpace)
: stats.totalSpace
)
};
}, [stats]);
useEffect(() => {
setMounted(true);
}, []);
const progress = useMemo(() => {
if (!mounted) return 0;
return Math.floor(
((Number(totalSpace.original) - Number(freeSpace.original)) /
Number(totalSpace.original)) *
100
);
}, [totalSpace, freeSpace, mounted]);
return (
<Card className="flex w-[280px] shrink-0 flex-col bg-app-box/50 !p-0 ">
<div className="flex flex-row items-center gap-5 p-4 px-6 ">
{!!stats.freeSpace && (
<CircularProgress
radius={40}
progress={progress}
strokeWidth={6}
trackStrokeWidth={6}
strokeColor={progress > 90 ? '#E14444' : '#2599FF'}
fillColor="transparent"
trackStrokeColor={isDark ? '#252631' : '#efefef'}
strokeLinecap="square"
className="flex items-center justify-center"
transition="stroke-dashoffset 1s ease 0s, stroke 1s ease"
>
<div className="absolute text-lg font-semibold">
{remainingSpace.value}
<span className="ml-0.5 text-tiny opacity-60">
{remainingSpace.unit}
</span>
</div>
</CircularProgress>
)}
<div className="flex flex-col overflow-hidden">
<Icon className="-ml-1" name={icon as any} size={60} />
<span className="truncate font-medium">{name}</span>
<span className="mt-1 truncate text-tiny text-ink-faint">
{freeSpace.value}
{freeSpace.unit} free of {totalSpace.value}
{totalSpace.unit}
</span>
</div>
</div>
<div className="flex h-10 flex-row items-center gap-1.5 border-t border-app-line px-2">
<Pill className="uppercase">{connectionType || 'Local'}</Pill>
<div className="grow" />
{/* <Button size="icon" variant="outline">
<Ellipsis className="h-3 w-3 opacity-50" />
</Button> */}
</div>
</Card>
);
};
export default StatCard;

View file

@ -0,0 +1,216 @@
import { ArrowClockwise, Broadcast, Key, Laptop, SlidersHorizontal } from '@phosphor-icons/react';
import { DriveAmazonS3, DriveDropbox, Mobile, Server, Tablet } from '@sd/assets/icons';
import {
useBridgeQuery,
useCache,
useLibraryQuery,
useNodes,
useOnlineLocations
} from '@sd/client';
import { useRouteTitle } from '~/hooks/useRouteTitle';
import { hardwareModelToIcon } from '~/util/hardware';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { SearchContextProvider, useSearch } from '../search';
import SearchBar from '../search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal';
import TopBarOptions, { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
import FileKindStatistics from './FileKindStats';
import OverviewSection from './Layout/Section';
import LibraryStatistics from './LibraryStats';
import NewCard from './NewCard';
import StatisticItem from './StatCard';
export const Component = () => {
useRouteTitle('Overview');
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items) ?? [];
const onlineLocations = useOnlineLocations();
const { data: node } = useBridgeQuery(['nodeState']);
const search = useSearch({
open: true
});
const stats = useLibraryQuery(['library.statistics']);
return (
<SearchContextProvider search={search}>
<div>
<TopBarPortal
left={
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">Library Overview</span>
</div>
}
center={<SearchBar />}
// right={
// <TopBarOptions
// options={[
// [
// {
// toolTipLabel: 'Spacedrop',
// onClick: () => {},
// icon: <Broadcast className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// },
// {
// toolTipLabel: 'Key Manager',
// onClick: () => {},
// icon: <Key className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// },
// {
// toolTipLabel: 'Overview Display Settings',
// onClick: () => {},
// icon: <SlidersHorizontal className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// }
// ]
// ]}
// />
// }
/>
<div className="mt-4 flex flex-col gap-3 pt-3">
<OverviewSection>
<LibraryStatistics />
</OverviewSection>
<OverviewSection>
<FileKindStatistics />
</OverviewSection>
<OverviewSection count={1} title="Devices">
{node && (
<StatisticItem
name={node.name}
icon={hardwareModelToIcon(node.device_model as any)}
totalSpace={stats.data?.statistics?.total_bytes_capacity || '0'}
freeSpace={stats.data?.statistics?.total_bytes_free || '0'}
color="#0362FF"
connectionType={null}
/>
)}
{/* <StatisticItem
name="Jamie's MacBook"
icon="Laptop"
total_space="4098046511104"
free_space="969004651119"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Jamie's iPhone"
icon="Mobile"
total_space="500046511104"
free_space="39006511104"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Titan NAS"
icon="Server"
total_space="60000046511104"
free_space="43000046511104"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Jamie's iPad"
icon="Tablet"
total_space="1074077906944"
free_space="121006553275"
color="#0362FF"
connection_type="lan"
/>
<StatisticItem
name="Jamie's Air"
icon="Laptop"
total_space="4098046511104"
free_space="969004651119"
color="#0362FF"
connection_type="p2p"
/> */}
<NewCard
icons={['Laptop', 'Server', 'SilverBox', 'Tablet']}
text="Spacedrive works best on all your devices."
// buttonText="Connect a device"
/>
{/**/}
</OverviewSection>
<OverviewSection count={locations.length} title="Locations">
{locations?.map((item) => (
<StatisticItem
key={item.id}
name={item.name || 'Unnamed Location'}
icon="Folder"
totalSpace={item.size_in_bytes || [0]}
color="#0362FF"
connectionType={null}
/>
))}
{!locations?.length && (
<NewCard
icons={['HDD', 'Folder', 'Globe', 'SD']}
text="Connect a local path, volume or network location to Spacedrive."
buttonText="Add a Location"
/>
)}
</OverviewSection>
<OverviewSection count={0} title="Cloud Drives">
{/* <StatisticItem
name="James Pine"
icon="DriveDropbox"
total_space="104877906944"
free_space="074877906944"
color="#0362FF"
connection_type="cloud"
/>
<StatisticItem
name="Spacedrive S3"
icon="DriveAmazonS3"
total_space="1074877906944"
free_space="704877906944"
color="#0362FF"
connection_type="cloud"
/> */}
<NewCard
icons={[
'DriveAmazonS3',
'DriveDropbox',
'DriveGoogleDrive',
'DriveOneDrive'
// 'DriveBox'
]}
text="Connect your cloud accounts to Spacedrive."
// buttonText="Connect a cloud"
/>
</OverviewSection>
{/* <OverviewSection title="Locations">
<div className="flex flex-row gap-2">
{locations.map((location) => (
<div
key={location.id}
className="flex w-[100px] flex-col items-center gap-2"
>
<Icon size={80} name="Folder" />
<span className="truncate text-xs font-medium">
{location.name}
</span>
</div>
))}
</div>
</OverviewSection> */}
</div>
</div>
</SearchContextProvider>
);
};

View file

@ -0,0 +1,92 @@
import { useMemo } from 'react';
import { ObjectFilterArgs, ObjectKindEnum, ObjectOrder, SearchFilterArgs } from '@sd/client';
import { Icon } from '~/components';
import { useRouteTitle } from '~/hooks';
import Explorer from './Explorer';
import { ExplorerContextProvider } from './Explorer/Context';
import { useObjectsExplorerQuery } from './Explorer/queries/useObjectsExplorerQuery';
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explorer/store';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
import { EmptyNotice } from './Explorer/View/EmptyNotice';
import { SearchContextProvider, SearchOptions, useSearch } from './search';
import SearchBar from './search/SearchBar';
import { TopBarPortal } from './TopBar/Portal';
export function Component() {
useRouteTitle('Recents');
const explorerSettings = useExplorerSettings({
settings: useMemo(() => {
return createDefaultExplorerSettings<ObjectOrder>({ order: null });
}, []),
orderingKeys: objectOrderingKeysSchema
});
const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
const fixedFilters = useMemo<SearchFilterArgs[]>(
() => [
// { object: { dateAccessed: { from: new Date(0).toISOString() } } },
...(explorerSettingsSnapshot.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [])
],
[explorerSettingsSnapshot.layoutMode]
);
const search = useSearch({
fixedFilters
});
const objects = useObjectsExplorerQuery({
arg: {
take: 100,
filters: [
...search.allFilters,
// TODO: Add filter to search options
{ object: { dateAccessed: { from: new Date(0).toISOString() } } }
]
},
explorerSettings
});
const explorer = useExplorer({
...objects,
isFetchingNextPage: objects.query.isFetchingNextPage,
settings: explorerSettings
});
return (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Recents</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="Collection" size={128} />}
message="Recents are created when you open a file."
/>
}
/>
</ExplorerContextProvider>
);
}

View file

@ -19,8 +19,8 @@ import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Ex
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { 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 = () => {

View file

@ -1,4 +1,12 @@
import { CircleDashed, Cube, Folder, Icon, SelectionSlash, Textbox } from '@phosphor-icons/react';
import {
CircleDashed,
Cube,
Folder,
Icon,
SelectionSlash,
Tag,
Textbox
} from '@phosphor-icons/react';
import { useState } from 'react';
import {
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
//

View file

@ -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();

View file

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

View file

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

View file

@ -0,0 +1,134 @@
import { useEffect, useMemo } from 'react';
import { useSearchParams as useRawSearchParams } from 'react-router-dom';
import { ObjectKindEnum, ObjectOrder } from '@sd/client';
import { Icon } from '~/components';
import { useRouteTitle } from '~/hooks';
import { SearchContextProvider, SearchOptions, useSearch } from '.';
import Explorer from '../Explorer';
import { ExplorerContextProvider } from '../Explorer/Context';
import { useObjectsExplorerQuery } from '../Explorer/queries/useObjectsExplorerQuery';
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
import { TopBarPortal } from '../TopBar/Portal';
import SearchBar from './SearchBar';
export * from './context';
export * from './SearchOptions';
export * from './useSearch';
export function Component() {
useRouteTitle('Search');
const explorerSettings = useExplorerSettings({
settings: useMemo(() => {
return createDefaultExplorerSettings<ObjectOrder>({ order: null });
}, []),
orderingKeys: objectOrderingKeysSchema
});
const search = useSearchWithFilters(explorerSettings);
const objects = useObjectsExplorerQuery({
arg: {
take: 100,
filters: search.allFilters
},
explorerSettings
});
const explorer = useExplorer({
...objects,
isFetchingNextPage: objects.query.isFetchingNextPage,
settings: explorerSettings
});
return (
<ExplorerContextProvider explorer={explorer}>
<SearchContextProvider search={search}>
<TopBarPortal
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Search</span>
</div>
}
right={<DefaultTopBarOptions />}
>
{search.open && (
<>
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
<SearchOptions />
</>
)}
</TopBarPortal>
</SearchContextProvider>
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="Collection" size={128} />}
message="No recent items"
/>
}
/>
</ExplorerContextProvider>
);
}
function useSearchWithFilters(explorerSettings: UseExplorerSettings<ObjectOrder>) {
const [searchParams, setSearchParams] = useRawSearchParams();
const explorerSettingsSnapshot = explorerSettings.useSettingsSnapshot();
const fixedFilters = useMemo(
() => [
...(explorerSettingsSnapshot.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [])
],
[explorerSettingsSnapshot.layoutMode]
);
const filtersParam = searchParams.get('filters');
const dynamicFilters = useMemo(() => JSON.parse(filtersParam ?? '[]'), [filtersParam]);
const searchQueryParam = searchParams.get('search');
const search = useSearch({
open: !!searchQueryParam || dynamicFilters.length > 0 || undefined,
search: searchParams.get('search') ?? undefined,
fixedFilters,
dynamicFilters
});
useEffect(() => {
setSearchParams(
(p) => {
if (search.dynamicFilters.length > 0)
p.set('filters', JSON.stringify(search.dynamicFilters));
else p.delete('filters');
return p;
},
{ replace: true }
);
}, [search.dynamicFilters, setSearchParams]);
const searchQuery = search.search;
useEffect(() => {
setSearchParams(
(p) => {
if (searchQuery !== '') p.set('search', searchQuery);
else p.delete('search');
return p;
},
{ replace: true }
);
}, [searchQuery, setSearchParams]);
return search;
}

View file

@ -20,8 +20,8 @@ import { tw } from '@sd/ui';
import { useLocale, useOperatingSystem } from '~/hooks';
import { 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')}

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

@ -0,0 +1,14 @@
import * as Icons from '@sd/assets/icons';
import { getIcon } from '@sd/assets/util';
import { HardwareModel } from '@sd/client';
export function hardwareModelToIcon(hardwareModel: HardwareModel) {
switch (hardwareModel) {
case 'MacBookPro':
return 'Laptop';
case 'MacStudio':
return 'SilverBox';
default:
return 'Laptop';
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -29,6 +29,7 @@ import Code20 from './Code-20.png';
import Collection_Light from './Collection_Light.png';
import 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,

View file

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

View file

@ -0,0 +1,130 @@
import { byteSize } from '.';
import { getItemFilePath, getItemLocation, getItemObject, type ObjectKindKey } from '..';
import type { ExplorerItem } from '../core';
import { ObjectKind } from './objectKind';
// ItemData is a single data structure understood by the Explorer, we map all ExplorerItems to this structure in this file
// we use `null` instead of `?` optional values intentionally
export interface ItemData {
name: string | null;
fullName: string | null;
size: ReturnType<typeof byteSize>;
kind: ObjectKindKey;
isDir: boolean;
casId: string | null;
extension: string | null;
locationId: number | null;
dateIndexed: string | null;
dateCreated: string | null;
dateModified: string | null;
dateAccessed: string | null;
thumbnailKey: string[]; // default behavior is to render a single thumbnail
thumbnailKeys?: string[][]; // if set, we can render multiple thumbnails
hasLocalThumbnail: boolean; // this is overwritten when new thumbnails are generated
customIcon: string | null;
}
// this function maps an ExplorerItem to an ItemData
export function getExplorerItemData(data?: ExplorerItem | null): ItemData {
const itemData = getDefaultItemData();
if (!data) return itemData;
// a typesafe switch statement for all the different types of ExplorerItems
switch (data.type) {
// the getItemObject and getItemFilePath type-guards mean we can handle the following types in one case
case 'Object':
case 'NonIndexedPath':
case 'Path': {
// handle object
const object = getItemObject(data);
if (object?.kind) itemData.kind = ObjectKind[object?.kind] ?? 'Unknown';
// Objects only have dateCreated and dateAccessed
itemData.dateCreated = object?.date_created ?? null;
itemData.dateAccessed = object?.date_accessed ?? null;
// handle thumbnail based on provided key
// This could be better, but for now we're mapping the backend property to two different local properties (thumbnailKey, thumbnailKeys) for backward compatibility
if (data.thumbnail) {
itemData.thumbnailKey = data.thumbnail;
itemData.thumbnailKeys = [data.thumbnail];
}
itemData.hasLocalThumbnail = !!data.thumbnail;
// handle file path
const filePath = getItemFilePath(data);
if (filePath) {
itemData.name = filePath.name;
itemData.fullName = getFullName(filePath.name, filePath.extension);
itemData.size = byteSize(filePath.size_in_bytes_bytes);
itemData.isDir = filePath.is_dir ?? false;
itemData.extension = filePath.extension?.toLocaleLowerCase() ?? null;
//
if ('cas_id' in filePath) itemData.casId = filePath.cas_id;
if ('location_id' in filePath) itemData.locationId = filePath.location_id;
if ('date_indexed' in filePath) itemData.dateIndexed = filePath.date_indexed;
if ('date_modified' in filePath) itemData.dateModified = filePath.date_modified;
}
break;
}
// the following types do not have a file_path or an object associated, and must be handled from scratch
case 'Location': {
const location = getItemLocation(data);
if (location) {
if (location.total_capacity != null && location.available_capacity != null)
itemData.size = byteSize(location.total_capacity - location.available_capacity);
itemData.name = location.name;
itemData.fullName = location.name;
itemData.kind = ObjectKind[ObjectKind.Folder] ?? 'Unknown';
itemData.isDir = true;
itemData.locationId = location.id;
}
break;
}
case 'SpacedropPeer': {
itemData.name = data.item.name;
itemData.customIcon = 'Laptop';
break;
}
case 'Label': {
itemData.name = data.item.name;
itemData.customIcon = 'Tag';
itemData.thumbnailKey = data.thumbnails[0] ?? [];
itemData.thumbnailKeys = data.thumbnails;
itemData.hasLocalThumbnail = !!data.thumbnails;
itemData.kind = 'Label';
console.log({ itemData });
break;
}
}
return itemData;
}
export function getFullName(
filePathName: string | null,
filePathExtension?: string | null
): string {
return `${filePathName}${filePathExtension ? `.${filePathExtension}` : ''}`;
}
function getDefaultItemData(kind: ObjectKindKey = 'Unknown'): ItemData {
return {
name: null,
fullName: null,
size: byteSize(0),
kind: 'Unknown',
isDir: false,
casId: null,
extension: null,
locationId: null,
dateIndexed: null,
dateCreated: null,
dateModified: null,
dateAccessed: null,
thumbnailKey: [],
hasLocalThumbnail: false,
customIcon: null
};
}

View file

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

View file

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

View file

@ -1,154 +0,0 @@
import { useMemo } from 'react';
import type { Object } from '..';
import type { ExplorerItem, FilePath, NonIndexedPathItem } from '../core';
import { byteSize } from '../lib';
import { ObjectKind } from './objectKind';
export function getItemObject(data: ExplorerItem) {
return data.type === 'Object' ? data.item : data.type === 'Path' ? data.item.object : null;
}
export function getItemFilePath(data: ExplorerItem) {
if (data.type === 'Path' || data.type === 'NonIndexedPath') return data.item;
return (data.type === 'Object' && data.item.file_paths[0]) || null;
}
export function getEphemeralPath(data: ExplorerItem) {
return data.type === 'NonIndexedPath' ? data.item : null;
}
export function getIndexedItemFilePath(data: ExplorerItem) {
return data.type === 'Path'
? data.item
: data.type === 'Object'
? data.item.file_paths[0] ?? null
: null;
}
export function getItemLocation(data: ExplorerItem) {
return data.type === 'Location' ? data.item : null;
}
export function getItemSpacedropPeer(data: ExplorerItem) {
return data.type === 'SpacedropPeer' ? data.item : null;
}
export function getExplorerItemData(data?: null | ExplorerItem) {
const itemObj = data ? getItemObject(data) : null;
const kind = (itemObj?.kind ? ObjectKind[itemObj.kind] : null) ?? 'Unknown';
const itemData = {
name: null as string | null,
fullName: null as string | null,
size: byteSize(0),
kind,
isDir: false,
casId: null as string | null,
extension: null as string | null,
locationId: null as number | null,
dateIndexed: null as string | null,
dateCreated:
data?.item && 'date_created' in data.item
? data.item.date_created
: itemObj?.date_created ?? null,
dateModified: null as string | null,
dateAccessed: itemObj?.date_accessed ?? null,
thumbnailKey: data?.thumbnail_key ?? [],
hasLocalThumbnail: data?.has_local_thumbnail ?? false, // this will be overwritten if new thumbnail is generated
customIcon: null as string | null
};
if (!data) return itemData;
const filePath = getItemFilePath(data);
const location = getItemLocation(data);
if (filePath) {
itemData.name = filePath.name;
itemData.fullName = `${filePath.name}${filePath.extension ? `.${filePath.extension}` : ''}`;
itemData.size = byteSize(filePath.size_in_bytes_bytes);
itemData.isDir = filePath.is_dir ?? false;
itemData.extension = filePath.extension?.toLocaleLowerCase() ?? null;
if ('kind' in filePath) itemData.kind = ObjectKind[filePath.kind] ?? 'Unknown';
if ('cas_id' in filePath) itemData.casId = filePath.cas_id;
if ('location_id' in filePath) itemData.locationId = filePath.location_id;
if ('date_indexed' in filePath) itemData.dateIndexed = filePath.date_indexed;
if ('date_modified' in filePath) itemData.dateModified = filePath.date_modified;
} else if (location) {
if (location.total_capacity != null && location.available_capacity != null)
itemData.size = byteSize(location.total_capacity - location.available_capacity);
itemData.name = location.name;
itemData.fullName = location.name;
itemData.kind = ObjectKind[ObjectKind.Folder] ?? 'Unknown';
itemData.isDir = true;
itemData.locationId = location.id;
itemData.dateIndexed = location.date_created;
} else if (data.type === 'SpacedropPeer') {
itemData.name = data.item.name;
itemData.customIcon = 'Laptop';
}
if (data.type == 'Path' && itemData.isDir) itemData.kind = 'Folder';
return itemData;
}
export const useItemsAsObjects = (items: ExplorerItem[]) => {
return useMemo(() => {
const array: Object[] = [];
for (const item of items) {
switch (item.type) {
case 'Path': {
if (!item.item.object) return [];
array.push(item.item.object);
break;
}
case 'Object': {
array.push(item.item);
break;
}
default:
return [];
}
}
return array;
}, [items]);
};
export const useItemsAsFilePaths = (items: ExplorerItem[]) => {
return useMemo(() => {
const array: FilePath[] = [];
for (const item of items) {
switch (item.type) {
case 'Path': {
array.push(item.item);
break;
}
case 'Object': {
// this isn't good but it's the current behaviour
const filePath = item.item.file_paths[0];
if (filePath) array.push(filePath);
else return [];
break;
}
default:
return [];
}
}
return array;
}, [items]);
};
export const useItemsAsEphemeralPaths = (items: ExplorerItem[]) => {
return useMemo(() => {
return items
.filter((item) => item.type === 'NonIndexedPath')
.map((item) => item.item as NonIndexedPathItem);
}, [items]);
};

View file

@ -1,11 +1,98 @@
import { QueryClient } from '@tanstack/react-query';
import { 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';

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