Bunch 'O Features (#336)

* CRUD for tags.

* Implement tags query in open.rs and make some changes to CRUD.

* Tag update.

* Hopefully working get tags.

* added node config + spaces schema

* add missing routes

* begin tag ui

* renaming query names to better fit convention

* tags progress

* tag edit

* fix delete tag description

Co-authored-by: xPolar <polar@polar.blue>
This commit is contained in:
Jamie Pine 2022-07-17 20:45:04 -07:00 committed by GitHub
parent 77bf17e2be
commit 9961c49759
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 941 additions and 157 deletions

View file

@ -63,7 +63,7 @@
"windows": [
{
"title": "Spacedrive",
"width": 1200,
"width": 1400,
"height": 725,
"minWidth": 700,
"minHeight": 500,

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LibraryQuery } from "./LibraryQuery";
export type ClientQuery = { key: "NodeGetLibraries" } | { key: "NodeGetState" } | { key: "SysGetVolumes" } | { key: "JobGetRunning" } | { key: "GetNodes" } | { key: "LibraryQuery", params: { library_id: string, query: LibraryQuery, } };
export type ClientQuery = { key: "GetLibraries" } | { key: "GetNode" } | { key: "GetVolumes" } | { key: "GetNodes" } | { key: "LibraryQuery", params: { library_id: string, query: LibraryQuery, } };

View file

@ -2,5 +2,6 @@
import type { File } from "./File";
import type { JobReport } from "./JobReport";
import type { LocationResource } from "./LocationResource";
import type { Tag } from "./Tag";
export type CoreResource = "Client" | "Library" | { Location: LocationResource } | { File: File } | { Job: JobReport } | "Tag";
export type CoreResource = { key: "Client" } | { key: "Library" } | { key: "Location", data: LocationResource } | { key: "File", data: File } | { key: "Job", data: JobReport } | { key: "Tag", data: Tag };

View file

@ -5,6 +5,8 @@ import type { LibraryConfigWrapped } from "./LibraryConfigWrapped";
import type { LocationResource } from "./LocationResource";
import type { NodeState } from "./NodeState";
import type { Statistics } from "./Statistics";
import type { Tag } from "./Tag";
import type { TagWithFiles } from "./TagWithFiles";
import type { Volume } from "./Volume";
export type CoreResponse = { key: "Success", data: null } | { key: "Error", data: string } | { key: "NodeGetLibraries", data: Array<LibraryConfigWrapped> } | { key: "SysGetVolumes", data: Array<Volume> } | { key: "SysGetLocation", data: LocationResource } | { key: "SysGetLocations", data: Array<LocationResource> } | { key: "LibGetExplorerDir", data: DirectoryWithContents } | { key: "NodeGetState", data: NodeState } | { key: "LocCreate", data: LocationResource } | { key: "JobGetRunning", data: Array<JobReport> } | { key: "JobGetHistory", data: Array<JobReport> } | { key: "GetLibraryStatistics", data: Statistics };
export type CoreResponse = { key: "Success", data: null } | { key: "Error", data: string } | { key: "GetLibraries", data: Array<LibraryConfigWrapped> } | { key: "GetVolumes", data: Array<Volume> } | { key: "TagCreateResponse", data: Tag } | { key: "GetTag", data: Tag | null } | { key: "GetTags", data: Array<Tag> } | { key: "GetLocation", data: LocationResource } | { key: "GetLocations", data: Array<LocationResource> } | { key: "GetExplorerDir", data: DirectoryWithContents } | { key: "GetNode", data: NodeState } | { key: "LocCreate", data: LocationResource } | { key: "OpenTag", data: Array<TagWithFiles> } | { key: "GetRunningJobs", data: Array<JobReport> } | { key: "GetJobHistory", data: Array<JobReport> } | { key: "GetLibraryStatistics", data: Statistics };

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LibraryCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileSetFavorite", params: { id: number, favorite: boolean, } } | { key: "FileDelete", params: { id: number, } } | { key: "TagCreate", params: { name: string, color: string, } } | { key: "TagUpdate", params: { name: string, color: string, } } | { key: "TagAssign", params: { file_id: number, tag_id: number, } } | { key: "TagDelete", params: { id: number, } } | { key: "LocCreate", params: { path: string, } } | { key: "LocUpdate", params: { id: number, name: string | null, } } | { key: "LocDelete", params: { id: number, } } | { key: "LocFullRescan", params: { id: number, } } | { key: "LocQuickRescan", params: { id: number, } } | { key: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } };
export type LibraryCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileSetFavorite", params: { id: number, favorite: boolean, } } | { key: "FileDelete", params: { id: number, } } | { key: "TagCreate", params: { name: string, color: string, } } | { key: "TagUpdate", params: { id: number, name: string | null, color: string | null, } } | { key: "TagAssign", params: { file_id: number, tag_id: number, } } | { key: "TagDelete", params: { id: number, } } | { key: "LocCreate", params: { path: string, } } | { key: "LocUpdate", params: { id: number, name: string | null, } } | { key: "LocDelete", params: { id: number, } } | { key: "LocFullRescan", params: { id: number, } } | { key: "LocQuickRescan", params: { id: number, } } | { key: "VolUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } };

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LibraryQuery = { key: "LibGetTags" } | { key: "JobGetHistory" } | { key: "SysGetLocations" } | { key: "SysGetLocation", params: { id: number, } } | { key: "LibGetExplorerDir", params: { location_id: number, path: string, limit: number, } } | { key: "GetLibraryStatistics" };
export type LibraryQuery = { key: "GetJobHistory" } | { key: "GetLocations" } | { key: "GetLocation", params: { id: number, } } | { key: "GetRunningJobs" } | { key: "GetExplorerDir", params: { location_id: number, path: string, limit: number, } } | { key: "GetLibraryStatistics" } | { key: "GetTags" } | { key: "GetFilesTagged", params: { tag_id: number, } };

3
core/bindings/Tag.ts Normal file
View file

@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface Tag { id: number, pub_id: string, name: string | null, color: string | null, total_files: number | null, redundancy_goal: number | null, date_created: string, date_modified: string, }

View file

@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { File } from "./File";
import type { Tag } from "./Tag";
export interface TagOnFile { tag_id: number, tag: Tag | null, file_id: number, file: File | null, date_created: string, }

View file

@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Tag } from "./Tag";
import type { TagOnFile } from "./TagOnFile";
export interface TagWithFiles { tag: Tag, files_with_tag: Array<TagOnFile>, }

View file

@ -24,4 +24,7 @@ export * from './bindings/NodeConfig';
export * from './bindings/NodeState';
export * from './bindings/Platform';
export * from './bindings/Statistics';
export * from './bindings/Tag';
export * from './bindings/TagOnFile';
export * from './bindings/TagWithFiles';
export * from './bindings/Volume';

View file

@ -0,0 +1,62 @@
/*
Warnings:
- You are about to drop the `label_on_files` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `tags_on_files` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "label_on_files";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "tags_on_files";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "tags_on_file" (
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tag_id" INTEGER NOT NULL,
"file_id" INTEGER NOT NULL,
PRIMARY KEY ("tag_id", "file_id"),
CONSTRAINT "tags_on_file_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "files" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "tags_on_file_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
);
-- CreateTable
CREATE TABLE "label_on_file" (
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"label_id" INTEGER NOT NULL,
"file_id" INTEGER NOT NULL,
PRIMARY KEY ("label_id", "file_id"),
CONSTRAINT "label_on_file_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "files" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "label_on_file_label_id_fkey" FOREIGN KEY ("label_id") REFERENCES "labels" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
);
-- CreateTable
CREATE TABLE "spaces" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pub_id" TEXT NOT NULL,
"name" TEXT,
"description" TEXT,
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date_modified" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "file_in_space" (
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"space_id" INTEGER NOT NULL,
"file_id" INTEGER NOT NULL,
PRIMARY KEY ("space_id", "file_id"),
CONSTRAINT "file_in_space_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "files" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "file_in_space_space_id_fkey" FOREIGN KEY ("space_id") REFERENCES "spaces" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
);
-- CreateIndex
CREATE UNIQUE INDEX "spaces_pub_id_key" ON "spaces"("pub_id");

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tags" ADD COLUMN "color" TEXT;

View file

@ -133,6 +133,7 @@ model File {
tags TagOnFile[]
labels LabelOnFile[]
albums FileInAlbum[]
spaces FileInSpace[]
paths FilePath[]
comments Comment[]
media_data MediaData?
@ -227,6 +228,7 @@ model Tag {
id Int @id @default(autoincrement())
pub_id String @unique
name String?
color String?
total_files Int? @default(0)
redundancy_goal Int? @default(1)
date_created DateTime @default(now())
@ -246,7 +248,7 @@ model TagOnFile {
file File @relation(fields: [file_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
@@id([tag_id, file_id])
@@map("tags_on_files")
@@map("tags_on_file")
}
model Label {
@ -270,7 +272,32 @@ model LabelOnFile {
file File @relation(fields: [file_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
@@id([label_id, file_id])
@@map("label_on_files")
@@map("label_on_file")
}
model Space {
id Int @id @default(autoincrement())
pub_id String @unique
name String?
description String?
date_created DateTime @default(now())
date_modified DateTime @default(now())
files FileInSpace[]
@@map("spaces")
}
model FileInSpace {
date_created DateTime @default(now())
space_id Int
space Space @relation(fields: [space_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
file_id Int
file File @relation(fields: [file_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
@@id([space_id, file_id])
@@map("file_in_space")
}
model Job {

View file

@ -2,8 +2,9 @@ use crate::{
encode::THUMBNAIL_CACHE_DIR_NAME,
file::{DirectoryWithContents, FileError, FilePath},
library::LibraryContext,
prisma::file_path,
prisma::{file_path, tag, tag_on_file},
sys::get_location,
tag::{Tag, TagError, TagOnFile, TagWithFiles},
};
use std::path::Path;
@ -60,3 +61,29 @@ pub async fn open_dir(
contents: file_paths,
})
}
pub async fn open_tag(ctx: &LibraryContext, tag_id: i32) -> Result<TagWithFiles, TagError> {
let tag: Tag = ctx
.db
.tag()
.find_unique(tag::id::equals(tag_id))
.exec()
.await?
.ok_or_else(|| TagError::TagNotFound(tag_id))?
.into();
let files_with_tag: Vec<TagOnFile> = ctx
.db
.tag_on_file()
.find_many(vec![tag_on_file::tag_id::equals(tag_id)])
.exec()
.await?
.into_iter()
.map(Into::into)
.collect();
Ok(TagWithFiles {
tag,
files_with_tag,
})
}

View file

@ -158,7 +158,7 @@ pub async fn set_note(
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::LibGetExplorerDir {
query: LibraryQuery::GetExplorerDir {
limit: 0,
path: "".to_string(),
location_id: 0,
@ -185,7 +185,7 @@ pub async fn favorite(
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::LibGetExplorerDir {
query: LibraryQuery::GetExplorerDir {
limit: 0,
path: "".to_string(),
location_id: 0,

View file

@ -3,6 +3,7 @@ use super::{
Job, JobManager,
};
use crate::{library::LibraryContext, ClientQuery, CoreEvent, LibraryQuery};
use std::{sync::Arc, time::Duration};
use tokio::{
sync::{
@ -172,7 +173,10 @@ impl Worker {
}
}
ctx.emit(CoreEvent::InvalidateQueryDebounced(
ClientQuery::JobGetRunning,
ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::GetRunningJobs,
},
))
.await;
}
@ -180,12 +184,15 @@ impl Worker {
worker.job_report.status = JobStatus::Completed;
worker.job_report.update(&ctx).await.unwrap_or(());
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::JobGetRunning))
.await;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::GetRunningJobs,
}))
.await;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::JobGetHistory,
query: LibraryQuery::GetJobHistory,
}))
.await;
break;
@ -196,9 +203,10 @@ impl Worker {
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::JobGetHistory,
query: LibraryQuery::GetJobHistory,
}))
.await;
break;
}
}

View file

@ -8,6 +8,8 @@ use std::{
path::{Path, PathBuf},
sync::Arc,
};
use tag::{Tag, TagWithFiles};
use thiserror::Error;
use tokio::sync::{
mpsc::{self, unbounded_channel, UnboundedReceiver, UnboundedSender},
@ -24,6 +26,7 @@ mod library;
mod node;
mod prisma;
mod sys;
mod tag;
mod util;
// a wrapper around external input with a returning sender channel for core to respond
@ -235,15 +238,18 @@ impl Node {
CoreResponse::Success(())
}
// CRUD for tags
LibraryCommand::TagCreate { name: _, color: _ } => todo!(),
LibraryCommand::TagAssign {
file_id: _,
tag_id: _,
} => todo!(),
LibraryCommand::TagUpdate { name: _, color: _ } => todo!(),
LibraryCommand::TagDelete { id: _ } => todo!(),
LibraryCommand::TagCreate { name, color } => {
tag::create_tag(ctx, name, color).await?
}
LibraryCommand::TagAssign { file_id, tag_id } => {
tag::tag_assign(ctx, file_id, tag_id).await?
}
LibraryCommand::TagDelete { id } => tag::tag_delete(ctx, id).await?,
LibraryCommand::TagUpdate { id, name, color } => {
tag::update_tag(ctx, id, name, color).await?
}
// CRUD for libraries
LibraryCommand::SysVolumeUnmount { id: _ } => todo!(),
LibraryCommand::VolUnmount { id: _ } => todo!(),
LibraryCommand::GenerateThumbsForLocation { id, path } => {
ctx.spawn_job(Box::new(ThumbnailJob {
location_id: id,
@ -269,18 +275,15 @@ impl Node {
// query sources of data
async fn exec_query(&self, query: ClientQuery) -> Result<CoreResponse, CoreError> {
Ok(match query {
ClientQuery::NodeGetLibraries => CoreResponse::NodeGetLibraries(
self.library_manager.get_all_libraries_config().await,
),
ClientQuery::NodeGetState => CoreResponse::NodeGetState(NodeState {
ClientQuery::GetLibraries => {
CoreResponse::GetLibraries(self.library_manager.get_all_libraries_config().await)
}
ClientQuery::GetNode => CoreResponse::GetNode(NodeState {
config: self.config.get().await,
data_path: self.config.data_directory().to_str().unwrap().to_string(),
}),
ClientQuery::SysGetVolumes => CoreResponse::SysGetVolumes(sys::Volume::get_volumes()?),
ClientQuery::JobGetRunning => {
CoreResponse::JobGetRunning(self.jobs.get_running().await)
}
ClientQuery::GetNodes => todo!(),
ClientQuery::GetVolumes => CoreResponse::GetVolumes(sys::Volume::get_volumes()?),
ClientQuery::LibraryQuery { library_id, query } => {
let ctx = match self.library_manager.get_ctx(library_id.clone()).await {
Some(ctx) => ctx,
@ -290,28 +293,34 @@ impl Node {
}
};
match query {
LibraryQuery::SysGetLocations => {
CoreResponse::SysGetLocations(sys::get_locations(&ctx).await?)
LibraryQuery::GetLocations => {
CoreResponse::GetLocations(sys::get_locations(&ctx).await?)
}
LibraryQuery::GetRunningJobs => {
CoreResponse::GetRunningJobs(self.jobs.get_running().await)
}
// get location from library
LibraryQuery::SysGetLocation { id } => {
CoreResponse::SysGetLocation(sys::get_location(&ctx, id).await?)
LibraryQuery::GetLocation { id } => {
CoreResponse::GetLocation(sys::get_location(&ctx, id).await?)
}
// return contents of a directory for the explorer
LibraryQuery::LibGetExplorerDir {
LibraryQuery::GetExplorerDir {
path,
location_id,
limit: _,
} => CoreResponse::LibGetExplorerDir(
} => CoreResponse::GetExplorerDir(
file::explorer::open_dir(&ctx, &location_id, &path).await?,
),
LibraryQuery::LibGetTags => todo!(),
LibraryQuery::JobGetHistory => {
CoreResponse::JobGetHistory(JobManager::get_history(&ctx).await?)
LibraryQuery::GetJobHistory => {
CoreResponse::GetJobHistory(JobManager::get_history(&ctx).await?)
}
LibraryQuery::GetLibraryStatistics => CoreResponse::GetLibraryStatistics(
library::Statistics::calculate(&ctx).await?,
),
LibraryQuery::GetTags => tag::get_all_tags(ctx).await?,
LibraryQuery::GetFilesTagged { tag_id } => {
tag::get_files_for_tag(ctx, tag_id).await?
}
}
}
})
@ -347,27 +356,68 @@ pub enum ClientCommand {
#[ts(export)]
pub enum LibraryCommand {
// Files
FileReadMetaData { id: i32 },
FileSetNote { id: i32, note: Option<String> },
FileSetFavorite { id: i32, favorite: bool },
FileReadMetaData {
id: i32,
},
FileSetNote {
id: i32,
note: Option<String>,
},
FileSetFavorite {
id: i32,
favorite: bool,
},
// FileEncrypt { id: i32, algorithm: EncryptionAlgorithm },
FileDelete { id: i32 },
FileDelete {
id: i32,
},
// Tags
TagCreate { name: String, color: String },
TagUpdate { name: String, color: String },
TagAssign { file_id: i32, tag_id: i32 },
TagDelete { id: i32 },
TagCreate {
name: String,
color: String,
},
TagUpdate {
id: i32,
name: Option<String>,
color: Option<String>,
},
TagAssign {
file_id: i32,
tag_id: i32,
},
TagDelete {
id: i32,
},
// Locations
LocCreate { path: String },
LocUpdate { id: i32, name: Option<String> },
LocDelete { id: i32 },
LocFullRescan { id: i32 },
LocQuickRescan { id: i32 },
LocCreate {
path: String,
},
LocUpdate {
id: i32,
name: Option<String>,
},
LocDelete {
id: i32,
},
LocFullRescan {
id: i32,
},
LocQuickRescan {
id: i32,
},
// System
SysVolumeUnmount { id: i32 },
GenerateThumbsForLocation { id: i32, path: String },
VolUnmount {
id: i32,
},
GenerateThumbsForLocation {
id: i32,
path: String,
},
// PurgeDatabase,
IdentifyUniqueFiles { id: i32, path: String },
IdentifyUniqueFiles {
id: i32,
path: String,
},
}
/// is a query destined for the core
@ -375,10 +425,9 @@ pub enum LibraryCommand {
#[serde(tag = "key", content = "params")]
#[ts(export)]
pub enum ClientQuery {
NodeGetLibraries,
NodeGetState,
SysGetVolumes,
JobGetRunning,
GetLibraries,
GetNode,
GetVolumes,
GetNodes,
LibraryQuery {
library_id: String,
@ -391,18 +440,22 @@ pub enum ClientQuery {
#[serde(tag = "key", content = "params")]
#[ts(export)]
pub enum LibraryQuery {
LibGetTags,
JobGetHistory,
SysGetLocations,
SysGetLocation {
GetJobHistory,
GetLocations,
GetLocation {
id: i32,
},
LibGetExplorerDir {
GetRunningJobs,
GetExplorerDir {
location_id: i32,
path: String,
limit: i32,
},
GetLibraryStatistics,
GetTags,
GetFilesTagged {
tag_id: i32,
},
}
// represents an event this library can emit
@ -433,15 +486,19 @@ pub struct NodeState {
pub enum CoreResponse {
Success(()),
Error(String),
NodeGetLibraries(Vec<LibraryConfigWrapped>),
SysGetVolumes(Vec<sys::Volume>),
SysGetLocation(sys::LocationResource),
SysGetLocations(Vec<sys::LocationResource>),
LibGetExplorerDir(file::DirectoryWithContents),
NodeGetState(NodeState),
GetLibraries(Vec<LibraryConfigWrapped>),
GetVolumes(Vec<sys::Volume>),
TagCreateResponse(tag::Tag),
GetTag(Option<tag::Tag>),
GetTags(Vec<tag::Tag>),
GetLocation(sys::LocationResource),
GetLocations(Vec<sys::LocationResource>),
GetExplorerDir(file::DirectoryWithContents),
GetNode(NodeState),
LocCreate(sys::LocationResource),
JobGetRunning(Vec<JobReport>),
JobGetHistory(Vec<JobReport>),
OpenTag(Vec<tag::TagWithFiles>),
GetRunningJobs(Vec<JobReport>),
GetJobHistory(Vec<JobReport>),
GetLibraryStatistics(library::Statistics),
}
@ -462,6 +519,7 @@ pub enum CoreError {
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
#[serde(tag = "key", content = "data")]
#[ts(export)]
pub enum CoreResource {
Client,
@ -469,5 +527,5 @@ pub enum CoreResource {
Location(sys::LocationResource),
File(file::File),
Job(JobReport),
Tag,
Tag(tag::Tag),
}

View file

@ -138,7 +138,7 @@ impl LibraryManager {
self.libraries.write().await.push(library);
self.node_context
.emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries))
.emit(CoreEvent::InvalidateQuery(ClientQuery::GetLibraries))
.await;
Ok(())
@ -184,7 +184,7 @@ impl LibraryManager {
.await?;
self.node_context
.emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries))
.emit(CoreEvent::InvalidateQuery(ClientQuery::GetLibraries))
.await;
Ok(())
}
@ -209,7 +209,7 @@ impl LibraryManager {
libraries.retain(|l| l.id != id);
self.node_context
.emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries))
.emit(CoreEvent::InvalidateQuery(ClientQuery::GetLibraries))
.await;
Ok(())
}

View file

@ -212,7 +212,7 @@ pub async fn create_location(
Err(e) => Err(LocationError::DotfileWriteFailure(e, path.to_string()))?,
}
// ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations))
// ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::GetLocations))
// .await;
location
@ -239,7 +239,7 @@ pub async fn delete_location(ctx: &LibraryContext, location_id: i32) -> Result<(
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::SysGetLocations,
query: LibraryQuery::GetLocations,
}))
.await;

188
core/src/tag/mod.rs Normal file
View file

@ -0,0 +1,188 @@
use crate::{
file::File,
library::LibraryContext,
prisma::{
self, file,
tag::{self},
tag_on_file,
},
ClientQuery, CoreError, CoreEvent, CoreResponse, LibraryQuery,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Tag {
pub id: i32,
pub pub_id: String,
pub name: Option<String>,
pub color: Option<String>,
pub total_files: Option<i32>,
pub redundancy_goal: Option<i32>,
pub date_created: chrono::DateTime<chrono::Utc>,
pub date_modified: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TagOnFile {
pub tag_id: i32,
pub tag: Option<Tag>,
pub file_id: i32,
pub file: Option<File>,
pub date_created: chrono::DateTime<chrono::Utc>,
}
impl Into<Tag> for tag::Data {
fn into(self) -> Tag {
Tag {
id: self.id,
pub_id: self.pub_id,
name: self.name,
color: self.color,
total_files: self.total_files,
redundancy_goal: self.redundancy_goal,
date_created: self.date_created.into(),
date_modified: self.date_modified.into(),
}
}
}
impl Into<TagOnFile> for tag_on_file::Data {
fn into(self) -> TagOnFile {
TagOnFile {
tag_id: self.tag_id,
tag: self.tag.map(|t| (*t).into()),
file_id: self.file_id,
file: self.file.map(|f| (*f).into()),
date_created: self.date_created.into(),
}
}
}
#[derive(Serialize, Deserialize, TS, Debug)]
#[ts(export)]
pub struct TagWithFiles {
pub tag: Tag,
pub files_with_tag: Vec<TagOnFile>,
}
#[derive(Error, Debug)]
pub enum TagError {
#[error("Tag not found")]
TagNotFound(i32),
#[error("Database error")]
DatabaseError(#[from] prisma::QueryError),
}
pub async fn create_tag(
ctx: LibraryContext,
name: String,
color: String,
) -> Result<CoreResponse, CoreError> {
let created_tag = ctx
.db
.tag()
.create(
tag::pub_id::set(uuid::Uuid::new_v4().to_string()),
vec![tag::name::set(Some(name)), tag::color::set(Some(color))],
)
.exec()
.await
.unwrap();
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::GetTags,
}))
.await;
Ok(CoreResponse::TagCreateResponse(created_tag.into()))
}
pub async fn update_tag(
ctx: LibraryContext,
id: i32,
name: Option<String>,
color: Option<String>,
) -> Result<CoreResponse, CoreError> {
ctx.db
.tag()
.find_unique(tag::id::equals(id))
.update(vec![tag::name::set(name), tag::color::set(color)])
.exec()
.await
.unwrap();
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::GetTags,
}))
.await;
Ok(CoreResponse::Success(()))
}
pub async fn tag_assign(
ctx: LibraryContext,
file_id: i32,
tag_id: i32,
) -> Result<CoreResponse, CoreError> {
ctx.db.tag_on_file().create(
tag_on_file::tag::link(tag::UniqueWhereParam::IdEquals(tag_id)),
tag_on_file::file::link(file::UniqueWhereParam::IdEquals(file_id)),
vec![],
);
Ok(CoreResponse::Success(()))
}
pub async fn tag_delete(ctx: LibraryContext, id: i32) -> Result<CoreResponse, CoreError> {
ctx.db
.tag()
.find_unique(tag::id::equals(id))
.delete()
.exec()
.await?
.unwrap();
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(),
query: LibraryQuery::GetTags,
}))
.await;
Ok(CoreResponse::Success(()))
}
pub async fn get_files_for_tag(ctx: LibraryContext, id: i32) -> Result<CoreResponse, CoreError> {
let tag: Option<Tag> = ctx
.db
.tag()
.find_unique(tag::id::equals(id))
.exec()
.await?
.map(Into::into);
Ok(CoreResponse::GetTag(tag))
}
pub async fn get_all_tags(ctx: LibraryContext) -> Result<CoreResponse, CoreError> {
let tags: Vec<Tag> = ctx
.db
.tag()
.find_many(vec![])
.exec()
.await?
.into_iter()
.map(Into::into)
.collect();
Ok(CoreResponse::GetTags(tags))
}

View file

@ -51,7 +51,7 @@ export const useLibraryStore = create<LibraryStore>()(
// is memorized and can be used safely in any component
export const useCurrentLibrary = () => {
const { currentLibraryUuid, switchLibrary } = useLibraryStore();
const { data: libraries } = useBridgeQuery('NodeGetLibraries', undefined, {});
const { data: libraries } = useBridgeQuery('GetLibraries', undefined, {});
// memorize library to avoid re-running find function
const currentLibrary = useMemo(() => {

View file

@ -38,10 +38,12 @@
"phosphor-react": "^1.4.1",
"pretty-bytes": "^6.0.0",
"react": "^18.1.0",
"react-colorful": "^5.5.1",
"react-countup": "^6.2.0",
"react-dom": "^18.1.0",
"react-dropzone": "^14.2.1",
"react-error-boundary": "^3.1.4",
"react-hook-form": "^7.31.3",
"react-hotkeys-hook": "^3.4.6",
"react-json-view": "^1.21.3",
"react-loading-icons": "^1.1.0",

View file

@ -16,7 +16,7 @@ const queryClient = new QueryClient();
function RouterContainer(props: { props: AppProps }) {
useCoreEvents();
const [appProps, setAppProps] = useState(props.props);
const { data: client } = useBridgeQuery('NodeGetState');
const { data: client } = useBridgeQuery('GetNode');
useEffect(() => {
setAppProps({

View file

@ -17,6 +17,10 @@ import AppearanceSettings from './screens/settings/client/AppearanceSettings';
import ExtensionSettings from './screens/settings/client/ExtensionsSettings';
import GeneralSettings from './screens/settings/client/GeneralSettings';
import KeybindSettings from './screens/settings/client/KeybindSettings';
import PrivacySettings from './screens/settings/client/PrivacySettings';
import AboutSpacedrive from './screens/settings/info/AboutSpacedrive';
import Changelog from './screens/settings/info/Changelog';
import Support from './screens/settings/info/Support';
import ContactsSettings from './screens/settings/library/ContactsSettings';
import KeysSettings from './screens/settings/library/KeysSetting';
import LibraryGeneralSettings from './screens/settings/library/LibraryGeneralSettings';
@ -34,7 +38,7 @@ export function AppRouter() {
let location = useLocation();
let state = location.state as { backgroundLocation?: Location };
const libraryState = useLibraryStore();
const { data: libraries } = useBridgeQuery('NodeGetLibraries');
const { data: libraries } = useBridgeQuery('GetLibraries');
// TODO: This can be removed once we add a setup flow to the app
useEffect(() => {
@ -79,6 +83,10 @@ export function AppRouter() {
<Route path="tags" element={<TagsSettings />} />
<Route path="nodes" element={<NodesSettings />} />
<Route path="keys" element={<KeysSettings />} />
<Route path="privacy" element={<PrivacySettings />} />
<Route path="about" element={<AboutSpacedrive />} />
<Route path="changelog" element={<Changelog />} />
<Route path="support" element={<Support />} />
</Route>
<Route path="explorer/:id" element={<ExplorerScreen />} />
<Route path="tag/:id" element={<TagScreen />} />

View file

@ -21,7 +21,7 @@ interface IColumn {
const PADDING_SIZE = 130;
// Function ensure no types are loss, but guarantees that they are Column[]
// Function ensure no types are lost, but guarantees that they are Column[]
function ensureIsColumns<T extends IColumn[]>(data: T) {
return data;
}
@ -48,20 +48,19 @@ const GridItemContainer = styled.div`
`;
export const FileList: React.FC<{ location_id: number; path: string; limit: number }> = (props) => {
const path = props.path;
const size = useWindowSize();
const tableContainer = useRef<null | HTMLDivElement>(null);
const VList = useRef<null | VirtuosoHandle>(null);
const { data: client } = useBridgeQuery('NodeGetState', undefined, {
const { data: client } = useBridgeQuery('GetNode', undefined, {
refetchOnWindowFocus: false
});
const path = props.path;
const { selectedRowIndex, setSelectedRowIndex, setLocationId, layoutMode } = useExplorerStore();
const [goingUp, setGoingUp] = useState(false);
const { data: currentDir } = useLibraryQuery('LibGetExplorerDir', {
const { data: currentDir } = useLibraryQuery('GetExplorerDir', {
location_id: props.location_id,
path,
limit: props.limit
@ -124,11 +123,7 @@ export const FileList: React.FC<{ location_id: number; path: string; limit: numb
);
return (
<div
ref={tableContainer}
style={{ marginTop: -44 }}
className="w-full pl-2 bg-white cursor-default dark:bg-gray-600"
>
<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 }}
>

View file

@ -22,7 +22,7 @@ export const SidebarLink = (props: NavLinkProps & { children: React.ReactNode })
{({ isActive }) => (
<span
className={clsx(
'max-w mb-[2px] text-gray-550 dark:text-gray-150 rounded px-2 py-1 flex flex-row flex-grow items-center font-medium text-sm',
'max-w mb-[2px] text-gray-550 dark:text-gray-300 rounded px-2 py-1 flex flex-row flex-grow items-center font-medium text-sm',
{
'!bg-primary !text-white hover:bg-primary dark:hover:bg-primary': isActive
},
@ -81,7 +81,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const appProps = useContext(AppPropsContext);
const { data: locationsResponse, isError: isLocationsError } = useLibraryQuery('SysGetLocations');
const { data: locationsResponse, isError: isLocationsError } = useLibraryQuery('GetLocations');
let locations = Array.isArray(locationsResponse) ? locationsResponse : [];
@ -96,13 +96,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { mutate: createLocation } = useLibraryCommand('LocCreate');
const tags = [
{ id: 1, name: 'Keepsafe', color: '#FF6788' },
{ id: 2, name: 'OBS', color: '#BF88FF' },
{ id: 3, name: 'BlackMagic', color: '#F0C94A' },
{ id: 4, name: 'Camera Roll', color: '#00F0DB' },
{ id: 5, name: 'Spacedrive', color: '#00F079' }
];
const { data: tags } = useLibraryQuery('GetTags');
return (
<div
@ -192,15 +186,6 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<Icon component={PhotographIcon} />
Photos
</SidebarLink>
{isExperimental ? (
<SidebarLink to="debug">
<Icon component={Code} />
Debug
</SidebarLink>
) : (
<></>
)}
</div>
<div>
<Heading>Locations</Heading>
@ -256,11 +241,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<div>
<Heading>Tags</Heading>
<div className="mb-2">
{tags.map((tag, index) => (
{tags?.slice(0, 6).map((tag, index) => (
<SidebarLink key={index} to={`tag/${tag.id}`} className="">
<div
className="w-[12px] h-[12px] rounded-full"
style={{ backgroundColor: tag.color }}
style={{ backgroundColor: tag.color || '#efefef' }}
/>
<span className="ml-2 text-sm">{tag.name}</span>
</SidebarLink>

View file

@ -51,7 +51,7 @@ const MiddleTruncatedText = ({
};
export default function RunningJobsWidget() {
const { data: jobs } = useBridgeQuery('JobGetRunning');
const { data: jobs } = useBridgeQuery('GetRunningJobs');
return (
<div className="flex flex-col space-y-4">

View file

@ -31,19 +31,27 @@ export interface TopBarButtonProps
}
interface SearchBarProps extends DefaultProps {}
const TopBarButton: React.FC<TopBarButtonProps> = ({ icon: Icon, ...props }) => {
const TopBarButton: React.FC<TopBarButtonProps> = ({
icon: Icon,
left,
right,
group,
active,
className,
...props
}) => {
return (
<button
{...props}
className={clsx(
'mr-[1px] py-0.5 px-0.5 text-md font-medium hover:bg-gray-150 dark:transparent dark:hover:bg-gray-550 rounded-md transition-colors duration-100',
{
'rounded-r-none rounded-l-none': props.group && !props.left && !props.right,
'rounded-r-none': props.group && props.left,
'rounded-l-none': props.group && props.right,
'dark:bg-gray-550': props.active
'rounded-r-none rounded-l-none': group && !left && !right,
'rounded-r-none': group && left,
'rounded-l-none': group && right,
'dark:bg-gray-550': active
},
props.className
className
)}
>
<Icon weight={'regular'} className="m-0.5 w-5 h-5 text-gray-450 dark:text-gray-150" />

View file

@ -17,7 +17,7 @@ export const InputContainer: React.FC<InputContainerProps> = (props) => {
className={clsx('flex flex-col w-full', !props.mini && 'pb-6', props.className)}
{...props}
>
<h3 className="mb-1 font-medium text-gray-700 dark:text-gray-100">{props.title}</h3>
<h3 className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">{props.title}</h3>
{!!props.description && <p className="mb-2 text-sm text-gray-400 ">{props.description}</p>}
{!props.mini && props.children}
</div>

View file

@ -0,0 +1,40 @@
import clsx from 'clsx';
import React, { useCallback, useRef, useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import useClickOutside from '../../hooks/useClickOutside';
interface PopoverPickerProps {
value: string;
onChange: (color: string) => void;
className?: string;
}
export const PopoverPicker = ({ value, onChange, className }: PopoverPickerProps) => {
const popover = useRef<HTMLDivElement | null>(null);
const [isOpen, toggle] = useState(false);
const close = useCallback(() => toggle(false), []);
useClickOutside(popover, close);
return (
<div className={clsx('relative flex items-center mt-3', className)}>
<div
className={clsx('w-5 h-5 rounded-full shadow ', isOpen && 'dark:border-gray-500')}
style={{ backgroundColor: value }}
onClick={() => toggle(true)}
/>
{/* <span className="inline ml-2 text-sm text-gray-200">Pick Color</span> */}
{isOpen && (
<div
style={{ top: 'calc(100% + 7px)' }}
className="absolute left-0 rounded-md shadow"
ref={popover}
>
<HexColorPicker color={value} onChange={onChange} />
</div>
)}
</div>
);
};

