mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-03-10 10:36:25 -04:00
Merge branch 'main' into mobile-library
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sdcore::Node;
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"fullscreen": false,
|
||||
"alwaysOnTop": false,
|
||||
"focus": false,
|
||||
"visible": false,
|
||||
"fileDropEnabled": false,
|
||||
"decorations": true,
|
||||
"transparent": true,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 {}),
|
||||
))
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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: ()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
67
core/src/object/mod.rs
Normal 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,
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
3
docs/architecture/albums.md
Normal file
3
docs/architecture/albums.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Albums
|
||||
|
||||
you can put photos here
|
||||
@@ -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
|
||||
3
docs/architecture/explorer.md
Normal file
3
docs/architecture/explorer.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Explorer
|
||||
|
||||
using the interface, features
|
||||
@@ -0,0 +1,3 @@
|
||||
# Extensions
|
||||
|
||||
extended functionality of Spacedrive
|
||||
6
docs/architecture/filesystem.md
Normal file
6
docs/architecture/filesystem.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# sVFS???
|
||||
|
||||
sVFS is a decentralized virtual filesystem
|
||||
|
||||
*not sure what to call this yet*
|
||||
|
||||
@@ -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.
|
||||
4
docs/architecture/libraries.md
Normal file
4
docs/architecture/libraries.md
Normal 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.
|
||||
|
||||
3
docs/architecture/locations.md
Normal file
3
docs/architecture/locations.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Locations
|
||||
|
||||
indexing, identifying, watching, .spacedrive folder, online/offline
|
||||
3
docs/architecture/nodes.md
Normal file
3
docs/architecture/nodes.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Nodes
|
||||
|
||||
p2p, connecting nodes, protocols.
|
||||
32
docs/architecture/objects.md
Normal file
32
docs/architecture/objects.md
Normal 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 |
|
||||
|
||||
7
docs/architecture/preview-media.md
Normal file
7
docs/architecture/preview-media.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Preview Media
|
||||
|
||||
Spacedrive generates compressed preview media for images, videos and text files.
|
||||
|
||||
|
||||
|
||||
ffmpeg, syncing, security
|
||||
9
docs/architecture/search.md
Normal file
9
docs/architecture/search.md
Normal 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.
|
||||
9
docs/architecture/spaces.md
Normal file
9
docs/architecture/spaces.md
Normal 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
14
docs/architecture/sync.md
Normal 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.
|
||||
5
docs/architecture/tags.md
Normal file
5
docs/architecture/tags.md
Normal 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -35,6 +35,6 @@
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}));
|
||||
|
||||
16
packages/client/src/types/file.ts
Normal file
16
packages/client/src/types/file.ts
Normal 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[];
|
||||
}
|
||||
1
packages/client/src/types/index.ts
Normal file
1
packages/client/src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './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"],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
212
packages/interface/src/components/explorer/Explorer.tsx
Normal file
212
packages/interface/src/components/explorer/Explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
packages/interface/src/components/explorer/FileItem.tsx
Normal file
72
packages/interface/src/components/explorer/FileItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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':
|
||||
88
packages/interface/src/components/explorer/FileThumb.tsx
Normal file
88
packages/interface/src/components/explorer/FileThumb.tsx
Normal 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;
|
||||
}
|
||||
135
packages/interface/src/components/explorer/Inspector.tsx
Normal file
135
packages/interface/src/components/explorer/Inspector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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" />;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
packages/interface/src/components/explorer/utils.ts
Normal file
9
packages/interface/src/components/explorer/utils.ts
Normal 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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
@@ -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: '' })
|
||||
}
|
||||
]
|
||||
]}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
46
packages/interface/src/screens/LocationExplorer.tsx
Normal file
46
packages/interface/src/screens/LocationExplorer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
19
packages/interface/src/screens/TagExplorer.tsx
Normal file
19
packages/interface/src/screens/TagExplorer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -150,4 +150,4 @@ body {
|
||||
left: 0;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
0
packages/interface/src/util/debounce.ts
Normal file
0
packages/interface/src/util/debounce.ts
Normal 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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ViewListIcon } from '@heroicons/react/solid';
|
||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user