Location Settings (#302)

* Delete locations

+ Show online status

* style tweaks

* tweaks

* location rescan button

* fix location delete db locking bug

* opting to remove self referencial relation on file_path

* correct query

* consolodate migration

* consolodate migrations
This commit is contained in:
Jamie Pine 2022-06-24 06:26:45 -07:00 committed by GitHub
parent 7cb6e4f574
commit 2cc3f3d95d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 416 additions and 167 deletions

View file

@ -17,6 +17,7 @@
"proptype",
"quicktime",
"repr",
"rescan",
"Roadmap",
"subpackage",
"svgr",

View file

@ -2,5 +2,8 @@
"name": "@sd/server",
"version": "0.0.0",
"main": "index.js",
"license": "GPL-3.0-only"
"license": "GPL-3.0-only",
"scripts": {
"dev": "cargo watch -x 'run -p server'"
}
}

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ClientCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileDelete", params: { id: number, } } | { key: "LibDelete", 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: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } };
export type ClientCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileDelete", params: { id: number, } } | { key: "LibDelete", 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: "LocRescan", params: { id: number, } } | { key: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } };

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JobStatus } from "./JobStatus";
export interface JobReport { id: string, name: string, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: string, }
export interface JobReport { id: string, name: string, data: string | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: string, }

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Platform } from "./Platform";
export interface LibraryNode { uuid: string, name: string, platform: Platform, tcp_address: string, last_seen: string, last_synchronized: string, }
export interface LibraryNode { uuid: string, name: string, platform: Platform, last_seen: string, }

View file

@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LibraryNode } from "./LibraryNode";
export interface LocationResource { id: number, name: string | null, path: string | null, total_capacity: number | null, available_capacity: number | null, is_removable: boolean | null, is_online: boolean, date_created: string, }
export interface LocationResource { id: number, name: string | null, path: string | null, total_capacity: number | null, available_capacity: number | null, is_removable: boolean | null, node: LibraryNode | null, is_online: boolean, date_created: string, }

View file

