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": [ "windows": [
{ {
"title": "Spacedrive", "title": "Spacedrive",
"width": 1200, "width": 1400,
"height": 725, "height": 725,
"minWidth": 700, "minWidth": 700,
"minHeight": 500, "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. // 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"; 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 { File } from "./File";
import type { JobReport } from "./JobReport"; import type { JobReport } from "./JobReport";
import type { LocationResource } from "./LocationResource"; 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 { LocationResource } from "./LocationResource";
import type { NodeState } from "./NodeState"; import type { NodeState } from "./NodeState";
import type { Statistics } from "./Statistics"; import type { Statistics } from "./Statistics";
import type { Tag } from "./Tag";
import type { TagWithFiles } from "./TagWithFiles";
import type { Volume } from "./Volume"; 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. // 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. // 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/NodeState';
export * from './bindings/Platform'; export * from './bindings/Platform';
export * from './bindings/Statistics'; export * from './bindings/Statistics';
export * from './bindings/Tag';
export * from './bindings/TagOnFile';
export * from './bindings/TagWithFiles';
export * from './bindings/Volume'; 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[] tags TagOnFile[]
labels LabelOnFile[] labels LabelOnFile[]
albums FileInAlbum[] albums FileInAlbum[]
spaces FileInSpace[]
paths FilePath[] paths FilePath[]
comments Comment[] comments Comment[]
media_data MediaData? media_data MediaData?
@ -227,6 +228,7 @@ model Tag {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
pub_id String @unique pub_id String @unique
name String? name String?
color String?
total_files Int? @default(0) total_files Int? @default(0)
redundancy_goal Int? @default(1) redundancy_goal Int? @default(1)
date_created DateTime @default(now()) date_created DateTime @default(now())
@ -246,7 +248,7 @@ model TagOnFile {
file File @relation(fields: [file_id], references: [id], onDelete: NoAction, onUpdate: NoAction) file File @relation(fields: [file_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
@@id([tag_id, file_id]) @@id([tag_id, file_id])
@@map("tags_on_files") @@map("tags_on_file")
} }
model Label { model Label {
@ -270,7 +272,32 @@ model LabelOnFile {
file File @relation(fields: [file_id], references: [id], onDelete: NoAction, onUpdate: NoAction) file File @relation(fields: [file_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
@@id([label_id, file_id]) @@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 { model Job {

View file

@ -2,8 +2,9 @@ use crate::{
encode::THUMBNAIL_CACHE_DIR_NAME, encode::THUMBNAIL_CACHE_DIR_NAME,
file::{DirectoryWithContents, FileError, FilePath}, file::{DirectoryWithContents, FileError, FilePath},
library::LibraryContext, library::LibraryContext,
prisma::file_path, prisma::{file_path, tag, tag_on_file},
sys::get_location, sys::get_location,
tag::{Tag, TagError, TagOnFile, TagWithFiles},
}; };
use std::path::Path; use std::path::Path;
@ -60,3 +61,29 @@ pub async fn open_dir(
contents: file_paths, 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 { ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(), library_id: ctx.id.to_string(),
query: LibraryQuery::LibGetExplorerDir { query: LibraryQuery::GetExplorerDir {
limit: 0, limit: 0,
path: "".to_string(), path: "".to_string(),
location_id: 0, location_id: 0,
@ -185,7 +185,7 @@ pub async fn favorite(
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery { ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(), library_id: ctx.id.to_string(),
query: LibraryQuery::LibGetExplorerDir { query: LibraryQuery::GetExplorerDir {
limit: 0, limit: 0,
path: "".to_string(), path: "".to_string(),
location_id: 0, location_id: 0,

View file

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

View file

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

View file

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

View file

@ -212,7 +212,7 @@ pub async fn create_location(
Err(e) => Err(LocationError::DotfileWriteFailure(e, path.to_string()))?, Err(e) => Err(LocationError::DotfileWriteFailure(e, path.to_string()))?,
} }
// ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations)) // ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::GetLocations))
// .await; // .await;
location location
@ -239,7 +239,7 @@ pub async fn delete_location(ctx: &LibraryContext, location_id: i32) -> Result<(
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery { ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery {
library_id: ctx.id.to_string(), library_id: ctx.id.to_string(),
query: LibraryQuery::SysGetLocations, query: LibraryQuery::GetLocations,
})) }))
.await; .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 // is memorized and can be used safely in any component
export const useCurrentLibrary = () => { export const useCurrentLibrary = () => {
const { currentLibraryUuid, switchLibrary } = useLibraryStore(); const { currentLibraryUuid, switchLibrary } = useLibraryStore();
const { data: libraries } = useBridgeQuery('NodeGetLibraries', undefined, {}); const { data: libraries } = useBridgeQuery('GetLibraries', undefined, {});
// memorize library to avoid re-running find function // memorize library to avoid re-running find function
const currentLibrary = useMemo(() => { const currentLibrary = useMemo(() => {

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ interface IColumn {
const PADDING_SIZE = 130; 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) { function ensureIsColumns<T extends IColumn[]>(data: T) {
return data; return data;
} }
@ -48,20 +48,19 @@ const GridItemContainer = styled.div`
`; `;
export const FileList: React.FC<{ location_id: number; path: string; limit: number }> = (props) => { export const FileList: React.FC<{ location_id: number; path: string; limit: number }> = (props) => {
const path = props.path;
const size = useWindowSize(); const size = useWindowSize();
const tableContainer = useRef<null | HTMLDivElement>(null); const tableContainer = useRef<null | HTMLDivElement>(null);
const VList = useRef<null | VirtuosoHandle>(null); const VList = useRef<null | VirtuosoHandle>(null);
const { data: client } = useBridgeQuery('NodeGetState', undefined, { const { data: client } = useBridgeQuery('GetNode', undefined, {
refetchOnWindowFocus: false refetchOnWindowFocus: false
}); });
const path = props.path;
const { selectedRowIndex, setSelectedRowIndex, setLocationId, layoutMode } = useExplorerStore(); const { selectedRowIndex, setSelectedRowIndex, setLocationId, layoutMode } = useExplorerStore();
const [goingUp, setGoingUp] = useState(false); const [goingUp, setGoingUp] = useState(false);
const { data: currentDir } = useLibraryQuery('LibGetExplorerDir', { const { data: currentDir } = useLibraryQuery('GetExplorerDir', {
location_id: props.location_id, location_id: props.location_id,
path, path,
limit: props.limit limit: props.limit
@ -124,11 +123,7 @@ export const FileList: React.FC<{ location_id: number; path: string; limit: numb
); );
return ( return (
<div <div ref={tableContainer} style={{ marginTop: -44 }} className="w-full pl-2 cursor-default ">
ref={tableContainer}
style={{ marginTop: -44 }}
className="w-full pl-2 bg-white cursor-default dark:bg-gray-600"
>
<LocationContext.Provider <LocationContext.Provider
value={{ location_id: props.location_id, data_path: client?.data_path as string }} 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 }) => ( {({ isActive }) => (
<span <span
className={clsx( 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 '!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 appProps = useContext(AppPropsContext);
const { data: locationsResponse, isError: isLocationsError } = useLibraryQuery('SysGetLocations'); const { data: locationsResponse, isError: isLocationsError } = useLibraryQuery('GetLocations');
let locations = Array.isArray(locationsResponse) ? locationsResponse : []; let locations = Array.isArray(locationsResponse) ? locationsResponse : [];
@ -96,13 +96,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { mutate: createLocation } = useLibraryCommand('LocCreate'); const { mutate: createLocation } = useLibraryCommand('LocCreate');
const tags = [ const { data: tags } = useLibraryQuery('GetTags');
{ 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' }
];
return ( return (
<div <div
@ -192,15 +186,6 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<Icon component={PhotographIcon} /> <Icon component={PhotographIcon} />
Photos Photos
</SidebarLink> </SidebarLink>
{isExperimental ? (
<SidebarLink to="debug">
<Icon component={Code} />
Debug
</SidebarLink>
) : (
<></>
)}
</div> </div>
<div> <div>
<Heading>Locations</Heading> <Heading>Locations</Heading>
@ -256,11 +241,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<div> <div>
<Heading>Tags</Heading> <Heading>Tags</Heading>
<div className="mb-2"> <div className="mb-2">
{tags.map((tag, index) => ( {tags?.slice(0, 6).map((tag, index) => (
<SidebarLink key={index} to={`tag/${tag.id}`} className=""> <SidebarLink key={index} to={`tag/${tag.id}`} className="">
<div <div
className="w-[12px] h-[12px] rounded-full" 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> <span className="ml-2 text-sm">{tag.name}</span>
</SidebarLink> </SidebarLink>

View file

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

View file

@ -31,19 +31,27 @@ export interface TopBarButtonProps
} }
interface SearchBarProps extends DefaultProps {} 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 ( return (
<button <button
{...props} {...props}
className={clsx( 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', '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 rounded-l-none': group && !left && !right,
'rounded-r-none': props.group && props.left, 'rounded-r-none': group && left,
'rounded-l-none': props.group && props.right, 'rounded-l-none': group && right,
'dark:bg-gray-550': props.active '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" /> <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)} className={clsx('flex flex-col w-full', !props.mini && 'pb-6', props.className)}
{...props} {...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.description && <p className="mb-2 text-sm text-gray-400 ">{props.description}</p>}
{!props.mini && props.children} {!props.mini && props.children}
</div> </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) => { export const DebugScreen: React.FC<{}> = (props) => {
const appPropsContext = useContext(AppPropsContext); const appPropsContext = useContext(AppPropsContext);
const { data: nodeState } = useBridgeQuery('NodeGetState'); const { data: nodeState } = useBridgeQuery('GetNode');
const { data: libraryState } = useBridgeQuery('NodeGetLibraries'); const { data: libraryState } = useBridgeQuery('GetLibraries');
const { data: jobs } = useBridgeQuery('JobGetRunning'); const { data: jobs } = useBridgeQuery('GetRunningJobs');
const { data: jobHistory } = useLibraryQuery('JobGetHistory'); const { data: jobHistory } = useLibraryQuery('GetJobHistory');
// const { mutate: purgeDB } = useBridgeCommand('PurgeDatabase', { // const { mutate: purgeDB } = useBridgeCommand('PurgeDatabase', {
// onMutate: () => { // onMutate: () => {
// alert('Database purged'); // alert('Database purged');

View file

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

View file

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

View file

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

View file

@ -58,7 +58,7 @@ function ExtensionItem(props: { extension: ExtensionItemData }) {
} }
export default function ExtensionSettings() { export default function ExtensionSettings() {
// const { data: volumes } = useBridgeQuery('SysGetVolumes'); // const { data: volumes } = useBridgeQuery('GetVolumes');
return ( return (
<SettingsContainer> <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 React from 'react';
import Card from '../../../components/layout/Card';
import { Toggle } from '../../../components/primitive';
import { SettingsContainer } from '../../../components/settings/SettingsContainer'; import { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader'; import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function GeneralSettings() { export default function GeneralSettings() {
// const { data: volumes } = useBridgeQuery('SysGetVolumes'); const { data: node } = useBridgeQuery('GetNode');
return ( return (
<SettingsContainer> <SettingsContainer>
@ -12,24 +17,53 @@ export default function GeneralSettings() {
title="General Settings" title="General Settings"
description="General settings related to this client." description="General settings related to this client."
/> />
{/* <InputContainer title="Volumes" description="A list of volumes running on this device."> <Card className="px-5 dark:bg-gray-600">
<div className="flex flex-row space-x-2"> <div className="flex flex-col w-full my-2">
<div className="flex flex-grow"> <div className="flex">
<Listbox <span className="font-semibold">Connected Node</span>
options={ <div className="flex-grow" />
volumes?.map((volume) => { <div className="space-x-2">
const name = volume.name && volume.name.length ? volume.name : volume.mount_point; <span className="px-2 py-[2px] rounded text-xs font-medium bg-gray-500">0 Peers</span>
return { <span className="px-1.5 py-[2px] rounded text-xs font-medium bg-primary-600">
key: name, Running
option: name, </span>
description: volume.mount_point </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>
</div> </div>
</InputContainer> */} </Card>
</SettingsContainer> </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-row pb-3 space-x-5">
<div className="flex flex-col flex-grow"> <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 <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@ -72,7 +72,9 @@ export default function LibraryGeneralSettings() {
/> />
</div> </div>
<div className="flex flex-col flex-grow"> <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 <Input
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}

View file

@ -16,7 +16,7 @@ import { SettingsHeader } from '../../../components/settings/SettingsHeader';
// ]; // ];
export default function LocationSettings() { export default function LocationSettings() {
const { data: locations } = useLibraryQuery('SysGetLocations'); const { data: locations } = useLibraryQuery('GetLocations');
const appProps = useContext(AppPropsContext); 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 { SettingsContainer } from '../../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../../components/settings/SettingsHeader'; import { SettingsHeader } from '../../../components/settings/SettingsHeader';
export default function TagsSettings() { 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 ( return (
<SettingsContainer> <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> </SettingsContainer>
); );
} }

View file

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

View file

@ -130,3 +130,24 @@ body {
.dialog-content[data-state='closed'] { .dialog-content[data-state='closed'] {
animation: bounceDown 100ms ease-in forwards; 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 prettier: ^2.6.2
pretty-bytes: ^6.0.0 pretty-bytes: ^6.0.0
react: ^18.1.0 react: ^18.1.0
react-colorful: ^5.5.1
react-countup: ^6.2.0 react-countup: ^6.2.0
react-dom: ^18.1.0 react-dom: ^18.1.0
react-dropzone: ^14.2.1 react-dropzone: ^14.2.1
react-error-boundary: ^3.1.4 react-error-boundary: ^3.1.4
react-hook-form: ^7.31.3
react-hotkeys-hook: ^3.4.6 react-hotkeys-hook: ^3.4.6
react-json-view: ^1.21.3 react-json-view: ^1.21.3
react-loading-icons: ^1.1.0 react-loading-icons: ^1.1.0
@ -347,10 +349,12 @@ importers:
phosphor-react: 1.4.1_react@18.1.0 phosphor-react: 1.4.1_react@18.1.0
pretty-bytes: 6.0.0 pretty-bytes: 6.0.0
react: 18.1.0 react: 18.1.0
react-colorful: 5.5.1_ef5jwxihqo6n7gxfmzogljlgcm
react-countup: 6.2.0_react@18.1.0 react-countup: 6.2.0_react@18.1.0
react-dom: 18.1.0_react@18.1.0 react-dom: 18.1.0_react@18.1.0
react-dropzone: 14.2.1_react@18.1.0 react-dropzone: 14.2.1_react@18.1.0
react-error-boundary: 3.1.4_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-hotkeys-hook: 3.4.6_ef5jwxihqo6n7gxfmzogljlgcm
react-json-view: 1.21.3_ohobp6rpsmerwlq5ipwfh5yigy react-json-view: 1.21.3_ohobp6rpsmerwlq5ipwfh5yigy
react-loading-icons: 1.1.0 react-loading-icons: 1.1.0
@ -13910,6 +13914,16 @@ packages:
react: 18.1.0 react: 18.1.0
dev: false 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: /react-countup/6.2.0_react@18.1.0:
resolution: {integrity: sha512-3WOKAQpWgjyFoH231SHEpIpHhDGb5g5EkTppM6T7vLa3X+8WMdw6750vVcY0wxysKiY00gTFhDwSB5qLU+xPZA==} resolution: {integrity: sha512-3WOKAQpWgjyFoH231SHEpIpHhDGb5g5EkTppM6T7vLa3X+8WMdw6750vVcY0wxysKiY00gTFhDwSB5qLU+xPZA==}
peerDependencies: peerDependencies: