diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 8ae8ba8e3..b773aa813 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,3 +1,8 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + use std::path::PathBuf; use sdcore::Node; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 7c5bfc74c..ef43c8130 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -71,6 +71,7 @@ "fullscreen": false, "alwaysOnTop": false, "focus": false, + "visible": false, "fileDropEnabled": false, "decorations": true, "transparent": true, diff --git a/apps/desktop/src/index.html b/apps/desktop/src/index.html index 4d441a0c7..b51fcedeb 100644 --- a/apps/desktop/src/index.html +++ b/apps/desktop/src/index.html @@ -1,5 +1,5 @@ - + diff --git a/apps/mobile/ios/Spacedrive/Info.plist b/apps/mobile/ios/Spacedrive/Info.plist index 1d07fc916..fd2881b25 100644 --- a/apps/mobile/ios/Spacedrive/Info.plist +++ b/apps/mobile/ios/Spacedrive/Info.plist @@ -1,83 +1,83 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Spacedrive - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 0.0.1 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - spacedrive - com.spacedrive.app - - - - CFBundleVersion - 1 - LSRequiresIPhoneOS - - NSAppTransportSecurity - NSAllowsArbitraryLoads - - NSExceptionDomains - - localhost + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Spacedrive + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 0.0.1 + CFBundleSignature + ???? + CFBundleURLTypes + - NSExceptionAllowsInsecureHTTPLoads - + CFBundleURLSchemes + + spacedrive + com.spacedrive.app + + + + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + UIBackgroundModes + + remote-notification + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Automatic + UIViewControllerBasedStatusBarAppearance + - UIBackgroundModes - - remote-notification - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - armv7 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Automatic - UIViewControllerBasedStatusBarAppearance - - - + \ No newline at end of file diff --git a/apps/mobile/src/screens/Location.tsx b/apps/mobile/src/screens/Location.tsx index 08c267ddd..2dd2525e7 100644 --- a/apps/mobile/src/screens/Location.tsx +++ b/apps/mobile/src/screens/Location.tsx @@ -6,8 +6,8 @@ import { SharedScreenProps } from '~/navigation/SharedScreens'; export default function LocationScreen({ navigation, route }: SharedScreenProps<'Location'>) { const { id } = route.params; return ( - - Location {id} + + Location {id} ); } diff --git a/apps/mobile/src/screens/Spaces.tsx b/apps/mobile/src/screens/Spaces.tsx index 706105973..10c8cc5b9 100644 --- a/apps/mobile/src/screens/Spaces.tsx +++ b/apps/mobile/src/screens/Spaces.tsx @@ -5,8 +5,8 @@ import { SpacesStackScreenProps } from '~/navigation/tabs/SpacesStack'; export default function SpacesScreen({ navigation }: SpacesStackScreenProps<'Spaces'>) { return ( - - Spaces + + Spaces ); } diff --git a/core/prisma/migrations/20220902014900_add_is_archived/migration.sql b/core/prisma/migrations/20220902014900_add_is_archived/migration.sql new file mode 100644 index 000000000..fa390a498 --- /dev/null +++ b/core/prisma/migrations/20220902014900_add_is_archived/migration.sql @@ -0,0 +1,28 @@ +-- AlterTable +ALTER TABLE "files" ADD COLUMN "extension" TEXT; +ALTER TABLE "files" ADD COLUMN "name" TEXT; + +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_locations" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "pub_id" BLOB NOT NULL, + "node_id" INTEGER, + "name" TEXT, + "local_path" TEXT, + "total_capacity" INTEGER, + "available_capacity" INTEGER, + "filesystem" TEXT, + "disk_type" INTEGER, + "is_removable" BOOLEAN, + "is_online" BOOLEAN NOT NULL DEFAULT true, + "is_archived" BOOLEAN NOT NULL DEFAULT false, + "date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "locations_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "nodes" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_locations" ("available_capacity", "date_created", "disk_type", "filesystem", "id", "is_online", "is_removable", "local_path", "name", "node_id", "pub_id", "total_capacity") SELECT "available_capacity", "date_created", "disk_type", "filesystem", "id", "is_online", "is_removable", "local_path", "name", "node_id", "pub_id", "total_capacity" FROM "locations"; +DROP TABLE "locations"; +ALTER TABLE "new_locations" RENAME TO "locations"; +CREATE UNIQUE INDEX "locations_pub_id_key" ON "locations"("pub_id"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 7bff3a98d..fbf71b6be 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -32,6 +32,7 @@ model SyncEvent { value String node Node @relation(fields: [node_id], references: [id]) + @@map("sync_events") } @@ -51,7 +52,7 @@ model Statistics { model Node { id Int @id @default(autoincrement()) - pub_id Bytes @unique + pub_id Bytes @unique name String platform Int @default(0) version String? @@ -63,6 +64,7 @@ model Node { jobs Job[] Location Location[] + @@map("nodes") } @@ -94,13 +96,13 @@ model Location { disk_type Int? is_removable Boolean? is_online Boolean @default(true) + is_archived Boolean @default(false) date_created DateTime @default(now()) - node Node? @relation(fields: [node_id], references: [id]) - file_paths FilePath[] + node Node? @relation(fields: [node_id], references: [id]) + file_paths FilePath[] indexer_rules IndexerRulesInLocation[] - @@unique([node_id, local_path]) @@map("locations") } @@ -111,6 +113,8 @@ model File { // full byte contents digested into sha256 checksum integrity_checksum String? @unique // basic metadata + name String? + extension String? kind Int @default(0) size_in_bytes String key_id Int? @@ -205,6 +209,7 @@ model Key { files File[] file_paths FilePath[] + @@map("keys") } @@ -230,7 +235,7 @@ model MediaData { model Tag { id Int @id @default(autoincrement()) - pub_id Bytes @unique + pub_id Bytes @unique name String? color String? total_files Int? @default(0) @@ -239,6 +244,7 @@ model Tag { date_modified DateTime @default(now()) tag_files TagOnFile[] + @@map("tags") } @@ -257,12 +263,13 @@ model TagOnFile { model Label { id Int @id @default(autoincrement()) - pub_id Bytes @unique + pub_id Bytes @unique name String? date_created DateTime @default(now()) date_modified DateTime @default(now()) label_files LabelOnFile[] + @@map("labels") } @@ -281,13 +288,14 @@ model LabelOnFile { model Space { id Int @id @default(autoincrement()) - pub_id Bytes @unique + pub_id Bytes @unique name String? description String? date_created DateTime @default(now()) date_modified DateTime @default(now()) files FileInSpace[] + @@map("spaces") } @@ -309,7 +317,7 @@ model Job { name String node_id Int action Int - status Int @default(0) + status Int @default(0) data Bytes? task_count Int @default(1) @@ -319,12 +327,13 @@ model Job { seconds_elapsed Int @default(0) nodes Node @relation(fields: [node_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@map("jobs") } model Album { id Int @id @default(autoincrement()) - pub_id Bytes @unique + pub_id Bytes @unique name String is_hidden Boolean @default(false) @@ -351,7 +360,7 @@ model FileInAlbum { model Comment { id Int @id @default(autoincrement()) - pub_id Bytes @unique + pub_id Bytes @unique content String date_created DateTime @default(now()) date_modified DateTime @default(now()) @@ -375,14 +384,14 @@ model IndexerRule { } model IndexerRulesInLocation { - date_created DateTime @default(now()) + date_created DateTime @default(now()) - location_id Int - location Location @relation(fields: [location_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + location_id Int + location Location @relation(fields: [location_id], references: [id], onDelete: NoAction, onUpdate: NoAction) indexer_rule_id Int indexer_rule IndexerRule @relation(fields: [indexer_rule_id], references: [id], onDelete: NoAction, onUpdate: NoAction) @@id([location_id, indexer_rule_id]) @@map("indexer_rules_in_location") -} \ No newline at end of file +} diff --git a/core/src/api/files.rs b/core/src/api/files.rs index 3c1728b20..bf6f749b5 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -1,4 +1,4 @@ -use crate::{api::locations::GetExplorerDirArgs, invalidate_query, prisma::file}; +use crate::{api::locations::LocationExplorerArgs, invalidate_query, prisma::file}; use rspc::Type; use serde::Deserialize; @@ -30,7 +30,7 @@ pub(crate) fn mount() -> RouterBuilder { .exec() .await?; - invalidate_query!(library, "locations.getExplorerDir"); + invalidate_query!(library, "locations.getExplorerData"); Ok(()) }) @@ -51,14 +51,15 @@ pub(crate) fn mount() -> RouterBuilder { invalidate_query!( library, - "locations.getExplorerDir": LibraryArgs, + "locations.getExplorerData": LibraryArgs, LibraryArgs { library_id: library.id, - arg: GetExplorerDirArgs { + arg: LocationExplorerArgs { // TODO: Set these arguments to the correct type location_id: 0, path: "".into(), limit: 0, + cursor: None, } } ); @@ -78,14 +79,15 @@ pub(crate) fn mount() -> RouterBuilder { invalidate_query!( library, - "locations.getExplorerDir": LibraryArgs, + "locations.getExplorerData": LibraryArgs, LibraryArgs { library_id: library.id, - arg: GetExplorerDirArgs { + arg: LocationExplorerArgs { // TODO: Set these arguments to the correct type location_id: 0, path: "".into(), limit: 0, + cursor: None, } } ); diff --git a/core/src/api/jobs.rs b/core/src/api/jobs.rs index d2f54143e..773c3ee26 100644 --- a/core/src/api/jobs.rs +++ b/core/src/api/jobs.rs @@ -77,6 +77,7 @@ pub(crate) fn mount() -> RouterBuilder { .exec() .await? .ok_or(LocationError::IdNotFound(args.id))?, + sub_path: Some(args.path), }, Box::new(FileIdentifierJob {}), )) diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 43c901374..cf8345303 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -5,7 +5,7 @@ use crate::{ fetch_location, indexer::indexer_rules::IndexerRuleCreateArgs, scan_location, with_indexer_rules, LocationCreateArgs, LocationError, LocationUpdateArgs, }, - prisma::{file_path, indexer_rule, indexer_rules_in_location, location}, + prisma::{file, file_path, indexer_rule, indexer_rules_in_location, location, tag}, }; use rspc::{self, ErrorCode, Type}; @@ -15,16 +15,32 @@ use tracing::info; use super::{LibraryArgs, RouterBuilder}; #[derive(Serialize, Deserialize, Type, Debug)] -pub struct DirectoryWithContents { - pub directory: file_path::Data, - pub contents: Vec, +pub struct ExplorerData { + pub context: ExplorerContext, + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Type, Debug)] +#[serde(tag = "type")] +pub enum ExplorerContext { + Location(location::Data), + Tag(tag::Data), + // Space(object_in_space::Data), +} + +#[derive(Serialize, Deserialize, Type, Debug)] +#[serde(tag = "type")] +pub enum ExplorerItem { + Path(Box), + Object(Box), } #[derive(Clone, Serialize, Deserialize, Type, Debug)] -pub struct GetExplorerDirArgs { +pub struct LocationExplorerArgs { pub location_id: i32, pub path: String, pub limit: i32, + pub cursor: Option, } pub(crate) fn mount() -> RouterBuilder { @@ -53,8 +69,8 @@ pub(crate) fn mount() -> RouterBuilder { .await?) }) .query( - "getExplorerDir", - |ctx, arg: LibraryArgs| async move { + "getExplorerData", + |ctx, arg: LibraryArgs| async move { let (args, library) = arg.get_library(&ctx).await?; let location = library @@ -63,7 +79,9 @@ pub(crate) fn mount() -> RouterBuilder { .find_unique(location::id::equals(args.location_id)) .exec() .await? - .unwrap(); + .ok_or_else(|| { + rspc::Error::new(ErrorCode::NotFound, "Location not found".into()) + })?; let directory = library .db @@ -90,9 +108,9 @@ pub(crate) fn mount() -> RouterBuilder { .exec() .await?; - Ok(DirectoryWithContents { - directory, - contents: file_paths + Ok(ExplorerData { + context: ExplorerContext::Location(location), + items: file_paths .into_iter() .map(|mut file_path| { if let Some(file) = &mut file_path.file.as_mut().unwrap_or_else( @@ -103,14 +121,12 @@ pub(crate) fn mount() -> RouterBuilder { .config() .data_directory() .join(THUMBNAIL_CACHE_DIR_NAME) - .join(location.id.to_string()) .join(&file.cas_id) .with_extension("webp"); file.has_thumbnail = thumb_path.exists(); } - - file_path + ExplorerItem::Path(Box::new(file_path)) }) .collect(), }) diff --git a/core/src/api/tags.rs b/core/src/api/tags.rs index 81b0ee819..671941f5b 100644 --- a/core/src/api/tags.rs +++ b/core/src/api/tags.rs @@ -1,12 +1,15 @@ -use crate::{ - invalidate_query, - prisma::{file, tag}, -}; - -use rspc::Type; +use rspc::{ErrorCode, Type}; use serde::Deserialize; +use tracing::log::info; use uuid::Uuid; +use crate::{ + api::locations::{ExplorerContext, ExplorerData, ExplorerItem}, + encode::THUMBNAIL_CACHE_DIR_NAME, + invalidate_query, + prisma::{file, tag, tag_on_file}, +}; + use super::{LibraryArgs, RouterBuilder}; #[derive(Type, Deserialize)] @@ -15,10 +18,11 @@ pub struct TagCreateArgs { pub color: String, } -#[derive(Type, Deserialize)] +#[derive(Debug, Type, Deserialize)] pub struct TagAssignArgs { pub file_id: i32, pub tag_id: i32, + pub unassign: bool, } #[derive(Type, Deserialize)] @@ -30,12 +34,77 @@ pub struct TagUpdateArgs { pub(crate) fn mount() -> RouterBuilder { RouterBuilder::new() - .query("get", |ctx, arg: LibraryArgs<()>| async move { + .query("getAll", |ctx, arg: LibraryArgs<()>| async move { let (_, library) = arg.get_library(&ctx).await?; Ok(library.db.tag().find_many(vec![]).exec().await?) }) - .query("getFilesForTag", |ctx, arg: LibraryArgs| async move { + .query("getExplorerData", |ctx, arg: LibraryArgs| async move { + let (tag_id, library) = arg.get_library(&ctx).await?; + + info!("Getting files for tag {}", tag_id); + + let tag = library + .db + .tag() + .find_unique(tag::id::equals(tag_id)) + .exec() + .await? + .ok_or_else(|| { + rspc::Error::new(ErrorCode::NotFound, format!("Tag not found")) + })?; + + let files: Vec = library + .db + .file() + .find_many(vec![file::tags::some(vec![tag_on_file::tag_id::equals( + tag_id, + )])]) + .with(file::paths::fetch(vec![])) + .exec() + .await? + .into_iter() + .map(|mut file| { + // sorry brendan + // grab the first path and tac on the name + let oldest_path = &file.paths.as_ref().unwrap()[0]; + file.name = Some(oldest_path.name.clone()); + file.extension = oldest_path.extension.clone(); + // a long term fix for this would be to have the indexer give the Object a name and extension, sacrificing its own and only store newly found Path names that differ from the Object name + + let thumb_path = library + .config() + .data_directory() + .join(THUMBNAIL_CACHE_DIR_NAME) + .join(&file.cas_id) + .with_extension("webp"); + + file.has_thumbnail = thumb_path.exists(); + + ExplorerItem::Object(Box::new(file)) + }) + .collect(); + + info!("Got files {}", files.len()); + + Ok(ExplorerData { + context: ExplorerContext::Tag(tag), + items: files, + }) + }) + .query("getForFile", |ctx, arg: LibraryArgs| async move { + let (file_id, library) = arg.get_library(&ctx).await?; + + Ok(library + .db + .tag() + .find_many(vec![tag::tag_files::some(vec![ + tag_on_file::file_id::equals(file_id), + ])]) + .exec() + .await?) + }) + .query("get", |ctx, arg: LibraryArgs| async move { let (tag_id, library) = arg.get_library(&ctx).await?; Ok(library @@ -65,7 +134,7 @@ pub(crate) fn mount() -> RouterBuilder { invalidate_query!( library, - "tags.get": LibraryArgs<()>, + "tags.getAll": LibraryArgs<()>, LibraryArgs { library_id: library.id, arg: () @@ -80,10 +149,33 @@ pub(crate) fn mount() -> RouterBuilder { |ctx, arg: LibraryArgs| async move { let (args, library) = arg.get_library(&ctx).await?; - library.db.tag_on_file().create( - tag::id::equals(args.tag_id), - file::id::equals(args.file_id), - vec![], + if args.unassign { + library + .db + .tag_on_file() + .delete(tag_on_file::tag_id_file_id(args.tag_id, args.file_id)) + .exec() + .await?; + } else { + library + .db + .tag_on_file() + .create( + tag::id::equals(args.tag_id), + file::id::equals(args.file_id), + vec![], + ) + .exec() + .await?; + } + + invalidate_query!( + library, + "tags.getForFile": LibraryArgs, + LibraryArgs { + library_id: library.id, + arg: args.file_id + } ); Ok(()) @@ -106,7 +198,7 @@ pub(crate) fn mount() -> RouterBuilder { invalidate_query!( library, - "tags.get": LibraryArgs<()>, + "tags.getAll": LibraryArgs<()>, LibraryArgs { library_id: library.id, arg: () @@ -123,7 +215,7 @@ pub(crate) fn mount() -> RouterBuilder { invalidate_query!( library, - "tags.get": LibraryArgs<()>, + "tags.getAll": LibraryArgs<()>, LibraryArgs { library_id: library.id, arg: () diff --git a/core/src/encode/thumb.rs b/core/src/encode/thumb.rs index 8dafcb0b4..e99906ddc 100644 --- a/core/src/encode/thumb.rs +++ b/core/src/encode/thumb.rs @@ -1,5 +1,5 @@ use crate::{ - api::{locations::GetExplorerDirArgs, CoreEvent, LibraryArgs}, + api::{locations::LocationExplorerArgs, CoreEvent, LibraryArgs}, invalidate_query, job::{JobError, JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext}, library::LibraryContext, @@ -56,8 +56,8 @@ impl StatefulJob for ThumbnailJob { let thumbnail_dir = library_ctx .config() .data_directory() - .join(THUMBNAIL_CACHE_DIR_NAME) - .join(state.init.location_id.to_string()); + .join(THUMBNAIL_CACHE_DIR_NAME); + // .join(state.init.location_id.to_string()); let location = library_ctx .db @@ -157,13 +157,14 @@ impl StatefulJob for ThumbnailJob { let library_ctx = ctx.library_ctx(); invalidate_query!( library_ctx, - "locations.getExplorerDir": LibraryArgs, + "locations.getExplorerData": LibraryArgs, LibraryArgs::new( library_ctx.id, - GetExplorerDirArgs { + LocationExplorerArgs { location_id: state.init.location_id, path: "".to_string(), - limit: 100 + limit: 100, + cursor: None, } ) ); diff --git a/core/src/file/cas/identifier.rs b/core/src/file/cas/identifier.rs index b96ae7ad1..e193318a3 100644 --- a/core/src/file/cas/identifier.rs +++ b/core/src/file/cas/identifier.rs @@ -28,6 +28,7 @@ pub struct FileIdentifierJob {} #[derive(Serialize, Deserialize, Clone)] pub struct FileIdentifierJobInit { pub location: location::Data, + pub sub_path: Option, // subpath to start from } #[derive(Serialize, Deserialize, Debug)] @@ -46,6 +47,7 @@ impl From<&FilePathIdAndLocationIdCursor> for file_path::UniqueWhereParam { pub struct FileIdentifierJobState { total_count: usize, task_count: usize, + location: location::Data, location_path: PathBuf, cursor: FilePathIdAndLocationIdCursor, } @@ -69,9 +71,15 @@ impl StatefulJob for FileIdentifierJob { let library = ctx.library_ctx(); - let location_path = state - .init - .location + let location = library + .db + .location() + .find_unique(location::id::equals(state.init.location.id)) + .exec() + .await? + .unwrap(); + + let location_path = location .local_path .as_ref() .map(PathBuf::from) @@ -89,6 +97,7 @@ impl StatefulJob for FileIdentifierJob { state.data = Some(FileIdentifierJobState { total_count, task_count, + location, location_path, cursor: FilePathIdAndLocationIdCursor { file_path_id: 1, @@ -116,13 +125,14 @@ impl StatefulJob for FileIdentifierJob { .expect("critical error: missing data on job state"); // get chunk of orphans to process - let file_paths = match get_orphan_file_paths(&ctx.library_ctx(), &data.cursor).await { - Ok(file_paths) => file_paths, - Err(e) => { - info!("Error getting orphan file paths: {:#?}", e); - return Ok(()); - } - }; + let file_paths = + match get_orphan_file_paths(&ctx.library_ctx(), &data.cursor, data.location.id).await { + Ok(file_paths) => file_paths, + Err(e) => { + info!("Error getting orphan file paths: {:#?}", e); + return Ok(()); + } + }; info!( "Processing {:?} orphan files. ({} completed of {})", file_paths.len(), @@ -305,6 +315,7 @@ async fn count_orphan_file_paths( async fn get_orphan_file_paths( ctx: &LibraryContext, cursor: &FilePathIdAndLocationIdCursor, + location_id: i32, ) -> Result, prisma_client_rust::QueryError> { info!( "discovering {} orphan file paths at cursor: {:?}", @@ -315,6 +326,7 @@ async fn get_orphan_file_paths( .find_many(vec![ file_path::file_id::equals(None), file_path::is_dir::equals(false), + file_path::location_id::equals(location_id), ]) .order_by(file_path::id::order(Direction::Asc)) .cursor(cursor.into()) @@ -344,6 +356,8 @@ async fn prepare_file( .as_ref() .join(file_path.materialized_path.as_str()); + info!("Reading path: {:?}", path); + let metadata = fs::metadata(&path).await?; // let date_created: DateTime = metadata.created().unwrap().into(); diff --git a/core/src/lib.rs b/core/src/lib.rs index 99438e9eb..776a5e5f5 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -16,6 +16,7 @@ pub(crate) mod job; pub(crate) mod library; pub(crate) mod location; pub(crate) mod node; +pub(crate) mod object; pub(crate) mod prisma; pub(crate) mod util; pub(crate) mod volume; diff --git a/core/src/location/mod.rs b/core/src/location/mod.rs index 418bac600..afc2981c8 100644 --- a/core/src/location/mod.rs +++ b/core/src/location/mod.rs @@ -263,6 +263,7 @@ pub async fn scan_location( ctx.queue_job(Job::new( FileIdentifierJobInit { location: location.clone(), + sub_path: None, }, Box::new(FileIdentifierJob {}), )) diff --git a/core/src/object/mod.rs b/core/src/object/mod.rs new file mode 100644 index 000000000..fd268ba87 --- /dev/null +++ b/core/src/object/mod.rs @@ -0,0 +1,67 @@ +// Objects are primarily created by the identifier from Paths +// Some Objects are purely virtual, unless they have one or more associated Paths, which refer to a file found in a Location +// Objects are what can be added to Spaces + +use rspc::Type; +use serde::{Deserialize, Serialize}; + +use crate::prisma; + +// The response to provide the Explorer when looking at Objects +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct ObjectsForExplorer { + pub objects: Vec, + // pub context: ExplorerContext, +} + +// #[derive(Debug, Serialize, Deserialize, Type)] +// pub enum ExplorerContext { +// Location(Box), +// Space(Box), +// Tag(Box), +// // Search(Box), +// } + +#[derive(Debug, Serialize, Deserialize, Type)] +pub enum ObjectData { + Object(Box), + Path(Box), +} + +#[derive(Debug, Serialize, Deserialize, Type)] +pub enum ObjectKind { + // A file that can not be identified by the indexer + Unknown, + // A known filetype, but without specific support + Document, + // A virtual filesystem directory + Folder, + // A file that contains human-readable text + TextFile, + // A virtual directory int + Package, + // An image file + Image, + // An audio file + Audio, + // A video file + Video, + // A compressed archive of data + Archive, + // An executable, program or application + Executable, + // A link to another object + Alias, + // Raw bytes encrypted by Spacedrive with self contained metadata + EncryptedBytes, + // A link can open web pages, apps or Spaces + Link, + // A special filetype that represents a preserved webpage + WebPageArchive, + // A widget is a mini app that can be placed in a Space at various sizes, associated Widget struct required + Widget, + // Albums can only have one level of children, and are associated with the Album struct + Album, + // Its like a folder, but appears like a stack of files, designed for burst photos / associated groups of files + Collection, +} diff --git a/core/src/util/db.rs b/core/src/util/db.rs index 35ba43999..08a59a021 100644 --- a/core/src/util/db.rs +++ b/core/src/util/db.rs @@ -101,9 +101,27 @@ pub async fn load_and_migrate(db_url: &str) -> Result>(); - let steps = &steps[0..steps.len() - 1]; + let step_count = steps.len(); + let steps = &steps[0..step_count - 1]; + for (i, step) in steps.iter().enumerate() { - client._execute_raw(raw!(*step)).exec().await?; + match client._execute_raw(raw!(*step)).exec().await { + Ok(_) => {} + Err(e) => { + // remove the failed migration record so next time it will be retried + // potentially an issue if steps were already applied, look into generating down migrations + client + .migration() + .delete(migration::checksum::equals(checksum.clone())) + .exec() + .await?; + + // TODO: Show UI alert with error message + panic!("Error applying migration step: {}", e); + } + } + // Note: there isn't much point storing the steps in the db if we don't generate down migrations and write logic to run the already applied steps in reverse for a failed migration. + // for now if a migration fails we abort entirely (see above panic) client .migration() .update( diff --git a/docs/architecture/albums.md b/docs/architecture/albums.md new file mode 100644 index 000000000..0b024bcdf --- /dev/null +++ b/docs/architecture/albums.md @@ -0,0 +1,3 @@ +# Albums + +you can put photos here \ No newline at end of file diff --git a/docs/architecture/database.md b/docs/architecture/database.md index 7c905126b..9fc5d324f 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -1,9 +1,3 @@ -## Database backup +# Database -## Database migrations - -Currently, migrations are applied on app launch with no visual feedback, backup or error handling. - -It doesn't appear that migrations are applied successfully - -## +prisma client rust, sqlite, migrations, backup \ No newline at end of file diff --git a/docs/architecture/explorer.md b/docs/architecture/explorer.md new file mode 100644 index 000000000..ab04aafbc --- /dev/null +++ b/docs/architecture/explorer.md @@ -0,0 +1,3 @@ +# Explorer + +using the interface, features \ No newline at end of file diff --git a/docs/architecture/extensions.md b/docs/architecture/extensions.md index e69de29bb..ed191fd47 100644 --- a/docs/architecture/extensions.md +++ b/docs/architecture/extensions.md @@ -0,0 +1,3 @@ +# Extensions + +extended functionality of Spacedrive \ No newline at end of file diff --git a/docs/architecture/filesystem.md b/docs/architecture/filesystem.md new file mode 100644 index 000000000..863b616ca --- /dev/null +++ b/docs/architecture/filesystem.md @@ -0,0 +1,6 @@ +# sVFS??? + +sVFS is a decentralized virtual filesystem + +*not sure what to call this yet* + diff --git a/docs/architecture/jobs.md b/docs/architecture/jobs.md index e69de29bb..33f69f873 100644 --- a/docs/architecture/jobs.md +++ b/docs/architecture/jobs.md @@ -0,0 +1,3 @@ +# Jobs + +jobs are computation tasks performed by nodes in a Spacedrive network, they can be created by any node and performed by any or all nodes. \ No newline at end of file diff --git a/docs/architecture/libraries.md b/docs/architecture/libraries.md new file mode 100644 index 000000000..4da0363c8 --- /dev/null +++ b/docs/architecture/libraries.md @@ -0,0 +1,4 @@ +# Libraries + +A library is a database, you can have many of them. They contain all the Spacedrive data, inluding file structure and metadata. + diff --git a/docs/architecture/locations.md b/docs/architecture/locations.md new file mode 100644 index 000000000..d7a496026 --- /dev/null +++ b/docs/architecture/locations.md @@ -0,0 +1,3 @@ +# Locations + +indexing, identifying, watching, .spacedrive folder, online/offline \ No newline at end of file diff --git a/docs/architecture/nodes.md b/docs/architecture/nodes.md new file mode 100644 index 000000000..33524444f --- /dev/null +++ b/docs/architecture/nodes.md @@ -0,0 +1,3 @@ +# Nodes + +p2p, connecting nodes, protocols. \ No newline at end of file diff --git a/docs/architecture/objects.md b/docs/architecture/objects.md new file mode 100644 index 000000000..e3420572f --- /dev/null +++ b/docs/architecture/objects.md @@ -0,0 +1,32 @@ +# Objects + +Objects are primarily created by the identifier from Paths. They can be created from any kind of file or directory. All metadata created around files in Spacedrive are directly attached to the Object for that file. + +A CAS id is generated from samples of the byte data, which is used to associate Objects uniquely with logical Paths found in a location. + +Some Objects are purely virtual, meaning they have no Path and are likely only used in a Space. + + + +## Types of object + +| Name | Description | Code | +| ---------------- | ------------------------------------------------------------ | ---- | +| Unknown | A file that can not be identified by the indexer | 0 | +| Document | A known filetype, but without specific support | 1 | +| Folder | A virtual filesystem directory | 2 | +| Text File | A file that contains human-readable text | 3 | +| Package | A folder that opens an application | 4 | +| Image | An image file | 5 | +| Audio | An audio file | 6 | +| Video | A video file | 7 | +| Archive | A compressed archive of data | 8 | +| Executable | An executable program or application | 9 | +| Alias | A link to another Object | 10 | +| Encrypted Bytes | Raw bytes with self contained metadata | 11 | +| Link | A link to a web page, application or Space | 12 | +| Web Page Archive | A snapshot of a webpage, with HTML, JS, images and screenshot | 13 | +| Widget | A widget is a mini app that can be placed in a Space at various sizes, associated Widget struct required | 14 | +| Album | Albums can only have one level of children, and are associated with the Album struct | 15 | +| Collection | Its like a folder, but appears like a stack of files, designed for burst photos/associated groups of files | 16 | + diff --git a/docs/architecture/preview-media.md b/docs/architecture/preview-media.md new file mode 100644 index 000000000..271f663a1 --- /dev/null +++ b/docs/architecture/preview-media.md @@ -0,0 +1,7 @@ +# Preview Media + +Spacedrive generates compressed preview media for images, videos and text files. + + + +ffmpeg, syncing, security \ No newline at end of file diff --git a/docs/architecture/search.md b/docs/architecture/search.md new file mode 100644 index 000000000..8a0071c40 --- /dev/null +++ b/docs/architecture/search.md @@ -0,0 +1,9 @@ +# Search + +Press CTRL+F while on a Spacedrive window to access search. + +By default you will search the active library, however by checking "Search all Libraries" you can perform a simotanious search of all libraries loaded on a Node. + +Search results return Objects, Locations, Albums, Tags and Spaces + +Search can be filtered by `ObjectKind`, as well as dates. \ No newline at end of file diff --git a/docs/architecture/spaces.md b/docs/architecture/spaces.md new file mode 100644 index 000000000..b2ae5a0e4 --- /dev/null +++ b/docs/architecture/spaces.md @@ -0,0 +1,9 @@ +# Spaces + +Spaces are virtual folders that can be shared publically on the internet, or privately with friends, family and teams. Spaces contain [Objects]() which can be physically stored on any connected Node, or by Spacedrive as a service. Objects can be organised and presented spacially, with various layouts and variable grid placements. Color theme, icon packs and typeogrpahy can be customized per Space. + + + +Objects can be added to a Space manually, or by matching a defined ruleset, similar to Tags. + +Spacedrive comes with pre-defined Spaces, such as: photos, videos, screenshots, documents, . \ No newline at end of file diff --git a/docs/architecture/sync.md b/docs/architecture/sync.md new file mode 100644 index 000000000..0b3f809af --- /dev/null +++ b/docs/architecture/sync.md @@ -0,0 +1,14 @@ +# Sync + +Spacedrive synchronizes library data in realtime across the distributed network of Nodes. + +Using a Unique Hybrid Logicial Clock for distributed time synchronization. + +A combination of several property level CRDT types: + +- **Local data** - migrations, statistics, sync events +- **Owned data** - locations, paths, volumes +- **Shared data** - objects, tags, spaces, jobs +- **Relationship data** - many to many tables + +Built in Rust on top of Prisma, it uses the schema file to determine these sync rules. \ No newline at end of file diff --git a/docs/architecture/tags.md b/docs/architecture/tags.md new file mode 100644 index 000000000..f699b71ae --- /dev/null +++ b/docs/architecture/tags.md @@ -0,0 +1,5 @@ +# Tags + +Spacedrive heavily incentivize using tags to organize files efficiently by giving them more functionality and easier ways to apply them, individually and in bulk. + +ways to add tags, tag automation \ No newline at end of file diff --git a/docs/changelog/10-4-22_0.1.0.md b/docs/changelog/10-4-22_0.1.0.md deleted file mode 100644 index cf8c40042..000000000 --- a/docs/changelog/10-4-22_0.1.0.md +++ /dev/null @@ -1,9 +0,0 @@ -# `0.1.0` - -**Sunday, April 10th 2022** - -A change happened. - -- A very specific change. -- Another extremely specific change. -- I love change. diff --git a/docs/changelog/index.md b/docs/changelog/index.md index f011ee97b..db2a3989b 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -1,3 +1,68 @@ -# Changelog +--------------------------------COMING SOON-------------------------------- -No releases yet. +# 0.1.0_beta + +After __ months of development we are extremely excited to be releasing the first version of Spacedrive as an early public beta. + +This is an MVP, and by no means feature complete. Please test out the features listed below and give us feedback via Discord, email or GitHub Issues :D + +This release is missing database synchronization between nodes (your devices), for now this renders connecting nodes useless, other than to transfer individual files. But don't worry, its coming very soon! + +*Features:* + +- Support for Windows, Linux and macOS, iOS and Android. + +- Basic onboarding flow, determine use-case and preferences. + +- Create [Libraries](../architecture/libraries.md) and switch between them. + +- Connect multiple [Nodes](../architecture/nodes.md) to a Library via LAN. + +- Add [Locations](../architecture/locations.md) to import files into Spacedrive. + - Indexer watch for changes and performs light re-scans. + + - Identifier generates checksum and categorizes files into [Objects]() + + - Define rules for indexer to ignore certain files or folders. + + *Eventually Clouds will be supported and added as Cloud Locations* + +- Browse Locations via the [Explorer](../architecture/explorer.md) and view previews and metadata. + - Viewer options: row/grid item size, gap adjustment, show/hide info. + - Context menu: rename, copy, duplicate, delete, favorite and add tags. + - Multi-select with dedicated context menu options. + - Open with default OS app, in-app viewer (images/text only) or Apple Quicklook + +- Automatically identify unique files to discover duplicates, shown in the inspector. + +- Generate [Preview Media](../architecture/preview-media.md) for image, video and text. + +- Create [Tags](../architecture/tags.md) and assign them to files, browse Tags in the Explorer. + +- Create [Spaces](../architecture/spaces.md) to organize and present files. + + - Automated Spaces can include files that match criteria. + + *Eventually Spaces will be sharable, publically or privately* + +- Create photo [Albums](../architecture/albums.md) and add images. + +- Library statistics: total capacity, database size, preview media size, free space. + +- [Search](../architecture/search.md) Library via search bar or CTRL+F. + + - Searches online and offline Locations, Spaces, Tags and Albums. + +- Drag and drop file transfer on a keybind. + + - Defaults to CTRL+Space, also possible from Explorer context menu. + +- Customize sidebar freely with section headings and flexible slots, include default layout. + +- Pause and resume [Jobs](../architecture/jobs.md) with recovery on crash via Job Manager widget. + +- Multi-window support. + +- Update installer. + +- Optional crash reporting. \ No newline at end of file diff --git a/docs/product/ideas.md b/docs/product/ideas.md deleted file mode 100644 index ada925939..000000000 --- a/docs/product/ideas.md +++ /dev/null @@ -1,10 +0,0 @@ -# Core view - -The primary screen in the Spacedrive app is of your entire virtual network, every "core" is a device under your full command. It is presented in a large panel list view, featuring bold visuals and quick interactions with your fleet. - -Recent files, recent locations and settings are part of these panels, that can be customized at will. -Visual indicators report the live status of this device. - -Key statistics of this devices are also present on these panels. - -A "Core" represents a device in your "Space". diff --git a/packages/client/package.json b/packages/client/package.json index fbdb41eed..1c9df3845 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -35,6 +35,6 @@ "typescript": "^4.7.4" }, "peerDependencies": { - "react": "^18.0.0" + "react": "^18.2.0" } } diff --git a/packages/client/src/stores/useExplorerStore.ts b/packages/client/src/stores/useExplorerStore.ts index c16399e34..5cb66ac2f 100644 --- a/packages/client/src/stores/useExplorerStore.ts +++ b/packages/client/src/stores/useExplorerStore.ts @@ -1,31 +1,53 @@ +import produce from 'immer'; import create from 'zustand'; type LayoutMode = 'list' | 'grid'; +export enum ExplorerKind { + Location, + Tag, + Space +} + type ExplorerStore = { - selectedRowIndex: number; layoutMode: LayoutMode; - setSelectedRowIndex: (index: number) => void; - locationId: number; - setLocationId: (index: number) => void; + locationId: number | null; // used by top bar + showInspector: boolean; + selectedRowIndex: number; + multiSelectIndexes: number[]; + contextMenuObjectId: number | null; newThumbnails: Record; addNewThumbnail: (cas_id: string) => void; - setLayoutMode: (mode: LayoutMode) => void; + selectMore: (indexes: number[]) => void; reset: () => void; + set: (changes: Partial) => void; }; export const useExplorerStore = create((set) => ({ layoutMode: 'grid', + locationId: null, + showInspector: true, selectedRowIndex: 1, - setSelectedRowIndex: (index) => set((state) => ({ ...state, selectedRowIndex: index })), - locationId: -1, - setLocationId: (id: number) => set((state) => ({ ...state, locationId: id })), + multiSelectIndexes: [], + contextMenuObjectId: -1, newThumbnails: {}, - addNewThumbnail: (cas_id: string) => - set((state) => ({ - ...state, - newThumbnails: { ...state.newThumbnails, [cas_id]: true } - })), - setLayoutMode: (mode: LayoutMode) => set((state) => ({ ...state, layoutMode: mode })), - reset: () => set(() => ({})) + addNewThumbnail: (cas_id) => + set((state) => + produce(state, (draft) => { + draft.newThumbnails[cas_id] = true; + }) + ), + selectMore: (indexes) => { + set((state) => + produce(state, (draft) => { + if (!draft.multiSelectIndexes.length && indexes.length) { + draft.multiSelectIndexes = [draft.selectedRowIndex, ...indexes]; + } else { + draft.multiSelectIndexes = [...new Set([...draft.multiSelectIndexes, ...indexes])]; + } + }) + ); + }, + reset: () => set(() => ({})), + set: (changes) => set((state) => ({ ...state, ...changes })) })); diff --git a/packages/client/src/types/file.ts b/packages/client/src/types/file.ts new file mode 100644 index 000000000..e48d01533 --- /dev/null +++ b/packages/client/src/types/file.ts @@ -0,0 +1,16 @@ +import { FilePath } from '@sd/core'; + +export interface ExplorerItem { + id: number; + name: string; + is_dir: boolean; + // kind: ObjectKind; + extension: string; + size_in_bytes: number; + created_at: string; + updated_at: string; + favorite?: boolean; + + // computed + paths?: FilePath[]; +} diff --git a/packages/client/src/types/index.ts b/packages/client/src/types/index.ts new file mode 100644 index 000000000..706b0d228 --- /dev/null +++ b/packages/client/src/types/index.ts @@ -0,0 +1 @@ +export * from './file'; diff --git a/packages/config/base.tsconfig.json b/packages/config/base.tsconfig.json index ee70cd9f1..459363828 100644 --- a/packages/config/base.tsconfig.json +++ b/packages/config/base.tsconfig.json @@ -19,7 +19,7 @@ "strict": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, - "jsx": "react", + "jsx": "react-jsx", "paths": { "@sd/interface": ["../../packages/interface"], "@sd/ui": ["../../packages/ui"], diff --git a/packages/interface/package.json b/packages/interface/package.json index 1a1448813..2ea606988 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -18,7 +18,7 @@ "@apollo/client": "^3.6.9", "@fontsource/inter": "^4.5.11", "@headlessui/react": "^1.6.6", - "@heroicons/react": "^1.0.6", + "@heroicons/react": "^2.0.10", "@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-dropdown-menu": "^1.0.0", "@radix-ui/react-icons": "^1.1.1", @@ -29,6 +29,7 @@ "@sd/client": "workspace:*", "@sd/core": "workspace:*", "@sd/ui": "workspace:*", + "@tailwindcss/forms": "^0.5.2", "@tanstack/react-query": "^4.0.10", "@tanstack/react-query-devtools": "^4.0.10", "@types/styled-components": "^5.1.25", @@ -66,6 +67,7 @@ "tailwindcss": "^3.1.6", "use-count-up": "^3.0.1", "use-debounce": "^8.0.3", + "zod": "^3.18.0", "zustand": "4.0.0" }, "devDependencies": { diff --git a/packages/interface/src/AppLayout.tsx b/packages/interface/src/AppLayout.tsx index 0c3ecbb11..a2670fbfe 100644 --- a/packages/interface/src/AppLayout.tsx +++ b/packages/interface/src/AppLayout.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx'; import React, { useContext } from 'react'; import { Outlet } from 'react-router-dom'; -import { Sidebar } from './components/file/Sidebar'; +import { Sidebar } from './components/layout/Sidebar'; export function AppLayout() { const appProps = useContext(AppPropsContext); diff --git a/packages/interface/src/AppRouter.tsx b/packages/interface/src/AppRouter.tsx index 61422b527..df99204ea 100644 --- a/packages/interface/src/AppRouter.tsx +++ b/packages/interface/src/AppRouter.tsx @@ -6,11 +6,11 @@ import { AppLayout } from './AppLayout'; import { NotFound } from './NotFound'; import { ContentScreen } from './screens/Content'; import { DebugScreen } from './screens/Debug'; -import { ExplorerScreen } from './screens/Explorer'; +import { LocationExplorer } from './screens/LocationExplorer'; import { OverviewScreen } from './screens/Overview'; import { PhotosScreen } from './screens/Photos'; import { RedirectPage } from './screens/Redirect'; -import { TagScreen } from './screens/Tag'; +import { TagExplorer } from './screens/TagExplorer'; import { SettingsScreen } from './screens/settings/Settings'; import AppearanceSettings from './screens/settings/client/AppearanceSettings'; import ExtensionSettings from './screens/settings/client/ExtensionsSettings'; @@ -101,8 +101,8 @@ export function AppRouter() { } /> } /> - } /> - } /> + } /> + } /> } /> diff --git a/packages/interface/src/components/device/Device.tsx b/packages/interface/src/components/device/Device.tsx index 3b5c98305..5f1f9a462 100644 --- a/packages/interface/src/components/device/Device.tsx +++ b/packages/interface/src/components/device/Device.tsx @@ -1,10 +1,10 @@ -import { KeyIcon } from '@heroicons/react/outline'; -import { CogIcon, LockClosedIcon } from '@heroicons/react/solid'; +import { KeyIcon } from '@heroicons/react/24/outline'; +import { CogIcon, LockClosedIcon } from '@heroicons/react/24/solid'; import { Button } from '@sd/ui'; import { Cloud, Desktop, DeviceMobileCamera, DotsSixVertical, Laptop } from 'phosphor-react'; import React, { useState } from 'react'; -import FileItem from '../file/FileItem'; +import FileItem from '../explorer/FileItem'; import Loader from '../primitive/Loader'; import ProgressBar from '../primitive/ProgressBar'; import { Tooltip } from '../tooltip/Tooltip'; @@ -56,29 +56,19 @@ export function Device(props: DeviceProps) { )}
- - - + - - +
- {props.locations.map((location, key) => ( - handleSelect(location.name)} - /> - ))} {props.locations.length === 0 && (
No locations
)} diff --git a/packages/interface/src/components/dialog/CreateLibraryDialog.tsx b/packages/interface/src/components/dialog/CreateLibraryDialog.tsx new file mode 100644 index 000000000..ef250038d --- /dev/null +++ b/packages/interface/src/components/dialog/CreateLibraryDialog.tsx @@ -0,0 +1,44 @@ +import { useBridgeMutation } from '@sd/client'; +import { Input } from '@sd/ui'; +import React, { useState } from 'react'; + +import Dialog from '../layout/Dialog'; + +interface Props { + children: React.ReactNode; +} + +export default function CreateLibraryDialog(props: Props) { + const [openCreateModal, setOpenCreateModal] = useState(false); + const [newLibName, setNewLibName] = useState(''); + + const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation( + 'library.create', + { + onSuccess: () => { + setOpenCreateModal(false); + } + } + ); + + return ( + createLibrary(newLibName)} + loading={createLibLoading} + submitDisabled={!newLibName} + ctaLabel="Create" + trigger={props.children} + > + setNewLibName(e.target.value)} + /> + + ); +} diff --git a/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx b/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx new file mode 100644 index 000000000..a11c2fc1c --- /dev/null +++ b/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx @@ -0,0 +1,37 @@ +import { useBridgeMutation } from '@sd/client'; +import { LibraryConfigWrapped } from '@sd/core'; +import { Input } from '@sd/ui'; +import React, { useState } from 'react'; + +import Dialog from '../layout/Dialog'; + +interface Props { + children: React.ReactNode; + libraryUuid: string; +} + +export default function DeleteLibraryDialog(props: Props) { + const [openDeleteModal, setOpenDeleteModal] = useState(false); + + const { mutate: deleteLib, isLoading: libDeletePending } = useBridgeMutation('library.delete', { + onSuccess: () => { + setOpenDeleteModal(false); + } + }); + + return ( + { + deleteLib(props.libraryUuid); + }} + loading={libDeletePending} + ctaDanger + ctaLabel="Delete" + trigger={props.children} + /> + ); +} diff --git a/packages/interface/src/components/explorer/Explorer.tsx b/packages/interface/src/components/explorer/Explorer.tsx new file mode 100644 index 000000000..cb80c25b3 --- /dev/null +++ b/packages/interface/src/components/explorer/Explorer.tsx @@ -0,0 +1,212 @@ +import { + rspc, + useExplorerStore, + useLibraryMutation, + useLibraryQuery, + useLibraryStore +} from '@sd/client'; +import { ExplorerData } from '@sd/core'; +import { + ArrowBendUpRight, + LockSimple, + Package, + Plus, + Share, + TagSimple, + Trash, + TrashSimple +} from 'phosphor-react'; +import React from 'react'; + +import { FileList } from '../explorer/FileList'; +import { Inspector } from '../explorer/Inspector'; +import { WithContextMenu } from '../layout/MenuOverlay'; +import { TopBar } from '../layout/TopBar'; + +interface Props { + data: ExplorerData; +} + +export default function Explorer(props: Props) { + const { selectedRowIndex, addNewThumbnail, contextMenuObjectId, showInspector } = + useExplorerStore(); + + const { currentLibraryUuid } = useLibraryStore(); + + const { data: tags } = useLibraryQuery(['tags.getAll'], {}); + + const { mutate: assignTag } = useLibraryMutation('tags.assign'); + + const { data: tagsForFile } = useLibraryQuery(['tags.getForFile', contextMenuObjectId || -1]); + + rspc.useSubscription(['jobs.newThumbnail', { library_id: currentLibraryUuid!, arg: null }], { + onNext: (cas_id) => { + addNewThumbnail(cas_id); + } + }); + + return ( +
+ { + const active = !!tagsForFile?.find((t) => t.id === tag.id); + return { + label: tag.name || '', + + // leftItem: t.id === tag.id)} />, + leftItem: ( +
+
+
+ ), + onClick(e) { + e.preventDefault(); + if (contextMenuObjectId != null) + assignTag({ + tag_id: tag.id, + file_id: contextMenuObjectId, + unassign: active + }); + } + }; + }) || [] + ] + } + ], + [ + { + label: 'More actions...', + icon: Plus, + + children: [ + // [ + // { + // label: 'Move to library', + // icon: FilePlus, + // children: [libraries?.map((library) => ({ label: library.config.name })) || []] + // }, + // { + // label: 'Remove from library', + // icon: FileX + // } + // ], + [ + { + label: 'Encrypt', + icon: LockSimple + }, + { + label: 'Compress', + icon: Package + }, + { + label: 'Convert to', + icon: ArrowBendUpRight, + + children: [ + [ + { + label: 'PNG' + }, + { + label: 'WebP' + } + ] + ] + } + // { + // label: 'Mint NFT', + // icon: TrashIcon + // } + ], + [ + { + label: 'Secure delete', + icon: TrashSimple + } + ] + ] + } + ], + [ + { + label: 'Delete', + icon: Trash, + danger: true + } + ] + ]} + > +
+ +
+ + {showInspector && ( +
+ {props.data.items[selectedRowIndex]?.id && ( + + )} +
+ )} +
+
+ +
+ ); +} diff --git a/packages/interface/src/components/explorer/FileItem.tsx b/packages/interface/src/components/explorer/FileItem.tsx new file mode 100644 index 000000000..b31c6c287 --- /dev/null +++ b/packages/interface/src/components/explorer/FileItem.tsx @@ -0,0 +1,72 @@ +import { ReactComponent as Folder } from '@sd/assets/svgs/folder.svg'; +import { LocationContext, useExplorerStore } from '@sd/client'; +import { ExplorerData, ExplorerItem, File, FilePath } from '@sd/core'; +import clsx from 'clsx'; +import React, { useContext } from 'react'; + +import icons from '../../assets/icons'; +import FileThumb from './FileThumb'; +import { isObject, isPath } from './utils'; + +interface Props extends React.HTMLAttributes { + data: ExplorerItem; + selected: boolean; + size: number; + index: number; +} + +export default function FileItem(props: Props) { + const { set } = useExplorerStore(); + const size = props.size || 100; + + return ( +
{ + const objectId = isObject(props.data) ? props.data.id : props.data.file?.id; + if (objectId != undefined) { + set({ contextMenuObjectId: objectId }); + if (props.index != undefined) set({ selectedRowIndex: props.index }); + } + }} + draggable + {...props} + className={clsx('inline-block w-[100px] mb-3', props.className)} + > +
+
+ +
+
+
+ + {props.data?.name}.{props.data?.extension} + +
+
+ ); +} diff --git a/packages/interface/src/components/file/FileList.tsx b/packages/interface/src/components/explorer/FileList.tsx similarity index 62% rename from packages/interface/src/components/file/FileList.tsx rename to packages/interface/src/components/explorer/FileList.tsx index a8f89d0db..32fd8108c 100644 --- a/packages/interface/src/components/file/FileList.tsx +++ b/packages/interface/src/components/explorer/FileList.tsx @@ -1,6 +1,6 @@ -import { DotsVerticalIcon } from '@heroicons/react/solid'; +import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'; import { LocationContext, useBridgeQuery, useExplorerStore, useLibraryQuery } from '@sd/client'; -import { FilePath } from '@sd/core'; +import { ExplorerContext, ExplorerItem, FilePath } from '@sd/core'; import clsx from 'clsx'; import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; @@ -10,6 +10,7 @@ import styled from 'styled-components'; import FileItem from './FileItem'; import FileThumb from './FileThumb'; +import { isPath } from './utils'; interface IColumn { column: string; @@ -43,7 +44,12 @@ const GridItemContainer = styled.div` flex-wrap: wrap; `; -export const FileList: React.FC<{ location_id: number; path: string; limit: number }> = (props) => { +interface Props { + context: ExplorerContext; + data: ExplorerItem[]; +} + +export const FileList: React.FC = (props) => { const size = useWindowSize(); const tableContainer = useRef(null); const VList = useRef(null); @@ -52,18 +58,9 @@ export const FileList: React.FC<{ location_id: number; path: string; limit: numb refetchOnWindowFocus: false }); - const { selectedRowIndex, setSelectedRowIndex, setLocationId, layoutMode } = useExplorerStore(); + const { selectedRowIndex, set, layoutMode } = useExplorerStore(); const [goingUp, setGoingUp] = useState(false); - const { data: currentDir } = useLibraryQuery([ - 'locations.getExplorerDir', - { - location_id: props.location_id, - path: props.path, - limit: props.limit - } - ]); - useEffect(() => { if (selectedRowIndex === 0 && goingUp) { VList.current?.scrollTo({ top: 0, behavior: 'smooth' }); @@ -75,36 +72,33 @@ export const FileList: React.FC<{ location_id: number; path: string; limit: numb } }, [goingUp, selectedRowIndex]); - useEffect(() => { - setLocationId(props.location_id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.location_id]); - useKey('ArrowUp', (e) => { e.preventDefault(); setGoingUp(true); if (selectedRowIndex !== -1 && selectedRowIndex !== 0) - setSelectedRowIndex(selectedRowIndex - 1); + set({ selectedRowIndex: selectedRowIndex - 1 }); }); useKey('ArrowDown', (e) => { e.preventDefault(); setGoingUp(false); - if (selectedRowIndex !== -1 && selectedRowIndex !== (currentDir?.contents.length ?? 1) - 1) - setSelectedRowIndex(selectedRowIndex + 1); + if (selectedRowIndex !== -1 && selectedRowIndex !== (props.data.length ?? 1) - 1) + set({ selectedRowIndex: selectedRowIndex + 1 }); }); const createRenderItem = (RenderItem: React.FC) => { return (index: number) => { - const row = currentDir?.contents?.[index]; + const row = props.data[index]; if (!row) return null; - return ; + return ; }; }; const Header = () => (
-

