[ENG-380] Interface code structure improvement (#581)

* beginnings of app directory

* settings mostly good

* colocate way more components

* flatten components folder

* reexport QueryClientProvider from client

* move CodeBlock back to interface

* colocate Explorer, KeyManager + more

* goddamn captialisation

* get toasts out of components

* please eslint

* no more src directory

* $ instead of :

* added back RowHeader component

* fix settings modal padding

* more spacing, less margin

* fix sidebar locations button

* fix tags sidebar link

* clean up back button

* added margin to explorer context menu to prevent contact with edge of viewport

* don't export QueryClientProvider from @sd/client

* basic guidelines

* import interface correctly

* remove old demo data

* fix onboarding layout

* fix onboarding navigation

* fix key manager settings button

---------

Co-authored-by: Jamie Pine <ijamespine@me.com>
This commit is contained in:
Brendan Allan 2023-02-28 13:29:48 +08:00 committed by GitHub
parent c6455dd439
commit c65d92ee4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
212 changed files with 3238 additions and 3640 deletions

View file

@ -61,6 +61,8 @@ If you are having issues ensure you are using the following versions of Rust and
- Rust version: **1.67.0**
- Node version: **18**
Be sure to read the [guidelines](https://spacedrive.com/docs/developers/prerequisites/guidelines) to make sure your code is a similar style to ours.
##### Mobile app
To run mobile app

View file

@ -6,8 +6,14 @@ import { listen } from '@tauri-apps/api/event';
import { convertFileSrc } from '@tauri-apps/api/tauri';
import { useEffect } from 'react';
import { getDebugState, hooks } from '@sd/client';
import SpacedriveInterface, { OperatingSystem, Platform, PlatformProvider } from '@sd/interface';
import { KeybindEvent, ErrorPage } from '@sd/interface';
import {
ErrorPage,
KeybindEvent,
OperatingSystem,
Platform,
PlatformProvider,
SpacedriveInterface
} from '@sd/interface';
import '@sd/ui/style';
const client = hooks.createClient({

View file

@ -1,7 +1,6 @@
// WARNING: BE CAREFUL SAVING THIS FILE WITH A FORMATTER ENABLED. The import order is important and goes against prettier's recommendations.
import React, { Suspense } from 'react';
import ReactDOM from 'react-dom/client';
import '@sd/ui/style';
// THIS MUST GO BEFORE importing the App
import '~/patches';
import App from './App';

View file

@ -10,7 +10,7 @@
"include": ["src"],
"references": [
{
"path": "../../packages/interface"
"path": "../../interface"
}
]
}

View file

@ -1,8 +1,8 @@
import { useQueryClient } from '@tanstack/react-query';
import { Heart } from 'phosphor-react-native';
import { useState } from 'react';
import { Pressable, PressableProps } from 'react-native';
import { Object as SDObject, useLibraryMutation } from '@sd/client';
import { useQueryClient } from '@tanstack/react-query';
type Props = {
data: SDObject;

View file

@ -22,7 +22,7 @@ const Note = (props: Props) => {
2000
);
const debouncedNote = useCallback((note: string) => debounce(note), [props.data.id, fileSetNote]);
const debouncedNote = useCallback((note: string) => debounce(note), [debounce]);
return (
<View>

View file

@ -1,7 +1,7 @@
import { useQueryClient } from '@tanstack/react-query';
import { useBridgeMutation } from '@sd/client';
import { useRef } from 'react';
import { ModalRef, ConfirmModal } from '~/components/layout/Modal';
import { useBridgeMutation } from '@sd/client';
import { ConfirmModal, ModalRef } from '~/components/layout/Modal';
type Props = {
libraryUuid: string;

View file

@ -1,3 +1,4 @@
import { useQueryClient } from '@tanstack/react-query';
import { forwardRef, useEffect, useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import ColorPicker from 'react-native-wheel-color-picker';
@ -8,7 +9,6 @@ import { Modal, ModalRef } from '~/components/layout/Modal';
import { Button } from '~/components/primitive/Button';
import useForwardedRef from '~/hooks/useForwardedRef';
import { tw, twStyle } from '~/lib/tailwind';
import { useQueryClient } from '@tanstack/react-query';
const CreateTagModal = forwardRef<ModalRef, unknown>((_, ref) => {
const queryClient = useQueryClient();

View file

@ -1,3 +1,4 @@
import { useQueryClient } from '@tanstack/react-query';
import { forwardRef, useEffect, useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import { Tag, useLibraryMutation } from '@sd/client';
@ -8,7 +9,6 @@ import { Modal, ModalRef } from '~/components/layout/Modal';
import { Button } from '~/components/primitive/Button';
import useForwardedRef from '~/hooks/useForwardedRef';
import { tw, twStyle } from '~/lib/tailwind';
import { useQueryClient } from '@tanstack/react-query';
type Props = {
tag: Tag;

View file

@ -2,7 +2,7 @@ import { createWSClient, loggerLink, wsLink } from '@rspc/client';
import { QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query';
import { useEffect } from 'react';
import { getDebugState, hooks } from '@sd/client';
import SpacedriveInterface, { Platform, PlatformProvider } from '@sd/interface';
import { Platform, PlatformProvider, SpacedriveInterface } from '@sd/interface';
import demoData from './demoData.json';
globalThis.isDev = import.meta.env.DEV;

View file

@ -7,7 +7,7 @@
"include": ["src", "src/demoData.json"],
"references": [
{
"path": "../../packages/interface"
"path": "../../interface"
}
]
}

View file

@ -0,0 +1,26 @@
---
index: 2
---
# Guidelines
## `@sd/interface`
Most interface code should live inside the `app` directory,
with the folder structure resembling the app's routing structure.
We currently use [React Router](https://reactrouter.com/) and take full advantage of nested and config-based routing
### Casing
- All files/folders containing a route should be `lower-kebab-case`
- Dynamic routes should be `camelCase` and have their parameter name prefixed with `$`
- All other files/folders should be `PascalCase` (expect for `index` files inside `PascalCase` folders)
### Layouts
If a folder of routes has a component that should be applied to _every_ sub-route,
the component's file should be named `Layout.tsx` and applied in the parent folder's routing configuration as the `element` property.
For components that should wrap a subset of routes,
name the file with something ending in `Layout.tsx` (but not `Layout.tsx` itself!).
We then recommend using [layout routes](https://reactrouter.com/en/main/route/route#layout-routes) to apply the layout without introducing a new `path` segment.

View file

@ -3,18 +3,16 @@ import { FallbackProps } from 'react-error-boundary';
import { useDebugState } from '@sd/client';
import { Button } from '@sd/ui';
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<ErrorPage
message={error.message}
sendReportBtn={() => {
captureException(error);
resetErrorBoundary();
}}
reloadBtn={resetErrorBoundary}
/>
);
}
export default ({ error, resetErrorBoundary }: FallbackProps) => (
<ErrorPage
message={error.message}
sendReportBtn={() => {
captureException(error);
resetErrorBoundary();
}}
reloadBtn={resetErrorBoundary}
/>
);
export function ErrorPage({
reloadBtn,

View file

@ -1,8 +1,9 @@
import { useNavigate } from 'react-router';
import { Button } from '@sd/ui';
export default function NotFound() {
export default () => {
const navigate = useNavigate();
return (
<div className="bg-app/80 w-full">
<div
@ -22,4 +23,4 @@ export default function NotFound() {
</div>
</div>
);
}
};

View file

@ -0,0 +1,139 @@
import { Clipboard, FileX, Image, Plus, Repeat, Share, ShieldCheck } from 'phosphor-react';
import { PropsWithChildren, useMemo } from 'react';
import { useLibraryMutation } from '@sd/client';
import { ContextMenu as CM } from '@sd/ui';
import { useExplorerParams } from '~/app/$libraryId/location/$id';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import { usePlatform } from '~/util/Platform';
export const OpenInNativeExplorer = () => {
const platform = usePlatform();
const os = useOperatingSystem();
const osFileBrowserName = useMemo(() => {
if (os === 'macOS') {
return 'Finder';
} else {
return 'Explorer';
}
}, [os]);
return (
<>
{platform.openPath && (
<CM.Item
label={`Open in ${osFileBrowserName}`}
keybind="⌘Y"
onClick={() => {
alert('TODO: Open in FS');
// console.log('TODO', store.contextMenuActiveItem);
// platform.openPath!('/Users/oscar/Desktop'); // TODO: Work out the file path from the backend
}}
/>
)}
</>
);
};
export default (props: PropsWithChildren) => {
const store = useExplorerStore();
const params = useExplorerParams();
const generateThumbsForLocation = useLibraryMutation('jobs.generateThumbsForLocation');
const objectValidator = useLibraryMutation('jobs.objectValidator');
const rescanLocation = useLibraryMutation('locations.fullRescan');
const copyFiles = useLibraryMutation('files.copyFiles');
const cutFiles = useLibraryMutation('files.cutFiles');
return (
<div className="relative">
<CM.Root trigger={props.children}>
<OpenInNativeExplorer />
<CM.Separator />
<CM.Item
label="Share"
icon={Share}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
/>
<CM.Separator />
<CM.Item
onClick={() => store.locationId && rescanLocation.mutate(store.locationId)}
label="Re-index"
icon={Repeat}
/>
<CM.Item
label="Paste"
keybind="⌘V"
hidden={!store.cutCopyState.active}
onClick={() => {
if (store.cutCopyState.actionType == 'Copy') {
store.locationId &&
copyFiles.mutate({
source_location_id: store.cutCopyState.sourceLocationId,
source_path_id: store.cutCopyState.sourcePathId,
target_location_id: store.locationId,
target_path: params.path,
target_file_name_suffix: null
});
} else {
store.locationId &&
cutFiles.mutate({
source_location_id: store.cutCopyState.sourceLocationId,
source_path_id: store.cutCopyState.sourcePathId,
target_location_id: store.locationId,
target_path: params.path
});
}
}}
icon={Clipboard}
/>
<CM.Item
label="Deselect"
hidden={!store.cutCopyState.active}
onClick={() => {
getExplorerStore().cutCopyState = {
...store.cutCopyState,
active: false
};
}}
icon={FileX}
/>
<CM.SubMenu label="More actions..." icon={Plus}>
<CM.Item
onClick={() =>
store.locationId &&
generateThumbsForLocation.mutate({ id: store.locationId, path: '' })
}
label="Regen Thumbnails"
icon={Image}
/>
<CM.Item
onClick={() =>
store.locationId && objectValidator.mutate({ id: store.locationId, path: '' })
}
label="Generate Checksums"
icon={ShieldCheck}
/>
</CM.SubMenu>
<CM.Separator />
</CM.Root>
</div>
);
};

View file

@ -0,0 +1,288 @@
import {
ArrowBendUpRight,
Copy,
FileX,
LockSimple,
LockSimpleOpen,
Package,
Plus,
Scissors,
Share,
TagSimple,
Trash,
TrashSimple
} from 'phosphor-react';
import { PropsWithChildren } from 'react';
import {
ExplorerItem,
isObject,
useLibraryContext,
useLibraryMutation,
useLibraryQuery
} from '@sd/client';
import { ContextMenu, dialogManager } from '@sd/ui';
import { useExplorerParams } from '~/app/$libraryId/location/$id';
import { showAlertDialog } from '~/components/AlertDialog';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { usePlatform } from '~/util/Platform';
import { OpenInNativeExplorer } from '../ContextMenu';
import DecryptDialog from './DecryptDialog';
import DeleteDialog from './DeleteDialog';
import EncryptDialog from './EncryptDialog';
import EraseDialog from './EraseDialog';
interface Props extends PropsWithChildren {
data: ExplorerItem;
}
export default ({ data, ...props }: Props) => {
const { library } = useLibraryContext();
const store = useExplorerStore();
const params = useExplorerParams();
const platform = usePlatform();
const objectData = data ? (isObject(data) ? data.item : data.item.object) : null;
const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false;
const mountedKeys = useLibraryQuery(['keys.listMounted']);
const hasMountedKeys = mountedKeys.data?.length ?? 0 > 0;
const copyFiles = useLibraryMutation('files.copyFiles');
return (
<div className="relative">
<ContextMenu.Root trigger={props.children}>
<ContextMenu.Item
label="Open"
keybind="⌘O"
onClick={() => {
// TODO: Replace this with a proper UI
window.location.href = platform.getFileUrl(
library.uuid,
store.locationId!,
data.item.id
);
}}
icon={Copy}
/>
<ContextMenu.Item label="Open with..." />
<ContextMenu.Separator />
{!store.showInspector && (
<>
<ContextMenu.Item
label="Details"
// icon={Sidebar}
onClick={() => (getExplorerStore().showInspector = true)}
/>
<ContextMenu.Separator />
</>
)}
<ContextMenu.Item label="Quick view" keybind="␣" />
<OpenInNativeExplorer />
<ContextMenu.Separator />
<ContextMenu.Item label="Rename" />
<ContextMenu.Item
label="Duplicate"
keybind="⌘D"
onClick={() => {
copyFiles.mutate({
source_location_id: store.locationId!,
source_path_id: data.item.id,
target_location_id: store.locationId!,
target_path: params.path,
target_file_name_suffix: ' copy'
});
}}
/>
<ContextMenu.Item
label="Cut"
keybind="⌘X"
onClick={() => {
getExplorerStore().cutCopyState = {
sourceLocationId: store.locationId!,
sourcePathId: data.item.id,
actionType: 'Cut',
active: true
};
}}
icon={Scissors}
/>
<ContextMenu.Item
label="Copy"
keybind="⌘C"
onClick={() => {
getExplorerStore().cutCopyState = {
sourceLocationId: store.locationId!,
sourcePathId: data.item.id,
actionType: 'Copy',
active: true
};
}}
icon={Copy}
/>
<ContextMenu.Item
label="Deselect"
hidden={!store.cutCopyState.active}
onClick={() => {
getExplorerStore().cutCopyState = {
...store.cutCopyState,
active: false
};
}}
icon={FileX}
/>
<ContextMenu.Separator />
<ContextMenu.Item
label="Share"
icon={Share}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
/>
<ContextMenu.Separator />
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={objectData?.id || 0} />
</ContextMenu.SubMenu>
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
<ContextMenu.Item
label="Encrypt"
icon={LockSimple}
keybind="⌘E"
onClick={() => {
if (keyManagerUnlocked && hasMountedKeys) {
dialogManager.create((dp) => (
<EncryptDialog {...dp} location_id={store.locationId!} path_id={data.item.id} />
));
} else if (!keyManagerUnlocked) {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
} else if (!hasMountedKeys) {
showAlertDialog({
title: 'No mounted keys',
value: 'No mounted keys were found. Please mount a key and try again.'
});
}
}}
/>
{/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */}
<ContextMenu.Item
label="Decrypt"
icon={LockSimpleOpen}
keybind="⌘D"
onClick={() => {
if (keyManagerUnlocked) {
dialogManager.create((dp) => (
<DecryptDialog {...dp} location_id={store.locationId!} path_id={data.item.id} />
));
} else {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
}
}}
/>
<ContextMenu.Item label="Compress" icon={Package} keybind="⌘B" />
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
<ContextMenu.Item label="PNG" />
<ContextMenu.Item label="WebP" />
</ContextMenu.SubMenu>
<ContextMenu.Item label="Rescan Directory" icon={Package} />
<ContextMenu.Item label="Regen Thumbnails" icon={Package} />
<ContextMenu.Item
variant="danger"
label="Secure delete"
icon={TrashSimple}
onClick={() => {
dialogManager.create((dp) => (
<EraseDialog
{...dp}
location_id={getExplorerStore().locationId!}
path_id={data.item.id}
/>
));
}}
/>
</ContextMenu.SubMenu>
<ContextMenu.Separator />
<ContextMenu.Item
icon={Trash}
label="Delete"
variant="danger"
keybind="⌘DEL"
onClick={() => {
dialogManager.create((dp) => (
<DeleteDialog
{...dp}
location_id={getExplorerStore().locationId!}
path_id={data.item.id}
/>
));
}}
/>
</ContextMenu.Root>
</div>
);
};
const AssignTagMenuItems = (props: { objectId: number }) => {
const tags = useLibraryQuery(['tags.list'], { suspense: true });
const tagsForObject = useLibraryQuery(['tags.getForObject', props.objectId], { suspense: true });
const assignTag = useLibraryMutation('tags.assign');
return (
<>
{tags.data?.map((tag, index) => {
const active = !!tagsForObject.data?.find((t) => t.id === tag.id);
return (
<ContextMenu.Item
key={tag.id}
keybind={`${index + 1}`}
onClick={(e) => {
e.preventDefault();
if (props.objectId === null) return;
assignTag.mutate({
tag_id: tag.id,
object_id: props.objectId,
unassign: active
});
}}
>
<div
className="mr-0.5 block h-[15px] w-[15px] rounded-full border"
style={{
backgroundColor: active ? tag.color || '#efefef' : 'transparent' || '#efefef',
borderColor: tag.color || '#efefef'
}}
/>
<p>{tag.name}</p>
</ContextMenu.Item>
);
})}
</>
);
};

View file

@ -1,17 +1,11 @@
import { RadioGroup } from '@headlessui/react';
import { Eye, EyeSlash, Info } from 'phosphor-react';
import { useState } from 'react';
import { Info } from 'phosphor-react';
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { Input, Switch, useZodForm, z } from '@sd/ui/src/forms';
import { showAlertDialog } from '~/util/dialog';
import { usePlatform } from '../../util/Platform';
import { Tooltip } from '../tooltip/Tooltip';
interface DecryptDialogProps extends UseDialogProps {
location_id: number;
path_id: number;
}
import { Tooltip } from '@sd/ui';
import { PasswordInput, Switch, useZodForm, z } from '@sd/ui/src/forms';
import { showAlertDialog } from '~/components/AlertDialog';
import { usePlatform } from '~/util/Platform';
const schema = z.object({
type: z.union([z.literal('password'), z.literal('key')]),
@ -21,7 +15,12 @@ const schema = z.object({
saveToKeyManager: z.boolean()
});
export const DecryptFileDialog = (props: DecryptDialogProps) => {
interface Props extends UseDialogProps {
location_id: number;
path_id: number;
}
export default (props: Props) => {
const platform = usePlatform();
const dialog = useDialog(props);
@ -55,10 +54,6 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
}
});
const [show, setShow] = useState({ password: false });
const PasswordCurrentEyeIcon = show.password ? EyeSlash : Eye;
const form = useZodForm({
defaultValues: {
type: hasMountedKeys ? 'key' : 'password',
@ -91,13 +86,13 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
loading={decryptFile.isLoading}
ctaLabel="Decrypt"
>
<RadioGroup
value={form.watch('type')}
onChange={(e: 'key' | 'password') => form.setValue('type', e)}
className="mt-2"
>
<span className="text-xs font-bold">Key Type</span>
<div className="mt-2 flex flex-row gap-2">
<div className="space-y-2 py-2">
<h2 className="text-xs font-bold">Key Type</h2>
<RadioGroup
value={form.watch('type')}
onChange={(e: 'key' | 'password') => form.setValue('type', e)}
className="mt-2 flex flex-row gap-2"
>
<RadioGroup.Option disabled={!hasMountedKeys} value="key">
{({ checked }) => (
<Button
@ -117,12 +112,10 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
</Button>
)}
</RadioGroup.Option>
</div>
</RadioGroup>
</RadioGroup>
{form.watch('type') === 'key' && (
<div className="relative mt-3 mb-2 flex grow">
<div className="space-x-2">
{form.watch('type') === 'key' && (
<div className="flex flex-row items-center">
<Switch
className="bg-app-selected"
size="sm"
@ -130,76 +123,58 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
checked={form.watch('mountAssociatedKey')}
onCheckedChange={(e) => form.setValue('mountAssociatedKey', e)}
/>
<span className="ml-3 mt-0.5 text-xs font-medium">Automatically mount key</span>
<Tooltip label="The key linked with the file will be automatically mounted">
<Info className="text-ink-faint ml-1.5 mt-0.5 h-4 w-4" />
</Tooltip>
</div>
<span className="ml-3 mt-0.5 text-xs font-medium">Automatically mount key</span>
<Tooltip label="The key linked with the file will be automatically mounted">
<Info className="text-ink-faint ml-1.5 mt-0.5 h-4 w-4" />
</Tooltip>
</div>
)}
)}
{form.watch('type') === 'password' && (
<>
<div className="relative mt-3 mb-2 flex grow">
<Input
className={`w-max grow !py-0.5`}
{form.watch('type') === 'password' && (
<>
<PasswordInput
placeholder="Password"
type={show.password ? 'text' : 'password'}
size="sm"
{...form.register('password', { required: true })}
/>
<Button
onClick={() => setShow((old) => ({ ...old, password: !old.password }))}
size="icon"
className="absolute right-[5px] top-[5px] border-none"
type="button"
>
<PasswordCurrentEyeIcon className="h-4 w-4" />
</Button>
</div>
<div className="relative mt-3 mb-2 flex grow">
<div className="space-x-2">
<div className="flex flex-row items-center">
<Switch
className="bg-app-selected"
size="sm"
{...form.register('saveToKeyManager')}
/>
<span className="ml-3 mt-0.5 text-xs font-medium">Save to Key Manager</span>
<Tooltip label="This key will be saved to the key manager">
<Info className="text-ink-faint ml-1.5 mt-0.5 h-4 w-4" />
</Tooltip>
</div>
<span className="ml-3 mt-0.5 text-xs font-medium">Save to Key Manager</span>
<Tooltip label="This key will be saved to the key manager">
<Info className="text-ink-faint ml-1.5 mt-0.5 h-4 w-4" />
</Tooltip>
</div>
</>
)}
</>
)}
<div className="mt-4 mb-3 grid w-full grid-cols-2 gap-4">
<div className="flex flex-col">
<span className="text-xs font-bold">Output file</span>
<Button
size="sm"
variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
className="mt-2 h-[23px] text-xs leading-3"
type="button"
onClick={() => {
// if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
if (!platform.saveFilePickerDialog) {
// TODO: Support opening locations on web
showAlertDialog({
title: 'Error',
value: "System dialogs aren't supported on this platform."
});
return;
}
platform.saveFilePickerDialog().then((result) => {
if (result) form.setValue('outputPath', result as string);
<h2 className="text-xs font-bold">Output file</h2>
<Button
size="sm"
variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
className="h-[23px] text-xs leading-3"
type="button"
onClick={() => {
// if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
if (!platform.saveFilePickerDialog) {
// TODO: Support opening locations on web
showAlertDialog({
title: 'Error',
value: "System dialogs aren't supported on this platform."
});
}}
>
Select
</Button>
</div>
return;
}
platform.saveFilePickerDialog().then((result) => {
if (result) form.setValue('outputPath', result as string);
});
}}
>
Select
</Button>
</div>
</Dialog>
);

View file

@ -1,20 +1,18 @@
import { useLibraryMutation } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm, z } from '@sd/ui/src/forms';
import { useZodForm } from '@sd/ui/src/forms';
interface DeleteDialogProps extends UseDialogProps {
interface Propps extends UseDialogProps {
location_id: number;
path_id: number;
}
const schema = z.object({});
export const DeleteFileDialog = (props: DeleteDialogProps) => {
export default (props: Propps) => {
const dialog = useDialog(props);
const deleteFile = useLibraryMutation('files.deleteFiles');
const form = useZodForm({
schema
});
const form = useZodForm();
const onSubmit = form.handleSubmit(() =>
deleteFile.mutateAsync({
location_id: props.location_id,

View file

@ -1,12 +1,17 @@
import { Algorithm, useLibraryMutation, useLibraryQuery } from '@sd/client';
import {
Algorithm,
hashingAlgoSlugSchema,
slugFromHashingAlgo,
useLibraryMutation,
useLibraryQuery
} from '@sd/client';
import { Button, Dialog, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui';
import { CheckBox, useZodForm, z } from '@sd/ui/src/forms';
import { getHashingAlgorithmString } from '~/screens/settings/library/KeysSetting';
import { showAlertDialog } from '~/components/AlertDialog';
import { usePlatform } from '~/util/Platform';
import { showAlertDialog } from '~/util/dialog';
import { SelectOptionKeyList } from '../key/KeyList';
import { KeyListSelectOptions } from '../../KeyManager/List';
interface EncryptDialogProps extends UseDialogProps {
interface Props extends UseDialogProps {
location_id: number;
path_id: number;
}
@ -14,13 +19,13 @@ interface EncryptDialogProps extends UseDialogProps {
const schema = z.object({
key: z.string(),
encryptionAlgo: z.string(),
hashingAlgo: z.string(),
hashingAlgo: hashingAlgoSlugSchema,
metadata: z.boolean(),
previewMedia: z.boolean(),
outputPath: z.string()
});
export const EncryptFileDialog = ({ ...props }: EncryptDialogProps) => {
export default (props: Props) => {
const dialog = useDialog(props);
const platform = usePlatform();
@ -29,7 +34,7 @@ export const EncryptFileDialog = ({ ...props }: EncryptDialogProps) => {
const hashAlg = keys.data?.find((key) => {
return key.uuid === uuid;
})?.hashing_algorithm;
hashAlg && form.setValue('hashingAlgo', getHashingAlgorithmString(hashAlg));
hashAlg && form.setValue('hashingAlgo', slugFromHashingAlgo(hashAlg));
};
const keys = useLibraryQuery(['keys.list']);
@ -44,17 +49,13 @@ export const EncryptFileDialog = ({ ...props }: EncryptDialogProps) => {
showAlertDialog({
title: 'Success',
value:
'The encryption job has started successfully. You may track the progress in the job overview panel.',
inputBox: false,
description: ''
'The encryption job has started successfully. You may track the progress in the job overview panel.'
});
},
onError: () => {
showAlertDialog({
title: 'Error',
value: 'The encryption job failed to start.',
inputBox: false,
description: ''
value: 'The encryption job failed to start.'
});
}
});
@ -96,7 +97,7 @@ export const EncryptFileDialog = ({ ...props }: EncryptDialogProps) => {
UpdateKey(e);
}}
>
{mountedUuids.data && <SelectOptionKeyList keys={mountedUuids.data} />}
{mountedUuids.data && <KeyListSelectOptions keys={mountedUuids.data} />}
</Select>
</div>
<div className="flex flex-col">

View file

@ -1,10 +1,9 @@
import { useState } from 'react';
import { useLibraryMutation } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { Dialog, Slider, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm, z } from '@sd/ui/src/forms';
import Slider from '../primitive/Slider';
interface EraseDialogProps extends UseDialogProps {
interface Props extends UseDialogProps {
location_id: number;
path_id: number;
}
@ -13,7 +12,7 @@ const schema = z.object({
passes: z.number()
});
export const EraseFileDialog = (props: EraseDialogProps) => {
export default (props: Props) => {
const dialog = useDialog(props);
const eraseFile = useLibraryMutation('files.eraseFiles');

View file

@ -1,23 +1,9 @@
import clsx from 'clsx';
import { HTMLAttributes } from 'react';
import { ExplorerItem, ObjectKind, isObject } from '@sd/client';
import { cva, tw } from '@sd/ui';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { ExplorerItemContextMenu } from './ExplorerContextMenu';
import { FileThumb } from './FileThumb';
const NameArea = tw.div`flex justify-center`;
const nameContainerStyles = cva(
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-xs font-medium',
{
variants: {
selected: {
true: 'bg-accent text-white'
}
}
}
);
import ContextMenu from './ContextMenu';
import FileThumb from './Thumb';
interface Props extends HTMLAttributes<HTMLDivElement> {
data: ExplorerItem;
@ -26,16 +12,14 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
}
function FileItem({ data, selected, index, ...rest }: Props) {
const objectData = data ? (isObject(data) ? data.item : data.item.object) : null;
const isVid = ObjectKind[objectData?.kind || 0] === 'Video';
const item = data.item;
const explorerStore = useExplorerStore();
return (
<ExplorerItemContextMenu data={data}>
<ContextMenu data={data}>
<div
onContextMenu={(e) => {
onContextMenu={() => {
if (index != undefined) {
getExplorerStore().selectedRowIndex = index;
}
@ -59,14 +43,19 @@ function FileItem({ data, selected, index, ...rest }: Props) {
>
<FileThumb data={data} size={explorerStore.gridItemSize} />
</div>
<NameArea>
<span className={nameContainerStyles({ selected })}>
<div className="flex justify-center">
<span
className={clsx(
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-xs font-medium',
selected && 'bg-accent text-white'
)}
>
{item.name}
{item.extension && `.${item.extension}`}
</span>
</NameArea>
</div>
</div>
</ExplorerItemContextMenu>
</ContextMenu>
);
}

View file

@ -3,12 +3,11 @@ import clsx from 'clsx';
import dayjs from 'dayjs';
import { HTMLAttributes } from 'react';
import { ExplorerItem, ObjectKind, isObject, isPath } from '@sd/client';
import { getExplorerStore } from '../../hooks/useExplorerStore';
import { ExplorerItemContextMenu } from './ExplorerContextMenu';
import { ColumnKey, columns } from './FileColumns';
import { FileThumb } from './FileThumb';
import { InfoPill } from './Inspector';
import { getExplorerItemData } from './util';
import { InfoPill } from '../Inspector';
import { getExplorerItemData } from '../util';
import ContextMenu from './ContextMenu';
import { columns } from './RowHeader';
import FileThumb from './Thumb';
interface Props extends HTMLAttributes<HTMLDivElement> {
data: ExplorerItem;
@ -16,35 +15,35 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
selected: boolean;
}
function FileRow({ data, index, selected, ...props }: Props) {
return (
<ExplorerItemContextMenu className="w-full" data={data}>
<div
{...props}
className={clsx(
'table-body-row mr-2 flex w-full flex-row rounded-lg border-2',
selected ? 'border-accent' : 'border-transparent',
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
)}
>
{columns.map((col) => (
<div
key={col.key}
className="table-body-cell flex items-center px-4 py-2 pr-2"
style={{ width: col.width }}
>
<RenderCell data={data} colKey={col.key} />
</div>
))}
</div>
</ExplorerItemContextMenu>
);
export default ({ data, index, selected, ...props }: Props) => (
<ContextMenu data={data}>
<div
{...props}
className={clsx(
'table-body-row mr-2 flex w-full flex-row rounded-lg border-2',
selected ? 'border-accent' : 'border-transparent',
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
)}
>
{columns.map((col) => (
<div
key={col.key}
className="table-body-cell flex items-center px-4 py-2 pr-2"
style={{ width: col.width }}
>
<Cell data={data} colKey={col.key} />
</div>
))}
</div>
</ContextMenu>
);
interface CellProps {
colKey: (typeof columns)[number]['key'];
data: ExplorerItem;
}
const RenderCell: React.FC<{
colKey: ColumnKey;
data: ExplorerItem;
}> = ({ colKey, data }) => {
const Cell = ({ colKey, data }: CellProps) => {
const objectData = data ? (isObject(data) ? data.item : data.item.object) : null;
const { cas_id } = getExplorerItemData(data);
@ -83,14 +82,8 @@ const RenderCell: React.FC<{
</InfoPill>
</div>
);
// case 'meta_integrity_hash':
// return <span className="truncate">{value}</span>;
// case 'tags':
// return renderCellWithIcon(MusicNoteIcon);
default:
return <></>;
}
};
export default FileRow;

View file

@ -0,0 +1,32 @@
interface Column {
column: string;
key: string;
width: number;
}
export const columns = [
{ column: 'Name', key: 'name', width: 280 },
{ column: 'Type', key: 'extension', width: 150 },
{ column: 'Size', key: 'size', width: 100 },
{ column: 'Date Created', key: 'date_created', width: 150 },
{ column: 'Content ID', key: 'cas_id', width: 150 }
] as const satisfies Readonly<Column[]>;
export const ROW_HEADER_HEIGHT = 40;
export const RowHeader = () => (
<div
style={{ height: ROW_HEADER_HEIGHT }}
className="sticky mr-2 flex w-full flex-row rounded-lg border-2 border-transparent"
>
{columns.map((col) => (
<div
key={col.column}
className="flex items-center px-4 py-2 pr-2"
style={{ width: col.width, marginTop: -ROW_HEADER_HEIGHT * 2 }}
>
<span className="text-xs font-medium ">{col.column}</span>
</div>
))}
</div>
);

View file

@ -8,18 +8,18 @@ import Video from '@sd/assets/images/Video.png';
import clsx from 'clsx';
import { CSSProperties } from 'react';
import { ExplorerItem } from '@sd/client';
import { Folder } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
import { Folder } from '../icons/Folder';
import { getExplorerItemData } from './util';
import { getExplorerItemData } from '../util';
// const icons = import.meta.glob('../../../../assets/icons/*.svg');
interface FileItemProps {
interface Props {
data: ExplorerItem;
size: number;
className?: string;
}
export function FileThumb({ data, size, className }: FileItemProps) {
export default ({ data, size, className }: Props) => {
const { cas_id, isDir, kind, hasThumbnail, extension } = getExplorerItemData(data);
// 10 percent of the size
@ -59,14 +59,14 @@ export function FileThumb({ data, size, className }: FileItemProps) {
: {}
}
/>
{extension && kind === 'Video' && size > 80 && (
<div className="absolute bottom-[22%] right-2 rounded bg-black/60 py-0.5 px-1 text-[9px] font-semibold uppercase opacity-70">
{extension && kind === 'Video' && hasThumbnail && size > 80 && (
<div className="absolute bottom-[13%] right-[5%] rounded bg-black/60 py-0.5 px-1 text-[9px] font-semibold uppercase opacity-70">
{extension}
</div>
)}
</div>
);
}
};
interface FileThumbImgProps {
isDir: boolean;
cas_id: string | null;

View file

@ -1,10 +1,8 @@
import { useCallback, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { useLibraryMutation } from '@sd/client';
import { Object as SDObject } from '@sd/client';
import { TextArea } from '@sd/ui';
import { Object as SDObject, useLibraryMutation } from '@sd/client';
import { Divider, TextArea } from '@sd/ui';
import { MetaContainer, MetaTitle } from '../Inspector';
import { Divider } from './Divider';
interface Props {
data: SDObject;

View file

@ -2,7 +2,7 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import { Barcode, CircleWavyCheck, Clock, Cube, Hash, Link, Lock, Snowflake } from 'phosphor-react';
import { useEffect, useState } from 'react';
import { ComponentProps, useEffect, useState } from 'react';
import {
ExplorerContext,
ExplorerItem,
@ -11,24 +11,17 @@ import {
isObject,
useLibraryQuery
} from '@sd/client';
import { Button, tw } from '@sd/ui';
import { DefaultProps } from '../primitive/types';
import { Tooltip } from '../tooltip/Tooltip';
import { FileThumb } from './FileThumb';
import { Divider } from './inspector/Divider';
import FavoriteButton from './inspector/FavoriteButton';
import Note from './inspector/Note';
import { Button, Divider, Tooltip, tw } from '@sd/ui';
import FileThumb from '../File/Thumb';
import FavoriteButton from './FavoriteButton';
import Note from './Note';
export const InfoPill = tw.span`inline border border-transparent px-1 text-[11px] font-medium shadow shadow-app-shade/5 bg-app-selected rounded-md text-ink-dull`;
export const PlaceholderPill = tw.span`inline border px-1 text-[11px] shadow shadow-app-shade/10 rounded-md bg-transparent border-dashed border-app-active transition hover:text-ink-faint hover:border-ink-faint font-medium text-ink-faint/70`;
export const PlaceholderPill = tw.span`inline border px-1 text-[11px] shadow shadow-app-shade/10 rounded-md bg-transparent border-dashed border-app-active transition hover:text-ink-faint hover:border-ink-faint font-medium text-ink-faint/70`;
export const MetaContainer = tw.div`flex flex-col px-4 py-1.5`;
export const MetaTitle = tw.h5`text-xs font-bold`;
export const MetaKeyName = tw.h5`text-xs flex-shrink-0 flex-wrap-0`;
export const MetaValue = tw.p`text-xs break-all text-ink truncate`;
const MetaTextLine = tw.div`flex items-center my-0.5 text-xs text-ink-dull`;
@ -37,7 +30,7 @@ const InspectorIcon = ({ component: Icon, ...props }: any) => (
<Icon weight="bold" {...props} className={clsx('mr-2 shrink-0', props.className)} />
);
interface Props extends DefaultProps<HTMLDivElement> {
interface Props extends ComponentProps<'div'> {
context?: ExplorerContext;
data?: ExplorerItem;
}

View file

@ -1,15 +1,9 @@
import { PropsWithChildren, useState } from 'react';
import { Select, SelectOption } from '@sd/ui';
import { getExplorerStore, useExplorerStore } from '../../hooks/useExplorerStore';
import Slider from '../primitive/Slider';
import { useState } from 'react';
import { Select, SelectOption, Slider, tw } from '@sd/ui';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
function Heading({ children }: PropsWithChildren) {
return <div className="text-ink-dull text-xs font-semibold">{children}</div>;
}
function SubHeading({ children }: PropsWithChildren) {
return <div className="text-ink-dull mb-1 text-xs font-medium">{children}</div>;
}
const Heading = tw.div`text-ink-dull text-xs font-semibold`;
const Subheading = tw.div`text-ink-dull mb-1 text-xs font-medium`;
const sortOptions = {
name: 'Name',
@ -20,7 +14,7 @@ const sortOptions = {
date_last_opened: 'Date Last Opened'
};
export function ExplorerOptionsPanel() {
export default () => {
const [sortBy, setSortBy] = useState('name');
const [stackBy, setStackBy] = useState('kind');
@ -29,7 +23,7 @@ export function ExplorerOptionsPanel() {
return (
<div className="p-4 ">
{/* <Heading>Explorer Appearance</Heading> */}
<SubHeading>Item size</SubHeading>
<Subheading>Item size</Subheading>
<Slider
onValueChange={(value) => {
getExplorerStore().gridItemSize = value[0] || 100;
@ -42,7 +36,7 @@ export function ExplorerOptionsPanel() {
/>
<div className="my-2 mt-4 grid grid-cols-2 gap-2">
<div className="flex flex-col">
<SubHeading>Sort by</SubHeading>
<Subheading>Sort by</Subheading>
<Select value={sortBy} size="sm" onChange={setSortBy}>
{Object.entries(sortOptions).map(([value, text]) => (
<SelectOption key={value} value={value}>
@ -52,7 +46,7 @@ export function ExplorerOptionsPanel() {
</Select>
</div>
<div className="flex flex-col">
<SubHeading>Stack by</SubHeading>
<Subheading>Stack by</Subheading>
<Select value={stackBy} size="sm" onChange={setStackBy}>
<SelectOption value="kind">Kind</SelectOption>
<SelectOption value="location">Location</SelectOption>
@ -62,4 +56,4 @@ export function ExplorerOptionsPanel() {
</div>
</div>
);
}
};

View file

@ -12,19 +12,15 @@ import {
SquaresFour,
Tag
} from 'phosphor-react';
import { forwardRef, useEffect, useRef } from 'react';
import { ComponentProps, forwardRef, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Button, Input, Popover, cva } from '@sd/ui';
import DragRegion from '~/components/layout/DragRegion';
import { getExplorerStore, useExplorerStore } from '../../hooks/useExplorerStore';
import { useOperatingSystem } from '../../hooks/useOperatingSystem';
import { KeybindEvent } from '../../util/keybind';
import { KeyManager } from '../key/KeyManager';
import { Shortcut } from '../primitive/Shortcut';
import { DefaultProps } from '../primitive/types';
import { Tooltip } from '../tooltip/Tooltip';
import { ExplorerOptionsPanel } from './ExplorerOptionsPanel';
import { Button, Input, Popover, Shortcut, Tooltip, cva } from '@sd/ui';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import { KeybindEvent } from '~/util/keybind';
import { KeyManager } from '../KeyManager';
import OptionsPanel from './OptionsPanel';
export interface TopBarButtonProps {
children: React.ReactNode;
@ -75,59 +71,61 @@ const TopBarButton = forwardRef<HTMLButtonElement, TopBarButtonProps>(
}
);
export const SearchBar = forwardRef<HTMLInputElement, DefaultProps>((props, forwardedRef) => {
const {
register,
handleSubmit,
reset,
formState: { isDirty, dirtyFields }
} = useForm();
export const SearchBar = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
(props, forwardedRef) => {
const {
register,
handleSubmit,
reset,
formState: { dirtyFields }
} = useForm();
const { ref, ...searchField } = register('searchField', {
onBlur: (e) => {
// if there's no text in the search bar, don't mark it as dirty so the key hint shows
if (!dirtyFields.searchField) reset();
}
});
const { ref, ...searchField } = register('searchField', {
onBlur: () => {
// if there's no text in the search bar, don't mark it as dirty so the key hint shows
if (!dirtyFields.searchField) reset();
}
});
const platform = useOperatingSystem(false);
const os = useOperatingSystem(true);
const platform = useOperatingSystem(false);
const os = useOperatingSystem(true);
return (
<form onSubmit={handleSubmit(() => null)} className="relative flex h-7">
<Input
ref={(el) => {
ref(el);
return (
<form onSubmit={handleSubmit(() => null)} className="relative flex h-7">
<Input
ref={(el) => {
ref(el);
if (typeof forwardedRef === 'function') forwardedRef(el);
else if (forwardedRef) forwardedRef.current = el;
}}
placeholder="Search"
className={clsx('w-32 transition-all focus:w-52', props.className)}
{...searchField}
/>
<div
className={clsx(
'pointer-events-none absolute right-1 flex h-7 items-center space-x-1 opacity-70 peer-focus:invisible'
)}
>
{platform === 'browser' ? (
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
) : os === 'macOS' ? (
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
) : (
<Shortcut chars="CTRL+F" aria-label={'Press CTRL-F to focus search bar'} />
)}
</div>
</form>
);
});
if (typeof forwardedRef === 'function') forwardedRef(el);
else if (forwardedRef) forwardedRef.current = el;
}}
placeholder="Search"
className={clsx('w-32 transition-all focus:w-52', props.className)}
{...searchField}
/>
<div
className={clsx(
'pointer-events-none absolute right-1 flex h-7 items-center space-x-1 opacity-70 peer-focus:invisible'
)}
>
{platform === 'browser' ? (
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
) : os === 'macOS' ? (
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
) : (
<Shortcut chars="CTRL+F" aria-label={'Press CTRL-F to focus search bar'} />
)}
</div>
</form>
);
}
);
export type TopBarProps = DefaultProps & {
export type TopBarProps = {
showSeparator?: boolean;
};
export const TopBar: React.FC<TopBarProps> = (props) => {
export default (props: TopBarProps) => {
const platform = useOperatingSystem(false);
const os = useOperatingSystem(true);
@ -235,8 +233,8 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
<Tooltip label="List view">
<TopBarButton
rounding="none"
active={store.layoutMode === 'list'}
onClick={() => (getExplorerStore().layoutMode = 'list')}
active={store.layoutMode === 'rows'}
onClick={() => (getExplorerStore().layoutMode = 'rows')}
>
<Rows className={TOP_BAR_ICON_STYLE} />
</TopBarButton>
@ -326,7 +324,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
}
>
<div className="block w-[250px] ">
<ExplorerOptionsPanel />
<OptionsPanel />
</div>
</Popover>
</Tooltip>
@ -342,7 +340,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
>
<SidebarSimple
weight={store.showInspector ? 'fill' : 'regular'}
className={clsx(TOP_BAR_ICON_STYLE, 'scale-x-[-1] transform')}
className={clsx(TOP_BAR_ICON_STYLE, 'scale-x-[-1]')}
/>
</TopBarButton>
</Tooltip>

View file

@ -4,12 +4,11 @@ import { useSearchParams } from 'react-router-dom';
import { useKey, useOnWindowResize } from 'rooks';
import { ExplorerContext, ExplorerItem, isPath } from '@sd/client';
import { ExplorerLayoutMode, getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { LIST_VIEW_HEADER_HEIGHT, ListViewHeader } from './FileColumns';
import FileItem from './FileItem';
import FileRow from './FileRow';
import FileItem from './File/Item';
import FileRow from './File/Row';
import { ROW_HEADER_HEIGHT, RowHeader } from './File/RowHeader';
const TOP_BAR_HEIGHT = 46;
// const GRID_TEXT_AREA_HEIGHT = 25;
interface Props {
context: ExplorerContext;
@ -17,7 +16,7 @@ interface Props {
onScroll?: (posY: number) => void;
}
export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
export const VirtualizedList = memo(({ data, onScroll }: Props) => {
const scrollRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
@ -64,7 +63,7 @@ export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
getScrollElement: () => scrollRef.current,
overscan: 200,
estimateSize: () => itemSize,
measureElement: (index) => itemSize
measureElement: () => itemSize
});
// TODO: Make scroll adjustment work with both list and grid layout, currently top bar offset disrupts positioning of list, and grid just doesn't work
@ -101,20 +100,17 @@ export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
<div
ref={scrollRef}
className="custom-scroll explorer-scroll h-screen"
onClick={(e) => {
getExplorerStore().selectedRowIndex = -1;
}}
onClick={() => (getExplorerStore().selectedRowIndex = -1)}
>
<div
ref={innerRef}
className="relative w-full"
style={{
height: rowVirtualizer.getTotalSize(),
marginTop:
layoutMode === 'list' ? TOP_BAR_HEIGHT + LIST_VIEW_HEADER_HEIGHT : TOP_BAR_HEIGHT
marginTop: layoutMode === 'rows' ? TOP_BAR_HEIGHT + ROW_HEADER_HEIGHT : TOP_BAR_HEIGHT
}}
>
{layoutMode === 'list' && <ListViewHeader />}
{layoutMode === 'rows' && <RowHeader />}
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
@ -124,9 +120,9 @@ export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
transform: `translateY(${virtualRow.start}px)`
}}
>
{layoutMode === 'list' && (
{layoutMode === 'rows' && (
<WrappedItem
kind="list"
kind="rows"
isSelected={explorerStore.selectedRowIndex === virtualRow.index}
index={virtualRow.index}
item={data[virtualRow.index]!}
@ -181,7 +177,7 @@ const WrappedItem = memo(({ item, index, isSelected, kind }: WrappedItemProps) =
[isSelected, index]
);
const ItemComponent = kind === 'list' ? FileRow : FileItem;
const ItemComponent = kind === 'rows' ? FileRow : FileItem;
return (
<ItemComponent

View file

@ -1,9 +1,9 @@
import { useCallback, useEffect, useState } from 'react';
import { ExplorerData, rspc, useLibraryContext } from '@sd/client';
import { useExplorerStore } from '~/hooks/useExplorerStore';
import { Inspector } from '../explorer/Inspector';
import { ExplorerContextMenu } from './ExplorerContextMenu';
import { TopBar } from './ExplorerTopBar';
import { Inspector } from '../Explorer/Inspector';
import ExplorerContextMenu from './ContextMenu';
import TopBar from './TopBar';
import { VirtualizedList } from './VirtualizedList';
interface Props {

View file

@ -1,14 +1,10 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import { DotsThree, Eye, Key as KeyIcon } from 'phosphor-react';
import { MutableRefObject, PropsWithChildren, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { animated, useTransition } from 'react-spring';
import { useLibraryMutation } from '@sd/client';
import { Button } from '@sd/ui';
import { DefaultProps } from '../primitive/types';
import { Tooltip } from '../tooltip/Tooltip';
export type KeyManagerProps = DefaultProps;
import { Button, Tooltip } from '@sd/ui';
// TODO: Replace this with Prisma type when integrating with backend
export interface Key {
@ -37,10 +33,8 @@ interface Props extends DropdownMenu.MenuContentProps {
export const KeyDropdown = ({
trigger,
children,
disabled,
transformOrigin,
className,
...props
className
}: PropsWithChildren<Props>) => {
const [open, setOpen] = useState(false);
@ -87,7 +81,7 @@ export const KeyDropdown = ({
);
};
export const Key: React.FC<{ data: Key; index: number }> = ({ data, index }) => {
export const Key = ({ data }: { data: Key }) => {
const mountKey = useLibraryMutation('keys.mount');
const unmountKey = useLibraryMutation('keys.unmount');
const deleteKey = useLibraryMutation('keys.deleteFromLibrary');

View file

@ -0,0 +1,56 @@
import { useMemo, useRef } from 'react';
import { useLibraryQuery } from '@sd/client';
import { SelectOption } from '@sd/ui';
import { DummyKey, Key } from './Key';
// ideal for going within a select box
// can use mounted or unmounted keys, just provide different inputs
export const KeyListSelectOptions = (props: { keys: string[] }) => (
<>
{props.keys.map((key) => (
<SelectOption key={key} value={key}>
Key {key.substring(0, 8).toUpperCase()}
</SelectOption>
))}
</>
);
export default () => {
const keys = useLibraryQuery(['keys.list']);
const mountedUuids = useLibraryQuery(['keys.listMounted']);
const defaultKey = useLibraryQuery(['keys.getDefault']);
const mountingQueue = useRef(new Set<string>());
const [mountedKeys, unmountedKeys] = useMemo(
() => [
keys.data?.filter((key) => mountedUuids.data?.includes(key.uuid)) ?? [],
keys.data?.filter((key) => !mountedUuids.data?.includes(key.uuid)) ?? []
],
[keys, mountedUuids]
);
if (keys.data?.length === 0) {
return <DummyKey text="No keys available" />;
}
return (
<>
{[...mountedKeys, ...unmountedKeys]?.map((key) => (
<Key
key={key.uuid}
data={{
id: key.uuid,
name: `Key ${key.uuid.substring(0, 8).toUpperCase()}`,
queue: mountingQueue.current,
mounted: mountedKeys.includes(key),
default: defaultKey.data === key.uuid,
memoryOnly: key.memory_only,
automount: key.automount
// key stats need including here at some point
}}
/>
))}
</>
);
};

View file

@ -1,19 +1,18 @@
import cryptoRandomString from 'crypto-random-string';
import { Eye, EyeSlash, Info } from 'phosphor-react';
import { useEffect, useRef, useState } from 'react';
import { Algorithm, useLibraryMutation } from '@sd/client';
import { Button, CategoryHeading, Input, Select, SelectOption, Switch, tw } from '@sd/ui';
import { getHashingAlgorithmSettings } from '../../screens/settings/library/KeysSetting';
import Slider from '../primitive/Slider';
import { Tooltip } from '../tooltip/Tooltip';
import {
Algorithm,
HASHING_ALGOS,
HashingAlgoSlug,
generatePassword,
useLibraryMutation
} from '@sd/client';
import { Button, CategoryHeading, Input, Select, SelectOption, Slider, Switch, tw } from '@sd/ui';
import { Tooltip } from '@sd/ui';
const KeyHeading = tw(CategoryHeading)`mb-1`;
export const generatePassword = (length: number) => {
return cryptoRandomString({ length, type: 'ascii-printable' });
};
export function KeyMounter() {
export default () => {
const ref = useRef<HTMLInputElement>(null);
const [showKey, setShowKey] = useState(false);
const [librarySync, setLibrarySync] = useState(true);
@ -23,7 +22,7 @@ export function KeyMounter() {
const [key, setKey] = useState('');
const [encryptionAlgo, setEncryptionAlgo] = useState('XChaCha20Poly1305');
const [hashingAlgo, setHashingAlgo] = useState('Argon2id-s');
const [hashingAlgo, setHashingAlgo] = useState<HashingAlgoSlug>('Argon2id-s');
const createKey = useLibraryMutation('keys.add');
const CurrentEyeIcon = showKey ? EyeSlash : Eye;
@ -123,7 +122,11 @@ export function KeyMounter() {
</div>
<div className="flex flex-col">
<span className="text-xs font-bold">Hashing</span>
<Select className="mt-2" onChange={setHashingAlgo} value={hashingAlgo}>
<Select
className="mt-2"
onChange={(s) => setHashingAlgo(s as HashingAlgoSlug)}
value={hashingAlgo}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
@ -140,7 +143,7 @@ export function KeyMounter() {
onClick={() => {
setKey('');
const hashing_algorithm = getHashingAlgorithmSettings(hashingAlgo);
const hashing_algorithm = HASHING_ALGOS[hashingAlgo];
createKey.mutate({
algorithm: encryptionAlgo as Algorithm,
@ -155,4 +158,4 @@ export function KeyMounter() {
</Button>
</div>
);
}
};

View file

@ -0,0 +1,68 @@
import { useState } from 'react';
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, PasswordInput } from '@sd/ui';
import { showAlertDialog } from '~/components/AlertDialog';
export default () => {
const keyringSk = useLibraryQuery(['keys.getSecretKey'], { initialData: '' });
const unlockKeyManager = useLibraryMutation('keys.unlockKeyManager', {
onError: () =>
showAlertDialog({
title: 'Unlock Error',
value: 'The information provided to the key manager was incorrect'
})
});
const isKeyManagerUnlocking = useLibraryQuery(['keys.isKeyManagerUnlocking']);
const [masterPassword, setMasterPassword] = useState('');
const [secretKey, setSecretKey] = useState('');
const [enterSkManually, setEnterSkManually] = useState(keyringSk?.data === null);
return (
<div className="space-y-2 p-2">
<PasswordInput
size="sm"
placeholder="Master Password"
value={masterPassword}
onChange={(e) => setMasterPassword(e.target.value)}
autoFocus
/>
{enterSkManually && (
<PasswordInput
size="sm"
placeholder="Secret Key"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
autoFocus
/>
)}
<Button
className="w-full"
variant="accent"
disabled={
unlockKeyManager.isLoading || isKeyManagerUnlocking.data !== null
? isKeyManagerUnlocking.data!
: false
}
onClick={() => {
if (masterPassword !== '') {
setMasterPassword('');
setSecretKey('');
unlockKeyManager.mutate({ password: masterPassword, secret_key: secretKey });
}
}}
>
Unlock
</Button>
{!enterSkManually && (
<p className="text-accent" onClick={() => setEnterSkManually(true)}>
or enter secret key manually
</p>
)}
</div>
);
};

View file

@ -0,0 +1,95 @@
import { Gear, Lock } from 'phosphor-react';
import { useLibraryContext, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, ButtonLink, Tabs } from '@sd/ui';
import KeyList from './List';
import KeyMounter from './Mounter';
import NotUnlocked from './NotUnlocked';
export function KeyManager() {
const isUnlocked = useLibraryQuery(['keys.isUnlocked']);
if (!isUnlocked?.data) return <NotUnlocked />;
else return <Unlocked />;
}
const Unlocked = () => {
const { library } = useLibraryContext();
const unmountAll = useLibraryMutation('keys.unmountAll');
const clearMasterPassword = useLibraryMutation('keys.clearMasterPassword');
return (
<div>
<Tabs.Root defaultValue="mount">
<div className="flex flex-col">
<Tabs.List>
<Tabs.Trigger className="text-sm font-medium" value="mount">
Mount
</Tabs.Trigger>
<Tabs.Trigger className="text-sm font-medium" value="keys">
Keys
</Tabs.Trigger>
<div className="grow" />
<Button
size="icon"
onClick={() => {
unmountAll.mutate(null);
clearMasterPassword.mutate(null);
}}
variant="subtle"
className="text-ink-faint"
>
<Lock className="text-ink-faint h-4 w-4" />
</Button>
<ButtonLink
to={`/${library.uuid}/settings/library/keys`}
size="icon"
variant="subtle"
className="text-ink-faint"
>
<Gear className="text-ink-faint h-4 w-4" />
</ButtonLink>
</Tabs.List>
</div>
<Tabs.Content value="keys">
<Keys />
</Tabs.Content>
<Tabs.Content value="mount">
<KeyMounter />
</Tabs.Content>
</Tabs.Root>
</div>
);
};
const Keys = () => {
const unmountAll = useLibraryMutation(['keys.unmountAll']);
return (
<div className="flex h-full max-h-[360px] flex-col">
<div className="custom-scroll overlay-scroll p-3">
<div className="">
{/* <CategoryHeading>Mounted keys</CategoryHeading> */}
<div className="space-y-1.5">
<KeyList />
</div>
</div>
</div>
<div className="border-app-line flex w-full rounded-b-md border-t p-2">
<Button
size="sm"
variant="gray"
onClick={() => {
unmountAll.mutate(null);
}}
>
Unmount All
</Button>
<div className="grow" />
<Button size="sm" variant="gray">
Close
</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,40 @@
import { useLibraryMutation } from '@sd/client';
import { dialogManager } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
import AddLocationDialog from '../../settings/library/locations/AddDialog';
export default () => {
const platform = usePlatform();
const createLocation = useLibraryMutation('locations.create');
return (
<button
onClick={() => {
if (platform.platform === 'web') {
dialogManager.create((dp) => <AddLocationDialog {...dp} />);
} else {
if (!platform.openDirectoryPickerDialog) {
alert('Opening a dialogue is not supported on this platform!');
return;
}
platform.openDirectoryPickerDialog().then((result) => {
// TODO: Pass indexer rules ids to create location
if (result)
createLocation.mutate({
path: result as string,
indexer_rules_ids: []
});
});
}
}}
className="
border-sidebar-line hover:border-sidebar-selected cursor-normal text-ink-faint mt-1 w-full rounded
border border-dashed px-2 py-1 text-center
text-xs font-medium transition
"
>
Add Location
</button>
);
};

View file

@ -0,0 +1,85 @@
import { getDebugState, useBridgeQuery, useDebugState } from '@sd/client';
import { Button, Popover, Select, SelectOption, Switch } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
import Setting from '../../settings/Setting';
export default () => {
const buildInfo = useBridgeQuery(['buildInfo']);
const nodeState = useBridgeQuery(['nodeState']);
const debugState = useDebugState();
const platform = usePlatform();
return (
<Popover
className="p-4 focus:outline-none"
transformOrigin="bottom left"
trigger={
<h1 className="text-ink-faint/50 ml-1 w-full text-[7pt]">
v{buildInfo.data?.version || '-.-.-'} - {buildInfo.data?.commit || 'dev'}
</h1>
}
>
<div className="block h-96 w-[430px]">
<Setting
mini
title="rspc Logger"
description="Enable the logger link so you can see what's going on in the browser logs."
>
<Switch
checked={debugState.rspcLogger}
onClick={() => (getDebugState().rspcLogger = !debugState.rspcLogger)}
/>
</Setting>
{platform.openPath && (
<Setting
mini
title="Open Data Directory"
description="Quickly get to your Spacedrive database"
>
<div className="mt-2">
<Button
size="sm"
variant="gray"
onClick={() => {
if (nodeState?.data?.data_path) platform.openPath!(nodeState?.data?.data_path);
}}
>
Open
</Button>
</div>
</Setting>
)}
<Setting
mini
title="React Query Devtools"
description="Configure the React Query devtools."
>
<Select
value={debugState.reactQueryDevtools}
size="sm"
onChange={(value) => (getDebugState().reactQueryDevtools = value as any)}
>
<SelectOption value="disabled">Disabled</SelectOption>
<SelectOption value="invisible">Invisible</SelectOption>
<SelectOption value="enabled">Enabled</SelectOption>
</Select>
</Setting>
{/* {platform.showDevtools && (
<SettingContainer
mini
title="Devtools"
description="Allow opening browser devtools in a production build"
>
<div className="mt-2">
<Button size="sm" variant="gray" onClick={platform.showDevtools}>
Show
</Button>
</div>
</SettingContainer>
)} */}
</div>
</Popover>
);
};

View file

@ -0,0 +1,5 @@
import clsx from 'clsx';
export default ({ component: Icon, ...props }: any) => (
<Icon weight="bold" {...props} className={clsx('mr-2 h-4 w-4', props.className)} />
);

View file

@ -4,7 +4,6 @@ import {
ArrowsClockwise,
Camera,
Copy,
DotsThree,
Eye,
Fingerprint,
Folder,
@ -18,9 +17,7 @@ import {
X
} from 'phosphor-react';
import { JobReport, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, CategoryHeading, Popover, PopoverClose, tw } from '@sd/ui';
import ProgressBar from '../primitive/ProgressBar';
import { Tooltip } from '../tooltip/Tooltip';
import { Button, CategoryHeading, PopoverClose, ProgressBar, Tooltip } from '@sd/ui';
interface JobNiceData {
name: string;
@ -93,12 +90,6 @@ const StatusColors: Record<JobReport['status'], string> = {
Paused: 'text-gray-500'
};
function elapsed(seconds: number) {
return new Date(seconds * 1000).toUTCString().match(/(\d\d:\d\d:\d\d)/)?.[0];
}
const HeaderContainer = tw.div`z-20 flex items-center w-full h-10 px-2 border-b border-app-line/50 rounded-t-md bg-app-button/70`;
export function JobsManager() {
const runningJobs = useLibraryQuery(['jobs.getRunning']);
const jobs = useLibraryQuery(['jobs.getHistory']);
@ -106,7 +97,7 @@ export function JobsManager() {
return (
<div className="h-full overflow-hidden pb-10">
<HeaderContainer>
<div className="border-app-line/50 bg-app-button/70 z-20 flex h-10 w-full items-center rounded-t-md border-b px-2">
<CategoryHeading className="ml-2">Recent Jobs</CategoryHeading>
<div className="grow" />
@ -122,7 +113,7 @@ export function JobsManager() {
</Tooltip>
</Button>
</PopoverClose>
</HeaderContainer>
</div>
<div className="custom-scroll inspector-scroll mr-1 h-full overflow-x-hidden">
<div className="">
<div className="py-1">

View file

@ -0,0 +1,59 @@
import clsx from 'clsx';
import { Gear, Lock, Plus } from 'phosphor-react';
import { useClientContext } from '@sd/client';
import { Dropdown, dialogManager } from '@sd/ui';
import CreateDialog from '../../settings/node/libraries/CreateDialog';
export default () => {
const { library, libraries, currentLibraryId } = useClientContext();
return (
<Dropdown.Root
// we override the sidebar dropdown item's hover styles
// because the dark style clashes with the sidebar
itemsClassName="dark:bg-sidebar-box dark:border-sidebar-line mt-1 dark:divide-menu-selected/30 shadow-none"
button={
<Dropdown.Button
variant="gray"
className={clsx(
`text-ink w-full `,
// these classname overrides are messy
// but they work
`!bg-sidebar-box !border-sidebar-line/50 active:!border-sidebar-line active:!bg-sidebar-button ui-open:!bg-sidebar-button ui-open:!border-sidebar-line ring-offset-sidebar`,
(library === null || libraries.isLoading) && '!text-ink-faint'
)}
>
<span className="truncate">
{libraries.isLoading ? 'Loading...' : library ? library.config.name : ' '}
</span>
</Dropdown.Button>
}
>
<Dropdown.Section>
{libraries.data?.map((lib) => (
<Dropdown.Item
to={`/${lib.uuid}/overview`}
key={lib.uuid}
selected={lib.uuid === currentLibraryId}
>
{lib.config.name}
</Dropdown.Item>
))}
</Dropdown.Section>
<Dropdown.Section>
<Dropdown.Item
icon={Plus}
onClick={() => dialogManager.create((dp) => <CreateDialog {...dp} />)}
>
New Library
</Dropdown.Item>
<Dropdown.Item icon={Gear} to="settings/library">
Manage Library
</Dropdown.Item>
<Dropdown.Item icon={Lock} onClick={() => alert('TODO: Not implemented yet!')}>
Lock
</Dropdown.Item>
</Dropdown.Section>
</Dropdown.Root>
);
};

View file

@ -0,0 +1,36 @@
import { cva } from 'class-variance-authority';
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
const styles = cva(
'max-w ring-offset-sidebar focus:ring-accent flex grow flex-row items-center gap-0.5 truncate rounded px-2 py-1 text-sm font-medium outline-none focus:ring-2 focus:ring-offset-2',
{
variants: {
active: {
true: 'bg-sidebar-selected/40 text-ink',
false: 'text-ink-dull'
},
transparent: {
true: 'bg-opacity-90',
false: ''
}
}
}
);
export default (props: PropsWithChildren<NavLinkProps>) => {
const os = useOperatingSystem();
return (
<NavLink
{...props}
className={({ isActive }) =>
clsx(styles({ active: isActive, transparent: os === 'macOS' }), props.className)
}
>
{props.children}
</NavLink>
);
};

View file

@ -0,0 +1,19 @@
import { PropsWithChildren } from 'react';
import { CategoryHeading } from '@sd/ui';
export default (
props: PropsWithChildren<{
name: string;
actionArea?: React.ReactNode;
}>
) => (
<div className="group mt-5">
<div className="mb-1 flex items-center justify-between">
<CategoryHeading className="ml-1">{props.name}</CategoryHeading>
<div className="text-ink-faint opacity-0 transition-all duration-300 hover:!opacity-100 group-hover:opacity-30">
{props.actionArea}
</div>
</div>
{props.children}
</div>
);

View file

@ -0,0 +1,259 @@
import clsx from 'clsx';
import {
ArchiveBox,
Broadcast,
CheckCircle,
CirclesFour,
CopySimple,
Crosshair,
Eraser,
FilmStrip,
Gear,
MonitorPlay,
Planet
} from 'phosphor-react';
import { useEffect } from 'react';
import { Link, NavLink } from 'react-router-dom';
import {
arraysEqual,
useClientContext,
useDebugState,
useLibraryQuery,
useOnlineLocations
} from '@sd/client';
import { Button, ButtonLink, Folder, Loader, Popover, Tooltip } from '@sd/ui';
import { SubtleButton } from '~/components/SubtleButton';
import { MacTrafficLights } from '~/components/TrafficLights';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import { OperatingSystem, usePlatform } from '~/util/Platform';
import AddLocationButton from './AddLocationButton';
import DebugPopover from './DebugPopover';
import Icon from './Icon';
import { JobsManager } from './JobManager';
import LibrariesDropdown from './LibrariesDropdown';
import SidebarLink from './Link';
import Section from './Section';
export default () => {
const os = useOperatingSystem();
useEffect(() => {
// Prevent the dropdown button to be auto focused on launch
// Hacky but it works
setTimeout(() => {
if (!document.activeElement || !('blur' in document.activeElement)) return;
(document.activeElement.blur as () => void)();
});
}, []);
return (
<div
className={clsx(
'border-sidebar-divider bg-sidebar relative flex min-h-full w-44 shrink-0 grow-0 flex-col space-y-2 border-r px-2.5 pb-2',
macOnly(os, 'bg-opacity-[0.75]')
)}
>
<WindowControls />
<LibrariesDropdown />
<Contents />
<Footer />
</div>
);
};
const WindowControls = () => {
const { platform } = usePlatform();
const os = useOperatingSystem();
const showControls = window.location.search.includes('showControls');
if (platform === 'tauri' || showControls) {
return (
<div data-tauri-drag-region className={clsx('shrink-0', macOnly(os, 'h-7'))}>
{/* We do not provide the onClick handlers for 'MacTrafficLights' because this is only used in demo mode */}
{showControls && <MacTrafficLights className="absolute top-[13px] left-[13px] z-50" />}
</div>
);
}
return null;
};
const LibrarySection = () => {
const locations = useLibraryQuery(['locations.list'], { keepPreviousData: true });
const tags = useLibraryQuery(['tags.list'], { keepPreviousData: true });
const onlineLocations = useOnlineLocations();
return (
<>
<Section
name="Locations"
actionArea={
<Link to="settings/library/locations">
<SubtleButton />
</Link>
}
>
{locations.data?.map((location) => {
const online = onlineLocations?.some((l) => arraysEqual(location.pub_id, l));
return (
<SidebarLink
className="group relative w-full"
to={`location/${location.id}`}
key={location.id}
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Folder size={18} />
<div
className={clsx(
'absolute right-0 bottom-0.5 h-1.5 w-1.5 rounded-full',
online ? 'bg-green-500' : 'bg-red-500'
)}
/>
</div>
<span className="shrink-0 grow">{location.name}</span>
</SidebarLink>
);
})}
{(locations.data?.length || 0) < 4 && <AddLocationButton />}
</Section>
{!!tags.data?.length && (
<Section
name="Tags"
actionArea={
<NavLink to="settings/library/tags">
<SubtleButton />
</NavLink>
}
>
<div className="mt-1 mb-2">
{tags.data?.slice(0, 6).map((tag, index) => (
<SidebarLink key={index} to={`tag/${tag.id}`} className="">
<div
className="h-[12px] w-[12px] rounded-full"
style={{ backgroundColor: tag.color || '#efefef' }}
/>
<span className="ml-1.5 text-sm">{tag.name}</span>
</SidebarLink>
))}
</div>
</Section>
)}
</>
);
};
const Contents = () => {
const { library } = useClientContext();
return (
<div className="no-scrollbar mask-fade-out flex grow flex-col overflow-x-hidden overflow-y-scroll pb-10">
<div className="space-y-0.5">
<SidebarLink to="overview">
<Icon component={Planet} />
Overview
</SidebarLink>
<SidebarLink to="spaces">
<Icon component={CirclesFour} />
Spaces
</SidebarLink>
{/* <SidebarLink to="people">
<Icon component={UsersThree} />
People
</SidebarLink> */}
<SidebarLink to="media">
<Icon component={MonitorPlay} />
Media
</SidebarLink>
<SidebarLink to="spacedrop">
<Icon component={Broadcast} />
Spacedrop
</SidebarLink>
<SidebarLink to="imports">
<Icon component={ArchiveBox} />
Imports
</SidebarLink>
</div>
{library && <LibrarySection />}
<Section name="Tools" actionArea={<SubtleButton />}>
<SidebarLink to="duplicate-finder">
<Icon component={CopySimple} />
Duplicate Finder
</SidebarLink>
<SidebarLink to="lost-and-found">
<Icon component={Crosshair} />
Find a File
</SidebarLink>
<SidebarLink to="cache-cleaner">
<Icon component={Eraser} />
Cache Cleaner
</SidebarLink>
<SidebarLink to="media-encoder">
<Icon component={FilmStrip} />
Media Encoder
</SidebarLink>
</Section>
<div className="grow" />
</div>
);
};
const IsRunningJob = () => {
const { data: isRunningJob } = useLibraryQuery(['jobs.isRunning']);
return isRunningJob ? (
<Loader className="h-[20px] w-[20px]" />
) : (
<CheckCircle className="h-5 w-5" />
);
};
const Footer = () => {
const { library } = useClientContext();
const debugState = useDebugState();
return (
<div className="space-y-1">
<div className="flex">
<ButtonLink
to="settings/client/general"
size="icon"
variant="subtle"
className="text-ink-faint ring-offset-sidebar"
>
<Tooltip label="Settings">
<Gear className="h-5 w-5" />
</Tooltip>
</ButtonLink>
<Popover
trigger={
<Button
size="icon"
variant="subtle"
className="radix-state-open:bg-sidebar-selected/50 text-ink-faint ring-offset-sidebar"
disabled={!library}
>
{library && (
<Tooltip label="Recent Jobs">
<IsRunningJob />
</Tooltip>
)}
</Button>
}
>
<div className="block h-96 w-[430px]">
<JobsManager />
</div>
</Popover>
</div>
{debugState.enabled && <DebugPopover />}
</div>
);
};
// cute little helper to decrease code clutter
const macOnly = (platform: OperatingSystem | undefined, classnames: string) =>
platform === 'macOS' ? classnames : '';

View file

@ -1,9 +1,9 @@
import * as ToastPrimitive from '@radix-ui/react-toast';
import clsx from 'clsx';
import { useToasts } from '../../hooks/useToasts';
import { useToasts } from '~/hooks/useToasts';
export function Toasts() {
const { toasts, addToast, removeToast } = useToasts();
export default () => {
const { toasts, removeToast } = useToasts();
return (
<div className="fixed right-0 flex">
<ToastPrimitive.Provider>
@ -71,4 +71,4 @@ export function Toasts() {
</ToastPrimitive.Provider>
</div>
);
}
};

View file

@ -1,13 +1,12 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Navigate, Outlet, useParams } from 'react-router-dom';
import { ClientContextProvider, LibraryContextProvider, useClientContext } from '@sd/client';
import { Sidebar } from '~/components/layout/Sidebar';
import { Toasts } from '~/components/primitive/Toasts';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import { useLibraryId } from './util';
import Sidebar from './Sidebar';
import Toasts from './Toasts';
function AppLayout() {
const Layout = () => {
const { libraries, library } = useClientContext();
const os = useOperatingSystem();
@ -15,7 +14,7 @@ function AppLayout() {
if (library === null && libraries.data) {
const firstLibrary = libraries.data[0];
if (firstLibrary) return <Navigate to={`${firstLibrary.uuid}/overview`} />;
if (firstLibrary) return <Navigate to={`/${firstLibrary.uuid}/overview`} />;
else return <Navigate to="/" />;
}
@ -49,14 +48,14 @@ function AppLayout() {
<Toasts />
</div>
);
}
};
export default () => {
const currentLibraryId = useLibraryId();
const params = useParams<{ libraryId: string }>();
return (
<ClientContextProvider currentLibraryId={currentLibraryId ?? null}>
<AppLayout />
<ClientContextProvider currentLibraryId={params.libraryId ?? null}>
<Layout />
</ClientContextProvider>
);
};

View file

@ -0,0 +1,36 @@
import clsx from 'clsx';
import { PropsWithChildren, RefObject, createContext, useContext, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Outlet } from 'react-router';
import DragRegion from '~/components/DragRegion';
const PageLayoutContext = createContext<{ ref: RefObject<HTMLDivElement> } | null>(null);
export default () => {
const ref = useRef<HTMLDivElement>(null);
return (
<PageLayoutContext.Provider value={{ ref }}>
<div
className={clsx('custom-scroll page-scroll app-background flex h-screen w-full flex-col')}
>
<DragRegion ref={ref} />
<div className="flex h-screen w-full flex-col p-5 pt-0">
<Outlet />
</div>
</div>
</PageLayoutContext.Provider>
);
};
export const DragChildren = ({ children }: PropsWithChildren) => {
const ctx = useContext(PageLayoutContext);
if (!ctx) throw new Error('Missing PageLayoutContext');
const target = ctx.ref.current;
if (!target) return null;
return createPortal(children, target);
};

View file

@ -1,7 +1,6 @@
import { useBridgeQuery, useLibraryMutation, useLibraryQuery } from '@sd/client';
import CodeBlock from '~/components/primitive/Codeblock';
import { CodeBlock } from '~/components/Codeblock';
import { usePlatform } from '~/util/Platform';
import { ScreenContainer } from './_Layout';
// TODO: Bring this back with a button in the sidebar near settings at the bottom
export default function DebugScreen() {
@ -17,10 +16,9 @@ export default function DebugScreen() {
// });
const { mutate: identifyFiles } = useLibraryMutation('jobs.identifyUniqueFiles');
return (
<ScreenContainer>
<div className="flex flex-col space-y-5 p-5 pt-2 pb-7">
<h1 className="text-lg font-bold ">Developer Debugger</h1>
{/* <div className="flex flex-row pb-4 space-x-2">
<div className="flex flex-col space-y-5 p-5 pt-2 pb-7">
<h1 className="text-lg font-bold ">Developer Debugger</h1>
{/* <div className="flex flex-row pb-4 space-x-2">
<Button
className="w-40"
variant="gray"
@ -34,15 +32,14 @@ export default function DebugScreen() {
Open data folder
</Button>
</div> */}
<h1 className="text-sm font-bold ">Running Jobs</h1>
<CodeBlock src={{ ...jobs }} />
<h1 className="text-sm font-bold ">Job History</h1>
<CodeBlock src={{ ...jobHistory }} />
<h1 className="text-sm font-bold ">Node State</h1>
<CodeBlock src={{ ...nodeState }} />
<h1 className="text-sm font-bold ">Libraries</h1>
<CodeBlock src={{ ...libraryState }} />
</div>
</ScreenContainer>
<h1 className="text-sm font-bold ">Running Jobs</h1>
<CodeBlock src={{ ...jobs }} />
<h1 className="text-sm font-bold ">Job History</h1>
<CodeBlock src={{ ...jobHistory }} />
<h1 className="text-sm font-bold ">Node State</h1>
<CodeBlock src={{ ...nodeState }} />
<h1 className="text-sm font-bold ">Libraries</h1>
<CodeBlock src={{ ...libraryState }} />
</div>
);
}

View file

@ -0,0 +1,28 @@
import { RouteObject } from 'react-router-dom';
import { lazyEl } from '~/util';
import settingsRoutes from './settings';
export default [
{
element: lazyEl(() => import("./PageLayout")),
children: [
{
path: 'overview',
element: lazyEl(() => import('./overview'))
},
{ path: 'people', element: lazyEl(() => import('./people'))},
{ path: 'media', element: lazyEl(() => import('./media')) },
{ path: 'spaces', element: lazyEl(() => import('./spaces')) },
{ path: 'debug', element: lazyEl(() => import('./debug')) },
{ path: 'spacedrop', element: lazyEl(() => import('./spacedrop')) },
]
},
{ path: 'location/:id', element: lazyEl(() => import('./location/$id')) },
{ path: 'tag/:id', element: lazyEl(() => import('./tag/$id')) },
{
path: 'settings',
element: lazyEl(() => import('./settings/Layout')),
children: settingsRoutes
},
{ path: '*', element: lazyEl(() => import('./404')) }
] satisfies RouteObject[];

View file

@ -1,8 +1,8 @@
import { useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useLibraryQuery } from '@sd/client';
import Explorer from '~/components/explorer/Explorer';
import { getExplorerStore } from '~/hooks/useExplorerStore';
import Explorer from '../Explorer';
export function useExplorerParams() {
const { id } = useParams<{ id?: string }>();
@ -15,7 +15,7 @@ export function useExplorerParams() {
return { location_id, path, limit };
}
export default function LocationExplorer() {
export default () => {
const { location_id, path } = useExplorerParams();
useEffect(() => {
@ -39,4 +39,4 @@ export default function LocationExplorer() {
<Explorer data={explorerData.data} />
</div>
);
}
};

View file

@ -0,0 +1,5 @@
import { ScreenHeading } from '@sd/ui';
export default function MediaScreen() {
return <ScreenHeading>Media</ScreenHeading>;
}

View file

@ -1,4 +1,3 @@
import { useQueryClient } from '@tanstack/react-query';
import byteSize from 'byte-size';
import clsx from 'clsx';
import {
@ -14,13 +13,10 @@ import {
} from 'phosphor-react';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
import { Statistics, useLibraryQuery } from '@sd/client';
import { Statistics, useLibraryContext, useLibraryQuery } from '@sd/client';
import { Card } from '@sd/ui';
import useCounter from '~/hooks/useCounter';
import { useLibraryId } from '~/util';
import { usePlatform } from '~/util/Platform';
import { ScreenContainer } from './_Layout';
import { useEffect } from 'react';
interface StatItemProps {
title: string;
@ -91,7 +87,7 @@ const StatItem = (props: StatItemProps) => {
export default function OverviewScreen() {
const platform = usePlatform();
const libraryId = useLibraryId();
const { library } = useLibraryContext();
const stats = useLibraryQuery(['library.getStatistics'], {
initialData: { ...EMPTY_STATISTICS }
@ -100,45 +96,43 @@ export default function OverviewScreen() {
overviewMounted = true;
return (
<ScreenContainer>
<div className="flex h-screen w-full flex-col">
{/* STAT HEADER */}
<div className="flex w-full">
{/* STAT CONTAINER */}
<div className="-mb-1 flex h-20 overflow-hidden">
{Object.entries(stats?.data || []).map(([key, value]) => {
if (!displayableStatItems.includes(key)) return null;
return (
<StatItem
key={`${libraryId} ${key}`}
title={StatItemNames[key as keyof Statistics]!}
bytes={BigInt(value)}
isLoading={platform.demoMode ? false : stats.isLoading}
/>
);
})}
</div>
<div className="grow" />
<div className="flex h-screen w-full flex-col">
{/* STAT HEADER */}
<div className="flex w-full">
{/* STAT CONTAINER */}
<div className="-mb-1 flex h-20 overflow-hidden">
{Object.entries(stats?.data || []).map(([key, value]) => {
if (!displayableStatItems.includes(key)) return null;
return (
<StatItem
key={`${library.uuid} ${key}`}
title={StatItemNames[key as keyof Statistics]!}
bytes={BigInt(value)}
isLoading={platform.demoMode ? false : stats.isLoading}
/>
);
})}
</div>
<div className="mt-4 grid grid-cols-5 gap-3 pb-4">
<CategoryButton icon={Heart} category="Favorites" />
<CategoryButton icon={FileText} category="Documents" />
<CategoryButton icon={Camera} category="Movies" />
<CategoryButton icon={FrameCorners} category="Screenshots" />
<CategoryButton icon={AppWindow} category="Applications" />
<CategoryButton icon={Wrench} category="Projects" />
<CategoryButton icon={CloudArrowDown} category="Downloads" />
<CategoryButton icon={MusicNote} category="Music" />
<CategoryButton icon={Image} category="Albums" />
<CategoryButton icon={Heart} category="Favorites" />
</div>
<Card className="text-ink-dull">
<b>Note: </b>&nbsp; This is a pre-alpha build of Spacedrive, many features are yet to be
functional.
</Card>
<div className="flex h-4 w-full shrink-0" />
<div className="grow" />
</div>
</ScreenContainer>
<div className="mt-4 grid grid-cols-5 gap-3 pb-4">
<CategoryButton icon={Heart} category="Favorites" />
<CategoryButton icon={FileText} category="Documents" />
<CategoryButton icon={Camera} category="Movies" />
<CategoryButton icon={FrameCorners} category="Screenshots" />
<CategoryButton icon={AppWindow} category="Applications" />
<CategoryButton icon={Wrench} category="Projects" />
<CategoryButton icon={CloudArrowDown} category="Downloads" />
<CategoryButton icon={MusicNote} category="Music" />
<CategoryButton icon={Image} category="Albums" />
<CategoryButton icon={Heart} category="Favorites" />
</div>
<Card className="text-ink-dull">
<b>Note: </b>&nbsp; This is a pre-alpha build of Spacedrive, many features are yet to be
functional.
</Card>
<div className="flex h-4 w-full shrink-0" />
</div>
);
}

View file

@ -0,0 +1,5 @@
import { ScreenHeading } from '@sd/ui';
export default () => {
return <ScreenHeading>People</ScreenHeading>;
};

View file

@ -0,0 +1,46 @@
import { PropsWithChildren, ReactNode, Suspense } from 'react';
import { Outlet } from 'react-router';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import DragRegion from '../../../components/DragRegion';
import Sidebar from './Sidebar';
export default () => {
const os = useOperatingSystem();
return (
<div className="app-background flex w-full flex-row">
<Sidebar />
<div className="w-full">
{os !== 'browser' ? (
<div data-tauri-drag-region className="h-3 w-full" />
) : (
<div className="h-5" />
)}
<Suspense>
<DragRegion />
<Outlet />
</Suspense>
</div>
</div>
);
};
interface HeaderProps extends PropsWithChildren {
title: string;
description: string | ReactNode;
rightArea?: ReactNode;
}
export const Heading = (props: HeaderProps) => {
return (
<div className="mb-3 flex">
{props.children}
<div className="grow">
<h1 className="text-2xl font-bold">{props.title}</h1>
<p className="mt-1 text-sm text-gray-400">{props.description}</p>
</div>
{props.rightArea}
<hr className="border-gray-550 mt-4" />
</div>
);
};

View file

@ -0,0 +1,45 @@
import { CaretLeft } from 'phosphor-react';
import { PropsWithChildren } from 'react';
import { useNavigate } from 'react-router';
import { Button, Divider, tw } from '@sd/ui';
interface Props extends PropsWithChildren {
title: string;
topRight?: React.ReactNode;
}
const PageOuter = tw.div`flex h-screen flex-col m-3 -mt-4`;
const Page = tw.div`flex-1 w-full border rounded-md shadow-md shadow-app-shade/30 border-app-box bg-app-box/20`;
const PageInner = tw.div`flex flex-col max-w-4xl w-full h-screen py-6`;
const HeaderArea = tw.div`flex flex-row px-8 items-center space-x-4 mb-2`;
const ContentContainer = tw.div`px-8 pt-5 -mt-1 space-y-6 custom-scroll page-scroll`;
export default ({ children, title, topRight }: Props) => (
<PageOuter>
<Page>
<PageInner>
<HeaderArea>
<BackButton />
<h3 className="grow text-lg font-semibold">{title}</h3>
{topRight}
</HeaderArea>
<div className="px-8">
<Divider />
</div>
<ContentContainer>{children}</ContentContainer>
</PageInner>
</Page>
</PageOuter>
);
const BackButton = () => {
const navigate = useNavigate();
return (
<Button variant="outline" size="icon" onClick={() => navigate(-1)}>
<div className="flex h-4 w-4 justify-center">
<CaretLeft weight="bold" className="text-ink-dull w-[12px] " aria-hidden="true" />
</div>
</Button>
);
};

View file

@ -0,0 +1,10 @@
import { Outlet } from 'react-router';
export default () => (
<div className="custom-scroll page-scroll relative flex h-full max-h-screen w-full grow-0">
<div className="flex w-full max-w-4xl flex-col space-y-6 px-12 pt-2 pb-5">
<Outlet />
<div className="block h-20" />
</div>
</div>
);

View file

@ -1,17 +1,17 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { DefaultProps } from './types';
interface InputContainerProps extends DefaultProps<HTMLDivElement> {
interface Props {
title: string;
description?: string;
mini?: boolean;
className?: string;
}
export function InputContainer({ mini, ...props }: PropsWithChildren<InputContainerProps>) {
export default ({ mini, ...props }: PropsWithChildren<Props>) => {
return (
<div className="flex flex-row">
<div {...props} className={clsx('flex w-full flex-col', !mini && 'pb-6', props.className)}>
<div className={clsx('flex w-full flex-col', !mini && 'pb-6', props.className)}>
<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>}
{!mini && props.children}
@ -19,4 +19,4 @@ export function InputContainer({ mini, ...props }: PropsWithChildren<InputContai
{mini && props.children}
</div>
);
}
};

View file

@ -0,0 +1,108 @@
import {
Books,
FlyingSaucer,
GearSix,
Graph,
HardDrive,
Heart,
Key,
KeyReturn,
PaintBrush,
PuzzlePiece,
Receipt,
ShareNetwork,
ShieldCheck,
TagSimple
} from 'phosphor-react';
import { tw } from '@sd/ui';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import Icon from '../Layout/Sidebar/Icon';
import SidebarLink from '../Layout/Sidebar/Link';
const Heading = tw.div`mb-1 ml-1 text-xs font-semibold text-gray-400`;
const Section = tw.div`space-y-0.5`;
export default () => {
const os = useOperatingSystem();
return (
<div className="border-app-line/50 custom-scroll no-scrollbar h-full w-60 max-w-[180px] shrink-0 border-r pb-5">
{os !== 'browser' ? (
<div data-tauri-drag-region className="h-5 w-full" />
) : (
<div className="h-3" />
)}
<div className="space-y-6 px-4 py-3">
<Section>
<Heading>Client</Heading>
<SidebarLink to="client/general">
<Icon component={GearSix} />
General
</SidebarLink>
<SidebarLink to="node/libraries">
<Icon component={Books} />
Libraries
</SidebarLink>
<SidebarLink to="client/privacy">
<Icon component={ShieldCheck} />
Privacy
</SidebarLink>
<SidebarLink to="client/appearance">
<Icon component={PaintBrush} />
Appearance
</SidebarLink>
<SidebarLink to="client/keybindings">
<Icon component={KeyReturn} />
Keybinds
</SidebarLink>
<SidebarLink to="client/extensions">
<Icon component={PuzzlePiece} />
Extensions
</SidebarLink>
</Section>
<Section>
<Heading>Library</Heading>
<SidebarLink to="library/general">
<Icon component={GearSix} />
General
</SidebarLink>
<SidebarLink to="library/nodes">
<Icon component={ShareNetwork} />
Nodes
</SidebarLink>
<SidebarLink to="library/locations">
<Icon component={HardDrive} />
Locations
</SidebarLink>
<SidebarLink to="library/tags">
<Icon component={TagSimple} />
Tags
</SidebarLink>
<SidebarLink to="library/keys">
<Icon component={Key} />
Keys
</SidebarLink>
</Section>
<Section>
<Heading>Resources</Heading>
<SidebarLink to="resources/about">
<Icon component={FlyingSaucer} />
About
</SidebarLink>
<SidebarLink to="resources/changelog">
<Icon component={Receipt} />
Changelog
</SidebarLink>
<SidebarLink to="resources/dependencies">
<Icon component={Graph} />
Dependencies
</SidebarLink>
<SidebarLink to="resources/support">
<Icon component={Heart} />
Support
</SidebarLink>
</Section>
</div>
</div>
);
};

View file

@ -0,0 +1,55 @@
import { useEffect } from 'react';
import { forms } from '@sd/ui';
import { Heading } from '../Layout';
import Setting from '../Setting';
const { Form, Switch, useZodForm, z } = forms;
const schema = z.object({
uiAnimations: z.boolean(),
syncThemeWithSystem: z.boolean(),
blurEffects: z.boolean()
});
export default function AppearanceSettings() {
const form = useZodForm({
schema
});
const onSubmit = form.handleSubmit(async (data) => {
console.log({ data });
});
useEffect(() => {
const subscription = form.watch(() => onSubmit());
return () => subscription.unsubscribe();
}, [form, onSubmit]);
return (
<Form form={form} onSubmit={onSubmit}>
<Heading title="Appearance" description="Change the look of your client." />
<Setting
mini
title="Sync Theme with System"
description="The theme of the client will change based on your system theme."
>
<Switch {...form.register('syncThemeWithSystem')} className="m-2 ml-4" />
</Setting>
<Setting
mini
title="UI Animations"
description="Dialogs and other UI elements will animate when opening and closing."
>
<Switch {...form.register('uiAnimations')} className="m-2 ml-4" />
</Setting>
<Setting
mini
title="Blur Effects"
description="Some components will have a blur effect applied to them."
>
<Switch {...form.register('blurEffects')} className="m-2 ml-4" />
</Setting>
</Form>
);
}

View file

@ -1,7 +1,5 @@
import { MagnifyingGlass } from 'phosphor-react';
import { Button, Card, GridLayout, Input, SearchInput } from '@sd/ui';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { Button, Card, GridLayout, SearchInput } from '@sd/ui';
import { Heading } from '../Layout';
// extensions should cache their logos in the app data folder
interface ExtensionItemData {
@ -59,8 +57,8 @@ export default function ExtensionSettings() {
// const { data: volumes } = useBridgeQuery('GetVolumes');
return (
<SettingsContainer>
<SettingsHeader
<>
<Heading
title="Extensions"
description="Install extensions to extend the functionality of this client."
rightArea={<SearchInput outerClassnames="mt-1.5" placeholder="Search extensions" />}
@ -71,6 +69,6 @@ export default function ExtensionSettings() {
<ExtensionItem key={extension.uuid} extension={extension} />
))}
</GridLayout>
</SettingsContainer>
</>
);
}

View file

@ -1,25 +1,21 @@
import { Database } from 'phosphor-react';
import { getDebugState, useBridgeQuery, useDebugState } from '@sd/client';
import { Card, Input, Switch, tw } from '@sd/ui';
import { InputContainer } from '~/components/primitive/InputContainer';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { usePlatform } from '~/util/Platform';
import { Heading } from '../Layout';
import Setting from '../Setting';
const NodePill = tw.div`px-1.5 py-[2px] rounded text-xs font-medium bg-app-selected`;
const NodeSettingLabel = tw.div`mb-1 text-xs font-medium`;
export default function GeneralSettings() {
const { data: node } = useBridgeQuery(['nodeState']);
export default () => {
const node = useBridgeQuery(['nodeState']);
const platform = usePlatform();
const debugState = useDebugState();
return (
<SettingsContainer>
<SettingsHeader
title="General Settings"
description="General settings related to this client."
/>
<>
<Heading title="General Settings" description="General settings related to this client." />
<Card className="px-5">
<div className="my-2 flex w-full flex-col">
<div className="flex flex-row items-center justify-between">
@ -34,11 +30,22 @@ export default function GeneralSettings() {
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col">
<NodeSettingLabel>Node Name</NodeSettingLabel>
<Input value={node?.name} />
<Input
value={node.data?.name}
onChange={() => {
/* TODO */
}}
/>
</div>
<div className="flex flex-col">
<NodeSettingLabel>Node Port</NodeSettingLabel>
<Input contentEditable={false} value={node?.p2p_port || 5795} />
<Input
contentEditable={false}
value={node.data?.p2p_port || 5795}
onChange={() => {
/* TODO */
}}
/>
</div>
</div>
<div className="mt-5 flex items-center space-x-3">
@ -48,8 +55,8 @@ export default function GeneralSettings() {
<div className="mt-3">
<div
onClick={() => {
if (node && platform?.openLink) {
platform.openLink(node.data_path);
if (node.data && platform?.openLink) {
platform.openLink(node.data.data_path);
}
}}
className="text-ink-faint text-sm font-medium"
@ -57,12 +64,12 @@ export default function GeneralSettings() {
<b className="mr-2 inline truncate">
<Database className="mr-1 mt-[-2px] inline h-4 w-4" /> Data Folder
</b>
<span className="select-text">{node?.data_path}</span>
<span className="select-text">{node.data?.data_path}</span>
</div>
</div>
</div>
</Card>
<InputContainer
<Setting
mini
title="Debug mode"
description="Enable extra debugging features within the app."
@ -71,7 +78,7 @@ export default function GeneralSettings() {
checked={debugState.enabled}
onClick={() => (getDebugState().enabled = !debugState.enabled)}
/>
</InputContainer>
</SettingsContainer>
</Setting>
</>
);
}
};

View file

@ -0,0 +1,10 @@
import { RouteObject } from "react-router";
import { lazyEl } from "~/util";
export default [
{ path: 'general', element: lazyEl(() => import('./general')) },
{ path: 'appearance', element: lazyEl(() => import('./appearance')) },
{ path: 'keybindings', element: lazyEl(() => import('./keybindings')) },
{ path: 'extensions', element: lazyEl(() => import('./extensions')) },
{ path: 'privacy', element: lazyEl(() => import('./privacy')) },
] satisfies RouteObject[]

View file

@ -1,16 +1,15 @@
import { useState } from 'react';
import { Switch } from '@sd/ui';
import { InputContainer } from '~/components/primitive/InputContainer';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { Heading } from '../Layout';
import Setting from '../Setting';
export default function AppearanceSettings() {
const [syncWithLibrary, setSyncWithLibrary] = useState(true);
return (
<SettingsContainer>
<>
{/* I don't care what you think the "right" way to write "keybinds" is, I simply refuse to refer to it as "keybindings" */}
<SettingsHeader title="Keybinds" description="Manage client keybinds" />
<InputContainer
<Heading title="Keybinds" description="Manage client keybinds" />
<Setting
mini
title="Sync with Library"
description="If enabled your keybinds will be synced with library, otherwise they will apply only to this client."
@ -20,7 +19,7 @@ export default function AppearanceSettings() {
onCheckedChange={setSyncWithLibrary}
className="m-2 ml-4"
/>
</InputContainer>
</SettingsContainer>
</Setting>
</>
);
}

View file

@ -1,22 +1,22 @@
import { useState } from 'react';
import { Switch } from '@sd/ui';
import { InputContainer } from '~/components/primitive/InputContainer';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { Heading } from '../Layout';
import Setting from '../Setting';
export default function PrivacySettings() {
const [shareUsageData, setShareUsageData] = useState(true);
const [blurEffects, setBlurEffects] = useState(true);
return (
<SettingsContainer>
<SettingsHeader title="Privacy" description="" />
<InputContainer
<>
<Heading title="Privacy" description="" />
<Setting
mini
title="Share Usage Data"
description="Share anonymous usage data to help us improve the app."
>
<Switch checked={shareUsageData} onCheckedChange={setShareUsageData} className="m-2 ml-4" />
</InputContainer>
</SettingsContainer>
</Setting>
</>
);
}

View file

@ -0,0 +1,28 @@
import { RouteObject } from 'react-router-dom';
import { lazyEl } from '~/util';
import clientRoutes from './client';
import libraryRoutes from './library';
import nodeRoutes from './node';
import resourcesRoutes from './resources';
export default [
{
path: 'client',
element: lazyEl(() => import('./OverviewLayout')),
children: clientRoutes
},
{
path: 'node',
element: lazyEl(() => import('./OverviewLayout')),
children: nodeRoutes
},
{
path: 'library',
children: libraryRoutes
},
{
path: 'resources',
element: lazyEl(() => import('./OverviewLayout')),
children: resourcesRoutes
}
] satisfies RouteObject[];

View file

@ -0,0 +1,9 @@
import { Heading } from '../Layout';
export default () => {
return (
<>
<Heading title="Backups" description="Manage database backups." />
</>
);
};

View file

@ -0,0 +1,9 @@
import { Heading } from '../Layout';
export default () => {
return (
<>
<Heading title="Contacts" description="Manage your contacts in Spacedrive." />
</>
);
};

View file

@ -1,12 +1,11 @@
import { useForm } from 'react-hook-form';
import { useBridgeMutation, useLibraryContext } from '@sd/client';
import { Button, Input, Switch } from '@sd/ui';
import { InputContainer } from '~/components/primitive/InputContainer';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { useDebouncedFormWatch } from '~/hooks/useDebouncedForm';
import { Heading } from '../Layout';
import Setting from '../Setting';
export default function LibraryGeneralSettings() {
export default () => {
const { library } = useLibraryContext();
const editLibrary = useBridgeMutation('library.edit');
@ -23,8 +22,8 @@ export default function LibraryGeneralSettings() {
);
return (
<SettingsContainer>
<SettingsHeader
<>
<Heading
title="Library Settings"
description="General settings related to the currently active library."
/>
@ -43,7 +42,7 @@ export default function LibraryGeneralSettings() {
</div>
</div>
<InputContainer
<Setting
mini
title="Encrypt Library"
description="Enable encryption for this library, this will only encrypt the Spacedrive database, not the files themselves."
@ -51,15 +50,15 @@ export default function LibraryGeneralSettings() {
<div className="ml-3 flex items-center">
<Switch checked={false} />
</div>
</InputContainer>
<InputContainer mini title="Export Library" description="Export this library to a file.">
</Setting>
<Setting mini title="Export Library" description="Export this library to a file.">
<div className="mt-2">
<Button size="sm" variant="gray">
Export
</Button>
</div>
</InputContainer>
<InputContainer
</Setting>
<Setting
mini
title="Delete Library"
description="This is permanent, your files will not be deleted, only the Spacedrive library."
@ -69,7 +68,7 @@ export default function LibraryGeneralSettings() {
Delete
</Button>
</div>
</InputContainer>
</SettingsContainer>
</Setting>
</>
);
}
};

View file

@ -0,0 +1,21 @@
import { RouteObject } from 'react-router';
import { lazyEl } from '~/util';
export default [
{
element: lazyEl(() => import('../OverviewLayout')),
children: [
{ path: 'contacts', element: lazyEl(() => import('./contacts')) },
{ path: 'keys', element: lazyEl(() => import('./keys')) },
{ path: 'security', element: lazyEl(() => import('./security')) },
{ path: 'sharing', element: lazyEl(() => import('./sharing')) },
{ path: 'sync', element: lazyEl(() => import('./sync')) },
{ path: 'tags', element: lazyEl(() => import('./tags')) },
{ path: 'general', element: lazyEl(() => import('./general')) },
{ path: 'tags', element: lazyEl(() => import('./tags')) },
{ path: 'nodes', element: lazyEl(() => import('./nodes')) },
{ path: 'locations', element: lazyEl(() => import('./locations')) }
]
},
{ path: 'locations/:id', element: lazyEl(() => import('./locations/$id')) }
] satisfies RouteObject[];

View file

@ -3,8 +3,8 @@ import { useState } from 'react';
import { useLibraryMutation } from '@sd/client';
import { Button, Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { forms } from '@sd/ui';
import { showAlertDialog } from '~/components/AlertDialog';
import { usePlatform } from '~/util/Platform';
import { showAlertDialog } from '~/util/dialog';
const { Input, useZodForm, z } = forms;
@ -14,9 +14,7 @@ const schema = z.object({
filePath: z.string()
});
export type BackupRestorationDialogProps = UseDialogProps;
export const BackupRestoreDialog = (props: BackupRestorationDialogProps) => {
export default (props: UseDialogProps) => {
const platform = usePlatform();
const restoreKeystoreMutation = useLibraryMutation('keys.restoreKeystore', {

View file

@ -1,13 +1,10 @@
import { Buffer } from 'buffer';
import { Clipboard } from 'phosphor-react';
import { useState } from 'react';
import { useLibraryQuery } from '@sd/client';
import { slugFromHashingAlgo, useLibraryQuery } from '@sd/client';
import { Button, Dialog, Input, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm, z } from '@sd/ui/src/forms';
import { getHashingAlgorithmString } from '~/screens/settings/library/KeysSetting';
import { SelectOptionKeyList } from '../key/KeyList';
type KeyViewerDialogProps = UseDialogProps;
import { useZodForm } from '@sd/ui/src/forms';
import { KeyListSelectOptions } from '~/app/$libraryId/KeyManager/List';
export const KeyUpdater = (props: {
uuid: string;
@ -25,17 +22,18 @@ export const KeyUpdater = (props: {
const keys = useLibraryQuery(['keys.list']);
const key = keys.data?.find((key) => key.uuid == props.uuid);
key && props.setEncryptionAlgo(key?.algorithm);
key && props.setHashingAlgo(getHashingAlgorithmString(key?.hashing_algorithm));
key && props.setContentSalt(Buffer.from(key.content_salt).toString('hex'));
if (key) {
props.setEncryptionAlgo(key?.algorithm);
props.setHashingAlgo(slugFromHashingAlgo(key?.hashing_algorithm));
props.setContentSalt(Buffer.from(key.content_salt).toString('hex'));
}
return <></>;
};
const schema = z.object({});
export const KeyViewerDialog = (props: KeyViewerDialogProps) => {
const form = useZodForm({ schema });
export default (props: UseDialogProps) => {
const form = useZodForm();
const dialog = useDialog(props);
const keys = useLibraryQuery(['keys.list'], {
@ -79,7 +77,7 @@ export const KeyViewerDialog = (props: KeyViewerDialogProps) => {
setKey(e);
}}
>
{keys.data && <SelectOptionKeyList keys={keys.data.map((key) => key.uuid)} />}
{keys.data && <KeyListSelectOptions keys={keys.data.map((key) => key.uuid)} />}
</Select>
</div>
</div>

View file

@ -1,24 +1,34 @@
import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react';
import { lazy, useState } from 'react';
import { Algorithm, useLibraryMutation } from '@sd/client';
import { Button, Dialog, Input, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui';
import { useState } from 'react';
import {
Algorithm,
HASHING_ALGOS,
HashingAlgoSlug,
generatePassword,
hashingAlgoSlugSchema,
useLibraryMutation
} from '@sd/client';
import {
Button,
Dialog,
Input,
PasswordMeter,
Select,
SelectOption,
UseDialogProps,
useDialog
} from '@sd/ui';
import { useZodForm, z } from '@sd/ui/src/forms';
import { getHashingAlgorithmSettings } from '~/screens/settings/library/KeysSetting';
import { showAlertDialog } from '~/util/dialog';
import { generatePassword } from '../key/KeyMounter';
const PasswordMeter = lazy(() => import('../key/PasswordMeter'));
export type MasterPasswordChangeDialogProps = UseDialogProps;
import { showAlertDialog } from '~/components/AlertDialog';
const schema = z.object({
masterPassword: z.string(),
masterPassword2: z.string(),
encryptionAlgo: z.string(),
hashingAlgo: z.string()
hashingAlgo: hashingAlgoSlugSchema
});
export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProps) => {
export default (props: UseDialogProps) => {
const changeMasterPassword = useLibraryMutation('keys.changeMasterPassword', {
onSuccess: () => {
showAlertDialog({
@ -62,7 +72,8 @@ export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProp
value: 'Passwords are not the same, please try again.'
});
} else {
const hashing_algorithm = getHashingAlgorithmSettings(data.hashingAlgo);
const hashing_algorithm = HASHING_ALGOS[data.hashingAlgo];
return changeMasterPassword.mutateAsync({
algorithm: data.encryptionAlgo as Algorithm,
hashing_algorithm,
@ -160,7 +171,7 @@ export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProp
<Select
className="mt-2"
value={form.watch('hashingAlgo')}
onChange={(e) => form.setValue('hashingAlgo', e)}
onChange={(e) => form.setValue('hashingAlgo', e as HashingAlgoSlug)}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>

View file

@ -1,22 +1,19 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import { Eye, EyeSlash, Lock, Plus } from 'phosphor-react';
import { PropsWithChildren, useState } from 'react';
import { PropsWithChildren, ReactNode, useState } from 'react';
import QRCode from 'react-qr-code';
import { animated, useTransition } from 'react-spring';
import { HashingAlgorithm, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, Input, dialogManager } from '@sd/ui';
import { BackupRestoreDialog } from '~/components/dialog/BackupRestoreDialog';
import { KeyViewerDialog } from '~/components/dialog/KeyViewerDialog';
import { MasterPasswordChangeDialog } from '~/components/dialog/MasterPasswordChangeDialog';
import { ListOfKeys } from '~/components/key/KeyList';
import { KeyMounter } from '~/components/key/KeyMounter';
import { DefaultProps } from '~/components/primitive/types';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { SettingsSubHeader } from '~/components/settings/SettingsSubHeader';
import { showAlertDialog } from '~/components/AlertDialog';
import { usePlatform } from '~/util/Platform';
import { showAlertDialog } from '~/util/dialog';
import KeyList from '../../../KeyManager/List';
import KeyMounter from '../../../KeyManager/Mounter';
import { Heading } from '../../Layout';
import BackupRestoreDialog from './BackupRestoreDialog';
import KeyViewerDialog from './KeyViewerDialog';
import MasterPasswordDialog from './MasterPasswordDialog';
interface Props extends DropdownMenu.MenuContentProps {
trigger: React.ReactNode;
@ -75,7 +72,7 @@ export const KeyMounterDropdown = ({
);
};
export default function KeysSettings() {
export default () => {
const platform = usePlatform();
const isUnlocked = useLibraryQuery(['keys.isUnlocked']);
const keyringSk = useLibraryQuery(['keys.getSecretKey'], { initialData: '' }); // assume true by default, as it will often be the case. need to fix this with an rspc subscription+such
@ -165,12 +162,7 @@ export default function KeysSettings() {
</Button>
{!enterSkManually && (
<div className="relative flex grow">
<p
className="text-accent mt-2"
onClick={(e) => {
setEnterSkManually(true);
}}
>
<p className="text-accent mt-2" onClick={() => setEnterSkManually(true)}>
or enter secret key manually
</p>
</div>
@ -180,145 +172,135 @@ export default function KeysSettings() {
} else {
return (
<>
<SettingsContainer>
<SettingsHeader
title="Keys"
description="Manage your keys."
rightArea={
<div className="flex flex-row items-center">
<Button
size="icon"
onClick={() => {
unmountAll.mutate(null);
clearMasterPassword.mutate(null);
}}
variant="subtle"
className="text-ink-faint"
>
<Lock className="text-ink-faint h-4 w-4" />
</Button>
<KeyMounterDropdown
trigger={
<Button size="icon" variant="subtle" className="text-ink-faint">
<Plus className="text-ink-faint h-4 w-4" />
</Button>
}
>
<KeyMounter />
</KeyMounterDropdown>
</div>
}
/>
{isUnlocked && (
<div className="grid space-y-2">
<ListOfKeys />
</div>
)}
{keyringSk?.data && (
<>
<SettingsSubHeader title="Secret key" />
{!viewSecretKey && (
<div className="flex flex-row">
<Button size="sm" variant="gray" onClick={() => setViewSecretKey(true)}>
View Secret Key
<Heading
title="Keys"
description="Manage your keys."
rightArea={
<div className="flex flex-row items-center">
<Button
size="icon"
onClick={() => {
unmountAll.mutate(null);
clearMasterPassword.mutate(null);
}}
variant="subtle"
className="text-ink-faint"
>
<Lock className="text-ink-faint h-4 w-4" />
</Button>
<KeyMounterDropdown
trigger={
<Button size="icon" variant="subtle" className="text-ink-faint">
<Plus className="text-ink-faint h-4 w-4" />
</Button>
</div>
)}
{viewSecretKey && (
<div
className="flex flex-row"
onClick={() => {
keyringSk.data && navigator.clipboard.writeText(keyringSk.data);
}}
>
<>
<QRCode size={128} value={keyringSk.data} />
<p className="mt-14 ml-6 text-xl font-bold">{keyringSk.data}</p>
</>
</div>
)}
</>
)}
<SettingsSubHeader title="Password Options" />
<div className="flex flex-row">
<Button
size="sm"
variant="gray"
className="mr-2"
onClick={() => dialogManager.create((dp) => <MasterPasswordChangeDialog {...dp} />)}
>
Change Master Password
</Button>
<Button
size="sm"
variant="gray"
className="mr-2"
hidden={keys.data?.length === 0}
onClick={() => dialogManager.create((dp) => <KeyViewerDialog {...dp} />)}
>
View Key Values
</Button>
</div>
<SettingsSubHeader title="Data Recovery" />
<div className="flex flex-row">
<Button
size="sm"
variant="gray"
className="mr-2"
type="button"
onClick={() => {
if (!platform.saveFilePickerDialog) {
// TODO: Support opening locations on web
showAlertDialog({
title: 'Error',
value: "System dialogs aren't supported on this platform."
});
return;
}
platform.saveFilePickerDialog().then((result) => {
if (result) backupKeystore.mutate(result as string);
});
}}
>
Backup
</Button>
<Button
size="sm"
variant="gray"
className="mr-2"
onClick={() => dialogManager.create((dp) => <BackupRestoreDialog {...dp} />)}
>
Restore
</Button>
>
<KeyMounter />
</KeyMounterDropdown>
</div>
}
/>
{isUnlocked && (
<div className="grid space-y-2">
<KeyList />
</div>
</SettingsContainer>
)}
{keyringSk?.data && (
<>
<Subheading title="Secret key" />
{!viewSecretKey && (
<div className="flex flex-row">
<Button size="sm" variant="gray" onClick={() => setViewSecretKey(true)}>
View Secret Key
</Button>
</div>
)}
{viewSecretKey && (
<div
className="flex flex-row"
onClick={() => {
keyringSk.data && navigator.clipboard.writeText(keyringSk.data);
}}
>
<>
<QRCode size={128} value={keyringSk.data} />
<p className="mt-14 ml-6 text-xl font-bold">{keyringSk.data}</p>
</>
</div>
)}
</>
)}
<Subheading title="Password Options" />
<div className="flex flex-row">
<Button
size="sm"
variant="gray"
className="mr-2"
onClick={() => dialogManager.create((dp) => <MasterPasswordDialog {...dp} />)}
>
Change Master Password
</Button>
<Button
size="sm"
variant="gray"
className="mr-2"
hidden={keys.data?.length === 0}
onClick={() => dialogManager.create((dp) => <KeyViewerDialog {...dp} />)}
>
View Key Values
</Button>
</div>
<Subheading title="Data Recovery" />
<div className="flex flex-row">
<Button
size="sm"
variant="gray"
className="mr-2"
type="button"
onClick={() => {
if (!platform.saveFilePickerDialog) {
// TODO: Support opening locations on web
showAlertDialog({
title: 'Error',
value: "System dialogs aren't supported on this platform."
});
return;
}
platform.saveFilePickerDialog().then((result) => {
if (result) backupKeystore.mutate(result as string);
});
}}
>
Backup
</Button>
<Button
size="sm"
variant="gray"
className="mr-2"
onClick={() => dialogManager.create((dp) => <BackupRestoreDialog {...dp} />)}
>
Restore
</Button>
</div>
</>
);
}
};
interface SubheadingProps {
title: string;
rightArea?: ReactNode;
}
const table: Record<string, HashingAlgorithm> = {
'Argon2id-s': { name: 'Argon2id', params: 'Standard' },
'Argon2id-h': { name: 'Argon2id', params: 'Hardened' },
'Argon2id-p': { name: 'Argon2id', params: 'Paranoid' },
'BalloonBlake3-s': { name: 'BalloonBlake3', params: 'Standard' },
'BalloonBlake3-h': { name: 'BalloonBlake3', params: 'Hardened' },
'BalloonBlake3-p': { name: 'BalloonBlake3', params: 'Paranoid' }
};
// not sure of a suitable place for this function
export const getHashingAlgorithmSettings = (hashingAlgorithm: string): HashingAlgorithm => {
return table[hashingAlgorithm] || { name: 'Argon2id', params: 'Standard' };
};
// not sure of a suitable place for this function
export const getHashingAlgorithmString = (hashingAlgorithm: HashingAlgorithm): string => {
return Object.entries(table).find(
([_, hashAlg]) =>
hashAlg.name === hashingAlgorithm.name && hashAlg.params === hashingAlgorithm.params
)![0];
};
const Subheading = (props: SubheadingProps) => (
<div className="flex">
<div className="grow">
<h1 className="text-xl font-bold">{props.title}</h1>
</div>
{props.rightArea}
</div>
);

View file

@ -3,10 +3,9 @@ import { Archive, ArrowsClockwise, Info, Trash } from 'phosphor-react';
import { useFormState } from 'react-hook-form';
import { useParams } from 'react-router';
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, forms, tw } from '@sd/ui';
import { Divider } from '~/components/explorer/inspector/Divider';
import { SettingsSubPage } from '~/components/settings/SettingsSubPage';
import { Tooltip } from '~/components/tooltip/Tooltip';
import { Button, Divider, forms, tw } from '@sd/ui';
import { Tooltip } from '@sd/ui';
import ModalLayout from '../../ModalLayout';
import { IndexerRuleEditor } from './IndexerRuleEditor';
const InfoText = tw.p`mt-2 text-xs text-ink-faint`;
@ -16,10 +15,6 @@ const ToggleSection = tw.label`flex flex-row w-full`;
const { Form, Input, Switch, useZodForm, z } = forms;
export type EditLocationParams = {
id: string;
};
const schema = z.object({
displayName: z.string(),
localPath: z.string(),
@ -31,7 +26,9 @@ const schema = z.object({
export default function EditLocation() {
const queryClient = useQueryClient();
const { id } = useParams<keyof EditLocationParams>() as EditLocationParams;
const { id } = useParams<{
id: string;
}>();
useLibraryQuery(['locations.getById', Number(id)], {
onSuccess: (data) => {
@ -53,13 +50,13 @@ export default function EditLocation() {
const updateLocation = useLibraryMutation('locations.update', {
onError: (e) => console.log({ e }),
onSuccess: (e) => {
onSuccess: () => {
form.reset(form.getValues());
queryClient.invalidateQueries(['locations.list']);
}
});
const onSubmit = form.handleSubmit(async (data) =>
const onSubmit = form.handleSubmit((data) =>
updateLocation.mutateAsync({
id: Number(id),
name: data.displayName,
@ -75,8 +72,8 @@ export default function EditLocation() {
const { isDirty } = useFormState({ control: form.control });
return (
<Form form={form} onSubmit={onSubmit}>
<SettingsSubPage
<Form form={form} onSubmit={onSubmit} className="h-full w-full">
<ModalLayout
title="Edit Location"
topRight={
<div className="flex flex-row space-x-3">
@ -114,7 +111,6 @@ export default function EditLocation() {
</FlexCol>
</div>
<Divider />
<div className="space-y-2">
<ToggleSection>
<Label className="grow">Generate preview media for this Location</Label>
@ -140,7 +136,7 @@ export default function EditLocation() {
<InfoText className="mt-0 mb-1">
Indexer rules allow you to specify paths to ignore using RegEx.
</InfoText>
<IndexerRuleEditor locationId={id} />
<IndexerRuleEditor locationId={id!} />
</div>
<Divider />
<div className="flex space-x-5">
@ -183,7 +179,7 @@ export default function EditLocation() {
</div>
<Divider />
<div className="h-6" />
</SettingsSubPage>
</ModalLayout>
</Form>
);
}

View file

@ -0,0 +1,30 @@
import { useLibraryMutation } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm } from '@sd/ui/src/forms';
interface Props extends UseDialogProps {
onSuccess: () => void;
locationId: number;
}
export default (props: Props) => {
const dialog = useDialog(props);
const form = useZodForm();
const deleteLocation = useLibraryMutation('locations.delete', {
onSuccess: props.onSuccess
});
return (
<Dialog
form={form}
onSubmit={form.handleSubmit(() => deleteLocation.mutateAsync(props.locationId))}
dialog={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."
ctaDanger
ctaLabel="Delete"
/>
);
};

View file

@ -1,5 +1,5 @@
import { useLibraryQuery } from '@sd/client';
import { Card, Input, tw } from '@sd/ui';
import { Card, tw } from '@sd/ui';
interface Props {
locationId: string;

View file

@ -4,16 +4,14 @@ import { useState } from 'react';
import { useNavigate } from 'react-router';
import { arraysEqual, useLibraryMutation, useOnlineLocations } from '@sd/client';
import { Location, Node } from '@sd/client';
import { Button, Card, Dialog, UseDialogProps, dialogManager, useDialog } from '@sd/ui';
import { useZodForm, z } from '@sd/ui/src/forms';
import { Folder } from '../icons/Folder';
import { Tooltip } from '../tooltip/Tooltip';
import { Button, Card, Folder, Tooltip, dialogManager } from '@sd/ui';
import DeleteDialog from './DeleteDialog';
interface LocationListItemProps {
interface Props {
location: Location & { node: Node };
}
export default function LocationListItem({ location }: LocationListItemProps) {
export default ({ location }: Props) => {
const navigate = useNavigate();
const [hide, setHide] = useState(false);
@ -26,7 +24,7 @@ export default function LocationListItem({ location }: LocationListItemProps) {
return (
<Card
className="hover:bg-app-box/70 cursor-pointer"
className="hover:bg-app-box/70"
onClick={() => {
navigate(`${location.id}`);
}}
@ -59,11 +57,7 @@ export default function LocationListItem({ location }: LocationListItemProps) {
onClick={(e: { stopPropagation: () => void }) => {
e.stopPropagation();
dialogManager.create((dp) => (
<DeleteLocationDialog
{...dp}
onSuccess={() => setHide(true)}
locationId={location.id}
/>
<DeleteDialog {...dp} onSuccess={() => setHide(true)} locationId={location.id} />
));
}}
>
@ -90,31 +84,4 @@ export default function LocationListItem({ location }: LocationListItemProps) {
</div>
</Card>
);
}
interface DeleteLocationDialogProps extends UseDialogProps {
onSuccess: () => void;
locationId: number;
}
function DeleteLocationDialog(props: DeleteLocationDialogProps) {
const dialog = useDialog(props);
const form = useZodForm({ schema: z.object({}) });
const deleteLocation = useLibraryMutation('locations.delete', {
onSuccess: props.onSuccess
});
return (
<Dialog
form={form}
onSubmit={form.handleSubmit(() => deleteLocation.mutateAsync(props.locationId))}
dialog={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."
ctaDanger
ctaLabel="Delete"
/>
);
}
};

View file

@ -1,21 +1,19 @@
import { MagnifyingGlass } from 'phosphor-react';
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
import { LocationCreateArgs } from '@sd/client';
import { Button, Input, SearchInput, dialogManager } from '@sd/ui';
import AddLocationDialog from '~/components/dialog/AddLocationDialog';
import LocationListItem from '~/components/location/LocationListItem';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { Button, SearchInput, dialogManager } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
import { Heading } from '../../Layout';
import AddDialog from './AddDialog';
import ListItem from './ListItem';
export default function LocationSettings() {
export default () => {
const platform = usePlatform();
const locations = useLibraryQuery(['locations.list']);
const createLocation = useLibraryMutation('locations.create');
return (
<SettingsContainer>
<SettingsHeader
<>
<Heading
title="Locations"
description="Manage your storage locations."
rightArea={
@ -27,7 +25,7 @@ export default function LocationSettings() {
size="md"
onClick={() => {
if (platform.platform === 'web') {
dialogManager.create((dp) => <AddLocationDialog {...dp} />);
dialogManager.create((dp) => <AddDialog {...dp} />);
} else {
if (!platform.openDirectoryPickerDialog) {
alert('Opening a dialogue is not supported on this platform!');
@ -51,9 +49,9 @@ export default function LocationSettings() {
/>
<div className="grid space-y-2">
{locations.data?.map((location) => (
<LocationListItem key={location.id} location={location} />
<ListItem key={location.id} location={location} />
))}
</div>
</SettingsContainer>
</>
);
}
};

View file

@ -1,13 +1,12 @@
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { Heading } from '../Layout';
export default function NodesSettings() {
export default () => {
return (
<SettingsContainer>
<SettingsHeader
<>
<Heading
title="Nodes"
description="Manage the nodes connected to this library. A node is an instance of Spacedrive's backend, running on a device or server. Each node carries a copy of the database and synchronizes via peer-to-peer connections in realtime."
/>
</SettingsContainer>
</>
);
}
};

View file

@ -0,0 +1,9 @@
import { Heading } from '../Layout';
export default () => {
return (
<>
<Heading title="Security" description="Keep your client safe." />
</>
);
};

View file

@ -0,0 +1,9 @@
import { Heading } from '../Layout';
export default () => {
return (
<>
<Heading title="Sharing" description="Manage who has access to your libraries." />
</>
);
};

View file

@ -0,0 +1,9 @@
import { Heading } from '../Layout';
export default () => {
return (
<>
<Heading title="Sync" description="Manage how Spacedrive syncs." />
</>
);
};

View file

@ -0,0 +1,43 @@
import { useLibraryMutation } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { Input, useZodForm, z } from '@sd/ui/src/forms';
import ColorPicker from '~/components/ColorPicker';
export default (props: UseDialogProps) => {
const dialog = useDialog(props);
const form = useZodForm({
schema: z.object({
name: z.string(),
color: z.string()
}),
defaultValues: {
color: '#A717D9'
}
});
const createTag = useLibraryMutation('tags.create', {
onError: (e) => {
console.error('error', e);
}
});
return (
<Dialog
{...{ dialog, form }}
onSubmit={form.handleSubmit((data) => createTag.mutateAsync(data))}
title="Create New Tag"
description="Choose a name and color."
ctaLabel="Create"
>
<div className="relative mt-3 ">
<ColorPicker className="!absolute left-[9px] top-[-5px]" {...form.register('color')} />
<Input
{...form.register('name', { required: true })}
className="w-full pl-[40px]"
placeholder="Name"
/>
</div>
</Dialog>
);
};

View file

@ -0,0 +1,29 @@
import { useLibraryMutation } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm } from '@sd/ui/src/forms';
interface Props extends UseDialogProps {
tagId: number;
onSuccess: () => void;
}
export default (props: Props) => {
const dialog = useDialog(props);
const form = useZodForm();
const deleteTag = useLibraryMutation('tags.delete', {
onSuccess: props.onSuccess
});
return (
<Dialog
{...{ form, dialog }}
onSubmit={form.handleSubmit(() => deleteTag.mutateAsync(props.tagId))}
title="Delete Tag"
description="Are you sure you want to delete this tag? This cannot be undone and tagged files will be unlinked."
ctaDanger
ctaLabel="Delete"
/>
);
};

View file

@ -0,0 +1,70 @@
import { Trash } from 'phosphor-react';
import { Tag, useLibraryMutation } from '@sd/client';
import { Button, Switch, Tooltip, dialogManager } from '@sd/ui';
import { Form, Input, useZodForm, z } from '@sd/ui/src/forms';
import ColorPicker from '~/components/ColorPicker';
import { useDebouncedFormWatch } from '~/hooks/useDebouncedForm';
import Setting from '../../Setting';
import DeleteDialog from './DeleteDialog';
const schema = z.object({
name: z.string().nullable(),
color: z.string().nullable()
});
interface Props {
tag: Tag;
onDelete: () => void;
}
export default ({ tag, onDelete }: Props) => {
const updateTag = useLibraryMutation('tags.update');
const form = useZodForm({
schema,
defaultValues: tag
});
useDebouncedFormWatch(form, (data) =>
updateTag.mutate({
name: data.name ?? null,
color: data.color ?? null,
id: tag.id
})
);
return (
<Form form={form}>
<div className="mb-10 flex flex-row 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">
<ColorPicker className="!absolute left-[9px] top-[-5px]" {...form.register('color')} />
<Input className="w-28 pl-[40px]" {...form.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 {...form.register('name')} />
</div>
<div className="flex grow" />
<Button
variant="gray"
className="mt-[22px] h-[38px]"
onClick={() =>
dialogManager.create((dp) => (
<DeleteDialog {...dp} tagId={tag.id} onSuccess={onDelete} />
))
}
>
<Tooltip label="Delete Tag">
<Trash className="h-4 w-4" />
</Tooltip>
</Button>
</div>
<Setting mini title="Show in Spaces" description="Show this tag on the spaces screen.">
<Switch checked />
</Setting>
</Form>
);
};

View file

@ -0,0 +1,57 @@
import clsx from 'clsx';
import { useState } from 'react';
import { Tag, useLibraryQuery } from '@sd/client';
import { Button, Card, dialogManager } from '@sd/ui';
import { Heading } from '../../Layout';
import CreateDialog from './CreateDialog';
import EditForm from './EditForm';
export default function TagsSettings() {
const tags = useLibraryQuery(['tags.list']);
const [selectedTag, setSelectedTag] = useState<null | Tag>(tags.data?.[0] ?? null);
return (
<>
<Heading
title="Tags"
description="Manage your tags."
rightArea={
<div className="flex-row space-x-2">
<Button
variant="accent"
size="sm"
onClick={() => {
dialogManager.create((dp) => <CreateDialog {...dp} />);
}}
>
Create Tag
</Button>
</div>
}
/>
<Card className="!px-2">
<div className="m-1 flex flex-wrap gap-2">
{tags.data?.map((tag) => (
<div
onClick={() => setSelectedTag(tag.id === selectedTag?.id ? null : tag)}
key={tag.id}
className={clsx(
'flex items-center rounded px-1.5 py-0.5',
selectedTag?.id === tag.id && 'ring'
)}
style={{ backgroundColor: tag.color + 'CC' }}
>
<span className="text-xs text-white drop-shadow-md">{tag.name}</span>
</div>
))}
</div>
</Card>
{selectedTag ? (
<EditForm key={selectedTag.id} tag={selectedTag} onDelete={() => setSelectedTag(null)} />
) : (
<div className="text-sm font-medium text-gray-400">No Tag Selected</div>
)}
</>
);
}

View file

@ -0,0 +1,7 @@
import { RouteObject } from "react-router";
import { lazyEl } from "~/util";
export default [
{ path: 'p2p', element: lazyEl(() => import('./p2p')) },
{ path: 'libraries', element: lazyEl(() => import('./libraries')) },
] satisfies RouteObject[]

View file

@ -1,13 +1,24 @@
import { useQueryClient } from '@tanstack/react-query';
import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react';
import { lazy, useState } from 'react';
import { Algorithm, useBridgeMutation } from '@sd/client';
import { Button, Dialog, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui';
import { useState } from 'react';
import {
Algorithm,
HASHING_ALGOS,
HashingAlgoSlug,
generatePassword,
hashingAlgoSlugSchema,
useBridgeMutation
} from '@sd/client';
import {
Button,
Dialog,
PasswordMeter,
Select,
SelectOption,
UseDialogProps,
useDialog
} from '@sd/ui';
import { forms } from '@sd/ui';
import { getHashingAlgorithmSettings } from '~/screens/settings/library/KeysSetting';
import { generatePassword } from '../key/KeyMounter';
const PasswordMeter = lazy(() => import('../key/PasswordMeter'));
const { Input, z, useZodForm } = forms;
@ -16,12 +27,10 @@ const schema = z.object({
password: z.string(),
password_validate: z.string(),
algorithm: z.string(),
hashing_algorithm: z.string()
hashing_algorithm: hashingAlgoSlugSchema
});
type Props = UseDialogProps;
export default function CreateLibraryDialog(props: Props) {
export default (props: UseDialogProps) => {
const dialog = useDialog(props);
const form = useZodForm({
@ -58,7 +67,7 @@ export default function CreateLibraryDialog(props: Props) {
await createLibrary.mutateAsync({
...data,
algorithm: data.algorithm as Algorithm,
hashing_algorithm: getHashingAlgorithmSettings(data.hashing_algorithm),
hashing_algorithm: HASHING_ALGOS[data.hashing_algorithm],
auth: {
type: 'Password',
value: data.password
@ -170,7 +179,7 @@ export default function CreateLibraryDialog(props: Props) {
<Select
className="mt-2"
value={form.watch('hashing_algorithm')}
onChange={(e) => form.setValue('hashing_algorithm', e)}
onChange={(e) => form.setValue('hashing_algorithm', e as HashingAlgoSlug)}
>
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
@ -185,4 +194,4 @@ export default function CreateLibraryDialog(props: Props) {
<PasswordMeter password={form.watch('password')} />
</Dialog>
);
}
};

View file

@ -0,0 +1,47 @@
import { Database, DotsSixVertical, Pencil, Trash } from 'phosphor-react';
import { LibraryConfigWrapped } from '@sd/client';
import { Button, ButtonLink, Card, Tooltip, dialogManager, tw } from '@sd/ui';
import DeleteDialog from './DeleteDialog';
const Pill = tw.span`px-1.5 ml-2 py-[2px] rounded text-xs font-medium bg-accent`;
interface Props {
library: LibraryConfigWrapped;
current: boolean;
}
export default (props: Props) => (
<Card>
<DotsSixVertical weight="bold" className="mt-[15px] mr-3 opacity-30" />
<div className="my-0.5 flex-1">
<h3 className="font-semibold">
{props.library.config.name}
{props.current && <Pill>Current</Pill>}
</h3>
<p className="text-ink-dull mt-0.5 text-xs">{props.library.uuid}</p>
</div>
<div className="flex flex-row items-center space-x-2">
<Button className="!p-1.5" variant="gray">
<Tooltip label="TODO">
<Database className="h-4 w-4" />
</Tooltip>
</Button>
<ButtonLink className="!p-1.5" to="../../library/general" variant="gray">
<Tooltip label="Edit Library">
<Pencil className="h-4 w-4" />
</Tooltip>
</ButtonLink>
<Button
className="!p-1.5"
variant="gray"
onClick={() => {
dialogManager.create((dp) => <DeleteDialog {...dp} libraryUuid={props.library.uuid} />);
}}
>
<Tooltip label="Delete Library">
<Trash className="h-4 w-4" />
</Tooltip>
</Button>
</div>
</Card>
);

View file

@ -0,0 +1,49 @@
import { useBridgeQuery, useLibraryContext } from '@sd/client';
import { Button, dialogManager } from '@sd/ui';
import { Heading } from '../../Layout';
import CreateDialog from './CreateDialog';
import ListItem from './ListItem';
export default () => {
const libraries = useBridgeQuery(['library.list']);
const { library } = useLibraryContext();
return (
<>
<Heading
title="Libraries"
description="The database contains all library data and file metadata."
rightArea={
<div className="flex-row space-x-2">
<Button
variant="accent"
size="sm"
onClick={() => {
dialogManager.create((dp) => <CreateDialog {...dp} />);
}}
>
Add Library
</Button>
</div>
}
/>
<div className="space-y-2">
{libraries.data
?.sort((a, b) => {
if (a.uuid === library.uuid) return -1;
if (b.uuid === library.uuid) return 1;
return 0;
})
.map((library) => (
<ListItem
current={library.uuid === library.uuid}
key={library.uuid}
library={library}
/>
))}
</div>
</>
);
};

View file

@ -1,25 +1,24 @@
import { Input, Switch } from '@sd/ui';
import { InputContainer } from '~/components/primitive/InputContainer';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
import { Heading } from '../Layout';
import Setting from '../Setting';
export default function P2PSettings() {
return (
<SettingsContainer>
<SettingsHeader
<>
<Heading
title="P2P Settings"
description="Manage how this node communicates with other nodes."
/>
<InputContainer
<Setting
mini
title="Enable Node Discovery"
description="Allow or block this node from calling an external server to assist in forming a peer-to-peer connection. "
>
<Switch checked />
</InputContainer>
</Setting>
<InputContainer
<Setting
title="Discovery Server"
description="Configuration server to aid with establishing peer-to-peer to connections between nodes over the internet. Disabling will result in nodes only being accessible over LAN and direct IP connections."
>
@ -29,7 +28,7 @@ export default function P2PSettings() {
<a className="text-accent hover:text-accent p-1 text-sm font-bold">Change</a>
</div>
</div>
</InputContainer>
</SettingsContainer>
</Setting>
</>
);
}

View file

@ -1,6 +1,5 @@
import Logo from '@sd/assets/images/logo.png';
import { useBridgeQuery } from '@sd/client';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
export default function AboutSpacedrive() {
@ -12,7 +11,7 @@ export default function AboutSpacedrive() {
os === 'browser' ? 'Web' : os == 'macOS' ? os : os.charAt(0).toUpperCase() + os.slice(1);
return (
<SettingsContainer>
<>
<div className="flex flex-row items-center">
<img src={Logo} className="mr-8 h-[88px] w-[88px]" />
<div className="flex flex-col">
@ -25,6 +24,6 @@ export default function AboutSpacedrive() {
</span>
</div>
</div>
</SettingsContainer>
</>
);
}

View file

@ -0,0 +1,9 @@
import { Heading } from '../Layout';
export default function Changelog() {
return (
<>
<Heading title="Changelog" description="See what cool new features we're making" />
</>
);
}

View file

@ -0,0 +1,9 @@
import { RouteObject } from "react-router";
import { lazyEl } from "~/util";
export default [
{ path: 'about', element: lazyEl(() => import('./about')) },
{ path: 'changelog', element: lazyEl(() => import('./changelog')) },
{ path: 'dependencies', element: lazyEl(() => import('./dependencies')) },
{ path: 'support', element: lazyEl(() => import('./support')) },
] satisfies RouteObject[]

View file

@ -0,0 +1,9 @@
import { Heading } from '../Layout';
export default function Support() {
return (
<>
<Heading title="Support" description="" />
</>
);
}

Some files were not shown because too many files have changed in this diff Show more