@ -0,0 +1,48 @@
-- AlterTable
ALTER TABLE "jobs" ADD COLUMN "data" TEXT;
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_locations" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pub_id" TEXT NOT NULL,
"node_id" INTEGER,
"name" TEXT,
"local_path" TEXT,
"total_capacity" INTEGER,
"available_capacity" INTEGER,
"filesystem" TEXT,
"disk_type" INTEGER,
"is_removable" BOOLEAN,
"is_online" BOOLEAN NOT NULL DEFAULT true,
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "locations_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "nodes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_locations" ("available_capacity", "date_created", "disk_type", "filesystem", "id", "is_online", "is_removable", "local_path", "name", "node_id", "pub_id", "total_capacity") SELECT "available_capacity", "date_created", "disk_type", "filesystem", "id", "is_online", "is_removable", "local_path", "name", "node_id", "pub_id", "total_capacity" FROM "locations";
DROP TABLE "locations";
ALTER TABLE "new_locations" RENAME TO "locations";
CREATE UNIQUE INDEX "locations_pub_id_key" ON "locations"("pub_id");
CREATE TABLE "new_file_paths" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"is_dir" BOOLEAN NOT NULL DEFAULT false,
"location_id" INTEGER,
"materialized_path" TEXT NOT NULL,
"name" TEXT NOT NULL,
"extension" TEXT,
"file_id" INTEGER,
"parent_id" INTEGER,
"key_id" INTEGER,
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date_modified" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date_indexed" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "file_paths_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "locations" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "file_paths_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "files" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "file_paths_key_id_fkey" FOREIGN KEY ("key_id") REFERENCES "keys" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_file_paths" ("date_created", "date_indexed", "date_modified", "extension", "file_id", "id", "is_dir", "key_id", "location_id", "materialized_path", "name", "parent_id") SELECT "date_created", "date_indexed", "date_modified", "extension", "file_id", "id", "is_dir", "key_id", "location_id", "materialized_path", "name", "parent_id" FROM "file_paths";
DROP TABLE "file_paths";
ALTER TABLE "new_file_paths" RENAME TO "file_paths";
CREATE INDEX "file_paths_location_id_idx" ON "file_paths"("location_id");
CREATE UNIQUE INDEX "file_paths_location_id_materialized_path_name_extension_key" ON "file_paths"("location_id", "materialized_path", "name", "extension");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -74,6 +74,7 @@ model Node {
sync_events SyncEvent[]
jobs Job[]
Location Location[]
@@map("nodes")
}
@ -107,14 +108,14 @@ model Location {
is_online Boolean @default(true)
date_created DateTime @default(now())
node Node? @relation(fields: [node_id], references: [id])
file_paths FilePath[]
@@map("locations")
}
model File {
id Int @id @default(autoincrement())
// content addressable storage id - sha256
// this does not need to be unique, as incoming replicas will always ignore if at least one exists
// content addressable storage id - sha256 sampled checksum
cas_id String @unique
// full byte contents digested into sha256 checksum
integrity_checksum String? @unique
@ -157,7 +158,7 @@ model FilePath {
id Int @id @default(autoincrement())
is_dir Boolean @default(false)
// location that owns this path
location_id Int
location_id Int?
// a path generated from local file_path ids eg: "34/45/67/890"
materialized_path String
// the name and extension
@ -175,14 +176,17 @@ model FilePath {
date_modified DateTime @default(now())
date_indexed DateTime @default(now())
file File? @relation(fields: [file_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
location Location? @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
parent FilePath? @relation("directory_file_paths", fields: [parent_id], references: [id])
children FilePath[] @relation("directory_file_paths")
file File? @relation(fields: [file_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
location Location? @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
// NOTE: this self relation for the file tree was causing SQLite to go to forever bed, disabling until workaround
// parent FilePath? @relation("directory_file_paths", fields: [parent_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
// children FilePath[] @relation("directory_file_paths")
key Key? @relation(fields: [key_id], references: [id])
@@unique([location_id, materialized_path, name, extension])
@@index([location_id])
@@map("file_paths")
}
@ -282,11 +286,12 @@ model LabelOnFile {
}
model Job {
id String @id
id String @id
name String
node_id Int
action Int
status Int @default(0)
status Int @default(0)
data String?
task_count Int @default(1)
completed_task_count Int @default(0)

View file

@ -151,7 +151,7 @@ pub async fn get_images(
path: &str,
) -> Result<Vec<file_path::Data>, std::io::Error> {
let mut params = vec![
file_path::location_id::equals(location_id),
file_path::location_id::equals(Some(location_id)),
file_path::extension::in_vec(vec![
"png".to_string(),
"jpeg".to_string(),

View file

@ -159,7 +159,12 @@ impl Job for FileIdentifierJob {
}
// handle loop end
let last_row = file_paths.last().unwrap();
let last_row = match file_paths.last() {
Some(l) => l,
None => {
break;
}
};
cursor = last_row.id;
completed += 1;

View file

@ -22,7 +22,7 @@ pub async fn open_dir(
let directory = db
.file_path()
.find_first(vec![
file_path::location_id::equals(location.id),
file_path::location_id::equals(Some(location.id)),
file_path::materialized_path::equals(path.into()),
file_path::is_dir::equals(true),
])
@ -35,7 +35,7 @@ pub async fn open_dir(
let mut file_paths: Vec<FilePath> = db
.file_path()
.find_many(vec![
file_path::location_id::equals(location.id),
file_path::location_id::equals(Some(location.id)),
file_path::parent_id::equals(Some(directory.id)),
])
.with(file_path::file::fetch())

View file

@ -110,7 +110,7 @@ impl Into<FilePath> for file_path::Data {
materialized_path: self.materialized_path,
file_id: self.file_id,
parent_id: self.parent_id,
location_id: self.location_id,
location_id: self.location_id.unwrap_or(0),
date_indexed: self.date_indexed.into(),
name: self.name,
extension: self.extension,

View file

@ -18,7 +18,8 @@ use std::{
use tokio::sync::Mutex;
use ts_rs::TS;
const MAX_WORKERS: usize = 4;
// db is single threaded, nerd
const MAX_WORKERS: usize = 1;
#[async_trait::async_trait]
pub trait Job: Send + Sync + Debug {
@ -40,6 +41,7 @@ impl Jobs {
running_workers: HashMap::new(),
}
}
pub async fn ingest(&mut self, ctx: &CoreContext, job: Box<dyn Job>) {
// create worker to process job
if self.running_workers.len() < MAX_WORKERS {
@ -57,6 +59,7 @@ impl Jobs {
self.job_queue.push_back(job);
}
}
pub fn ingest_queue(&mut self, _ctx: &CoreContext, job: Box<dyn Job>) {
self.job_queue.push_back(job);
}
@ -69,6 +72,7 @@ impl Jobs {
self.ingest(ctx, job).await;
}
}
pub async fn get_running(&self) -> Vec<JobReport> {
let mut ret = vec![];
@ -78,6 +82,19 @@ impl Jobs {
}
ret
}
pub async fn queue_pending_job(ctx: &CoreContext) -> Result<(), JobError> {
let db = &ctx.database;
let next_job = db
.job()
.find_first(vec![job::status::equals(JobStatus::Queued.int_value())])
.exec()
.await?;
Ok(())
}
pub async fn get_history(ctx: &CoreContext) -> Result<Vec<JobReport>, JobError> {
let db = &ctx.database;
let jobs = db
@ -103,6 +120,7 @@ pub enum JobReportUpdate {
pub struct JobReport {
pub id: String,
pub name: String,
pub data: Option<String>,
// client_id: i32,
#[ts(type = "string")]
pub date_created: chrono::DateTime<chrono::Utc>,
@ -131,6 +149,7 @@ impl Into<JobReport> for job::Data {
completed_task_count: self.completed_task_count,
date_created: self.date_created.into(),
date_modified: self.date_modified.into(),
data: self.data,
message: String::new(),
seconds_elapsed: self.seconds_elapsed,
}
@ -147,6 +166,7 @@ impl JobReport {
date_modified: chrono::Utc::now(),
status: JobStatus::Queued,
task_count: 0,
data: None,
completed_task_count: 0,
message: String::new(),
seconds_elapsed: 0,
@ -154,6 +174,13 @@ impl JobReport {
}
pub async fn create(&self, ctx: &CoreContext) -> Result<(), JobError> {
let config = get_nodestate();
let mut params = Vec::new();
if let Some(_) = &self.data {
params.push(job::data::set(self.data.clone()))
}
ctx.database
.job()
.create(
@ -161,7 +188,7 @@ impl JobReport {
job::name::set(self.name.clone()),
job::action::set(1),
job::nodes::link(node::id::equals(config.node_id)),
vec![],
params,
)
.exec()
.await?;

View file

@ -36,6 +36,10 @@ impl WorkerContext {
.send(WorkerEvent::Progressed(updates))
.unwrap_or(());
}
// save the job data to
// pub fn save_data () {
// }
}
// a worker is a dedicated thread that runs a single job

View file

@ -258,13 +258,11 @@ impl Node {
CoreResponse::Success(())
}
ClientCommand::LocDelete { id } => {
ctx.database
.location()
.find_unique(location::id::equals(id))
.delete()
.exec()
.await?;
sys::delete_location(&ctx, id).await?;
CoreResponse::Success(())
}
ClientCommand::LocRescan { id } => {
sys::scan_location(&ctx, id, String::new());
CoreResponse::Success(())
}
// CRUD for files
@ -374,6 +372,7 @@ pub enum ClientCommand {
LocCreate { path: String },
LocUpdate { id: i32, name: Option<String> },
LocDelete { id: i32 },
LocRescan { id: i32 },
// System
SysVolumeUnmount { id: i32 },
GenerateThumbsForLocation { id: i32, path: String },

View file

@ -19,11 +19,18 @@ pub struct LibraryNode {
pub uuid: String,
pub name: String,
pub platform: Platform,
pub tcp_address: String,
#[ts(type = "string")]
pub last_seen: DateTime<Utc>,
#[ts(type = "string")]
pub last_synchronized: DateTime<Utc>,
}
impl Into<LibraryNode> for node::Data {
fn into(self) -> LibraryNode {
LibraryNode {
uuid: self.pub_id,
name: self.name,
platform: IntEnum::from_int(self.platform).unwrap(),
last_seen: self.last_seen.into(),
}
}
}
#[repr(i32)]

View file

@ -1,10 +1,11 @@
use crate::{
encode::ThumbnailJob,
file::{cas::FileIdentifierJob, indexer::IndexerJob},
node::get_nodestate,
prisma::location,
node::{get_nodestate, LibraryNode},
prisma::{file_path, location},
ClientQuery, CoreContext, CoreEvent,
};
use prisma_client_rust::{raw, PrismaValue};
use serde::{Deserialize, Serialize};
use std::{fs, io, io::Write, path::Path};
use thiserror::Error;
@ -21,13 +22,14 @@ pub struct LocationResource {
pub total_capacity: Option<i32>,
pub available_capacity: Option<i32>,
pub is_removable: Option<bool>,
pub node: Option<LibraryNode>,
pub is_online: bool,
#[ts(type = "string")]
pub date_created: chrono::DateTime<chrono::Utc>,
}
impl Into<LocationResource> for location::Data {
fn into(self) -> LocationResource {
fn into(mut self) -> LocationResource {
LocationResource {
id: self.id,
name: self.name,
@ -35,6 +37,7 @@ impl Into<LocationResource> for location::Data {
total_capacity: self.total_capacity,
available_capacity: self.available_capacity,
is_removable: self.is_removable,
node: self.node.take().unwrap_or(None).map(|node| (*node).into()),
is_online: self.is_online,
date_created: self.date_created.into(),
}
@ -81,26 +84,24 @@ pub async fn get_location(
Ok(location.into())
}
pub fn scan_location(ctx: &CoreContext, location_id: i32, path: String) {
ctx.spawn_job(Box::new(IndexerJob { path: path.clone() }));
ctx.queue_job(Box::new(FileIdentifierJob { location_id, path }));
// TODO: make a way to stop jobs so this can be canceled without rebooting app
// ctx.queue_job(Box::new(ThumbnailJob {
// location_id,
// path: "".to_string(),
// background: false,
// }));
}
pub async fn new_location_and_scan(
ctx: &CoreContext,
path: &str,
) -> Result<LocationResource, SysError> {
let location = create_location(&ctx, path).await?;
ctx.spawn_job(Box::new(IndexerJob {
path: path.to_string(),
}));
ctx.queue_job(Box::new(FileIdentifierJob {
location_id: location.id,
path: path.to_string(),
}));
ctx.queue_job(Box::new(ThumbnailJob {
location_id: location.id,
path: "".to_string(),
background: false,
}));
scan_location(&ctx, location.id, path.to_string());
Ok(location)
}
@ -108,7 +109,12 @@ pub async fn new_location_and_scan(
pub async fn get_locations(ctx: &CoreContext) -> Result<Vec<LocationResource>, SysError> {
let db = &ctx.database;
let locations = db.location().find_many(vec![]).exec().await?;
let locations = db
.location()
.find_many(vec![])
.with(location::node::fetch())
.exec()
.await?;
// turn locations into LocationResource
let locations: Vec<LocationResource> = locations
@ -175,6 +181,7 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
)),
location::is_online::set(true),
location::local_path::set(Some(path.to_string())),
location::node_id::set(Some(config.node_id)),
],
)
.exec()
@ -213,6 +220,29 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
Ok(location.into())
}
pub async fn delete_location(ctx: &CoreContext, location_id: i32) -> Result<(), SysError> {
let db = &ctx.database;
db.file_path()
.find_many(vec![file_path::location_id::equals(Some(location_id))])
.delete()
.exec()
.await?;
db.location()
.find_unique(location::id::equals(location_id))
.delete()
.exec()
.await?;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations))
.await;
println!("Location {} deleted", location_id);
Ok(())
}
#[derive(Error, Debug)]
pub enum LocationError {
#[error("Failed to create location (uuid {uuid:?})")]

View file

@ -19,7 +19,7 @@ export function AppLayout() {
return false;
}}
className={clsx(
'flex flex-row h-screen overflow-hidden text-gray-900 select-none dark:text-white',
'flex flex-row h-screen overflow-hidden text-gray-900 select-none dark:text-white cursor-default',
isWindowRounded && 'rounded-xl',
hasWindowBorder && 'border border-gray-200 dark:border-gray-500'
)}

View file

@ -6,6 +6,7 @@ import React, { useState } from 'react';
import { Rings } from 'react-loading-icons';
import FileItem from '../file/FileItem';
import Loader from '../primitive/Loader';
import ProgressBar from '../primitive/ProgressBar';
export interface DeviceProps {
@ -45,13 +46,7 @@ export function Device(props: DeviceProps) {
<div className="flex flex-grow" />
{props.runningJob && (
<div className="flex flex-row ml-5 bg-gray-300 bg-opacity-50 rounded-md dark:bg-gray-550">
<Rings
stroke="#2599FF"
strokeOpacity={4}
strokeWidth={10}
speed={0.5}
className="ml-0.5 mt-[2px] -mr-1 w-7 h-7"
/>
<Loader />
<div className="flex flex-col p-2">
<span className="mb-[2px] -mt-1 truncate text-gray-450 text-tiny">
{props.runningJob.task}...

View file

@ -35,20 +35,20 @@ export const Inspector = (props: {
location?: LocationResource;
selectedFile?: FilePath;
}) => {
const file_path = props.selectedFile;
let full_path = `${props.location?.path}/${file_path?.materialized_path}`;
const file_path = props.selectedFile,
full_path = `${props.location?.path}/${file_path?.materialized_path}`,
file_id = props.selectedFile?.file?.id || -1;
// notes are stored in global state by their file id
// notes are cached in a store by their file id
// this is so we can ensure every note has been sent to Rust even
// when quickly navigating files, which cancels update function
const { notes, setNote, unCacheNote } = useInspectorState();
const file_id = props.selectedFile?.file?.id || -1;
// show cached note over server note
// show cached note over server note, important to check for undefined not falsey
const note =
notes[file_id] === undefined ? props.selectedFile?.file?.note || null : notes[file_id];
// when input is updated
// when input is updated, cache note
function handleNoteUpdate(e: React.ChangeEvent<HTMLTextAreaElement>) {
if (e.target.value !== note) {
setNote(file_id, e.target.value);
@ -63,95 +63,95 @@ export const Inspector = (props: {
}, [note]);
return (
<Transition
as={React.Fragment}
show={true}
enter="transition-translate ease-in-out duration-200"
enterFrom="translate-x-64"
enterTo="translate-x-0"
leave="transition-translate ease-in-out duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-64"
>
<div className="flex p-2 pr-1 mr-1 pb-[51px] w-72 flex-wrap overflow-x-hidden custom-scroll inspector-scroll">
{!!file_path && (
<div className="flex flex-col pb-2 overflow-hidden bg-white rounded-lg select-text dark:bg-gray-600 bg-opacity-70">
<div className="flex items-center justify-center w-full h-64 overflow-hidden rounded-t-lg bg-gray-50 dark:bg-gray-900">
<FileThumb
hasThumbnailOverride={false}
className="!m-0 flex flex-shrink flex-grow-0"
file={file_path}
locationId={props.locationId}
/>
</div>
<h3 className="pt-3 pl-3 text-base font-bold">{file_path?.name}</h3>
<div className="flex flex-row m-3 space-x-2">
<Button size="sm" noPadding>
<Heart className="w-[18px] h-[18px]" />
</Button>
<Button size="sm" noPadding>
<ShareIcon className="w-[18px] h-[18px]" />
</Button>
<Button size="sm" noPadding>
<Link className="w-[18px] h-[18px]" />
</Button>
</div>
{file_path?.file?.cas_id && (
<MetaItem title="Unique Content ID" value={file_path.file.cas_id as string} />
)}
<Divider />
<MetaItem title="URI" value={full_path} />
<Divider />
<MetaItem
title="Date Created"
value={moment(file_path?.date_created).format('MMMM Do YYYY, h:mm:ss a')}
// <Transition
// as={React.Fragment}
// show={true}
// enter="transition-translate ease-in-out duration-200"
// enterFrom="translate-x-64"
// enterTo="translate-x-0"
// leave="transition-translate ease-in-out duration-200"
// leaveFrom="translate-x-0"
// leaveTo="translate-x-64"
// >
<div className="flex p-2 pr-1 mr-1 pb-[51px] w-[330px] flex-wrap overflow-x-hidden custom-scroll inspector-scroll">
{!!file_path && (
<div className="flex flex-col w-full pb-2 overflow-hidden bg-white rounded-lg select-text dark:bg-gray-600 bg-opacity-70">
<div className="flex items-center justify-center w-full h-64 overflow-hidden rounded-t-lg bg-gray-50 dark:bg-gray-900">
<FileThumb
hasThumbnailOverride={false}
className="!m-0 flex flex-shrink flex-grow-0"
file={file_path}
locationId={props.locationId}
/>
<Divider />
<MetaItem
title="Date Indexed"
value={moment(file_path?.date_indexed).format('MMMM Do YYYY, h:mm:ss a')}
/>
{!file_path?.is_dir && (
<>
<Divider />
<div className="flex flex-row items-center px-3 py-2 meta-item">
{file_path?.extension && (
<span className="inline px-1 mr-1 text-xs font-bold uppercase bg-gray-500 rounded-md text-gray-150">
{file_path?.extension}
</span>
)}
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">
{file_path?.extension
? //@ts-ignore
types[file_path.extension.toUpperCase()]?.descriptions.join(' / ')
: 'Unknown'}
</p>
</div>
{file_path.file && (
<>
<Divider />
<MetaItem
title="Note"
value={
<TextArea
className="mt-2 text-xs leading-snug !py-2"
value={note || ''}
onChange={handleNoteUpdate}
/>
}
/>
</>
</div>
<h3 className="pt-3 pl-3 text-base font-bold">{file_path?.name}</h3>
<div className="flex flex-row m-3 space-x-2">
<Button size="sm" noPadding>
<Heart className="w-[18px] h-[18px]" />
</Button>
<Button size="sm" noPadding>
<ShareIcon className="w-[18px] h-[18px]" />
</Button>
<Button size="sm" noPadding>
<Link className="w-[18px] h-[18px]" />
</Button>
</div>
{file_path?.file?.cas_id && (
<MetaItem title="Unique Content ID" value={file_path.file.cas_id as string} />
)}
<Divider />
<MetaItem title="URI" value={full_path} />
<Divider />
<MetaItem
title="Date Created"
value={moment(file_path?.date_created).format('MMMM Do YYYY, h:mm:ss a')}
/>
<Divider />
<MetaItem
title="Date Indexed"
value={moment(file_path?.date_indexed).format('MMMM Do YYYY, h:mm:ss a')}
/>
{!file_path?.is_dir && (
<>
<Divider />
<div className="flex flex-row items-center px-3 py-2 meta-item">
{file_path?.extension && (
<span className="inline px-1 mr-1 text-xs font-bold uppercase bg-gray-500 rounded-md text-gray-150">
{file_path?.extension}
</span>
)}
</>
)}
{/* <div className="flex flex-row m-3">
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">
{file_path?.extension
? //@ts-ignore
types[file_path.extension.toUpperCase()]?.descriptions.join(' / ')
: 'Unknown'}
</p>
</div>
{file_path.file && (
<>
<Divider />
<MetaItem
title="Note"
value={
<TextArea
className="mt-2 text-xs leading-snug !py-2"
value={note || ''}
onChange={handleNoteUpdate}
/>
}
/>
</>
)}
</>
)}
{/* <div className="flex flex-row m-3">
<Button size="sm">Mint</Button>
</div> */}
{/* <MetaItem title="Date Last Modified" value={file?.date_modified} />
{/* <MetaItem title="Date Last Modified" value={file?.date_modified} />
<MetaItem title="Date Indexed" value={file?.date_indexed} /> */}
</div>
)}
</div>
</Transition>
</div>
)}
</div>
// </Transition>
);
};

View file

@ -93,7 +93,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
return (
<div
className={clsx(
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-600',
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-750',
{
'dark:!bg-opacity-40': appProps?.platform === 'macOS'
}

View file

@ -1,14 +1,19 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Button } from '@sd/ui';
import clsx from 'clsx';
import React, { ReactNode } from 'react';
import Loader from '../primitive/Loader';
export interface DialogProps {
trigger: ReactNode;
ctaLabel?: string;
ctaDanger?: boolean;
ctaAction?: () => void;
title?: string;
description?: string;
children: ReactNode;
children?: ReactNode;
loading?: boolean;
}
export default function Dialog(props: DialogProps) {
@ -26,12 +31,21 @@ export default function Dialog(props: DialogProps) {
{props.children}
</div>
<div className="flex flex-row justify-end px-3 py-3 space-x-2 bg-gray-600 border-t border-gray-550">
{props.loading && <Loader />}
<div className="flex-grow" />
<DialogPrimitive.Close asChild>
<Button size="sm" variant="gray">
<Button loading={props.loading} disabled={props.loading} size="sm" variant="gray">
Close
</Button>
</DialogPrimitive.Close>
<Button onClick={props.ctaAction} size="sm" variant="primary">
<Button
onClick={props.ctaAction}
size="sm"
loading={props.loading}
disabled={props.loading}
variant={props.ctaDanger ? 'colored' : 'primary'}
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
>
{props.ctaLabel}
</Button>
</div>

View file

@ -104,7 +104,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
<TopBarButton
icon={ArrowsClockwise}
onClick={() => {
generateThumbsForLocation({ id: locationId, path: '' });
// generateThumbsForLocation({ id: locationId, path: '' });
}}
/>
</div>

View file

@ -0,0 +1,86 @@
import { DotsVerticalIcon, RefreshIcon } from '@heroicons/react/outline';
import { CogIcon, TrashIcon } from '@heroicons/react/solid';
import { command, useBridgeCommand } from '@sd/client';
import { LocationResource } from '@sd/core';
import { Button } from '@sd/ui';
import clsx from 'clsx';
import React, { useState } from 'react';
import { Folder } from '../icons/Folder';
import Dialog from '../layout/Dialog';
interface LocationListItemProps {
location: LocationResource;
}
export default function LocationListItem({ location }: LocationListItemProps) {
const [hide, setHide] = useState(false);
const { mutate: locRescan } = useBridgeCommand('LocRescan');
const { mutate: deleteLoc, isLoading: locDeletePending } = useBridgeCommand('LocDelete', {
onSuccess: () => {
setHide(true);
}
});
if (hide) return <></>;
return (
<div className="flex w-full px-4 py-2 border border-gray-500 rounded-lg bg-gray-550">
<DotsVerticalIcon className="w-5 h-5 mt-3 mr-1 -ml-3 cursor-move drag-handle opacity-10" />
<Folder size={30} className="mr-3" />
<div className="flex flex-col">
<h1 className="pt-0.5 text-sm font-semibold">{location.name}</h1>
<p className="mt-0.5 text-sm select-text text-gray-250">
<span className="py-[1px] px-1 bg-gray-500 rounded mr-1">{location.node?.name}</span>
{location.path}
</p>
</div>
<div className="flex flex-grow" />
<div className="flex h-[45px] p-2 space-x-2">
<Button disabled variant="gray" className="!py-1.5 !px-2 pointer-events-none flex">
<>
<div
className={clsx(
'w-2 h-2 rounded-full',
location.is_online ? 'bg-green-500' : 'bg-red-500'
)}
/>
<span className="ml-1.5 text-xs text-gray-350">
{location.is_online ? 'Online' : 'Offline'}
</span>
</>
</Button>
<Dialog
title="Delete Location"
description="Deleting a location will also remove all files associated with it from the Spacedrive database, the files themselves will not be deleted."
ctaAction={() => {
deleteLoc({ id: location.id });
}}
loading={locDeletePending}
ctaDanger
ctaLabel="Delete"
trigger={
<Button variant="gray" className="!p-1.5">
<TrashIcon className="w-4 h-4" />
</Button>
}
/>
<Button
variant="gray"
className="!p-1.5"
onClick={() => {
// this should cause a lite directory rescan, but this will do for now, so the button does something useful
locRescan({ id: location.id });
}}
>
<RefreshIcon className="w-4 h-4" />
</Button>
{/* <Button variant="gray" className="!p-1.5">
<CogIcon className="w-4 h-4" />
</Button> */}
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import clsx from 'clsx';
import React from 'react';
import { Puff } from 'react-loading-icons';
export default function Loader(props: { className?: string }) {
return (
<Puff
stroke="#2599FF"
strokeOpacity={4}
strokeWidth={5}
speed={1}
className={clsx('ml-0.5 mt-[2px] -mr-1 w-7 h-7', props.className)}
/>
);
}

View file

@ -17,8 +17,9 @@ export const DebugScreen: React.FC<{}> = (props) => {
// });
const { mutate: identifyFiles } = useBridgeCommand('IdentifyUniqueFiles');
return (
<div className="flex flex-col w-full h-screen p-5 custom-scroll page-scroll">
<div className="flex flex-col space-y-5 pb-7">
<div className="flex flex-col w-full h-screen custom-scroll page-scroll">
<div data-tauri-drag-region className="flex flex-shrink-0 w-full h-5" />
<div className="flex flex-col p-5 pt-2 space-y-5 pb-7">
<h1 className="text-lg font-bold ">Developer Debugger</h1>
<div className="flex flex-row pb-4 space-x-2">
<Button

View file

@ -1,5 +1,7 @@
import { useBridgeQuery } from '@sd/client';
import React from 'react';
import LocationListItem from '../../components/location/LocationListItem';
import { InputContainer } from '../../components/primitive/InputContainer';
import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader';
@ -11,18 +13,20 @@ import { SettingsHeader } from '../../components/settings/SettingsHeader';
// ];
export default function LocationSettings() {
// const locations = useBridgeQuery("SysGetLocation")
const { data: locations } = useBridgeQuery('SysGetLocations');
console.log({ locations });
return (
<SettingsContainer>
{/*<Button size="sm">Add Location</Button>*/}
<SettingsHeader title="Locations" description="Manage your settings related to locations." />
<InputContainer
title="Something about a vault"
description="Local cache storage for media previews and thumbnails."
>
<div className="flex flex-row space-x-2"></div>
</InputContainer>
<SettingsHeader title="Locations" description="Manage your storage locations." />
<div className="grid space-y-2">
{locations?.map((location) => (
<LocationListItem key={location.id} location={location} />
))}
</div>
</SettingsContainer>
);
}

View file

@ -40,14 +40,12 @@ const variants = {
dark:hover:bg-gray-500
dark:bg-opacity-80
dark:hover:bg-opacity-100
dark:active:bg-gray-550
dark:active:opacity-80
border-gray-200
hover:border-gray-300
active:border-gray-200
dark:border-gray-500
dark:active:border-gray-600
dark:hover:border-gray-500
text-gray-700
@ -68,6 +66,12 @@ const variants = {
hover:border-primary-500
active:border-primary-700
`,
colored: `
text-white
shadow-sm
hover:bg-opacity-90
active:bg-opacity-100
`,
selected: `bg-gray-100 dark:bg-gray-500
text-black hover:text-black active:text-black dark:hover:text-white dark:text-white
`
@ -114,7 +118,7 @@ export const Button = forwardRef<
) => {
className = clsx(
'border rounded-md items-center transition-colors duration-100 cursor-default',
{ 'opacity-5': loading, '!p-1': noPadding },
{ 'opacity-70': loading, '!p-1': noPadding },
{ 'justify-center': !justifyLeft },
sizes[size || 'default'],
variants[variant || 'default'],