View file

@ -0,0 +1,35 @@
import { useEffect } from 'react';
// Improved version of https://usehooks.com/useOnClickOutside/
const useClickOutside = (ref: any, handler: any) => {
useEffect(() => {
let startedInside = false;
let startedWhenMounted = false;
const listener = (event: any) => {
// Do nothing if `mousedown` or `touchstart` started inside ref element
if (startedInside || !startedWhenMounted) return;
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target)) return;
handler(event);
};
const validateEventStart = (event: any) => {
startedWhenMounted = ref.current;
startedInside = ref.current && ref.current.contains(event.target);
};
document.addEventListener('mousedown', validateEventStart);
document.addEventListener('touchstart', validateEventStart);
document.addEventListener('click', listener);
return () => {
document.removeEventListener('mousedown', validateEventStart);
document.removeEventListener('touchstart', validateEventStart);
document.removeEventListener('click', listener);
};
}, [ref, handler]);
};
export default useClickOutside;

View file

@ -7,10 +7,10 @@ import CodeBlock from '../components/primitive/Codeblock';
export const DebugScreen: React.FC<{}> = (props) => {
const appPropsContext = useContext(AppPropsContext);
const { data: nodeState } = useBridgeQuery('NodeGetState');
const { data: libraryState } = useBridgeQuery('NodeGetLibraries');
const { data: jobs } = useBridgeQuery('JobGetRunning');
const { data: jobHistory } = useLibraryQuery('JobGetHistory');
const { data: nodeState } = useBridgeQuery('GetNode');
const { data: libraryState } = useBridgeQuery('GetLibraries');
const { data: jobs } = useBridgeQuery('GetRunningJobs');
const { data: jobHistory } = useLibraryQuery('GetJobHistory');
// const { mutate: purgeDB } = useBridgeCommand('PurgeDatabase', {
// onMutate: () => {
// alert('Database purged');

View file

@ -19,17 +19,17 @@ export const ExplorerScreen: React.FC<{}> = () => {
const { selectedRowIndex } = useExplorerStore();
// Current Location
const { data: currentLocation } = useLibraryQuery('SysGetLocation', { id: location_id });
const { data: currentLocation } = useLibraryQuery('GetLocation', { id: location_id });
// Current Directory
const { data: currentDir } = useLibraryQuery(
'LibGetExplorerDir',
'GetExplorerDir',
{ location_id: location_id!, path, limit },
{ enabled: !!location_id }
);
return (
<div className="relative flex flex-col w-full bg-gray-600">
<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} />

View file

@ -102,7 +102,7 @@ const StatItem: React.FC<StatItemProps> = (props) => {
export const OverviewScreen = () => {
const { data: libraryStatistics, isLoading: isStatisticsLoading } =
useLibraryQuery('GetLibraryStatistics');
const { data: nodeState } = useBridgeQuery('NodeGetState');
const { data: nodeState } = useBridgeQuery('GetNode');
const { overviewStats, setOverviewStats } = useOverviewState();

View file

@ -1,4 +1,5 @@
import {
AnnotationIcon,
CogIcon,
CollectionIcon,
DatabaseIcon,
@ -8,12 +9,17 @@ import {
KeyIcon,
LibraryIcon,
LightBulbIcon,
LockClosedIcon,
ShieldCheckIcon,
SparklesIcon,
TagIcon,
TerminalIcon
} from '@heroicons/react/outline';
import {
BookOpen,
Cloud,
FlyingSaucer,
HandWaving,
HardDrive,
Hash,
Info,
@ -21,6 +27,7 @@ import {
PaintBrush,
PuzzlePiece,
ShareNetwork,
Shield,
UsersFour
} from 'phosphor-react';
import React from 'react';
@ -44,6 +51,10 @@ export const SettingsScreen: React.FC = () => {
<SettingsIcon component={CollectionIcon} />
Libraries
</SidebarLink>
<SidebarLink to="/settings/privacy">
<SettingsIcon component={ShieldCheckIcon} />
Privacy
</SidebarLink>
<SidebarLink to="/settings/appearance">
<SettingsIcon component={PaintBrush} />
Appearance
@ -86,7 +97,7 @@ export const SettingsScreen: React.FC = () => {
<SettingsIcon component={ShareNetwork} />
Sync
</SidebarLink> */}
<SettingsHeading>Advanced</SettingsHeading>
{/* <SettingsHeading>Advanced</SettingsHeading>
<SidebarLink to="/settings/p2p">
<SettingsIcon component={ShareNetwork} />
Networking
@ -94,15 +105,15 @@ export const SettingsScreen: React.FC = () => {
<SidebarLink to="/settings/experimental">
<SettingsIcon component={TerminalIcon} />
Developer
</SidebarLink>
</SidebarLink> */}
<SettingsHeading>Resources</SettingsHeading>
<SidebarLink to="/settings/about">
<SettingsIcon component={BookOpen} />
<SettingsIcon component={FlyingSaucer} />
About
</SidebarLink>
<SidebarLink to="/settings/changelog">
<SettingsIcon component={LightBulbIcon} />
<SettingsIcon component={AnnotationIcon} />
Changelog
</SidebarLink>
<SidebarLink to="/settings/support">

View file

@ -58,7 +58,7 @@ function ExtensionItem(props: { extension: ExtensionItemData }) {
}
export default function ExtensionSettings() {
// const { data: volumes } = useBridgeQuery('SysGetVolumes');
// const { data: volumes } = useBridgeQuery('GetVolumes');
return (
<SettingsContainer>

View file

@ -1,10 +1,15 @@
import { useBridgeQuery } from '@sd/client';
import { Input } from '@sd/ui';
import { Database } from 'phosphor-react';
import React from 'react';
import Card from '../../../components/layout/Card';
import { Toggle } from '../../../components/primitive';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function GeneralSettings() {
// const { data: volumes } = useBridgeQuery('SysGetVolumes');
const { data: node } = useBridgeQuery('GetNode');
return (
<SettingsContainer>
@ -12,24 +17,53 @@ export default function GeneralSettings() {
title="General Settings"
description="General settings related to this client."
/>
{/* <InputContainer title="Volumes" description="A list of volumes running on this device.">
<div className="flex flex-row space-x-2">
<div className="flex flex-grow">
<Listbox
options={
volumes?.map((volume) => {
const name = volume.name && volume.name.length ? volume.name : volume.mount_point;
return {
key: name,
option: name,
description: volume.mount_point
};
}) ?? []
}
/>
<Card className="px-5 dark:bg-gray-600">
<div className="flex flex-col w-full my-2">
<div className="flex">
<span className="font-semibold">Connected Node</span>
<div className="flex-grow" />
<div className="space-x-2">
<span className="px-2 py-[2px] rounded text-xs font-medium bg-gray-500">0 Peers</span>
<span className="px-1.5 py-[2px] rounded text-xs font-medium bg-primary-600">
Running
</span>
</div>
</div>
<hr className="mt-2 mb-4 border-gray-500 " />
<div className="flex flex-row space-x-4">
<div className="flex flex-col">
<span className="mb-1 text-xs font-medium text-gray-700 dark:text-gray-100">
Node Name
</span>
<Input value={node?.name} />
</div>
<div className="flex flex-col w-[100px]">
<span className="mb-1 text-xs font-medium text-gray-700 dark:text-gray-100">
Node Port
</span>
<Input contentEditable={false} value={node?.p2p_port || 5795} />
</div>
<div className="flex flex-col w-[295px]">
<span className="mb-1 text-xs font-medium text-gray-700 dark:text-gray-100">
Node ID
</span>
<Input contentEditable={false} value={node?.id} />
</div>
</div>
<div className="flex items-center mt-5 space-x-3">
<Toggle size="sm" value />
<span className="text-sm text-gray-200">Run daemon when app closed</span>
</div>
<div className="mt-3">
<span className="text-xs font-medium text-gray-700 dark:text-gray-400">
<Database className="inline w-4 h-4 mr-2 -mt-[2px]" />
<b className="mr-2">Data Folder</b>
{node?.data_path}
</span>
</div>
</div>
</InputContainer> */}
</Card>
</SettingsContainer>
);
}

View file

@ -0,0 +1,14 @@
import React from 'react';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function PrivacySettings() {
return (
<SettingsContainer>
<SettingsHeader title="Privacy" description="How Spacedrive handles your data" />
</SettingsContainer>
);
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function AboutSpacedrive() {
return (
<SettingsContainer>
<SettingsHeader title="About Spacedrive" description="The file manager from the future." />
<span>Version {}</span>
<div className="flex flex-col">
<span className="mb-1 text-sm font-bold">Created by</span>
<span className="max-w-md text-xs text-gray-400">
Jamie Pine, Brendan Allan, Oscar Beaumont, Haden Fletcher, Haris Mehrzad Benjamin Akar,
and many more.
</span>
</div>
</SettingsContainer>
);
}

View file

@ -0,0 +1,14 @@
import React from 'react';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function Changelog() {
return (
<SettingsContainer>
<SettingsHeader title="Changelog" description="See what cool new features we're making" />
</SettingsContainer>
);
}

View file

@ -0,0 +1,14 @@
import React from 'react';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function Support() {
return (
<SettingsContainer>
<SettingsHeader title="Support" description="" />
</SettingsContainer>
);
}

View file

@ -64,7 +64,7 @@ export default function LibraryGeneralSettings() {
/>
<div className="flex flex-row pb-3 space-x-5">
<div className="flex flex-col flex-grow">
<span className="mb-1 font-medium text-gray-700 dark:text-gray-100">Name</span>
<span className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">Name</span>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
@ -72,7 +72,9 @@ export default function LibraryGeneralSettings() {
/>
</div>
<div className="flex flex-col flex-grow">
<span className="mb-1 font-medium text-gray-700 dark:text-gray-100">Description</span>
<span className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">
Description
</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}

View file

@ -16,7 +16,7 @@ import { SettingsHeader } from '../../../components/settings/SettingsHeader';
// ];
export default function LocationSettings() {
const { data: locations } = useLibraryQuery('SysGetLocations');
const { data: locations } = useLibraryQuery('GetLocations');
const appProps = useContext(AppPropsContext);

View file

@ -1,12 +1,186 @@
import React from 'react';
import { TrashIcon } from '@heroicons/react/outline';
import { useLibraryCommand, useLibraryQuery } from '@sd/client';
import { Button, Input } from '@sd/ui';
import clsx from 'clsx';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useDebounce } from 'rooks';
import Card from '../../../components/layout/Card';
import Dialog from '../../../components/layout/Dialog';
import { Toggle } from '../../../components/primitive';
import { InputContainer } from '../../../components/primitive/InputContainer';
import { PopoverPicker } from '../../../components/primitive/PopoverPicker';
import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function TagsSettings() {
const [openCreateModal, setOpenCreateModal] = useState(false);
// creating new tag state
const [newColor, setNewColor] = useState('#A717D9');
const [newName, setNewName] = useState('');
const { data: tags } = useLibraryQuery('GetTags');
const [selectedTag, setSelectedTag] = useState<null | number>(null);
const currentTag = useMemo(() => {
return tags?.find((t) => t.id === selectedTag);
}, [tags, selectedTag]);
const { mutate: createTag, isLoading } = useLibraryCommand('TagCreate', {
onError: (e) => {
console.log('error', e);
},
onSuccess: (data) => {
setOpenCreateModal(false);
}
});
const { mutate: updateTag, isLoading: tagUpdateLoading } = useLibraryCommand('TagUpdate');
const { mutate: deleteTag, isLoading: tagDeleteLoading } = useLibraryCommand('TagDelete');
// set default selected tag
useEffect(() => {
if (!currentTag && tags?.length) {
setSelectedTag(tags[0].id);
}
}, [tags]);
useEffect(() => {
reset(currentTag);
}, [currentTag]);
const { register, handleSubmit, watch, reset, control } = useForm({ defaultValues: currentTag });
const submitTagUpdate = handleSubmit((data) => updateTag(data));
const autoUpdateTag = useCallback(useDebounce(submitTagUpdate, 500), []);
useEffect(() => {
const subscription = watch(() => autoUpdateTag());
return () => subscription.unsubscribe();
});
return (
<SettingsContainer>
<SettingsHeader title="Tags" description="Manage your tags." />
<SettingsHeader
title="Tags"
description="Manage your tags."
rightArea={
<div className="flex-row space-x-2">
<Dialog
open={openCreateModal}
onOpenChange={setOpenCreateModal}
title="Create New Tag"
description="Choose a name and color."
ctaAction={() => {
createTag({
name: newName,
color: newColor
});
}}
loading={isLoading}
ctaLabel="Create"
trigger={
<Button variant="primary" size="sm">
Create Tag
</Button>
}
>
<div className="relative mt-3 ">
<PopoverPicker
className="!absolute left-[9px] -top-[3px]"
value={newColor}
onChange={setNewColor}
/>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full pl-[40px]"
placeholder="Name"
/>
</div>
</Dialog>
</div>
}
/>
<Card className="!px-2 dark:bg-gray-800">
<div className="flex flex-wrap gap-2 m-1">
{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>
</Card>
{currentTag ? (
<form onSubmit={submitTagUpdate}>
<div className="flex flex-row mb-10 space-x-3">
<div className="flex flex-col">
<span className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">
Color
</span>
<div className="relative">
<Controller
name="color"
control={control}
render={({ field: { onChange, value } }) => (
<PopoverPicker
className="!absolute left-[9px] -top-[3px]"
value={value || ''}
onChange={onChange}
/>
)}
/>
<Input className="w-28 pl-[40px]" {...register('color')} />
</div>
</div>
<div className="flex flex-col ">
<span className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">
Name
</span>
<Input {...register('name')} />
</div>
<div className="flex flex-grow"></div>
<Dialog
title="Delete Tag"
description="Are you sure you want to delete this tag? This cannot be undone and tagged files will be unlinked."
ctaAction={() => {
deleteTag({ id: currentTag.id });
}}
loading={tagDeleteLoading}
ctaDanger
ctaLabel="Delete"
trigger={
<Button variant="gray" className="h-[38px] mt-[22px]">
<TrashIcon className="w-4 h-4" />
</Button>
}
/>
</div>
<InputContainer
mini
title="Show in Spaces"
description="Show this tag on the spaces screen."
>
<Toggle value />
</InputContainer>
</form>
) : (
<div className="text-sm font-medium text-gray-400">No Tag Selected</div>
)}
</SettingsContainer>
);
}

View file

@ -65,7 +65,7 @@ export default function LibrarySettings() {
}
});
const { data: libraries } = useBridgeQuery('NodeGetLibraries');
const { data: libraries } = useBridgeQuery('GetLibraries');
function createNewLib() {
if (newLibName) {

View file

@ -130,3 +130,24 @@ body {
.dialog-content[data-state='closed'] {
animation: bounceDown 100ms ease-in forwards;
}
.picker {
position: relative;
}
.swatch {
width: 28px;
height: 28px;
border-radius: 8px;
border: 3px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
.popover {
position: absolute;
top: calc(100% + 2px);
left: 0;
border-radius: 9px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}

View file

@ -298,10 +298,12 @@ importers:
prettier: ^2.6.2
pretty-bytes: ^6.0.0
react: ^18.1.0
react-colorful: ^5.5.1
react-countup: ^6.2.0
react-dom: ^18.1.0
react-dropzone: ^14.2.1
react-error-boundary: ^3.1.4
react-hook-form: ^7.31.3
react-hotkeys-hook: ^3.4.6
react-json-view: ^1.21.3
react-loading-icons: ^1.1.0
@ -347,10 +349,12 @@ importers:
phosphor-react: 1.4.1_react@18.1.0
pretty-bytes: 6.0.0
react: 18.1.0
react-colorful: 5.5.1_ef5jwxihqo6n7gxfmzogljlgcm
react-countup: 6.2.0_react@18.1.0
react-dom: 18.1.0_react@18.1.0
react-dropzone: 14.2.1_react@18.1.0
react-error-boundary: 3.1.4_react@18.1.0
react-hook-form: 7.32.0_react@18.1.0
react-hotkeys-hook: 3.4.6_ef5jwxihqo6n7gxfmzogljlgcm
react-json-view: 1.21.3_ohobp6rpsmerwlq5ipwfh5yigy
react-loading-icons: 1.1.0
@ -13910,6 +13914,16 @@ packages:
react: 18.1.0
dev: false
/react-colorful/5.5.1_ef5jwxihqo6n7gxfmzogljlgcm:
resolution: {integrity: sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
dev: false
/react-countup/6.2.0_react@18.1.0:
resolution: {integrity: sha512-3WOKAQpWgjyFoH231SHEpIpHhDGb5g5EkTppM6T7vLa3X+8WMdw6750vVcY0wxysKiY00gTFhDwSB5qLU+xPZA==}
peerDependencies: