Merge branch 'main' into mobile-library

This commit is contained in:
Utku Bakir
2022-09-06 15:30:09 +03:00
84 changed files with 1683 additions and 867 deletions

View File

@@ -1,3 +1,8 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use std::path::PathBuf;
use sdcore::Node;

View File

@@ -71,6 +71,7 @@
"fullscreen": false,
"alwaysOnTop": false,
"focus": false,
"visible": false,
"fileDropEnabled": false,
"decorations": true,
"transparent": true,

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />

View File

@@ -1,83 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Spacedrive</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>spacedrive</string>
<string>com.spacedrive.app</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Spacedrive</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>CFBundleURLSchemes</key>
<array>
<string>spacedrive</string>
<string>com.spacedrive.app</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true />
</dict>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key>
<true />
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<false />
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false />
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
</plist>

View File

@@ -6,8 +6,8 @@ import { SharedScreenProps } from '~/navigation/SharedScreens';
export default function LocationScreen({ navigation, route }: SharedScreenProps<'Location'>) {
const { id } = route.params;
return (
<View style={tw`flex-1 items-center justify-center`}>
<Text style={tw`font-bold text-xl text-white`}>Location {id}</Text>
<View style={tw`items-center justify-center flex-1`}>
<Text style={tw`text-xl font-bold text-white`}>Location {id}</Text>
</View>
);
}

View File

@@ -5,8 +5,8 @@ import { SpacesStackScreenProps } from '~/navigation/tabs/SpacesStack';
export default function SpacesScreen({ navigation }: SpacesStackScreenProps<'Spaces'>) {
return (
<View style={tw`flex-1 items-center justify-center`}>
<Text style={tw`font-bold text-xl text-white`}>Spaces</Text>
<View style={tw`items-center justify-center flex-1`}>
<Text style={tw`text-xl font-bold text-white`}>Spaces</Text>
</View>
);
}

View File

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

View File

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

View File

@@ -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<GetExplorerDirArgs>,
"locations.getExplorerData": LibraryArgs<LocationExplorerArgs>,
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<GetExplorerDirArgs>,
"locations.getExplorerData": LibraryArgs<LocationExplorerArgs>,
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,
}
}
);

View File

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

View File

@@ -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<file_path::Data>,
pub struct ExplorerData {
pub context: ExplorerContext,
pub items: Vec<ExplorerItem>,
}
#[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<file_path::Data>),
Object(Box<file::Data>),
}
#[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<String>,
}
pub(crate) fn mount() -> RouterBuilder {
@@ -53,8 +69,8 @@ pub(crate) fn mount() -> RouterBuilder {
.await?)
})
.query(
"getExplorerDir",
|ctx, arg: LibraryArgs<GetExplorerDirArgs>| async move {
"getExplorerData",
|ctx, arg: LibraryArgs<LocationExplorerArgs>| 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(),
})

View File

@@ -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<i32>| async move {
.query("getExplorerData", |ctx, arg: LibraryArgs<i32>| 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 <id={tag_id}> not found"))
})?;
let files: Vec<ExplorerItem> = 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<i32>| 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<i32>| 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<TagAssignArgs>| 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<i32>,
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: ()

View File

@@ -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<GetExplorerDirArgs>,
"locations.getExplorerData": LibraryArgs<LocationExplorerArgs>,
LibraryArgs::new(
library_ctx.id,
GetExplorerDirArgs {
LocationExplorerArgs {
location_id: state.init.location_id,
path: "".to_string(),
limit: 100
limit: 100,
cursor: None,
}
)
);

View File

@@ -28,6 +28,7 @@ pub struct FileIdentifierJob {}
#[derive(Serialize, Deserialize, Clone)]
pub struct FileIdentifierJobInit {
pub location: location::Data,
pub sub_path: Option<PathBuf>, // 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<Vec<file_path::Data>, 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<Utc> = metadata.created().unwrap().into();

View File

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

View File

@@ -263,6 +263,7 @@ pub async fn scan_location(
ctx.queue_job(Job::new(
FileIdentifierJobInit {
location: location.clone(),
sub_path: None,
},
Box::new(FileIdentifierJob {}),
))

67
core/src/object/mod.rs Normal file
View File

@@ -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<ObjectData>,
// pub context: ExplorerContext,
}
// #[derive(Debug, Serialize, Deserialize, Type)]
// pub enum ExplorerContext {
// Location(Box<prisma::file_path::Data>),
// Space(Box<prisma::file::Data>),
// Tag(Box<prisma::file::Data>),
// // Search(Box<prisma::file_path::Data>),
// }
#[derive(Debug, Serialize, Deserialize, Type)]
pub enum ObjectData {
Object(Box<prisma::file::Data>),
Path(Box<prisma::file_path::Data>),
}
#[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,
}

View File

@@ -101,9 +101,27 @@ pub async fn load_and_migrate(db_url: &str) -> Result<PrismaClient, MigrationErr
// Split the migrations file up into each individual step and apply them all
let steps = migration_file_raw.split(';').collect::<Vec<&str>>();
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(

View File

@@ -0,0 +1,3 @@
# Albums
you can put photos here

View File

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

View File

@@ -0,0 +1,3 @@
# Explorer
using the interface, features

View File

@@ -0,0 +1,3 @@
# Extensions
extended functionality of Spacedrive

View File

@@ -0,0 +1,6 @@
# sVFS???
sVFS is a decentralized virtual filesystem
*not sure what to call this yet*

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
# Locations
indexing, identifying, watching, .spacedrive folder, online/offline

View File

@@ -0,0 +1,3 @@
# Nodes
p2p, connecting nodes, protocols.

View File

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

View File

@@ -0,0 +1,7 @@
# Preview Media
Spacedrive generates compressed preview media for images, videos and text files.
ffmpeg, syncing, security

View File

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

View File

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

14
docs/architecture/sync.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,6 @@
"typescript": "^4.7.4"
},
"peerDependencies": {
"react": "^18.0.0"
"react": "^18.2.0"
}
}