{currentDir?.directory.name}

+ {props.context.name && ( +

{props.context.name}

+ )}
{columns.map((col) => ( @@ -113,7 +107,7 @@ export const FileList: React.FC<{ location_id: number; path: string; limit: numb className="relative flex flex-row items-center pl-2 table-head-cell group" style={{ width: col.width }} > - + {col.column}
))} @@ -124,80 +118,77 @@ export const FileList: React.FC<{ location_id: number; path: string; limit: numb return (
- - {layoutMode === 'grid' && ( - - )} - {layoutMode === 'list' && ( -
- }} - increaseViewportBy={{ top: 400, bottom: 200 }} - className="outline-none explorer-scroll" - /> - )} - + {layoutMode === 'grid' && ( + + )} + {layoutMode === 'list' && ( +
+ }} + increaseViewportBy={{ top: 400, bottom: 200 }} + className="outline-none explorer-scroll" + /> + )}
); }; interface RenderItemProps { - item: FilePath; + item: ExplorerItem; index: number; - dirId: number; } -const RenderGridItem: React.FC = ({ item, index, dirId }) => { - const { selectedRowIndex, setSelectedRowIndex } = useExplorerStore(); +const RenderGridItem: React.FC = ({ item, index }) => { + const { selectedRowIndex, set } = useExplorerStore(); const [_, setSearchParams] = useSearchParams(); return ( { - if (item.is_dir) { + if (item.type === 'Path' && item.is_dir) { setSearchParams({ path: item.materialized_path }); } }} - file={item} + index={index} + data={item} selected={selectedRowIndex === index} onClick={() => { - setSelectedRowIndex(selectedRowIndex == index ? -1 : index); + set({ selectedRowIndex: selectedRowIndex == index ? -1 : index }); }} + size={100} /> ); }; -const RenderRow: React.FC = ({ item, index, dirId }) => { - const { selectedRowIndex, setSelectedRowIndex } = useExplorerStore(); +const RenderRow: React.FC = ({ item, index }) => { + const { selectedRowIndex, set } = useExplorerStore(); const isActive = selectedRowIndex === index; const [_, setSearchParams] = useSearchParams(); return useMemo( () => (
setSelectedRowIndex(selectedRowIndex == index ? -1 : index)} + onClick={() => set({ selectedRowIndex: selectedRowIndex == index ? -1 : index })} onDoubleClick={() => { - if (item.is_dir) { + if (isPath(item) && item.is_dir) { setSearchParams({ path: item.materialized_path }); } }} @@ -213,7 +204,7 @@ const RenderRow: React.FC = ({ item, index, dirId }) => { className="flex items-center px-4 py-2 pr-2 table-body-cell" style={{ width: col.width }} > - +
))}
@@ -225,17 +216,14 @@ const RenderRow: React.FC = ({ item, index, dirId }) => { const RenderCell: React.FC<{ colKey: ColumnKey; - dirId: number; - file: FilePath; -}> = ({ colKey, file, dirId }) => { - const location = useContext(LocationContext); - + data: ExplorerItem; +}> = ({ colKey, data }) => { switch (colKey) { case 'name': return (
- +
{/* {colKey == 'name' && (() => { @@ -249,13 +237,13 @@ const RenderCell: React.FC<{ return ; } })()} */} - {file[colKey]} + {data[colKey]}
); // case 'size_in_bytes': // return {byteSize(Number(value || 0))}; case 'extension': - return {file[colKey]}; + return {data[colKey]}; // case 'meta_integrity_hash': // return {value}; // case 'tags': diff --git a/packages/interface/src/components/explorer/FileThumb.tsx b/packages/interface/src/components/explorer/FileThumb.tsx new file mode 100644 index 000000000..9b9e35c8b --- /dev/null +++ b/packages/interface/src/components/explorer/FileThumb.tsx @@ -0,0 +1,88 @@ +import { AppPropsContext, useExplorerStore } from '@sd/client'; +import { ExplorerItem } from '@sd/core'; +import clsx from 'clsx'; +import React, { useContext } from 'react'; + +import icons from '../../assets/icons'; +import { Folder } from '../icons/Folder'; +import { isObject, isPath } from './utils'; + +interface Props { + data: ExplorerItem; + size: number; + className?: string; + style?: React.CSSProperties; +} + +export default function FileThumb({ data, ...props }: Props) { + const appProps = useContext(AppPropsContext); + const { newThumbnails } = useExplorerStore(); + + if (isPath(data) && data.is_dir) return ; + + const cas_id = isObject(data) ? data.cas_id : data.file?.cas_id; + + if (!cas_id) return
; + + const has_thumbnail = isObject(data) + ? data.has_thumbnail + : isPath(data) + ? data.file?.has_thumbnail + : !!newThumbnails[cas_id]; + + const file_thumb_url = + has_thumbnail && appProps?.data_path + ? appProps?.convertFileSrc(`${appProps.data_path}/thumbnails/${cas_id}.webp`) + : undefined; + + if (file_thumb_url) + return ( + + ); + + const Icon = icons[data.extension as keyof typeof icons]; + + return ( +
+ + + + {Icon && ( +
+ + + {data.extension} + +
+ )} + + + +
+ ); + + return null; +} diff --git a/packages/interface/src/components/explorer/Inspector.tsx b/packages/interface/src/components/explorer/Inspector.tsx new file mode 100644 index 000000000..3971d1dbf --- /dev/null +++ b/packages/interface/src/components/explorer/Inspector.tsx @@ -0,0 +1,135 @@ +import { ShareIcon } from '@heroicons/react/24/solid'; +import { useLibraryMutation, useLibraryQuery } from '@sd/client'; +import { ExplorerContext, ExplorerItem, File, FilePath, Location } from '@sd/core'; +import { Button, TextArea } from '@sd/ui'; +import clsx from 'clsx'; +import moment from 'moment'; +import { Heart, Link } from 'phosphor-react'; +import React, { useCallback, useEffect, useState } from 'react'; + +import types from '../../constants/file-types.json'; +import { Tooltip } from '../tooltip/Tooltip'; +import FileThumb from './FileThumb'; +import { Divider } from './inspector/Divider'; +import FavoriteButton from './inspector/FavoriteButton'; +import { MetaItem } from './inspector/MetaItem'; +import Note from './inspector/Note'; +import { isObject } from './utils'; + +interface Props { + context?: ExplorerContext; + data: ExplorerItem; +} + +export const Inspector = (props: Props) => { + const is_dir = props.data?.type === 'Path' ? props.data.is_dir : false; + + const objectData = isObject(props.data) ? props.data : props.data.file; + + const { data: tags } = useLibraryQuery(['tags.getForFile', objectData?.id || -1]); + + return ( +
+ {!!props.data && ( + <> +
+ +
+
+

+ {props.data?.name}.{props.data?.extension} +

+ {objectData && ( +
+ + + + + + + + + +
+ )} + {!!tags?.length && ( + <> + + + {tags?.map((tag) => ( +
setSelectedTag(tag.id === selectedTag ? null : tag.id)} + key={tag.id} + className={clsx( + 'flex items-center rounded px-1.5 py-0.5' + // selectedTag === tag.id && 'ring' + )} + style={{ backgroundColor: tag.color + 'CC' }} + > + {tag.name} +
+ ))} +
+ } + /> + + )} + {props.context?.type == 'Location' && props.data?.type === 'Path' && ( + <> + + + + )} + + + + + {!is_dir && ( + <> + +
+ {props.data?.extension && ( + + {props.data?.extension} + + )} +

+ {props.data?.extension + ? //@ts-ignore + types[props.data.extension.toUpperCase()]?.descriptions.join(' / ') + : 'Unknown'} +

+
+ {objectData && ( + <> + + + {objectData.cas_id && ( + + )} + + )} + + )} +
+ + )} +
+ ); +}; diff --git a/packages/interface/src/components/explorer/inspector/Divider.tsx b/packages/interface/src/components/explorer/inspector/Divider.tsx new file mode 100644 index 000000000..88261358d --- /dev/null +++ b/packages/interface/src/components/explorer/inspector/Divider.tsx @@ -0,0 +1,3 @@ +import React from 'react'; + +export const Divider = () =>
; diff --git a/packages/interface/src/components/explorer/inspector/FavoriteButton.tsx b/packages/interface/src/components/explorer/inspector/FavoriteButton.tsx new file mode 100644 index 000000000..605a493d5 --- /dev/null +++ b/packages/interface/src/components/explorer/inspector/FavoriteButton.tsx @@ -0,0 +1,37 @@ +import { useLibraryMutation } from '@sd/client'; +import { File } from '@sd/core'; +import { Button } from '@sd/ui'; +import { Heart } from 'phosphor-react'; +import React, { useEffect, useState } from 'react'; + +interface Props { + data: File; +} + +export default function FavoriteButton(props: Props) { + const [favorite, setFavorite] = useState(false); + + useEffect(() => { + setFavorite(!!props.data?.favorite); + }, [props.data]); + + const { mutate: fileToggleFavorite, isLoading: isFavoriteLoading } = useLibraryMutation( + 'files.setFavorite' + // { + // onError: () => setFavorite(!!props.data?.favorite) + // } + ); + + const toggleFavorite = () => { + if (!isFavoriteLoading) { + fileToggleFavorite({ id: props.data.id, favorite: !favorite }); + setFavorite(!favorite); + } + }; + + return ( + + ); +} diff --git a/packages/interface/src/components/explorer/inspector/MetaItem.tsx b/packages/interface/src/components/explorer/inspector/MetaItem.tsx new file mode 100644 index 000000000..044b46088 --- /dev/null +++ b/packages/interface/src/components/explorer/inspector/MetaItem.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +interface MetaItemProps { + title?: string; + value: string | React.ReactNode; +} + +export const MetaItem = (props: MetaItemProps) => { + return ( +
+ {!!props.title &&
{props.title}
} + {typeof props.value === 'string' ? ( +

{props.value}

+ ) : ( + props.value + )} +
+ ); +}; diff --git a/packages/interface/src/components/explorer/inspector/Note.tsx b/packages/interface/src/components/explorer/inspector/Note.tsx new file mode 100644 index 000000000..5c760484c --- /dev/null +++ b/packages/interface/src/components/explorer/inspector/Note.tsx @@ -0,0 +1,56 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useLibraryMutation } from '@sd/client'; +import { File } from '@sd/core'; +import { TextArea } from '@sd/ui'; +import { debounce } from 'lodash'; +import React, { useCallback, useState } from 'react'; + +import { Divider } from './Divider'; +import { MetaItem } from './MetaItem'; + +interface Props { + data: File; +} + +export default function Note(props: Props) { + // notes are cached in a store by their file id + // this is so we can ensure every note has been sent to Rust even + // when quickly navigating files, which cancels update function + const [note, setNote] = useState((props.data as File)?.note || ''); + + const { mutate: fileSetNote } = useLibraryMutation('files.setNote'); + + const debouncedNote = useCallback( + debounce((note: string) => { + fileSetNote({ + id: props.data.id, + note + }); + }, 2000), + [props.data.id] + ); + + // when input is updated, cache note + function handleNoteUpdate(e: React.ChangeEvent) { + if (e.target.value !== note) { + setNote(e.target.value); + debouncedNote(e.target.value); + } + } + + return ( + <> + + + } + /> + + ); +} diff --git a/packages/interface/src/components/explorer/utils.ts b/packages/interface/src/components/explorer/utils.ts new file mode 100644 index 000000000..ca4a4bdba --- /dev/null +++ b/packages/interface/src/components/explorer/utils.ts @@ -0,0 +1,9 @@ +import { ExplorerItem, File, FilePath } from '@sd/core'; + +export function isPath(item: ExplorerItem): item is FilePath & { type: 'Path' } { + return item.type === 'Path'; +} + +export function isObject(item: ExplorerItem): item is File & { type: 'Object' } { + return item.type === 'Object'; +} diff --git a/packages/interface/src/components/file/FileItem.tsx b/packages/interface/src/components/file/FileItem.tsx deleted file mode 100644 index 8662c2ff6..000000000 --- a/packages/interface/src/components/file/FileItem.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { ReactComponent as Folder } from '@sd/assets/svgs/folder.svg'; -import { LocationContext } from '@sd/client'; -import { FilePath } from '@sd/core'; -import clsx from 'clsx'; -import React, { useContext } from 'react'; - -import icons from '../../assets/icons'; -import FileThumb from './FileThumb'; - -interface Props extends React.HTMLAttributes { - file?: FilePath | null; - selected?: boolean; -} - -export default function FileItem(props: Props) { - const location = useContext(LocationContext); - - return ( -
-
- {props.file?.is_dir ? ( -
-
- -
-
- ) : props.file?.file?.has_thumbnail ? ( -
-
- -
-
- ) : ( -
- - - - - - -
- {props.file?.extension && icons[props.file.extension as keyof typeof icons] ? ( - (() => { - const Icon = icons[props.file.extension as keyof typeof icons]; - return ( - - ); - })() - ) : ( - <> - )} - - {props.file?.extension} - -
-
- )} -
-
- - {props.file?.name} - -
-
- ); -} diff --git a/packages/interface/src/components/file/FileThumb.tsx b/packages/interface/src/components/file/FileThumb.tsx deleted file mode 100644 index e82f46832..000000000 --- a/packages/interface/src/components/file/FileThumb.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { AppPropsContext, useExplorerStore } from '@sd/client'; -import { FilePath } from '@sd/core'; -import clsx from 'clsx'; -import React, { useContext } from 'react'; - -import icons from '../../assets/icons'; -import { Folder } from '../icons/Folder'; - -export default function FileThumb(props: { - file: FilePath; - locationId: number; - className?: string; -}) { - const appProps = useContext(AppPropsContext); - const { newThumbnails } = useExplorerStore(); - - const hasNewThumbnail = !!newThumbnails[props.file.file?.cas_id ?? '']; - - if (props.file.is_dir) { - return ; - } - - if (appProps?.data_path && (props.file.file?.has_thumbnail || hasNewThumbnail)) { - return ( - - ); - } - - if (icons[props.file.extension as keyof typeof icons]) { - const Icon = icons[props.file.extension as keyof typeof icons]; - return ; - } - return
; -} diff --git a/packages/interface/src/components/file/Inspector.tsx b/packages/interface/src/components/file/Inspector.tsx deleted file mode 100644 index d398ddc23..000000000 --- a/packages/interface/src/components/file/Inspector.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { ShareIcon } from '@heroicons/react/solid'; -import { useLibraryMutation } from '@sd/client'; -import { FilePath, Location } from '@sd/core'; -import { Button, TextArea } from '@sd/ui'; -import moment from 'moment'; -import { Heart, Link } from 'phosphor-react'; -import React, { useCallback, useEffect, useState } from 'react'; - -import types from '../../constants/file-types.json'; -import { Tooltip } from '../tooltip/Tooltip'; -import FileThumb from './FileThumb'; - -interface MetaItemProps { - title: string; - value: string | React.ReactNode; -} - -const MetaItem = (props: MetaItemProps) => { - return ( -
-
{props.title}
- {typeof props.value === 'string' ? ( -

{props.value}

- ) : ( - props.value - )} -
- ); -}; - -const Divider = () =>
; - -function debounce(fn: (args: T) => void, delay: number): (args: T) => void { - let timerId: number | undefined; - return (...args) => { - clearTimeout(timerId); - timerId = setTimeout(() => fn(...args), delay); - }; -} - -export const Inspector = (props: { - locationId: number; - location?: Location | null; - selectedFile?: FilePath; -}) => { - const file_path = props.selectedFile, - file_id = props.selectedFile?.file?.id || -1; - - const [favorite, setFavorite] = useState(false); - const { mutate: fileToggleFavorite, isLoading: isFavoriteLoading } = useLibraryMutation( - 'files.setFavorite', - { - onError: () => setFavorite(!!props.selectedFile?.file?.favorite) - } - ); - const { mutate: fileSetNote } = useLibraryMutation('files.setNote'); - - const [note, setNote] = useState(props.selectedFile?.file?.note || ''); - useEffect(() => { - setNote(props.selectedFile?.file?.note || ''); - }, [props.selectedFile?.file?.note]); - const debouncedNote = useCallback( - debounce((note: string) => { - fileSetNote({ - id: file_id, - note - }); - }, 2000), - [file_id] - ); - - const toggleFavorite = () => { - if (!isFavoriteLoading) { - fileToggleFavorite({ id: file_id, favorite: !favorite }); - setFavorite(!favorite); - } - }; - - useEffect(() => { - setFavorite(!!props.selectedFile?.file?.favorite); - }, [props.selectedFile]); - - // when input is updated, cache note - function handleNoteUpdate(e: React.ChangeEvent) { - if (e.target.value !== note) { - setNote(e.target.value); - debouncedNote(e.target.value); - } - } - - return ( -
- {!!file_path && ( -
-
- -
-
-

{file_path?.name}

-
- - - - - - - - - -
- {file_path?.file?.cas_id && ( - - )} - - - - - - - {!file_path?.is_dir && ( - <> - -
- {file_path?.extension && ( - - {file_path?.extension} - - )} -

- {file_path?.extension - ? //@ts-ignore - types[file_path.extension.toUpperCase()]?.descriptions.join(' / ') - : 'Unknown'} -

-
- {file_path.file && ( - <> - - - } - /> - - )} - - )} -
-
- )} -
- ); -}; diff --git a/packages/interface/src/components/layout/Modal.tsx b/packages/interface/src/components/layout/Modal.tsx index 3c2ee67a3..3ef91a3e1 100644 --- a/packages/interface/src/components/layout/Modal.tsx +++ b/packages/interface/src/components/layout/Modal.tsx @@ -1,5 +1,5 @@ import { Transition } from '@headlessui/react'; -import { XIcon } from '@heroicons/react/solid'; +import { XMarkIcon } from '@heroicons/react/24/solid'; import { Button } from '@sd/ui'; import clsx from 'clsx'; import React from 'react'; @@ -39,7 +39,7 @@ export const Modal: React.FC = (props) => { variant="gray" className="!px-1.5 absolute top-2 right-2" > - + = (props) => { const { mutate: createLocation } = useLibraryMutation('locations.create'); - const { data: tags } = useLibraryQuery(['tags.get']); + const { data: tags } = useLibraryQuery(['tags.getAll']); return (
= (props) => { { name: 'Add Library', icon: PlusIcon, - onPress: () => { - alert('todo'); - // TODO: Show Dialog defined in `LibrariesSettings.tsx` - } + wrapItemComponent: CreateLibraryDialog }, { name: 'Lock', @@ -166,7 +164,6 @@ export const Sidebar: React.FC = (props) => { alert('todo'); } } - // { name: 'Hide', icon: EyeOffIcon } ] ]} /> @@ -181,7 +178,7 @@ export const Sidebar: React.FC = (props) => { Spaces - + Photos
@@ -193,7 +190,7 @@ export const Sidebar: React.FC = (props) => { {({ isActive }) => ( @@ -224,7 +221,11 @@ export const Sidebar: React.FC = (props) => { appProps?.openDialog({ directory: true }).then((result) => { console.log(result); // TODO: Pass indexer rules ids to create location - if (result) createLocation( { path: result as string, indexer_rules_ids: [] } as LocationCreateArgs); + if (result) + createLocation({ + path: result as string, + indexer_rules_ids: [] + } as LocationCreateArgs); }); }} className={clsx( diff --git a/packages/interface/src/components/layout/TopBar.tsx b/packages/interface/src/components/layout/TopBar.tsx index 5eda22281..b5804840d 100644 --- a/packages/interface/src/components/layout/TopBar.tsx +++ b/packages/interface/src/components/layout/TopBar.tsx @@ -1,8 +1,16 @@ -import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/outline'; +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; import { AppPropsContext, useExplorerStore, useLibraryMutation } from '@sd/client'; import { Dropdown } from '@sd/ui'; import clsx from 'clsx'; -import { ArrowsClockwise, IconProps, Key, List, Rows, SquaresFour } from 'phosphor-react'; +import { + ArrowsClockwise, + IconProps, + Key, + List, + Rows, + SidebarSimple, + SquaresFour +} from 'phosphor-react'; import React, { DetailedHTMLProps, HTMLAttributes, RefAttributes, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -38,7 +46,7 @@ const TopBarButton: React.FC = ({ 'rounded-r-none rounded-l-none': group && !left && !right, 'rounded-r-none': group && left, 'rounded-l-none': group && right, - 'dark:bg-gray-550': active + 'dark:bg-gray-500': active }, className )} @@ -72,7 +80,7 @@ const SearchBar = React.forwardRef((props, ref) }); export const TopBar: React.FC = (props) => { - const { locationId, layoutMode, setLayoutMode } = useExplorerStore(); + const { layoutMode, set, locationId, showInspector } = useExplorerStore(); const { mutate: generateThumbsForLocation } = useLibraryMutation( 'jobs.generateThumbsForLocation', { @@ -126,7 +134,7 @@ export const TopBar: React.FC = (props) => { <>
@@ -151,7 +159,7 @@ export const TopBar: React.FC = (props) => { left active={layoutMode === 'list'} icon={Rows} - onClick={() => setLayoutMode('list')} + onClick={() => set({ layoutMode: 'list' })} /> @@ -160,7 +168,7 @@ export const TopBar: React.FC = (props) => { right active={layoutMode === 'grid'} icon={SquaresFour} - onClick={() => setLayoutMode('grid')} + onClick={() => set({ layoutMode: 'grid' })} />
@@ -173,17 +181,23 @@ export const TopBar: React.FC = (props) => { {/* */} - + {/* { // generateThumbsForLocation({ id: locationId, path: '' }); }} /> - + */}
+ set({ showInspector: !showInspector })} + className="my-2" + icon={SidebarSimple} + /> = (props) => { { name: 'Generate Thumbs', icon: ArrowsClockwise, - onPress: () => generateThumbsForLocation({ id: locationId, path: '' }) + onPress: () => + locationId && generateThumbsForLocation({ id: locationId, path: '' }) }, { name: 'Identify Unique', icon: ArrowsClockwise, - onPress: () => identifyUniqueFiles({ id: locationId, path: '' }) + onPress: () => locationId && identifyUniqueFiles({ id: locationId, path: '' }) } ] ]} diff --git a/packages/interface/src/components/location/LocationListItem.tsx b/packages/interface/src/components/location/LocationListItem.tsx index ebb732bc0..f4a677041 100644 --- a/packages/interface/src/components/location/LocationListItem.tsx +++ b/packages/interface/src/components/location/LocationListItem.tsx @@ -1,9 +1,9 @@ -import { RefreshIcon } from '@heroicons/react/outline'; -import { TrashIcon } from '@heroicons/react/solid'; +import { TrashIcon } from '@heroicons/react/24/solid'; import { useLibraryMutation } from '@sd/client'; import { Location } from '@sd/core'; import { Button } from '@sd/ui'; import clsx from 'clsx'; +import { Repeat } from 'phosphor-react'; import React, { useState } from 'react'; import { Folder } from '../icons/Folder'; @@ -77,7 +77,7 @@ export default function LocationListItem({ location }: LocationListItemProps) { locRescan(location.id); }} > - + {/* +
); }; diff --git a/packages/interface/src/screens/Explorer.tsx b/packages/interface/src/screens/Explorer.tsx deleted file mode 100644 index 8c408fb4f..000000000 --- a/packages/interface/src/screens/Explorer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { rspc, useExplorerStore, useLibraryQuery, useLibraryStore } from '@sd/client'; -import React from 'react'; -import { useParams, useSearchParams } from 'react-router-dom'; - -import { FileList } from '../components/file/FileList'; -import { Inspector } from '../components/file/Inspector'; -import { TopBar } from '../components/layout/TopBar'; - -export const ExplorerScreen: React.FC = () => { - const [searchParams] = useSearchParams(); - const path = searchParams.get('path') || ''; - - const { id } = useParams(); - const location_id = Number(id); - - const [limit, setLimit] = React.useState(100); - - const { selectedRowIndex, addNewThumbnail } = useExplorerStore(); - - const library_id = useLibraryStore((state) => state.currentLibraryUuid); - rspc.useSubscription(['jobs.newThumbnail', { library_id: library_id!, arg: null }], { - onNext: (cas_id) => { - addNewThumbnail(cas_id); - } - }); - - // Current Location - const { data: currentLocation } = useLibraryQuery(['locations.getById', location_id]); - - // Current Directory - const { data: currentDir } = useLibraryQuery( - ['locations.getExplorerDir', { location_id: location_id!, path, limit }], - { enabled: !!location_id } - ); - - return ( -
- -
- - {currentDir?.contents && ( - - )} -
-
- ); -}; diff --git a/packages/interface/src/screens/LocationExplorer.tsx b/packages/interface/src/screens/LocationExplorer.tsx new file mode 100644 index 000000000..ad8179446 --- /dev/null +++ b/packages/interface/src/screens/LocationExplorer.tsx @@ -0,0 +1,46 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useExplorerStore, useLibraryQuery, useLibraryStore } from '@sd/client'; +import React, { useEffect } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import z from 'zod'; + +import Explorer from '../components/explorer/Explorer'; + +export function useExplorerParams() { + const { id } = useParams(); + const location_id = id ? Number(id) : -1; + + const [searchParams] = useSearchParams(); + const path = searchParams.get('path') || ''; + const limit = Number(searchParams.get('limit')) || 100; + + return { location_id, path, limit }; +} + +export const LocationExplorer: React.FC = () => { + const { location_id, path } = useExplorerParams(); + + // for top bar location context, could be replaced with react context as it is child component + const { set } = useExplorerStore(); + useEffect(() => { + set({ locationId: location_id }); + }, [location_id]); + + const library_id = useLibraryStore((state) => state.currentLibraryUuid); + + const explorerData = useLibraryQuery([ + 'locations.getExplorerData', + { + location_id: location_id, + path: path, + limit: 100, + cursor: null + } + ]); + + return ( +
+ {library_id && explorerData.data && } +
+ ); +}; diff --git a/packages/interface/src/screens/Overview.tsx b/packages/interface/src/screens/Overview.tsx index 88aad7064..24eb48215 100644 --- a/packages/interface/src/screens/Overview.tsx +++ b/packages/interface/src/screens/Overview.tsx @@ -1,4 +1,4 @@ -import { ExclamationCircleIcon, PlusIcon } from '@heroicons/react/solid'; +import { ExclamationCircleIcon, PlusIcon } from '@heroicons/react/24/solid'; import { useBridgeQuery, useLibraryQuery } from '@sd/client'; import { AppPropsContext } from '@sd/client'; import { Statistics } from '@sd/core'; diff --git a/packages/interface/src/screens/Tag.tsx b/packages/interface/src/screens/Tag.tsx deleted file mode 100644 index d64725629..000000000 --- a/packages/interface/src/screens/Tag.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; - -export const TagScreen: React.FC = () => { - const { id } = useParams(); - - return ( -
-

{id}

-
- ); -}; diff --git a/packages/interface/src/screens/TagExplorer.tsx b/packages/interface/src/screens/TagExplorer.tsx new file mode 100644 index 000000000..1e7c331e7 --- /dev/null +++ b/packages/interface/src/screens/TagExplorer.tsx @@ -0,0 +1,19 @@ +import { useLibraryQuery, useLibraryStore } from '@sd/client'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import Explorer from '../components/explorer/Explorer'; + +export const TagExplorer: React.FC = () => { + const { id } = useParams(); + const library_id = useLibraryStore((state) => state.currentLibraryUuid); + + const explorerData = useLibraryQuery(['tags.getExplorerData', Number(id)]); + const { data: tag } = useLibraryQuery(['tags.get', Number(id)]); + + return ( +
+ {library_id && id != undefined && explorerData.data && } +
+ ); +}; diff --git a/packages/interface/src/screens/settings/Settings.tsx b/packages/interface/src/screens/settings/Settings.tsx index 036ccca58..a80f2cdbb 100644 --- a/packages/interface/src/screens/settings/Settings.tsx +++ b/packages/interface/src/screens/settings/Settings.tsx @@ -1,23 +1,17 @@ -import { - AnnotationIcon, - CogIcon, - CollectionIcon, - HeartIcon, - KeyIcon, - ShieldCheckIcon, - TagIcon, -} from '@heroicons/react/outline'; +import { CogIcon, HeartIcon, KeyIcon, ShieldCheckIcon, TagIcon } from '@heroicons/react/24/outline'; +import { BuildingLibraryIcon } from '@heroicons/react/24/solid'; import { FlyingSaucer, HardDrive, KeyReturn, PaintBrush, PuzzlePiece, - ShareNetwork, + Receipt, + ShareNetwork } from 'phosphor-react'; import React from 'react'; -import { SidebarLink } from '../../components/file/Sidebar'; +import { SidebarLink } from '../../components/layout/Sidebar'; import { SettingsHeading, SettingsIcon, @@ -33,7 +27,7 @@ export const SettingsScreen: React.FC = () => { General - + Libraries @@ -98,7 +92,7 @@ export const SettingsScreen: React.FC = () => { About - + Changelog diff --git a/packages/interface/src/screens/settings/client/ExtensionsSettings.tsx b/packages/interface/src/screens/settings/client/ExtensionsSettings.tsx index b84cdedfb..f5bf08852 100644 --- a/packages/interface/src/screens/settings/client/ExtensionsSettings.tsx +++ b/packages/interface/src/screens/settings/client/ExtensionsSettings.tsx @@ -1,5 +1,5 @@ -import { SearchIcon } from '@heroicons/react/solid'; import { Button, Input } from '@sd/ui'; +import { MagnifyingGlass } from 'phosphor-react'; import React from 'react'; import { SettingsContainer } from '../../../components/settings/SettingsContainer'; @@ -66,7 +66,7 @@ export default function ExtensionSettings() { description="Install extensions to extend the functionality of this client." rightArea={
- +
} diff --git a/packages/interface/src/screens/settings/library/TagsSettings.tsx b/packages/interface/src/screens/settings/library/TagsSettings.tsx index 4a4d3137d..f62e490aa 100644 --- a/packages/interface/src/screens/settings/library/TagsSettings.tsx +++ b/packages/interface/src/screens/settings/library/TagsSettings.tsx @@ -1,4 +1,4 @@ -import { TrashIcon } from '@heroicons/react/outline'; +import { TrashIcon } from '@heroicons/react/24/outline'; import { useLibraryMutation, useLibraryQuery } from '@sd/client'; import { TagUpdateArgs } from '@sd/core'; import { Button, Input } from '@sd/ui'; @@ -21,7 +21,7 @@ export default function TagsSettings() { const [newColor, setNewColor] = useState('#A717D9'); const [newName, setNewName] = useState(''); - const { data: tags } = useLibraryQuery(['tags.get']); + const { data: tags } = useLibraryQuery(['tags.getAll']); const [selectedTag, setSelectedTag] = useState(null); diff --git a/packages/interface/src/screens/settings/node/LibrariesSettings.tsx b/packages/interface/src/screens/settings/node/LibrariesSettings.tsx index bd6425d70..0534a8169 100644 --- a/packages/interface/src/screens/settings/node/LibrariesSettings.tsx +++ b/packages/interface/src/screens/settings/node/LibrariesSettings.tsx @@ -1,24 +1,38 @@ -import { TrashIcon } from '@heroicons/react/outline'; -import { useBridgeMutation, useBridgeQuery } from '@sd/client'; +import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { useBridgeMutation, useBridgeQuery, useLibraryStore } from '@sd/client'; import { LibraryConfigWrapped } from '@sd/core'; import { Button, Input } from '@sd/ui'; import { DotsSixVertical } from 'phosphor-react'; import React, { useState } from 'react'; +import { useNavigate } from 'react-router'; +import CreateLibraryDialog from '../../../components/dialog/CreateLibraryDialog'; +import DeleteLibraryDialog from '../../../components/dialog/DeleteLibraryDialog'; import Card from '../../../components/layout/Card'; import Dialog from '../../../components/layout/Dialog'; import { SettingsContainer } from '../../../components/settings/SettingsContainer'; import { SettingsHeader } from '../../../components/settings/SettingsHeader'; function LibraryListItem(props: { library: LibraryConfigWrapped }) { + const navigate = useNavigate(); const [openDeleteModal, setOpenDeleteModal] = useState(false); + const { currentLibraryUuid, switchLibrary } = useLibraryStore(); + const { mutate: deleteLib, isLoading: libDeletePending } = useBridgeMutation('library.delete', { onSuccess: () => { setOpenDeleteModal(false); } }); + function handleEditLibrary() { + // switch library if requesting to edit non-current library + navigate('/settings/library'); + if (props.library.uuid !== currentLibraryUuid) { + switchLibrary(props.library.uuid); + } + } + return ( @@ -26,41 +40,22 @@ function LibraryListItem(props: { library: LibraryConfigWrapped }) {

{props.library.config.name}

{props.library.uuid}

-
- { - deleteLib(props.library.uuid); - }} - loading={libDeletePending} - ctaDanger - ctaLabel="Delete" - trigger={ - - } - /> +
+ + + +
); } export default function LibrarySettings() { - const [openCreateModal, setOpenCreateModal] = useState(false); - const [newLibName, setNewLibName] = useState(''); const { data: libraries } = useBridgeQuery(['library.get']); - const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation( - 'library.create', - { - onSuccess: () => { - setOpenCreateModal(false); - } - } - ); return ( @@ -69,28 +64,11 @@ export default function LibrarySettings() { description="The database contains all library data and file metadata." rightArea={
- createLibrary(newLibName)} - loading={createLibLoading} - submitDisabled={!newLibName} - ctaLabel="Create" - trigger={ - - } - > - setNewLibName(e.target.value)} - /> - + + +
} /> diff --git a/packages/interface/src/style.scss b/packages/interface/src/style.scss index ef44fb5b1..b1595a939 100644 --- a/packages/interface/src/style.scss +++ b/packages/interface/src/style.scss @@ -150,4 +150,4 @@ body { left: 0; border-radius: 9px; box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); -} +} \ No newline at end of file diff --git a/packages/interface/src/util/debounce.ts b/packages/interface/src/util/debounce.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/ui/package.json b/packages/ui/package.json index 4bfd9245c..1c32f3fba 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,13 +13,14 @@ }, "scripts": { "build": "tsc", - "storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006", + "storybook": "start-storybook -p 6006", "storybook:build": "build-storybook" }, "dependencies": { "@headlessui/react": "^1.6.6", - "@heroicons/react": "^1.0.6", + "@heroicons/react": "^2.0.10", "@radix-ui/react-context-menu": "^1.0.0", + "@tailwindcss/forms": "^0.5.2", "clsx": "^1.2.1", "phosphor-react": "^1.4.1", "postcss": "^8.4.14", diff --git a/packages/ui/src/ContextMenu.tsx b/packages/ui/src/ContextMenu.tsx index 25bf0921d..d9759e627 100644 --- a/packages/ui/src/ContextMenu.tsx +++ b/packages/ui/src/ContextMenu.tsx @@ -9,7 +9,10 @@ export interface ContextMenuItem { label: string; icon?: Icon; danger?: boolean; - onClick: () => void; + active?: boolean; + leftItem?: React.ReactNode; + rightItem?: React.ReactNode; + onClick?: React.MouseEventHandler; children?: ContextMenuSection[]; } @@ -19,15 +22,23 @@ export type ContextMenuSection = (ContextMenuItem | string)[]; export interface ContextMenuProps { items?: ContextMenuSection[]; className?: string; + isChild?: boolean; } export const ContextMenu: React.FC = (props) => { - const { items: sections = [], className, ...rest } = props; + const { items: sections = [], className, isChild, ...rest } = props; + + const ContentPrimitive = isChild ? ContextMenuPrimitive.SubContent : ContextMenuPrimitive.Content; return ( - { + // e.preventDefault(); + // }} + alignOffset={7} className={clsx( - 'shadow-2xl min-w-[15rem] shadow-gray-300 dark:shadow-gray-750 flex flex-col select-none cursor-default bg-gray-50 text-gray-800 border-gray-200 dark:bg-gray-650 dark:text-gray-100 dark:border-gray-550 text-left text-sm rounded gap-1.5 border py-1.5', + 'shadow-md min-w-[11rem] py-0.5 shadow-gray-300 dark:shadow-gray-750 flex flex-col select-none cursor-default bg-gray-50 text-gray-800 border-gray-200 dark:bg-gray-950 dark:text-gray-100 text-left text-sm rounded-lg ', className )} {...rest} @@ -35,36 +46,40 @@ export const ContextMenu: React.FC = (props) => { {sections.map((sec, i) => ( {i !== 0 && ( - + )} - + {sec.map((item) => { if (typeof item === 'string') return ( {item} ); - const { icon: ItemIcon = Question } = item; + const { icon: ItemIcon } = item; let ItemComponent: | typeof ContextMenuPrimitive.Item | typeof ContextMenuPrimitive.Trigger = ContextMenuPrimitive.Item; if ((item.children?.length ?? 0) > 0) - ItemComponent = ((props) => ( - - - {props.children} - + ItemComponent = (({ children, ref, ...props }) => ( + + + {children} + - - + + )) as typeof ContextMenuPrimitive.Trigger; return ( @@ -74,21 +89,28 @@ export const ContextMenu: React.FC = (props) => { textAlign: 'inherit' }} className={clsx( - 'focus:outline-none group cursor-default flex-1 px-1.5 py-0 group-first:pt-1.5', - { - 'text-red-600 dark:text-red-400': item.danger - } + 'focus:outline-none group cursor-default flex-1 px-1.5 py-1 group-first:pt-1.5 [&[data-state="open"]_div]:bg-primary', + item.danger && 'text-red-600 dark:text-red-400', + item.active && 'bg-gray-100 dark:bg-gray-950' )} onClick={item.onClick} key={item.label} > -
- {} +
+ {ItemIcon && } + {item.leftItem} - + {item.label} + {item.rightItem} {(item.children?.length ?? 0) > 0 && ( )} @@ -99,7 +121,7 @@ export const ContextMenu: React.FC = (props) => { ))} - + ); }; diff --git a/packages/ui/src/Dropdown.stories.tsx b/packages/ui/src/Dropdown.stories.tsx index 196462f7a..8b9bcdec6 100644 --- a/packages/ui/src/Dropdown.stories.tsx +++ b/packages/ui/src/Dropdown.stories.tsx @@ -1,4 +1,3 @@ -import { ViewListIcon } from '@heroicons/react/solid'; import { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; diff --git a/packages/ui/src/Dropdown.tsx b/packages/ui/src/Dropdown.tsx index a6320ae34..f00e9dfcb 100644 --- a/packages/ui/src/Dropdown.tsx +++ b/packages/ui/src/Dropdown.tsx @@ -1,5 +1,5 @@ import { Menu } from '@headlessui/react'; -import { ChevronDownIcon } from '@heroicons/react/solid'; +import { ChevronDownIcon } from '@heroicons/react/24/solid'; import clsx from 'clsx'; import React from 'react'; @@ -10,6 +10,7 @@ export type DropdownItem = { icon?: any; selected?: boolean; onPress?: () => any; + wrapItemComponent?: React.FC<{ children: React.ReactNode }>; }[]; export interface DropdownProps { @@ -60,29 +61,38 @@ export const Dropdown: React.FC = (props) => {
{item.map((button, index) => ( - {({ active }) => ( - - )} + {({ active }) => { + const WrappedItem = button.wrapItemComponent + ? button.wrapItemComponent + : (props: React.PropsWithChildren) => <>{props.children}; + + return ( + + + + ); + }} ))}
diff --git a/packages/ui/style/tailwind.js b/packages/ui/style/tailwind.js index 889966950..c1566e714 100644 --- a/packages/ui/style/tailwind.js +++ b/packages/ui/style/tailwind.js @@ -8,7 +8,7 @@ module.exports = function (app, options) { !options?.ignorePackages && '../../packages/*/src/**/*.{js,ts,jsx,tsx,html}', app ? `../../apps/${app}/src/**/*.{js,ts,jsx,tsx,html}` : `./src/**/*.{js,ts,jsx,tsx,html}` ], - darkMode: app == 'landing' ? 'class' : 'class', + darkMode: app == 'landing' ? 'class' : 'media', mode: 'jit', theme: { screens: { @@ -120,7 +120,7 @@ module.exports = function (app, options) { variants: { extend: {} }, - plugins: [] + plugins: [require('@tailwindcss/forms')] }; if (app === 'landing') { config.plugins.push(require('@tailwindcss/typography')); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ace8f46c..f41c6c66d 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