mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-07 06:43:29 +00:00
Merge remote-tracking branch 'origin/main' into fix-reactivity
This commit is contained in:
commit
4ab1e65059
|
@ -31,6 +31,7 @@ export type Procedures = {
|
|||
{ key: "files.setNote", input: LibraryArgs<SetNoteArgs>, result: null } |
|
||||
{ key: "jobs.generateThumbsForLocation", input: LibraryArgs<GenerateThumbsForLocationArgs>, result: null } |
|
||||
{ key: "jobs.identifyUniqueFiles", input: LibraryArgs<IdentifyUniqueFilesArgs>, result: null } |
|
||||
{ key: "jobs.objectValidator", input: LibraryArgs<ObjectValidatorArgs>, result: null } |
|
||||
{ key: "library.create", input: string, result: LibraryConfigWrapped } |
|
||||
{ key: "library.delete", input: string, result: null } |
|
||||
{ key: "library.edit", input: EditLibraryArgs, result: null } |
|
||||
|
@ -106,6 +107,8 @@ export interface NormalizedVec<T> { $type: string, edges: Array<T> }
|
|||
|
||||
export interface Object { id: number, cas_id: string, integrity_checksum: string | null, name: string | null, extension: string | null, kind: number, size_in_bytes: string, key_id: number | null, hidden: boolean, favorite: boolean, important: boolean, has_thumbnail: boolean, has_thumbstrip: boolean, has_video_preview: boolean, ipfs_id: string | null, note: string | null, date_created: string, date_modified: string, date_indexed: string }
|
||||
|
||||
export interface ObjectValidatorArgs { id: number, path: string }
|
||||
|
||||
export type RuleKind = "AcceptFilesByGlob" | "RejectFilesByGlob" | "AcceptIfChildrenDirectoriesArePresent" | "RejectIfChildrenDirectoriesArePresent"
|
||||
|
||||
export interface SetFavoriteArgs { id: number, favorite: boolean }
|
||||
|
|
|
@ -4,6 +4,7 @@ use crate::{
|
|||
object::{
|
||||
identifier_job::{FileIdentifierJob, FileIdentifierJobInit},
|
||||
preview::{ThumbnailJob, ThumbnailJobInit},
|
||||
validation::validator_job::{ObjectValidatorJob, ObjectValidatorJobInit},
|
||||
},
|
||||
prisma::location,
|
||||
};
|
||||
|
@ -56,6 +57,35 @@ pub(crate) fn mount() -> RouterBuilder {
|
|||
},
|
||||
)
|
||||
})
|
||||
.library_mutation("objectValidator", |t| {
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct ObjectValidatorArgs {
|
||||
pub id: i32,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
t(|_, args: ObjectValidatorArgs, library| async move {
|
||||
if fetch_location(&library, args.id).exec().await?.is_none() {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::NotFound,
|
||||
"Location not found".into(),
|
||||
));
|
||||
}
|
||||
|
||||
library
|
||||
.spawn_job(Job::new(
|
||||
ObjectValidatorJobInit {
|
||||
location_id: args.id,
|
||||
path: args.path,
|
||||
background: true,
|
||||
},
|
||||
Box::new(ObjectValidatorJob {}),
|
||||
))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.library_mutation("identifyUniqueFiles", |t| {
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct IdentifyUniqueFilesArgs {
|
||||
|
|
|
@ -5,6 +5,7 @@ use crate::{
|
|||
object::{
|
||||
identifier_job::{FileIdentifierJob, FileIdentifierJobInit},
|
||||
preview::{ThumbnailJob, ThumbnailJobInit},
|
||||
validation::validator_job::{ObjectValidatorJob, ObjectValidatorJobInit},
|
||||
},
|
||||
prisma::{indexer_rules_in_location, location, node},
|
||||
};
|
||||
|
@ -264,6 +265,15 @@ pub async fn scan_location(
|
|||
Box::new(ThumbnailJob {}),
|
||||
))
|
||||
.await;
|
||||
ctx.queue_job(Job::new(
|
||||
ObjectValidatorJobInit {
|
||||
location_id,
|
||||
path: PathBuf::new(),
|
||||
background: true,
|
||||
},
|
||||
Box::new(ObjectValidatorJob {}),
|
||||
))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -17,10 +17,6 @@ async fn read_at(file: &mut File, offset: u64, size: u64) -> Result<Vec<u8>, io:
|
|||
Ok(buf)
|
||||
}
|
||||
|
||||
fn to_hex_string(b: &[u8]) -> String {
|
||||
b.iter().map(|c| format!("{:02x}", c)).collect::<String>()
|
||||
}
|
||||
|
||||
pub async fn generate_cas_id(path: PathBuf, size: u64) -> Result<String, io::Error> {
|
||||
// open file reference
|
||||
let mut file = File::open(path).await?;
|
||||
|
@ -46,25 +42,25 @@ pub async fn generate_cas_id(path: PathBuf, size: u64) -> Result<String, io::Err
|
|||
hasher.update(&buf);
|
||||
}
|
||||
|
||||
let hex = to_hex_string(hasher.finalize().as_bytes());
|
||||
let hex = hasher.finalize().to_hex();
|
||||
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
// pub async fn full_checksum(path: &str) -> Result<String, io::Error> {
|
||||
// const BLOCK_SIZE: usize = 1048576;
|
||||
// //read file as buffer and convert to digest
|
||||
// let mut reader = File::open(path).await?;
|
||||
// let mut context = Hasher::new();
|
||||
// let mut buffer = [0; 1048576];
|
||||
// loop {
|
||||
// let read_count = reader.read(&mut buffer).await?;
|
||||
// context.update(&buffer[..read_count]);
|
||||
// if read_count != BLOCK_SIZE {
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// let hex = to_hex_string(context.finalize().as_bytes());
|
||||
pub async fn full_checksum(path: &str) -> Result<String, io::Error> {
|
||||
const BLOCK_SIZE: usize = 1048576;
|
||||
//read file as buffer and convert to digest
|
||||
let mut reader = File::open(path).await?;
|
||||
let mut context = Hasher::new();
|
||||
let mut buffer = [0; 1048576];
|
||||
loop {
|
||||
let read_count = reader.read(&mut buffer).await?;
|
||||
context.update(&buffer[..read_count]);
|
||||
if read_count != BLOCK_SIZE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let hex = to_hex_string(context.finalize().as_bytes());
|
||||
|
||||
// Ok(hex)
|
||||
// }
|
||||
Ok(hex)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
pub mod cas;
|
||||
pub mod identifier_job;
|
||||
pub mod preview;
|
||||
pub mod validation;
|
||||
|
||||
// Objects are primarily created by the identifier from Paths
|
||||
// Some Objects are purely virtual, unless they have one or more associated Paths, which refer to a file found in a Location
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
use blake3::Hasher;
|
||||
use std::path::PathBuf;
|
||||
use tokio::{
|
||||
fs::File,
|
||||
io::{self, AsyncReadExt},
|
||||
};
|
||||
|
||||
const BLOCK_SIZE: usize = 1048576;
|
||||
|
||||
pub async fn file_checksum(path: PathBuf) -> Result<String, io::Error> {
|
||||
let mut reader = File::open(path).await?;
|
||||
let mut context = Hasher::new();
|
||||
let mut buffer = vec![0; BLOCK_SIZE].into_boxed_slice();
|
||||
loop {
|
||||
let read_count = reader.read(&mut buffer).await?;
|
||||
context.update(&buffer[..read_count]);
|
||||
if read_count != BLOCK_SIZE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let hex = context.finalize().to_hex();
|
||||
|
||||
Ok(hex.to_string())
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
pub mod hash;
|
||||
pub mod validator_job;
|
|
@ -0,0 +1,153 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::{collections::VecDeque, path::PathBuf};
|
||||
|
||||
use crate::{
|
||||
job::{JobError, JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext},
|
||||
prisma::{self, file_path, location, object},
|
||||
};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use super::hash::file_checksum;
|
||||
|
||||
// The Validator is able to:
|
||||
// - generate a full byte checksum for Objects in a Location
|
||||
// - generate checksums for all Objects missing without one
|
||||
// - compare two objects and return true if they are the same
|
||||
pub struct ObjectValidatorJob {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ObjectValidatorJobState {
|
||||
pub root_path: PathBuf,
|
||||
pub task_count: usize,
|
||||
}
|
||||
|
||||
// The validator can
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ObjectValidatorJobInit {
|
||||
pub location_id: i32,
|
||||
pub path: PathBuf,
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ObjectValidatorJobStep {
|
||||
pub path: file_path::Data,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl StatefulJob for ObjectValidatorJob {
|
||||
type Data = ObjectValidatorJobState;
|
||||
type Init = ObjectValidatorJobInit;
|
||||
type Step = ObjectValidatorJobStep;
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"object_validator"
|
||||
}
|
||||
|
||||
async fn init(
|
||||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> Result<(), JobError> {
|
||||
let library_ctx = ctx.library_ctx();
|
||||
|
||||
state.steps = library_ctx
|
||||
.db
|
||||
.file_path()
|
||||
.find_many(vec![file_path::location_id::equals(state.init.location_id)])
|
||||
.exec()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|path| ObjectValidatorJobStep { path })
|
||||
.collect::<VecDeque<_>>();
|
||||
|
||||
let location = library_ctx
|
||||
.db
|
||||
.location()
|
||||
.find_unique(location::id::equals(state.init.location_id))
|
||||
.exec()
|
||||
.await?
|
||||
.unwrap();
|
||||
|
||||
state.data = Some(ObjectValidatorJobState {
|
||||
root_path: location.local_path.as_ref().map(PathBuf::from).unwrap(),
|
||||
task_count: state.steps.len(),
|
||||
});
|
||||
|
||||
ctx.progress(vec![JobReportUpdate::TaskCount(state.steps.len())]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_step(
|
||||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> Result<(), JobError> {
|
||||
let step = &state.steps[0];
|
||||
let library_ctx = ctx.library_ctx();
|
||||
|
||||
let data = state.data.as_ref().expect("fatal: missing job state");
|
||||
|
||||
let path = data.root_path.join(&step.path.materialized_path);
|
||||
|
||||
// skip directories
|
||||
if path.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(object_id) = step.path.object_id {
|
||||
// this is to skip files that already have checksums
|
||||
// i'm unsure what the desired behaviour is in this case
|
||||
// we can also compare old and new checksums here
|
||||
let object = library_ctx
|
||||
.db
|
||||
.object()
|
||||
.find_unique(object::id::equals(object_id))
|
||||
.exec()
|
||||
.await?
|
||||
.unwrap();
|
||||
if object.integrity_checksum.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hash = file_checksum(path).await?;
|
||||
|
||||
library_ctx
|
||||
.db
|
||||
.object()
|
||||
.update(
|
||||
object::id::equals(object_id),
|
||||
vec![prisma::object::SetParam::SetIntegrityChecksum(Some(hash))],
|
||||
)
|
||||
.exec()
|
||||
.await?;
|
||||
}
|
||||
|
||||
ctx.progress(vec![JobReportUpdate::CompletedTaskCount(
|
||||
state.step_number + 1,
|
||||
)]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finalize(
|
||||
&self,
|
||||
_ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult {
|
||||
let data = state
|
||||
.data
|
||||
.as_ref()
|
||||
.expect("critical error: missing data on job state");
|
||||
info!(
|
||||
"finalizing validator job at {}: {} tasks",
|
||||
data.root_path.display(),
|
||||
data.task_count
|
||||
);
|
||||
|
||||
Ok(Some(serde_json::to_value(&state.init)?))
|
||||
}
|
||||
}
|
|
@ -31,6 +31,7 @@ export type Procedures = {
|
|||
{ key: "files.setNote", input: LibraryArgs<SetNoteArgs>, result: null } |
|
||||
{ key: "jobs.generateThumbsForLocation", input: LibraryArgs<GenerateThumbsForLocationArgs>, result: null } |
|
||||
{ key: "jobs.identifyUniqueFiles", input: LibraryArgs<IdentifyUniqueFilesArgs>, result: null } |
|
||||
{ key: "jobs.objectValidator", input: LibraryArgs<ObjectValidatorArgs>, result: null } |
|
||||
{ key: "library.create", input: string, result: LibraryConfigWrapped } |
|
||||
{ key: "library.delete", input: string, result: null } |
|
||||
{ key: "library.edit", input: EditLibraryArgs, result: null } |
|
||||
|
@ -106,6 +107,8 @@ export interface NormalizedVec<T> { $type: string, edges: Array<T> }
|
|||
|
||||
export interface Object { id: number, cas_id: string, integrity_checksum: string | null, name: string | null, extension: string | null, kind: number, size_in_bytes: string, key_id: number | null, hidden: boolean, favorite: boolean, important: boolean, has_thumbnail: boolean, has_thumbstrip: boolean, has_video_preview: boolean, ipfs_id: string | null, note: string | null, date_created: string, date_modified: string, date_indexed: string }
|
||||
|
||||
export interface ObjectValidatorArgs { id: number, path: string }
|
||||
|
||||
export type RuleKind = "AcceptFilesByGlob" | "RejectFilesByGlob" | "AcceptIfChildrenDirectoriesArePresent" | "RejectIfChildrenDirectoriesArePresent"
|
||||
|
||||
export interface SetFavoriteArgs { id: number, favorite: boolean }
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon, PhotoIcon } from '@heroicons/react/24/outline';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, TagIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
OperatingSystem,
|
||||
getExplorerStore,
|
||||
|
@ -142,6 +142,15 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
}
|
||||
});
|
||||
|
||||
const { mutate: objectValidator } = useLibraryMutation(
|
||||
'jobs.objectValidator',
|
||||
{
|
||||
onMutate: (data) => {
|
||||
// console.log('ObjectValidator', data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
//create function to focus on search box when cmd+k is pressed
|
||||
|
@ -205,7 +214,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
<>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-[2.95rem] -mt-0.5 max-w z-10 pl-3 flex-shrink-0 items-center dark:bg-gray-650 border-gray-100 dark:border-gray-800 !bg-opacity-80 backdrop-blur"
|
||||
className="flex h-[2.95rem] -mt-0.5 max-w z-10 pl-3 flex-shrink-0 items-center dark:bg-gray-700 border-gray-100 !bg-opacity-80 backdrop-blur overflow-hidden rounded-tl-md"
|
||||
>
|
||||
<div className="flex ">
|
||||
<Tooltip label="Navigate back">
|
||||
|
@ -264,12 +273,12 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
// </Tooltip>
|
||||
}
|
||||
>
|
||||
<div className="block w-[350px] h-[435px]">
|
||||
<div className="block w-[350px]">
|
||||
<KeyManager />
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
<Tooltip label="Cloud">
|
||||
<TopBarButton icon={Cloud} />
|
||||
<Tooltip label="Tag Assign Mode">
|
||||
<TopBarButton icon={TagIcon} />
|
||||
</Tooltip>
|
||||
<Tooltip label="Refresh">
|
||||
<TopBarButton icon={ArrowsClockwise} />
|
||||
|
@ -312,6 +321,12 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
icon: ArrowsClockwise,
|
||||
onPress: () =>
|
||||
store.locationId && identifyUniqueFiles({ id: store.locationId, path: '' })
|
||||
},
|
||||
{
|
||||
name: 'Validate Objects',
|
||||
icon: ArrowsClockwise,
|
||||
onPress: () =>
|
||||
store.locationId && objectValidator({ id: store.locationId, path: '' })
|
||||
}
|
||||
]
|
||||
]}
|
||||
|
|
|
@ -52,7 +52,7 @@ function FileItem({ data, selected, index, ...rest }: Props) {
|
|||
<FileThumb
|
||||
className={clsx(
|
||||
'border-4 border-gray-250 shadow-md shadow-gray-750 object-cover max-w-full max-h-full w-auto overflow-hidden',
|
||||
isVid && 'border-gray-950 border-x-0 border-y-[9px]'
|
||||
isVid && 'border-gray-950 rounded border-x-0 border-y-[9px]'
|
||||
)}
|
||||
data={data}
|
||||
kind={data.extension === 'zip' ? 'zip' : isVid ? 'video' : 'other'}
|
||||
|
|
|
@ -60,24 +60,17 @@ export default function FileThumb({ data, ...props }: Props) {
|
|||
/>
|
||||
);
|
||||
|
||||
// Hacky (and temporary) way to integrate thumbnails
|
||||
if (props.kind === 'video') {
|
||||
return (
|
||||
<div className="">
|
||||
<img
|
||||
src={videoSvg}
|
||||
className={clsx('w-full overflow-hidden h-full', props.iconClassNames)}
|
||||
/>
|
||||
</div>
|
||||
<img src={videoSvg} className={clsx('w-full overflow-hidden h-full', props.iconClassNames)} />
|
||||
);
|
||||
}
|
||||
if (props.kind === 'zip') {
|
||||
return (
|
||||
<div className="">
|
||||
<img src={zipSvg} className={clsx('w-full overflow-hidden h-full')} />
|
||||
</div>
|
||||
);
|
||||
return <img src={zipSvg} className={clsx('w-full overflow-hidden h-full')} />;
|
||||
}
|
||||
|
||||
// return default file icon
|
||||
return (
|
||||
<div
|
||||
style={{ width: props.size * 0.8, height: props.size * 0.8 }}
|
||||
|
|
|
@ -46,25 +46,28 @@ export const Inspector = (props: Props) => {
|
|||
enabled: readyToFetch
|
||||
});
|
||||
|
||||
const isVid = isVideo(props.data?.extension || '');
|
||||
|
||||
return (
|
||||
<div className="p-2 pr-1 w-full overflow-x-hidden custom-scroll inspector-scroll pb-[55px]">
|
||||
<div className="-mt-[50px] pt-[55px] pl-1.5 pr-1 w-full h-screen overflow-x-hidden custom-scroll inspector-scroll pb-[55px]">
|
||||
{!!props.data && (
|
||||
<>
|
||||
<div className="flex bg-black items-center justify-center w-full h-64 mb-[10px] overflow-hidden rounded-lg ">
|
||||
<FileThumb
|
||||
iconClassNames="!my-10"
|
||||
iconClassNames="mx-10"
|
||||
size={230}
|
||||
className="!m-0 flex flex-shrink flex-grow-0"
|
||||
kind={props.data.extension === 'zip' ? 'zip' : isVid ? 'video' : 'other'}
|
||||
className="!m-0 flex bg-green-500 flex-shrink flex-grow-0"
|
||||
data={props.data}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full pt-0.5 pb-4 overflow-hidden bg-white rounded-lg shadow select-text dark:shadow-gray-700 dark:bg-gray-550 dark:bg-opacity-40">
|
||||
<h3 className="pt-3 pl-3 text-base font-bold">
|
||||
<div className="flex flex-col w-full pt-0.5 pb-1 overflow-hidden bg-white rounded-lg shadow select-text dark:shadow-gray-800/40 dark:bg-gray-550 dark:bg-opacity-40 border border-gray-550/70">
|
||||
<h3 className="pt-2 pb-1 pl-3 text-base font-bold">
|
||||
{props.data?.name}
|
||||
{props.data?.extension && `.${props.data.extension}`}
|
||||
</h3>
|
||||
{objectData && (
|
||||
<div className="flex flex-row m-3 space-x-2">
|
||||
<div className="flex flex-row mt-1 mx-3 space-x-0.5">
|
||||
<Tooltip label="Favorite">
|
||||
<FavoriteButton data={objectData} />
|
||||
</Tooltip>
|
||||
|
@ -84,9 +87,8 @@ export const Inspector = (props: Props) => {
|
|||
<>
|
||||
<Divider />
|
||||
<MetaItem
|
||||
// title="Tags"
|
||||
value={
|
||||
<div className="flex flex-wrap mt-1.5 gap-1.5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags?.data?.map((tag) => (
|
||||
<div
|
||||
// onClick={() => setSelectedTag(tag.id === selectedTag ? null : tag.id)}
|
||||
|
@ -157,3 +159,35 @@ export const Inspector = (props: Props) => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function isVideo(extension: string) {
|
||||
return [
|
||||
'avi',
|
||||
'asf',
|
||||
'mpeg',
|
||||
'mts',
|
||||
'mpe',
|
||||
'vob',
|
||||
'qt',
|
||||
'mov',
|
||||
'asf',
|
||||
'asx',
|
||||
'mjpeg',
|
||||
'ts',
|
||||
'mxf',
|
||||
'm2ts',
|
||||
'f4v',
|
||||
'wm',
|
||||
'3gp',
|
||||
'm4v',
|
||||
'wmv',
|
||||
'mp4',
|
||||
'webm',
|
||||
'flv',
|
||||
'mpg',
|
||||
'hevc',
|
||||
'ogv',
|
||||
'swf',
|
||||
'wtv'
|
||||
].includes(extension);
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
export const Divider = () => <div className="w-full my-1 h-[1px] bg-gray-100 dark:bg-gray-600" />;
|
||||
export const Divider = () => <div className="w-full my-1 h-[1px] bg-gray-100 dark:bg-gray-550" />;
|
||||
|
|
103
packages/interface/src/components/key/Key.tsx
Normal file
103
packages/interface/src/components/key/Key.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
KeyIcon,
|
||||
LockClosedIcon,
|
||||
LockOpenIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { Button, Input, Select, SelectOption } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import { Eject, EjectSimple, Plus } from 'phosphor-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Toggle } from '../primitive';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
|
||||
export type KeyManagerProps = DefaultProps;
|
||||
|
||||
// TODO: Replace this with Prisma type when integrating with backend
|
||||
export interface Key {
|
||||
id: string;
|
||||
name: string;
|
||||
mounted?: boolean;
|
||||
locked?: boolean;
|
||||
stats?: {
|
||||
objectCount?: number;
|
||||
containerCount?: number;
|
||||
};
|
||||
// Nodes this key is mounted on
|
||||
nodes?: string[]; // will be node object
|
||||
}
|
||||
|
||||
export const Key: React.FC<{ data: Key; index: number }> = ({ data, index }) => {
|
||||
const odd = (index || 0) % 2 === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-2 py-1.5 shadow-gray-900/20 text-sm text-gray-300 bg-gray-500/30 shadow-lg border-gray-500 rounded-lg'
|
||||
// !odd && 'bg-opacity-10'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<KeyIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 ml-1 mr-3',
|
||||
data.mounted
|
||||
? data.locked
|
||||
? 'text-primary-600'
|
||||
: 'text-primary-600'
|
||||
: 'text-gray-400/80'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col ">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="font-semibold">{data.name}</div>
|
||||
{data.mounted && (
|
||||
<div className="inline ml-2 px-1 text-[8pt] font-medium text-gray-300 bg-gray-500 rounded">
|
||||
{data.nodes?.length || 0 > 0 ? `${data.nodes?.length || 0} nodes` : 'This node'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="text-xs text-gray-300 opacity-30">#{data.id}</div> */}
|
||||
{data.stats ? (
|
||||
<div className="flex flex-row mt-[1px] space-x-3">
|
||||
{data.stats.objectCount && (
|
||||
<div className="text-[8pt] font-medium text-gray-200 opacity-30">
|
||||
{data.stats.objectCount} Objects
|
||||
</div>
|
||||
)}
|
||||
{data.stats.containerCount && (
|
||||
<div className="text-[8pt] font-medium text-gray-200 opacity-30">
|
||||
{data.stats.containerCount} Containers
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!data.mounted && (
|
||||
<div className="text-[8pt] font-medium text-gray-200 opacity-30">Key not mounted</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-x-1">
|
||||
{data.mounted && (
|
||||
<Tooltip label="Browse files">
|
||||
<Button noPadding>
|
||||
<EyeIcon className="w-4 h-4 text-gray-400" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button noPadding>
|
||||
<EllipsisVerticalIcon className="w-4 h-4 text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
59
packages/interface/src/components/key/KeyList.tsx
Normal file
59
packages/interface/src/components/key/KeyList.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { Button, CategoryHeading, Input, Select, SelectOption } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import { Eject, EjectSimple, Plus } from 'phosphor-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Toggle } from '../primitive';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import { Key } from './Key';
|
||||
|
||||
export type KeyListProps = DefaultProps;
|
||||
|
||||
export function KeyList(props: KeyListProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-[360px]">
|
||||
<div className="p-3 custom-scroll overlay-scroll">
|
||||
<div className="">
|
||||
{/* <CategoryHeading>Mounted keys</CategoryHeading> */}
|
||||
<div className="space-y-1.5">
|
||||
<Key
|
||||
index={0}
|
||||
data={{
|
||||
id: 'af5570f5a1810b7a',
|
||||
name: 'OBS Recordings',
|
||||
mounted: true,
|
||||
|
||||
nodes: ['node1', 'node2'],
|
||||
stats: { objectCount: 235, containerCount: 2 }
|
||||
}}
|
||||
/>
|
||||
<Key
|
||||
index={1}
|
||||
data={{
|
||||
id: 'af5570f5a1810b7a',
|
||||
name: 'Unknown Key',
|
||||
locked: true,
|
||||
mounted: true,
|
||||
stats: { objectCount: 45 }
|
||||
}}
|
||||
/>
|
||||
<Key index={2} data={{ id: '7324695a52da67b1', name: 'Spacedrive Company' }} />
|
||||
<Key index={3} data={{ id: 'b02303d68d05a562', name: 'Key 4' }} />
|
||||
<Key index={3} data={{ id: 'b02303d68d05a562', name: 'Key 5' }} />
|
||||
<Key index={3} data={{ id: 'b02303d68d05a562', name: 'Key 6' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full p-2 bg-gray-600 border-t border-gray-500 rounded-b-md">
|
||||
<Button size="sm" variant="gray">
|
||||
Unmount All
|
||||
</Button>
|
||||
<div className="flex-grow" />
|
||||
<Button size="sm" variant="gray">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,203 +1,30 @@
|
|||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
KeyIcon,
|
||||
LockClosedIcon,
|
||||
LockOpenIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { Button, Input } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import { Plus } from 'phosphor-react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { Tabs } from '@sd/ui';
|
||||
|
||||
import { Toggle } from '../primitive';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import { KeyList } from './KeyList';
|
||||
import { KeyMounter } from './KeyMounter';
|
||||
|
||||
export type KeyManagerProps = DefaultProps;
|
||||
|
||||
interface FakeKey {
|
||||
id: string;
|
||||
name: string;
|
||||
mounted?: boolean;
|
||||
locked?: boolean;
|
||||
stats?: {
|
||||
objectCount?: number;
|
||||
containerCount?: number;
|
||||
};
|
||||
// Nodes this key is mounted on
|
||||
nodes?: string[]; // will be node object
|
||||
}
|
||||
|
||||
function Heading({ children }: PropsWithChildren) {
|
||||
return <div className="mt-1 mb-1 text-xs font-semibold text-gray-300">{children}</div>;
|
||||
}
|
||||
|
||||
function Key({ data, index }: { data: FakeKey; index: number }) {
|
||||
const odd = (index || 0) % 2 === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-2 py-1.5 shadow-gray-900/20 text-sm text-gray-300 bg-gray-500/30 shadow-lg border-gray-500 rounded-lg'
|
||||
// !odd && 'bg-opacity-10'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<KeyIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 ml-1 mr-3',
|
||||
data.mounted
|
||||
? data.locked
|
||||
? 'text-primary-600'
|
||||
: 'text-primary-600'
|
||||
: 'text-gray-400/80'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col ">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="font-semibold">{data.name}</div>
|
||||
{data.mounted && (
|
||||
<div className="inline ml-2 px-1 text-[8pt] font-medium text-gray-300 bg-gray-500 rounded">
|
||||
{data.nodes?.length || 0 > 0 ? `${data.nodes?.length || 0} nodes` : 'This node'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="text-xs text-gray-300 opacity-30">#{data.id}</div> */}
|
||||
{data.stats && (
|
||||
<div className="flex flex-row mt-[1px] space-x-3">
|
||||
{data.stats.objectCount && (
|
||||
<div className="text-[8pt] font-medium text-gray-200 opacity-30">
|
||||
{data.stats.objectCount} Objects
|
||||
</div>
|
||||
)}
|
||||
{data.stats.containerCount && (
|
||||
<div className="text-[8pt] font-medium text-gray-200 opacity-30">
|
||||
{data.stats.containerCount} Containers
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-x-1">
|
||||
{data.mounted ? (
|
||||
<>
|
||||
<Tooltip label="Browse files">
|
||||
<Button noPadding>
|
||||
<EyeIcon className="w-4 h-4 text-gray-400" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{data.locked ? (
|
||||
<Tooltip label="Unlock key">
|
||||
<Button noPadding>
|
||||
<LockClosedIcon className="w-4 h-4 text-gray-400" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label="Lock key">
|
||||
<Button noPadding>
|
||||
<LockOpenIcon className="w-4 h-4 text-gray-400" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Tooltip label="Dismount key">
|
||||
<Button noPadding>
|
||||
<XMarkIcon className="w-4 h-4 text-gray-400" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyManager(props: KeyManagerProps) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [toggle, setToggle] = useState(false);
|
||||
|
||||
const CurrentEyeIcon = showKey ? EyeSlashIcon : EyeIcon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 pt-3">
|
||||
<Heading>Mount key</Heading>
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex flex-grow">
|
||||
<Input autoFocus type={showKey ? 'text' : 'password'} className="flex-grow !py-0.5" />
|
||||
<Button
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
noBorder
|
||||
noPadding
|
||||
className="absolute right-[5px] top-[5px]"
|
||||
>
|
||||
<CurrentEyeIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip className="flex" label="Mount key">
|
||||
<Button variant="gray" noPadding>
|
||||
<Plus weight="fill" className="w-4 h-4 mx-1" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-row items-center mt-3 mb-1">
|
||||
<Toggle className="dark:bg-gray-400/30" size="sm" value={toggle} onChange={setToggle} />
|
||||
<span className="ml-3 mt-[1px] font-medium text-xs">Sync with Library</span>
|
||||
<Tooltip label="This key will be mounted on all devices running your Library">
|
||||
<InformationCircleIcon className="w-4 h-4 ml-1.5 text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="pt-1.5 ml-0.5 text-[8pt] leading-snug text-gray-300 opacity-50 w-[90%]">
|
||||
Files encrypted with this key will be revealed and decrypted on the fly.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-gray-500" />
|
||||
<div className="p-3 custom-scroll overlay-scroll">
|
||||
<div className="">
|
||||
<Heading>Mounted keys</Heading>
|
||||
<div className="pt-1 space-y-1.5">
|
||||
<Key
|
||||
index={0}
|
||||
data={{
|
||||
id: 'af5570f5a1810b7a',
|
||||
name: 'OBS Recordings',
|
||||
mounted: true,
|
||||
|
||||
nodes: ['node1', 'node2'],
|
||||
stats: { objectCount: 235, containerCount: 2 }
|
||||
}}
|
||||
/>
|
||||
<Key
|
||||
index={1}
|
||||
data={{
|
||||
id: 'af5570f5a1810b7a',
|
||||
name: 'Unknown Key',
|
||||
locked: true,
|
||||
mounted: true,
|
||||
stats: { objectCount: 45 }
|
||||
}}
|
||||
/>
|
||||
<Key index={2} data={{ id: '7324695a52da67b1', name: 'Spacedrive Company' }} />
|
||||
<Key index={3} data={{ id: 'b02303d68d05a562', name: 'Key 4' }} />
|
||||
<Key index={3} data={{ id: 'b02303d68d05a562', name: 'Key 5' }} />
|
||||
<Key index={3} data={{ id: 'b02303d68d05a562', name: 'Key 6' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full p-2 bg-gray-600 border-t border-gray-500 rounded-b-md">
|
||||
<Button size="sm" variant="gray">
|
||||
Unmount All
|
||||
</Button>
|
||||
<div className="flex-grow" />
|
||||
<Button size="sm" variant="gray">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Tabs.Root defaultValue="mount">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger className="text-sm font-medium text-gray-300" value="mount">
|
||||
Mount
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="text-sm font-medium text-gray-300" value="keys">
|
||||
Keys
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="keys">
|
||||
<KeyList />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="mount">
|
||||
<KeyMounter />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
98
packages/interface/src/components/key/KeyMounter.tsx
Normal file
98
packages/interface/src/components/key/KeyMounter.tsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
KeyIcon,
|
||||
LockClosedIcon,
|
||||
LockOpenIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { Button, CategoryHeading, Input, Select, SelectOption } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import { Eject, EjectSimple, Plus } from 'phosphor-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Toggle } from '../primitive';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import { Key } from './Key';
|
||||
|
||||
export function KeyMounter() {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [toggle, setToggle] = useState(false);
|
||||
|
||||
const [key, setKey] = useState('');
|
||||
const [encryptionAlgo, setEncryptionAlgo] = useState('XChaCha20Poly1305');
|
||||
const [hashingAlgo, setHashingAlgo] = useState('Argon2id');
|
||||
|
||||
const CurrentEyeIcon = showKey ? EyeSlashIcon : EyeIcon;
|
||||
|
||||
// this keeps the input focused when switching tabs
|
||||
// feel free to replace with something cleaner
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
ref.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-3 pt-3 mb-1">
|
||||
<CategoryHeading>Mount key</CategoryHeading>
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex flex-grow">
|
||||
<Input
|
||||
ref={ref}
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
autoFocus
|
||||
type={showKey ? 'text' : 'password'}
|
||||
className="flex-grow !py-0.5"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
noBorder
|
||||
noPadding
|
||||
className="absolute right-[5px] top-[5px]"
|
||||
>
|
||||
<CurrentEyeIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center mt-3 mb-1">
|
||||
<Toggle className="dark:bg-gray-400/30" size="sm" value={toggle} onChange={setToggle} />
|
||||
<span className="ml-3 mt-[1px] font-medium text-xs">Sync with Library</span>
|
||||
<Tooltip label="This key will be mounted on all devices running your Library">
|
||||
<InformationCircleIcon className="w-4 h-4 ml-1.5 text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-2 gap-4 mt-4 mb-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold">Encryption</span>
|
||||
<Select className="mt-2" onChange={setEncryptionAlgo} value={encryptionAlgo}>
|
||||
<SelectOption value="XChaCha20Poly1305">XChaCha20Poly1305</SelectOption>
|
||||
<SelectOption value="Aes256Gcm">Aes256Gcm</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold">Hashing</span>
|
||||
<Select className="mt-2" onChange={setHashingAlgo} value={hashingAlgo}>
|
||||
<SelectOption value="Argon2id">Argon2id</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="pt-1.5 ml-0.5 text-[8pt] leading-snug text-gray-300 opacity-50 w-[90%]">
|
||||
Files encrypted with this key will be revealed and decrypted on the fly.
|
||||
</p>
|
||||
<Button className="w-full mt-2" variant="primary">
|
||||
Mount Key
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -2,7 +2,7 @@ import { CogIcon, LockClosedIcon, PhotoIcon } from '@heroicons/react/24/outline'
|
|||
import { PlusIcon } from '@heroicons/react/24/solid';
|
||||
import { useCurrentLibrary, useLibraryMutation, useLibraryQuery, usePlatform } from '@sd/client';
|
||||
import { LocationCreateArgs } from '@sd/client';
|
||||
import { Button, Dropdown, OverlayPanel } from '@sd/ui';
|
||||
import { Button, CategoryHeading, Dropdown, OverlayPanel } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import { CheckCircle, CirclesFour, Planet, WaveTriangle } from 'phosphor-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
@ -38,10 +38,6 @@ const Icon = ({ component: Icon, ...props }: any) => (
|
|||
<Icon weight="bold" {...props} className={clsx('w-4 h-4 mr-2', props.className)} />
|
||||
);
|
||||
|
||||
function Heading({ children }: PropsWithChildren) {
|
||||
return <div className="mt-5 mb-1 ml-1 text-xs font-semibold text-gray-300">{children}</div>;
|
||||
}
|
||||
|
||||
// cute little helper to decrease code clutter
|
||||
const macOnly = (platform: string | undefined, classnames: string) =>
|
||||
platform === 'macOS' ? classnames : '';
|
||||
|
@ -72,7 +68,7 @@ function LibraryScopedSection() {
|
|||
return (
|
||||
<>
|
||||
<div>
|
||||
<Heading>Locations</Heading>
|
||||
<CategoryHeading className="mt-5">Locations</CategoryHeading>
|
||||
{locations?.map((location) => {
|
||||
return (
|
||||
<div key={location.id} className="flex flex-row items-center">
|
||||
|
@ -136,7 +132,7 @@ function LibraryScopedSection() {
|
|||
</div>
|
||||
{tags?.length ? (
|
||||
<div>
|
||||
<Heading>Tags</Heading>
|
||||
<CategoryHeading className="mt-5">Tags</CategoryHeading>
|
||||
<div className="mb-2">
|
||||
{tags?.slice(0, 6).map((tag, index) => (
|
||||
<SidebarLink key={index} to={`tag/${tag.id}`} className="">
|
||||
|
|
|
@ -29,7 +29,7 @@ body {
|
|||
width: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
@apply bg-[#00000006] dark:bg-[#00000030] mt-[53px] rounded-[6px];
|
||||
@apply bg-[#00000006] dark:bg-[#00000000] mt-[53px] rounded-[6px];
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply rounded-[6px] bg-gray-300 dark:bg-gray-550;
|
||||
|
|
|
@ -20,20 +20,22 @@
|
|||
"@headlessui/react": "^1.7.3",
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@radix-ui/react-context-menu": "^1.0.0",
|
||||
"@radix-ui/react-select": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
"@radix-ui/react-dropdown-menu": "^1.0.0",
|
||||
"@radix-ui/react-select": "^1.0.0",
|
||||
"@radix-ui/react-tabs": "^1.0.0",
|
||||
"@sd/assets": "workspace:*",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"class-variance-authority": "^0.2.3",
|
||||
"@sd/assets": "workspace:*",
|
||||
"clsx": "^1.2.1",
|
||||
"phosphor-react": "^1.4.1",
|
||||
"postcss": "^8.4.17",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "6.4.2",
|
||||
"react-loading-icons": "^1.1.0",
|
||||
"react-router-dom": "6.4.2",
|
||||
"react-spring": "^9.5.5",
|
||||
"tailwind-styled-components": "2.1.7",
|
||||
"tailwindcss-radix": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
16
packages/ui/src/Tabs.tsx
Normal file
16
packages/ui/src/Tabs.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import tw from 'tailwind-styled-components';
|
||||
|
||||
export const Root = tw(TabsPrimitive.Root)`
|
||||
flex flex-col
|
||||
`;
|
||||
|
||||
export const Content = tw(TabsPrimitive.TabsContent)``;
|
||||
|
||||
export const List = tw(TabsPrimitive.TabsList)`
|
||||
flex flex-row p-2 items-center space-x-1 border-b border-gray-500/30
|
||||
`;
|
||||
|
||||
export const Trigger = tw(TabsPrimitive.TabsTrigger)`
|
||||
text-white px-1.5 py-0.5 rounded text-sm font-medium radix-state-active:bg-primary
|
||||
`;
|
3
packages/ui/src/Typography.tsx
Normal file
3
packages/ui/src/Typography.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
import tw from 'tailwind-styled-components';
|
||||
|
||||
export const CategoryHeading = tw.h3`mt-1 mb-1 text-xs font-semibold text-gray-300`;
|
|
@ -6,3 +6,5 @@ export * as ContextMenu from './ContextMenu';
|
|||
export * from './OverlayPanel';
|
||||
export * from './Input';
|
||||
export * from './Select';
|
||||
export * as Tabs from './Tabs';
|
||||
export * from './Typography';
|
||||
|
|
|
@ -521,6 +521,7 @@ importers:
|
|||
'@radix-ui/react-dialog': ^1.0.0
|
||||
'@radix-ui/react-dropdown-menu': ^1.0.0
|
||||
'@radix-ui/react-select': ^1.0.0
|
||||
'@radix-ui/react-tabs': ^1.0.0
|
||||
'@sd/assets': workspace:*
|
||||
'@sd/config': workspace:*
|
||||
'@storybook/addon-actions': ^6.5.12
|
||||
|
@ -556,6 +557,7 @@ importers:
|
|||
storybook: ^6.5.12
|
||||
storybook-tailwind-dark-mode: ^1.0.15
|
||||
style-loader: ^3.3.1
|
||||
tailwind-styled-components: 2.1.7
|
||||
tailwindcss: ^3.1.8
|
||||
tailwindcss-radix: ^2.6.0
|
||||
typescript: ^4.8.4
|
||||
|
@ -566,6 +568,7 @@ importers:
|
|||
'@radix-ui/react-dialog': 1.0.2_rj7ozvcq3uehdlnj3cbwzbi5ce
|
||||
'@radix-ui/react-dropdown-menu': 1.0.0_rj7ozvcq3uehdlnj3cbwzbi5ce
|
||||
'@radix-ui/react-select': 1.1.1_rj7ozvcq3uehdlnj3cbwzbi5ce
|
||||
'@radix-ui/react-tabs': 1.0.1_biqbaboplfbrettd7655fr4n2y
|
||||
'@sd/assets': link:../assets
|
||||
'@tailwindcss/forms': 0.5.3_tailwindcss@3.1.8
|
||||
class-variance-authority: 0.2.4_typescript@4.8.4
|
||||
|
@ -577,6 +580,7 @@ importers:
|
|||
react-loading-icons: 1.1.0
|
||||
react-router-dom: 6.4.2_biqbaboplfbrettd7655fr4n2y
|
||||
react-spring: 9.5.5_biqbaboplfbrettd7655fr4n2y
|
||||
tailwind-styled-components: 2.1.7_biqbaboplfbrettd7655fr4n2y
|
||||
tailwindcss-radix: 2.6.0
|
||||
devDependencies:
|
||||
'@babel/core': 7.19.3
|
||||
|
@ -4065,6 +4069,26 @@ packages:
|
|||
react-dom: 18.2.0_react@18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-roving-focus/1.0.1_biqbaboplfbrettd7655fr4n2y:
|
||||
resolution: {integrity: sha512-TB76u5TIxKpqMpUAuYH2VqMhHYKa+4Vs1NHygo/llLvlffN6mLVsFhz0AnSFlSBAvTBYVHYAkHAyEt7x1gPJOA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.19.4
|
||||
'@radix-ui/primitive': 1.0.0
|
||||
'@radix-ui/react-collection': 1.0.1_biqbaboplfbrettd7655fr4n2y
|
||||
'@radix-ui/react-compose-refs': 1.0.0_react@18.2.0
|
||||
'@radix-ui/react-context': 1.0.0_react@18.2.0
|
||||
'@radix-ui/react-direction': 1.0.0_react@18.2.0
|
||||
'@radix-ui/react-id': 1.0.0_react@18.2.0
|
||||
'@radix-ui/react-primitive': 1.0.1_biqbaboplfbrettd7655fr4n2y
|
||||
'@radix-ui/react-use-callback-ref': 1.0.0_react@18.2.0
|
||||
'@radix-ui/react-use-controllable-state': 1.0.0_react@18.2.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-select/1.1.1_rj7ozvcq3uehdlnj3cbwzbi5ce:
|
||||
resolution: {integrity: sha512-kvdEho5Yvf2HBaqVCQEJftpXE0zAR5ctMWzijoNhcifF7B09z4xgDyYsAgkwe40/S/8B1mEalG6cjmbkoWcpXw==}
|
||||
peerDependencies:
|
||||
|
@ -4140,6 +4164,25 @@ packages:
|
|||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-tabs/1.0.1_biqbaboplfbrettd7655fr4n2y:
|
||||
resolution: {integrity: sha512-mVNEwHwgjy2G9F7b39f9VY+jF0QUZykTm0Sdv+Uz6KC4KOEIa4HLDiHU8MeEZluRtZE3aqGYDhl93O7QbJDwhg==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.19.4
|
||||
'@radix-ui/primitive': 1.0.0
|
||||
'@radix-ui/react-context': 1.0.0_react@18.2.0
|
||||
'@radix-ui/react-direction': 1.0.0_react@18.2.0
|
||||
'@radix-ui/react-id': 1.0.0_react@18.2.0
|
||||
'@radix-ui/react-presence': 1.0.0_biqbaboplfbrettd7655fr4n2y
|
||||
'@radix-ui/react-primitive': 1.0.1_biqbaboplfbrettd7655fr4n2y
|
||||
'@radix-ui/react-roving-focus': 1.0.1_biqbaboplfbrettd7655fr4n2y
|
||||
'@radix-ui/react-use-controllable-state': 1.0.0_react@18.2.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-toast/1.1.1_biqbaboplfbrettd7655fr4n2y:
|
||||
resolution: {integrity: sha512-0wNOO9/O1jNvJQOVJD62HoquYqsC8KhWvRgmZ2vlw/5PW/K88mcS2G0gKCrWtcAC33rLttPU1JPd4SF+qzCZ6A==}
|
||||
peerDependencies:
|
||||
|
@ -8428,7 +8471,6 @@ packages:
|
|||
normalize-range: 0.1.2
|
||||
picocolors: 1.0.0
|
||||
postcss-value-parser: 4.2.0
|
||||
dev: false
|
||||
|
||||
/autoprefixer/10.4.12_postcss@8.4.18:
|
||||
resolution: {integrity: sha512-WrCGV9/b97Pa+jtwf5UGaRjgQIg7OK3D06GnoYoZNcG1Xb8Gt3EfuKjlhh9i/VtT16g6PYjZ69jdJ2g8FxSC4Q==}
|
||||
|
@ -12992,7 +13034,7 @@ packages:
|
|||
es6-error: 4.1.1
|
||||
matcher: 3.0.0
|
||||
roarr: 2.15.4
|
||||
semver: 7.3.7
|
||||
semver: 7.3.8
|
||||
serialize-error: 7.0.1
|
||||
dev: true
|
||||
|
||||
|
@ -16101,7 +16143,7 @@ packages:
|
|||
resolution: {integrity: sha512-jRVtMFTChbi2i/jqo/i2iP9634KMe+7K1v35mIdj3Mn59i5q27ZYhn+sW6npISM/PQg7HrP2kwtRBMmh5Uvzdg==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
semver: 7.3.7
|
||||
semver: 7.3.8
|
||||
dev: true
|
||||
|
||||
/node-addon-api/5.0.0:
|
||||
|
@ -17066,7 +17108,7 @@ packages:
|
|||
dependencies:
|
||||
'@types/node': 17.0.45
|
||||
jimp: 0.16.2
|
||||
minimist: 1.2.6
|
||||
minimist: 1.2.7
|
||||
dev: true
|
||||
|
||||
/pngjs/3.4.0:
|
||||
|
@ -17314,7 +17356,7 @@ packages:
|
|||
detect-libc: 2.0.1
|
||||
expand-template: 2.0.3
|
||||
github-from-package: 0.0.0
|
||||
minimist: 1.2.6
|
||||
minimist: 1.2.7
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 1.0.2
|
||||
node-abi: 3.26.0
|
||||
|
@ -19233,7 +19275,7 @@ packages:
|
|||
detect-libc: 2.0.1
|
||||
node-addon-api: 5.0.0
|
||||
prebuild-install: 7.1.1
|
||||
semver: 7.3.7
|
||||
semver: 7.3.8
|
||||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.1
|
||||
tunnel-agent: 0.6.0
|
||||
|
@ -20026,6 +20068,21 @@ packages:
|
|||
resolution: {integrity: sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A==}
|
||||
dev: true
|
||||
|
||||
/tailwind-merge/1.6.2:
|
||||
resolution: {integrity: sha512-/JbgwDwsI8F4aryXAcQ6tVOZvI/YyZqPqdkG/Dj/8XZq8JDp64XxZCIDbyp7/iOpIfWFkF8swEwjXQEQkAvJNw==}
|
||||
dev: false
|
||||
|
||||
/tailwind-styled-components/2.1.7_biqbaboplfbrettd7655fr4n2y:
|
||||
resolution: {integrity: sha512-L20oQlwWM+kbYHIrwvvMQ0HHqt1Z/tInnO/Q9zM0IV/vqUxSIcLAmhMwbbiZmyXTdAVz84N4s1GtIyGODq80Pw==}
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '>= 16.8.0'
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
tailwind-merge: 1.6.2
|
||||
dev: false
|
||||
|
||||
/tailwind/4.0.0:
|
||||
resolution: {integrity: sha512-LlUNoD/5maFG1h5kQ6/hXfFPdcnYw+1Z7z+kUD/W/E71CUMwcnrskxiBM8c3G8wmPsD1VvCuqGYMHviI8+yrmg==}
|
||||
dependencies:
|
||||
|
@ -21264,7 +21321,7 @@ packages:
|
|||
is-yarn-global: 0.3.0
|
||||
latest-version: 5.1.0
|
||||
pupa: 2.1.1
|
||||
semver: 7.3.7
|
||||
semver: 7.3.8
|
||||
semver-diff: 3.1.1
|
||||
xdg-basedir: 4.0.0
|
||||
dev: true
|
||||
|
|
Loading…
Reference in a new issue