View File

@@ -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<string, boolean>;
addNewThumbnail: (cas_id: string) => void;
setLayoutMode: (mode: LayoutMode) => void;
selectMore: (indexes: number[]) => void;
reset: () => void;
set: (changes: Partial<ExplorerStore>) => void;
};
export const useExplorerStore = create<ExplorerStore>((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 }))
}));

View File

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

View File

@@ -0,0 +1 @@
export * from './file';

View File

@@ -19,7 +19,7 @@
"strict": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"jsx": "react",
"jsx": "react-jsx",
"paths": {
"@sd/interface": ["../../packages/interface"],
"@sd/ui": ["../../packages/ui"],

View File

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

View File

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

View File

@@ -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() {
<Route path="changelog" element={<Changelog />} />
<Route path="support" element={<Support />} />
</Route>
<Route path="explorer/:id" element={<ExplorerScreen />} />
<Route path="tag/:id" element={<TagScreen />} />
<Route path="location/:id" element={<LocationExplorer />} />
<Route path="tag/:id" element={<TagExplorer />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>

View File

@@ -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) {
</div>
)}
<div className="flex flex-row ml-3 space-x-1">
<Tooltip label="Encrypt">
<Button className="!p-1 ">
<KeyIcon className="w-5 h-5" />
</Button>
<Button className="!p-1 ">
<KeyIcon className="w-5 h-5" />
</Button>
</Tooltip>
<Tooltip label="Settings">
<Button className="!p-1 ">
<CogIcon className="w-5 h-5" />
</Button>
<Button className="!p-1 ">
<CogIcon className="w-5 h-5" />
</Button>
</Tooltip>
</div>
</div>
<div className="px-4 pb-3 mt-3">
{props.locations.map((location, key) => (
<FileItem
key={key}
selected={selectedFile === location.name}
onClick={() => handleSelect(location.name)}
/>
))}
{props.locations.length === 0 && (
<div className="w-full my-5 text-center text-gray-450">No locations</div>
)}

View File

@@ -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 (
<Dialog
open={openCreateModal}
onOpenChange={setOpenCreateModal}
title="Create New Library"
description="Choose a name for your new library, you can configure this and more settings from the library settings later on."
ctaAction={() => createLibrary(newLibName)}
loading={createLibLoading}
submitDisabled={!newLibName}
ctaLabel="Create"
trigger={props.children}
>
<Input
className="flex-grow w-full mt-3"
value={newLibName}
placeholder="My Cool Library"
onChange={(e) => setNewLibName(e.target.value)}
/>
</Dialog>
);
}

View File

@@ -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 (
<Dialog
open={openDeleteModal}
onOpenChange={setOpenDeleteModal}
title="Delete Library"
description="Deleting a library will permanently the database, the files themselves will not be deleted."
ctaAction={() => {
deleteLib(props.libraryUuid);
}}
loading={libDeletePending}
ctaDanger
ctaLabel="Delete"
trigger={props.children}
/>
);
}

View File

@@ -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 (
<div className="relative">
<WithContextMenu
menu={[
[
// `file-${props.identifier}`,
{
label: 'Open'
},
{
label: 'Open with...'
}
],
[
{
label: 'Quick view'
},
{
label: 'Open in Finder'
}
],
[
{
label: 'Rename'
},
{
label: 'Duplicate'
}
],
[
{
label: 'Share',
icon: Share,
onClick(e) {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}
}
],
[
{
label: 'Assign tag',
icon: TagSimple,
children: [
tags?.map((tag) => {
const active = !!tagsForFile?.find((t) => t.id === tag.id);
return {
label: tag.name || '',
// leftItem: <Checkbox checked={!!tagsForFile?.find((t) => t.id === tag.id)} />,
leftItem: (
<div className="relative">
<div
className="block w-[15px] h-[15px] mr-0.5 border rounded-full"
style={{
backgroundColor: active
? tag.color || '#efefef'
: 'transparent' || '#efefef',
borderColor: tag.color || '#efefef'
}}
/>
</div>
),
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
}
]
]}
>
<div className="relative flex flex-col w-full bg-gray-650">
<TopBar />
<div className="relative flex flex-row w-full max-h-full">
<FileList data={props.data?.items || []} context={props.data.context} />
{showInspector && (
<div className="min-w-[260px] max-w-[260px]">
{props.data.items[selectedRowIndex]?.id && (
<Inspector
key={props.data.items[selectedRowIndex].id}
data={props.data.items[selectedRowIndex]}
/>
)}
</div>
)}
</div>
</div>
</WithContextMenu>
</div>
);
}

View File

@@ -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<HTMLDivElement> {
data: ExplorerItem;
selected: boolean;
size: number;
index: number;
}
export default function FileItem(props: Props) {
const { set } = useExplorerStore();
const size = props.size || 100;
return (
<div
onContextMenu={(e) => {
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)}
>
<div
style={{ width: size, height: size }}
className={clsx(
'border-2 border-transparent rounded-lg text-center mb-1 active:translate-y-[1px]',
{
'bg-gray-50 dark:bg-gray-750': props.selected
}
)}
>
<div
className={clsx(
'relative grid place-content-center min-w-0 h-full p-1 rounded border-transparent border-2 shrink-0'
)}
>
<FileThumb
className={clsx(
'border-4 border-gray-250 rounded-sm shadow-md shadow-gray-750 max-h-full max-w-full overflow-hidden'
)}
data={props.data}
size={100}
/>
</div>
</div>
<div className="flex justify-center">
<span
className={clsx(
'px-1.5 py-[1px] truncate text-center rounded-md text-xs font-medium text-gray-550 dark:text-gray-300 cursor-default',
{
'bg-primary !text-white': props.selected
}
)}
>
{props.data?.name}.{props.data?.extension}
</span>
</div>
</div>
);
}

View File

@@ -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> = (props) => {
const size = useWindowSize();
const tableContainer = useRef<null | HTMLDivElement>(null);
const VList = useRef<null | VirtuosoHandle>(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<RenderItemProps>) => {
return (index: number) => {
const row = currentDir?.contents?.[index];
const row = props.data[index];
if (!row) return null;
return <RenderItem key={index} index={index} item={row} dirId={currentDir?.directory.id} />;
return <RenderItem key={index} index={index} item={row} />;
};
};
const Header = () => (
<div>
<h1 className="pt-20 pl-4 text-xl font-bold ">{currentDir?.directory.name}</h1>
{props.context.name && (
<h1 className="pt-20 pl-4 text-xl font-bold ">{props.context.name}</h1>
)}
<div className="table-head">
<div className="flex flex-row p-2 table-head-row">
{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 }}
>
<DotsVerticalIcon className="absolute hidden w-5 h-5 -ml-5 cursor-move group-hover:block drag-handle opacity-10" />
<EllipsisHorizontalIcon className="absolute hidden w-5 h-5 -ml-5 cursor-move group-hover:block drag-handle opacity-10" />
<span className="text-sm font-medium text-gray-500">{col.column}</span>
</div>
))}
@@ -124,80 +118,77 @@ export const FileList: React.FC<{ location_id: number; path: string; limit: numb
return (
<div ref={tableContainer} style={{ marginTop: -44 }} className="w-full pl-2 cursor-default ">
<LocationContext.Provider
value={{ location_id: props.location_id, data_path: client?.data_path as string }}
>
{layoutMode === 'grid' && (
<VirtuosoGrid
ref={VList}
overscan={5000}
components={{
Item: GridItemContainer,
List: GridContainer
}}
style={{ height: size.innerHeight ?? 600 }}
totalCount={currentDir?.contents.length || 0}
itemContent={createRenderItem(RenderGridItem)}
className="w-full overflow-x-hidden outline-none explorer-scroll"
/>
)}
{layoutMode === 'list' && (
<Virtuoso
data={currentDir?.contents} // this might be redundant, row data is retrieved by index in renderRow
ref={VList}
style={{ height: size.innerHeight ?? 600 }}
totalCount={currentDir?.contents.length || 0}
itemContent={createRenderItem(RenderRow)}
components={{
Header,
Footer: () => <div className="w-full " />
}}
increaseViewportBy={{ top: 400, bottom: 200 }}
className="outline-none explorer-scroll"
/>
)}
</LocationContext.Provider>
{layoutMode === 'grid' && (
<VirtuosoGrid
ref={VList}
overscan={5000}
components={{
Item: GridItemContainer,
List: GridContainer
}}
style={{ height: size.innerHeight ?? 600 }}
totalCount={props.data.length || 0}
itemContent={createRenderItem(RenderGridItem)}
className="w-full overflow-x-hidden outline-none explorer-scroll"
/>
)}
{layoutMode === 'list' && (
<Virtuoso
data={props.data} // this might be redundant, row data is retrieved by index in renderRow
ref={VList}
style={{ height: size.innerHeight ?? 600 }}
totalCount={props.data.length || 0}
itemContent={createRenderItem(RenderRow)}
components={{
Header,
Footer: () => <div className="w-full " />
}}
increaseViewportBy={{ top: 400, bottom: 200 }}
className="outline-none explorer-scroll"
/>
)}
</div>
);
};
interface RenderItemProps {
item: FilePath;
item: ExplorerItem;
index: number;
dirId: number;
}
const RenderGridItem: React.FC<RenderItemProps> = ({ item, index, dirId }) => {
const { selectedRowIndex, setSelectedRowIndex } = useExplorerStore();
const RenderGridItem: React.FC<RenderItemProps> = ({ item, index }) => {
const { selectedRowIndex, set } = useExplorerStore();
const [_, setSearchParams] = useSearchParams();
return (
<FileItem
onDoubleClick={() => {
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<RenderItemProps> = ({ item, index, dirId }) => {
const { selectedRowIndex, setSelectedRowIndex } = useExplorerStore();
const RenderRow: React.FC<RenderItemProps> = ({ item, index }) => {
const { selectedRowIndex, set } = useExplorerStore();
const isActive = selectedRowIndex === index;
const [_, setSearchParams] = useSearchParams();
return useMemo(
() => (
<div
onClick={() => 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<RenderItemProps> = ({ item, index, dirId }) => {
className="flex items-center px-4 py-2 pr-2 table-body-cell"
style={{ width: col.width }}
>
<RenderCell file={item} dirId={dirId} colKey={col.key} />
<RenderCell data={item} colKey={col.key} />
</div>
))}
</div>
@@ -225,17 +216,14 @@ const RenderRow: React.FC<RenderItemProps> = ({ 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 (
<div className="flex flex-row items-center overflow-hidden">
<div className="flex items-center justify-center w-6 h-6 mr-3 shrink-0">
<FileThumb file={file} locationId={location.location_id} />
<FileThumb data={data} size={0} />
</div>
{/* {colKey == 'name' &&
(() => {
@@ -249,13 +237,13 @@ const RenderCell: React.FC<{
return <DocumentIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
}
})()} */}
<span className="text-xs truncate">{file[colKey]}</span>
<span className="text-xs truncate">{data[colKey]}</span>
</div>
);
// case 'size_in_bytes':
// return <span className="text-xs text-left">{byteSize(Number(value || 0))}</span>;
case 'extension':
return <span className="text-xs text-left">{file[colKey]}</span>;
return <span className="text-xs text-left">{data[colKey]}</span>;
// case 'meta_integrity_hash':
// return <span className="truncate">{value}</span>;
// case 'tags':

View File

@@ -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 <Folder size={props.size * 0.7} />;
const cas_id = isObject(data) ? data.cas_id : data.file?.cas_id;
if (!cas_id) return <div></div>;
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 (
<img
style={props.style}
className={clsx('pointer-events-none z-90', props.className)}
src={file_thumb_url}
/>
);
const Icon = icons[data.extension as keyof typeof icons];
return (
<div
style={{ width: props.size * 0.8, height: props.size * 0.8 }}
className="relative m-auto transition duration-200 "
>
<svg
// BACKGROUND
className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none top-1/2 left-1/2 fill-gray-150 dark:fill-gray-550"
width="100%"
height="100%"
viewBox="0 0 65 81"
style={{ filter: 'drop-shadow(0px 5px 2px rgb(0 0 0 / 0.05))' }}
>
<path d="M0 8C0 3.58172 3.58172 0 8 0H39.6863C41.808 0 43.8429 0.842855 45.3431 2.34315L53.5 10.5L62.6569 19.6569C64.1571 21.1571 65 23.192 65 25.3137V73C65 77.4183 61.4183 81 57 81H8C3.58172 81 0 77.4183 0 73V8Z" />
</svg>
{Icon && (
<div className="absolute flex flex-col items-center justify-center w-full h-full mt-0.5 ">
<Icon
className={clsx('w-full h-full ')}
style={{ width: props.size * 0.45, height: props.size * 0.45 }}
/>
<span className="text-xs font-bold text-center uppercase cursor-default text-gray-450">
{data.extension}
</span>
</div>
)}
<svg
// PEEL
width="28%"
height="28%"
className="absolute top-0 right-0 -translate-x-[35%] z-0 pointer-events-none fill-gray-50 dark:fill-gray-500"
viewBox="0 0 41 41"
>
<path d="M41.4116 40.5577H11.234C5.02962 40.5577 0 35.5281 0 29.3238V0L41.4116 40.5577Z" />
</svg>
</div>
);
return null;
}

View File

@@ -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 (
<div className="p-2 pr-1 overflow-x-hidden custom-scroll inspector-scroll pb-[55px]">
{!!props.data && (
<>
<div className="flex bg-black items-center justify-center w-full h-64 mb-[10px] overflow-hidden rounded-lg ">
<FileThumb size={230} className="!m-0 flex flex-shrink flex-grow-0" data={props.data} />
</div>
<div className="flex flex-col w-full pt-0.5 pb-4 overflow-hidden bg-white rounded-lg shadow select-text dark:shadow-gray-700 dark:bg-gray-550 dark:bg-opacity-40">
<h3 className="pt-3 pl-3 text-base font-bold">
{props.data?.name}.{props.data?.extension}
</h3>
{objectData && (
<div className="flex flex-row m-3 space-x-2">
<Tooltip label="Favorite">
<FavoriteButton data={objectData} />
</Tooltip>
<Tooltip label="Share">
<Button size="sm" noPadding>
<ShareIcon className="w-[18px] h-[18px]" />
</Button>
</Tooltip>
<Tooltip label="Link">
<Button size="sm" noPadding>
<Link className="w-[18px] h-[18px]" />
</Button>
</Tooltip>
</div>
)}
{!!tags?.length && (
<>
<Divider />
<MetaItem
// title="Tags"
value={
<div className="flex flex-wrap mt-1.5 gap-1.5">
{tags?.map((tag) => (
<div
// onClick={() => 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' }}
>
<span className="text-xs text-white drop-shadow-md">{tag.name}</span>
</div>
))}
</div>
}
/>
</>
)}
{props.context?.type == 'Location' && props.data?.type === 'Path' && (
<>
<Divider />
<MetaItem
title="URI"
value={`${props.context.local_path}/${props.data.materialized_path}`}
/>
</>
)}
<Divider />
<MetaItem
title="Date Created"
value={moment(props.data?.date_created).format('MMMM Do YYYY, h:mm:ss a')}
/>
<Divider />
<MetaItem
title="Date Indexed"
value={moment(props.data?.date_indexed).format('MMMM Do YYYY, h:mm:ss a')}
/>
{!is_dir && (
<>
<Divider />
<div className="flex flex-row items-center px-3 py-2 meta-item">
{props.data?.extension && (
<span className="inline px-1 mr-1 text-xs font-bold uppercase bg-gray-500 rounded-md text-gray-150">
{props.data?.extension}
</span>
)}
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">
{props.data?.extension
? //@ts-ignore
types[props.data.extension.toUpperCase()]?.descriptions.join(' / ')
: 'Unknown'}
</p>
</div>
{objectData && (
<>
<Note data={objectData} />
<Divider />
{objectData.cas_id && (
<MetaItem title="Unique Content ID" value={objectData.cas_id} />
)}
</>
)}
</>
)}
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,3 @@
import React from 'react';
export const Divider = () => <div className="w-full my-1 h-[1px] bg-gray-100 dark:bg-gray-550" />;

View File

@@ -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 (
<Button onClick={toggleFavorite} size="sm" noPadding>
<Heart weight={favorite ? 'fill' : 'regular'} className="w-[18px] h-[18px]" />
</Button>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
interface MetaItemProps {
title?: string;
value: string | React.ReactNode;
}
export const MetaItem = (props: MetaItemProps) => {
return (
<div data-tip={props.value} className="flex flex-col px-4 py-1.5 meta-item">
{!!props.title && <h5 className="text-xs font-bold">{props.title}</h5>}
{typeof props.value === 'string' ? (
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">{props.value}</p>
) : (
props.value
)}
</div>
);
};

View File

@@ -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<HTMLTextAreaElement>) {
if (e.target.value !== note) {
setNote(e.target.value);
debouncedNote(e.target.value);
}
}
return (
<>
<Divider />
<MetaItem
title="Note"
value={
<TextArea
className="mt-2 text-xs leading-snug !py-2"
value={note || ''}
onChange={handleNoteUpdate}
/>
}
/>
</>
);
}

View File

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

View File

@@ -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<HTMLDivElement> {
file?: FilePath | null;
selected?: boolean;
}
export default function FileItem(props: Props) {
const location = useContext(LocationContext);
return (
<div {...props} className={clsx('inline-block w-[100px] mb-3', props.className)} draggable>
<div
className={clsx(
'border-2 border-transparent rounded-lg text-center w-[100px] h-[100px] mb-1',
{
'bg-gray-50 dark:bg-gray-650': props.selected
}
)}
>
{props.file?.is_dir ? (
<div className="flex items-center justify-center w-full h-full active:translate-y-[1px]">
<div className="w-[70px]">
<Folder className="" />
</div>
</div>
) : props.file?.file?.has_thumbnail ? (
<div
className={clsx(
'flex items-center justify-center h-full p-1 overflow-hidden rounded border-gray-550 shrink-0',
props.selected && 'border-primary'
)}
>
<div className="border-4 rounded border-gray-550">
<FileThumb
className="rounded-sm"
file={props.file}
locationId={location.location_id}
/>
</div>
</div>
) : (
<div className="w-[64px] mt-1.5 m-auto transition duration-200 rounded-lg h-[90px] relative active:translate-y-[1px]">
<svg
className="absolute top-0 left-0 pointer-events-none fill-gray-150 dark:fill-gray-550"
width="65"
height="85"
viewBox="0 0 65 81"
>
<path d="M0 8C0 3.58172 3.58172 0 8 0H39.6863C41.808 0 43.8429 0.842855 45.3431 2.34315L53.5 10.5L62.6569 19.6569C64.1571 21.1571 65 23.192 65 25.3137V73C65 77.4183 61.4183 81 57 81H8C3.58172 81 0 77.4183 0 73V8Z" />
</svg>
<svg
width="22"
height="22"
className="absolute top-1 -right-[1px] z-0 fill-gray-50 dark:fill-gray-500 pointer-events-none"
viewBox="0 0 41 41"
>
<path d="M41.4116 40.5577H11.234C5.02962 40.5577 0 35.5281 0 29.3238V0L41.4116 40.5577Z" />
</svg>
<div className="absolute flex flex-col items-center justify-center w-full h-full">
{props.file?.extension && icons[props.file.extension as keyof typeof icons] ? (
(() => {
const Icon = icons[props.file.extension as keyof typeof icons];
return (
<Icon className="mt-2 pointer-events-none margin-auto w-[40px] h-[40px]" />
);
})()
) : (
<></>
)}
<span className="mt-1 text-xs font-bold text-center uppercase cursor-default text-gray-450">
{props.file?.extension}
</span>
</div>
</div>
)}
</div>
<div className="flex justify-center">
<span
className={clsx(
'px-1.5 py-[1px] truncate text-center rounded-md text-xs font-medium text-gray-550 dark:text-gray-300 cursor-default',
{
'bg-primary !text-white': props.selected
}
)}
>
{props.file?.name}
</span>
</div>
</div>
);
}

View File

@@ -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 <Folder size={100} />;
}
if (appProps?.data_path && (props.file.file?.has_thumbnail || hasNewThumbnail)) {
return (
<img
className={clsx('pointer-events-none z-90', props.className)}
src={appProps?.convertFileSrc(
`${appProps.data_path}/thumbnails/${props.locationId}/${props.file.file?.cas_id}.webp`
)}
/>
);
}
if (icons[props.file.extension as keyof typeof icons]) {
const Icon = icons[props.file.extension as keyof typeof icons];
return <Icon className={clsx('max-w-[170px] w-full h-full', props.className)} />;
}
return <div></div>;
}

View File

@@ -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 (
<div data-tip={props.value} className="flex flex-col px-3 py-1 meta-item">
<h5 className="text-xs font-bold">{props.title}</h5>
{typeof props.value === 'string' ? (
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">{props.value}</p>
) : (
props.value
)}
</div>
);
};
const Divider = () => <div className="w-full my-1 h-[1px] bg-gray-100 dark:bg-gray-550" />;
function debounce<T>(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<HTMLTextAreaElement>) {
if (e.target.value !== note) {
setNote(e.target.value);
debouncedNote(e.target.value);
}
}
return (
<div className="p-2 pr-1 w-[330px] overflow-x-hidden custom-scroll inspector-scroll pb-[55px]">
{!!file_path && (
<div>
<div className="flex items-center justify-center w-full h-64 mb-[10px] overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-900">
<FileThumb
className="!m-0 flex flex-shrink flex-grow-0"
file={file_path}
locationId={props.locationId}
/>
</div>
<div className="flex flex-col w-full pb-2 overflow-hidden bg-white rounded-lg select-text dark:bg-gray-550 dark:bg-opacity-40">
<h3 className="pt-3 pl-3 text-base font-bold">{file_path?.name}</h3>
<div className="flex flex-row m-3 space-x-2">
<Tooltip label="Favorite">
<Button onClick={toggleFavorite} size="sm" noPadding>
<Heart weight={favorite ? 'fill' : 'regular'} className="w-[18px] h-[18px]" />
</Button>
</Tooltip>
<Tooltip label="Share">
<Button size="sm" noPadding>
<ShareIcon className="w-[18px] h-[18px]" />
</Button>
</Tooltip>
<Tooltip label="Link">
<Button size="sm" noPadding>
<Link className="w-[18px] h-[18px]" />
</Button>
</Tooltip>
</div>
{file_path?.file?.cas_id && (
<MetaItem title="Unique Content ID" value={file_path.file.cas_id as string} />
)}
<Divider />
<MetaItem
title="URI"
value={`${props.location?.local_path}/${file_path?.materialized_path}`}
/>
<Divider />
<MetaItem
title="Date Created"
value={moment(file_path?.date_created).format('MMMM Do YYYY, h:mm:ss a')}
/>
<Divider />
<MetaItem
title="Date Indexed"
value={moment(file_path?.date_indexed).format('MMMM Do YYYY, h:mm:ss a')}
/>
{!file_path?.is_dir && (
<>
<Divider />
<div className="flex flex-row items-center px-3 py-2 meta-item">
{file_path?.extension && (
<span className="inline px-1 mr-1 text-xs font-bold uppercase bg-gray-500 rounded-md text-gray-150">
{file_path?.extension}
</span>
)}
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">
{file_path?.extension
? //@ts-ignore
types[file_path.extension.toUpperCase()]?.descriptions.join(' / ')
: 'Unknown'}
</p>
</div>
{file_path.file && (
<>
<Divider />
<MetaItem
title="Note"
value={
<TextArea
className="mt-2 text-xs leading-snug !py-2"
value={note || ''}
onChange={handleNoteUpdate}
/>
}
/>
</>
)}
</>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -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<ModalProps> = (props) => {
variant="gray"
className="!px-1.5 absolute top-2 right-2"
>
<XIcon className="w-4 h-4" />
<XMarkIcon className="w-4 h-4" />
</Button>
<Transition
show

View File

@@ -1,5 +1,5 @@
import { LockClosedIcon, PhotographIcon } from '@heroicons/react/outline';
import { CogIcon, PlusIcon } from '@heroicons/react/solid';
import { LockClosedIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { CogIcon, PlusIcon } from '@heroicons/react/24/solid';
import {
AppPropsContext,
useCurrentLibrary,
@@ -7,13 +7,14 @@ import {
useLibraryQuery,
useLibraryStore
} from '@sd/client';
import { LocationCreateArgs } from '@sd/core';
import { Button, Dropdown } from '@sd/ui';
import clsx from 'clsx';
import { CirclesFour, Planet } from 'phosphor-react';
import { CirclesFour, Planet, WaveTriangle } from 'phosphor-react';
import React, { useContext, useEffect } from 'react';
import { NavLink, NavLinkProps, useNavigate } from 'react-router-dom';
import { LocationCreateArgs } from '@sd/core';
import CreateLibraryDialog from '../dialog/CreateLibraryDialog';
import { Folder } from '../icons/Folder';
import RunningJobsWidget from '../jobs/RunningJobsWidget';
import { MacTrafficLights } from '../os/TrafficLights';
@@ -94,7 +95,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { mutate: createLocation } = useLibraryMutation('locations.create');
const { data: tags } = useLibraryQuery(['tags.get']);
const { data: tags } = useLibraryQuery(['tags.getAll']);
return (
<div
@@ -154,10 +155,7 @@ export const Sidebar: React.FC<SidebarProps> = (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<SidebarProps> = (props) => {
alert('todo');
}
}
// { name: 'Hide', icon: EyeOffIcon }
]
]}
/>
@@ -181,7 +178,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
Spaces
</SidebarLink>
<SidebarLink to="photos">
<Icon component={PhotographIcon} />
<Icon component={PhotoIcon} />
Photos
</SidebarLink>
</div>
@@ -193,7 +190,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<NavLink
className="relative w-full group"
to={{
pathname: `explorer/${location.id}`
pathname: `location/${location.id}`
}}
>
{({ isActive }) => (
@@ -224,7 +221,11 @@ export const Sidebar: React.FC<SidebarProps> = (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(

View File

@@ -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<TopBarButtonProps> = ({
'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<HTMLInputElement, DefaultProps>((props, ref)
});
export const TopBar: React.FC<TopBarProps> = (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<TopBarProps> = (props) => {
<>
<div
data-tauri-drag-region
className="flex h-[2.95rem] -mt-0.5 max-w z-10 pl-3 flex-shrink-0 items-center border-b dark:bg-gray-600 border-gray-100 dark:border-gray-800 !bg-opacity-90 backdrop-blur"
className="flex h-[2.95rem] -mt-0.5 max-w z-10 pl-3 flex-shrink-0 items-center dark:bg-gray-650 border-gray-100 dark:border-gray-800 !bg-opacity-80 backdrop-blur"
>
<div className="flex ">
<Tooltip label="Navigate back">
@@ -151,7 +159,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
left
active={layoutMode === 'list'}
icon={Rows}
onClick={() => setLayoutMode('list')}
onClick={() => set({ layoutMode: 'list' })}
/>
</Tooltip>
<Tooltip label="Grid view">
@@ -160,7 +168,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
right
active={layoutMode === 'grid'}
icon={SquaresFour}
onClick={() => setLayoutMode('grid')}
onClick={() => set({ layoutMode: 'grid' })}
/>
</Tooltip>
</div>
@@ -173,17 +181,23 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
{/* <Tooltip label="Cloud">
<TopBarButton icon={Cloud} />
</Tooltip> */}
<Tooltip label="Generate Thumbnails">
{/* <Tooltip label="Refresh">
<TopBarButton
icon={ArrowsClockwise}
onClick={() => {
// generateThumbsForLocation({ id: locationId, path: '' });
}}
/>
</Tooltip>
</Tooltip> */}
</div>
</div>
<div className="flex mr-3 space-x-2">
<TopBarButton
active={showInspector}
onClick={() => set({ showInspector: !showInspector })}
className="my-2"
icon={SidebarSimple}
/>
<Dropdown
// className="absolute block h-6 w-44 top-2 right-4"
align="right"
@@ -192,12 +206,13 @@ export const TopBar: React.FC<TopBarProps> = (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: '' })
}
]
]}

View File

@@ -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);
}}
>
<RefreshIcon className="w-4 h-4" />
<Repeat className="w-4 h-4" />
</Button>
{/* <Button variant="gray" className="!p-1.5">
<CogIcon className="w-4 h-4" />

View File

@@ -2,40 +2,21 @@ import clsx from 'clsx';
import React from 'react';
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
containerClassname?: string;
primaryColor?: string;
}
export const Checkbox: React.FC<CheckboxProps> = (props) => {
return (
<label
<input
{...props}
type="checkbox"
style={{}}
className={clsx(
'flex items-center text-sm font-medium text-gray-700 dark:text-gray-100',
props.containerClassname
)}
>
<input
{...props}
type="checkbox"
className={clsx(
`
bg-gray-50
hover:bg-gray-100
dark:bg-gray-800
border-gray-100
hover:border-gray-200
dark:border-gray-700
dark:hover:bg-gray-700
dark:hover:border-gray-600
transition
rounded
mr-2
text-primary
checked:ring-2 checked:ring-primary-500
`
form-check-input appearance-none h-4 w-4 border border-gray-300 rounded-sm bg-white checked:bg-blue-600 checked:border-blue-600 focus:outline-none transition duration-200 mt-1 align-top bg-no-repeat bg-center bg-contain float-left mr-2
`,
props.className
)}
/>
<span className="select-none">Checkbox</span>
</label>
props.className
)}
/>
);
};

View File

@@ -1,5 +1,5 @@
import { Listbox as ListboxPrimitive } from '@headlessui/react';
import { CheckIcon, SelectorIcon } from '@heroicons/react/solid';
import { CheckIcon, SunIcon } from '@heroicons/react/24/solid';
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
@@ -38,7 +38,7 @@ export default function Listbox(props: { options: ListboxOption[]; className?: s
)}
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SelectorIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
<SunIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxPrimitive.Button>

View File

@@ -1,18 +1,15 @@
import { Button } from '@sd/ui';
import React from 'react';
import { WithContextMenu } from '../components/layout/MenuOverlay';
export const ContentScreen: React.FC<unknown> = (props) => {
// const [address, setAddress] = React.useState('');
return (
<div className="flex flex-col w-full h-screen p-5 custom-scroll page-scroll">
{/* <div className="relative flex flex-col space-y-5 pb-7">
<LockClosedIcon className="absolute w-4 h-4 ml-3 text-gray-250 top-[30px]" />
<Input
className="pl-9"
placeholder="0f2z49zA"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div> */}
<WithContextMenu menu={[[{ label: 'jeff', children: [[{ label: 'jeff' }]] }]]}>
<Button variant="gray">Test</Button>
</WithContextMenu>
</div>
);
};

View File

@@ -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<unknown> = () => {
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 (
<div className="relative flex flex-col w-full bg-gray-650">
<TopBar />
<div className="relative flex flex-row w-full max-h-full">
<FileList location_id={location_id} path={path} limit={limit} />
{currentDir?.contents && (
<Inspector
location={currentLocation}
selectedFile={currentDir.contents[selectedRowIndex]}
locationId={location_id}
/>
)}
</div>
</div>
);
};

View File

@@ -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<unknown> = () => {
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 (
<div className="relative flex flex-col w-full">
{library_id && explorerData.data && <Explorer data={explorerData.data} />}
</div>
);
};

View File

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

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { useParams } from 'react-router-dom';
export const TagScreen: React.FC<unknown> = () => {
const { id } = useParams();
return (
<div className="w-full p-5">
<h1>{id}</h1>
</div>
);
};

View File

@@ -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<unknown> = () => {
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 (
<div className="w-full">
{library_id && id != undefined && explorerData.data && <Explorer data={explorerData.data} />}
</div>
);
};

View File

@@ -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
</SidebarLink>
<SidebarLink to="/settings/libraries">
<SettingsIcon component={CollectionIcon} />
<SettingsIcon component={BuildingLibraryIcon} />
Libraries
</SidebarLink>
<SidebarLink to="/settings/privacy">
@@ -98,7 +92,7 @@ export const SettingsScreen: React.FC = () => {
About
</SidebarLink>
<SidebarLink to="/settings/changelog">
<SettingsIcon component={AnnotationIcon} />
<SettingsIcon component={Receipt} />
Changelog
</SidebarLink>
<SidebarLink to="/settings/support">

View File

@@ -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={
<div className="relative mt-6">
<SearchIcon className="absolute w-[18px] h-auto top-[8px] left-[11px] text-gray-350" />
<MagnifyingGlass className="absolute w-[18px] h-auto top-[8px] left-[11px] text-gray-350" />
<Input className="w-56 !p-0.5 !pl-9" placeholder="Search extensions" />
</div>
}

View File

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

View File

@@ -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 (
<Card>
<DotsSixVertical weight="bold" className="mt-[15px] mr-3 opacity-30" />
@@ -26,41 +40,22 @@ function LibraryListItem(props: { library: LibraryConfigWrapped }) {
<h3 className="font-semibold">{props.library.config.name}</h3>
<p className="mt-0.5 text-xs text-gray-200">{props.library.uuid}</p>
</div>
<div>
<Dialog
open={openDeleteModal}
onOpenChange={setOpenDeleteModal}
title="Delete Library"
description="Deleting a library will permanently the database, the files themselves will not be deleted."
ctaAction={() => {
deleteLib(props.library.uuid);
}}
loading={libDeletePending}
ctaDanger
ctaLabel="Delete"
trigger={
<Button variant="gray" className="!p-1.5">
<TrashIcon className="w-4 h-4" />
</Button>
}
/>
<div className="mt-2 space-x-2">
<Button variant="gray" className="!p-1.5" onClick={handleEditLibrary}>
<PencilIcon className="w-4 h-4" />
</Button>
<DeleteLibraryDialog libraryUuid={props.library.uuid}>
<Button variant="gray" className="!p-1.5">
<TrashIcon className="w-4 h-4" />
</Button>
</DeleteLibraryDialog>
</div>
</Card>
);
}
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 (
<SettingsContainer>
@@ -69,28 +64,11 @@ export default function LibrarySettings() {
description="The database contains all library data and file metadata."
rightArea={
<div className="flex-row space-x-2">
<Dialog
open={openCreateModal}
onOpenChange={setOpenCreateModal}
title="Create New Library"
description="Choose a name for your new library, you can configure this and more settings from the library settings later on."
ctaAction={() => createLibrary(newLibName)}
loading={createLibLoading}
submitDisabled={!newLibName}
ctaLabel="Create"
trigger={
<Button variant="primary" size="sm">
Add Library
</Button>
}
>
<Input
className="flex-grow w-full mt-3"
value={newLibName}
placeholder="My Cool Library"
onChange={(e) => setNewLibName(e.target.value)}
/>
</Dialog>
<CreateLibraryDialog>
<Button variant="primary" size="sm">
Add Library
</Button>
</CreateLibraryDialog>
</div>
}
/>

View File

@@ -150,4 +150,4 @@ body {
left: 0;
border-radius: 9px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
}

View File

View File

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

View File

@@ -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<HTMLDivElement>;
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<ContextMenuProps> = (props) => {
const { items: sections = [], className, ...rest } = props;
const { items: sections = [], className, isChild, ...rest } = props;
const ContentPrimitive = isChild ? ContextMenuPrimitive.SubContent : ContextMenuPrimitive.Content;
return (
<ContextMenuPrimitive.Content
<ContentPrimitive
sideOffset={7}
// onInteractOutside={(e) => {
// 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<ContextMenuProps> = (props) => {
{sections.map((sec, i) => (
<React.Fragment key={i}>
{i !== 0 && (
<ContextMenuPrimitive.Separator className="border-0 border-b border-b-gray-300 dark:border-b-gray-550 mx-2" />
<ContextMenuPrimitive.Separator className="mx-2 border-0 border-b pointer-events-none border-b-gray-300 dark:border-b-gray-600" />
)}
<ContextMenuPrimitive.Group className="flex items-stretch flex-col gap-0.5">
<ContextMenuPrimitive.Group className="flex flex-col items-stretch">
{sec.map((item) => {
if (typeof item === 'string')
return (
<ContextMenuPrimitive.Label
key={item}
className="text-xs ml-2 mt-1 uppercase text-gray-400"
className="mt-1 ml-2 text-xs text-gray-400 uppercase"
>
{item}
</ContextMenuPrimitive.Label>
);
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) => (
<ContextMenuPrimitive.Root>
<ContextMenuPrimitive.Trigger {...props}>
{props.children}
</ContextMenuPrimitive.Trigger>
ItemComponent = (({ children, ref, ...props }) => (
<ContextMenuPrimitive.ContextMenuSub>
<ContextMenuPrimitive.SubTrigger {...props}>
{children}
</ContextMenuPrimitive.SubTrigger>
<ContextMenu items={item.children} className="relative -left-1 -top-2" />
</ContextMenuPrimitive.Root>
<ContextMenu
isChild
items={item.children}
className="relative -left-1 -top-2"
/>
</ContextMenuPrimitive.ContextMenuSub>
)) as typeof ContextMenuPrimitive.Trigger;
return (
@@ -74,21 +89,28 @@ export const ContextMenu: React.FC<ContextMenuProps> = (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}
>
<div className="px-1.5 py-[0.4em] group-focus:bg-gray-150 group-hover:bg-gray-150 dark:group-focus:bg-gray-550 dark:group-hover:bg-gray-550 flex flex-row gap-2.5 items-center rounded-sm">
{<ItemIcon size={18} />}
<div
className={clsx(
'flex py-[0.3em] flex-row items-center px-1 rounded group-focus:bg-primary group-hover:bg-primary',
item.danger &&
'group-focus:bg-red-500 group-hover:bg-red-500 group-focus:text-white group-hover:text-white'
)}
>
{ItemIcon && <ItemIcon size={18} />}
{item.leftItem}
<ContextMenuPrimitive.Label className="leading-snug flex-grow text-[14px] font-normal">
<ContextMenuPrimitive.Label className="ml-1.5 leading-snug flex-grow text-sm font-normal">
{item.label}
</ContextMenuPrimitive.Label>
{item.rightItem}
{(item.children?.length ?? 0) > 0 && (
<CaretRight weight="fill" size={12} alt="" />
)}
@@ -99,7 +121,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
</ContextMenuPrimitive.Group>
</React.Fragment>
))}
</ContextMenuPrimitive.Content>
</ContentPrimitive>
);
};

View File

@@ -1,4 +1,3 @@
import { ViewListIcon } from '@heroicons/react/solid';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';

View File

@@ -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<DropdownProps> = (props) => {
<div key={index} className="px-1 py-1 space-y-[2px]">
{item.map((button, index) => (
<Menu.Item key={index}>
{({ active }) => (
<button
onClick={button.onPress}
className={clsx(
'text-sm group flex grow shrink-0 rounded items-center w-full whitespace-nowrap px-2 py-1 mb-[2px] dark:hover:bg-gray-500',
{
'bg-gray-300 dark:!bg-gray-500 dark:hover:bg-gray-500': button.selected
// 'text-gray-900 dark:text-gray-200': !active
},
props.itemButtonClassName
)}
>
{button.icon && (
<button.icon
className={clsx('mr-2 w-4 h-4', {
'dark:text-gray-100': active,
'text-gray-600 dark:text-gray-200': !active
})}
/>
)}
<span className="text-left">{button.name}</span>
</button>
)}
{({ active }) => {
const WrappedItem = button.wrapItemComponent
? button.wrapItemComponent
: (props: React.PropsWithChildren) => <>{props.children}</>;
return (
<WrappedItem>
<button
onClick={button.onPress}
className={clsx(
'text-sm group flex grow shrink-0 rounded items-center w-full whitespace-nowrap px-2 py-1 mb-[2px] dark:hover:bg-gray-500',
{
'bg-gray-300 dark:!bg-gray-500 dark:hover:bg-gray-500':
button.selected
// 'text-gray-900 dark:text-gray-200': !active
},
props.itemButtonClassName
)}
>
{button.icon && (
<button.icon
className={clsx('mr-2 w-4 h-4', {
'dark:text-gray-100': active,
'text-gray-600 dark:text-gray-200': !active
})}
/>
)}
<span className="text-left">{button.name}</span>
</button>
</WrappedItem>
);
}}
</Menu.Item>
))}
</div>

View File

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

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.