[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** - Rust version: **1.67.0**
- Node version: **18** - 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 ##### Mobile app
To run 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 { convertFileSrc } from '@tauri-apps/api/tauri';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { getDebugState, hooks } from '@sd/client'; import { getDebugState, hooks } from '@sd/client';
import SpacedriveInterface, { OperatingSystem, Platform, PlatformProvider } from '@sd/interface'; import {
import { KeybindEvent, ErrorPage } from '@sd/interface'; ErrorPage,
KeybindEvent,
OperatingSystem,
Platform,
PlatformProvider,
SpacedriveInterface
} from '@sd/interface';
import '@sd/ui/style'; import '@sd/ui/style';
const client = hooks.createClient({ 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. // 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 React, { Suspense } from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import '@sd/ui/style';
// THIS MUST GO BEFORE importing the App // THIS MUST GO BEFORE importing the App
import '~/patches'; import '~/patches';
import App from './App'; import App from './App';

View file

@ -10,7 +10,7 @@
"include": ["src"], "include": ["src"],
"references": [ "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 { Heart } from 'phosphor-react-native';
import { useState } from 'react'; import { useState } from 'react';
import { Pressable, PressableProps } from 'react-native'; import { Pressable, PressableProps } from 'react-native';
import { Object as SDObject, useLibraryMutation } from '@sd/client'; import { Object as SDObject, useLibraryMutation } from '@sd/client';
import { useQueryClient } from '@tanstack/react-query';
type Props = { type Props = {
data: SDObject; data: SDObject;

View file

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

View file

@ -1,7 +1,7 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useBridgeMutation } from '@sd/client';
import { useRef } from 'react'; 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 = { type Props = {
libraryUuid: string; libraryUuid: string;

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import { createWSClient, loggerLink, wsLink } from '@rspc/client';
import { QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { getDebugState, hooks } from '@sd/client'; 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'; import demoData from './demoData.json';
globalThis.isDev = import.meta.env.DEV; globalThis.isDev = import.meta.env.DEV;

View file

@ -7,7 +7,7 @@
"include": ["src", "src/demoData.json"], "include": ["src", "src/demoData.json"],
"references": [ "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 { useDebugState } from '@sd/client';
import { Button } from '@sd/ui'; import { Button } from '@sd/ui';
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { export default ({ error, resetErrorBoundary }: FallbackProps) => (
return ( <ErrorPage
<ErrorPage message={error.message}
message={error.message} sendReportBtn={() => {
sendReportBtn={() => { captureException(error);
captureException(error); resetErrorBoundary();
resetErrorBoundary(); }}
}} reloadBtn={resetErrorBoundary}
reloadBtn={resetErrorBoundary} />
/> );
);
}
export function ErrorPage({ export function ErrorPage({
reloadBtn, reloadBtn,

View file

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

View file

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

View file

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

View file

@ -1,23 +1,9 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { HTMLAttributes } from 'react'; import { HTMLAttributes } from 'react';
import { ExplorerItem, ObjectKind, isObject } from '@sd/client'; import { ExplorerItem, ObjectKind, isObject } from '@sd/client';
import { cva, tw } from '@sd/ui';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore'; import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { ExplorerItemContextMenu } from './ExplorerContextMenu'; import ContextMenu from './ContextMenu';
import { FileThumb } from './FileThumb'; import FileThumb from './Thumb';
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'
}
}
}
);
interface Props extends HTMLAttributes<HTMLDivElement> { interface Props extends HTMLAttributes<HTMLDivElement> {
data: ExplorerItem; data: ExplorerItem;
@ -26,16 +12,14 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
} }
function FileItem({ data, selected, index, ...rest }: Props) { 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 item = data.item;
const explorerStore = useExplorerStore(); const explorerStore = useExplorerStore();
return ( return (
<ExplorerItemContextMenu data={data}> <ContextMenu data={data}>
<div <div
onContextMenu={(e) => { onContextMenu={() => {
if (index != undefined) { if (index != undefined) {
getExplorerStore().selectedRowIndex = index; getExplorerStore().selectedRowIndex = index;
} }
@ -59,14 +43,19 @@ function FileItem({ data, selected, index, ...rest }: Props) {
> >
<FileThumb data={data} size={explorerStore.gridItemSize} /> <FileThumb data={data} size={explorerStore.gridItemSize} />
</div> </div>
<NameArea> <div className="flex justify-center">
<span className={nameContainerStyles({ selected })}> <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.name}
{item.extension && `.${item.extension}`} {item.extension && `.${item.extension}`}
</span> </span>
</NameArea> </div>
</div> </div>
</ExplorerItemContextMenu> </ContextMenu>
); );
} }

View file

@ -3,12 +3,11 @@ import clsx from 'clsx';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { HTMLAttributes } from 'react'; import { HTMLAttributes } from 'react';
import { ExplorerItem, ObjectKind, isObject, isPath } from '@sd/client'; import { ExplorerItem, ObjectKind, isObject, isPath } from '@sd/client';
import { getExplorerStore } from '../../hooks/useExplorerStore'; import { InfoPill } from '../Inspector';
import { ExplorerItemContextMenu } from './ExplorerContextMenu'; import { getExplorerItemData } from '../util';
import { ColumnKey, columns } from './FileColumns'; import ContextMenu from './ContextMenu';
import { FileThumb } from './FileThumb'; import { columns } from './RowHeader';
import { InfoPill } from './Inspector'; import FileThumb from './Thumb';
import { getExplorerItemData } from './util';
interface Props extends HTMLAttributes<HTMLDivElement> { interface Props extends HTMLAttributes<HTMLDivElement> {
data: ExplorerItem; data: ExplorerItem;
@ -16,35 +15,35 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
selected: boolean; selected: boolean;
} }
function FileRow({ data, index, selected, ...props }: Props) { export default ({ data, index, selected, ...props }: Props) => (
return ( <ContextMenu data={data}>
<ExplorerItemContextMenu className="w-full" data={data}> <div
<div {...props}
{...props} className={clsx(
className={clsx( 'table-body-row mr-2 flex w-full flex-row rounded-lg border-2',
'table-body-row mr-2 flex w-full flex-row rounded-lg border-2', selected ? 'border-accent' : 'border-transparent',
selected ? 'border-accent' : 'border-transparent', index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]' )}
)} >
> {columns.map((col) => (
{columns.map((col) => ( <div
<div key={col.key}
key={col.key} className="table-body-cell flex items-center px-4 py-2 pr-2"
className="table-body-cell flex items-center px-4 py-2 pr-2" style={{ width: col.width }}
style={{ width: col.width }} >
> <Cell data={data} colKey={col.key} />
<RenderCell data={data} colKey={col.key} /> </div>
</div> ))}
))} </div>
</div> </ContextMenu>
</ExplorerItemContextMenu> );
);
interface CellProps {
colKey: (typeof columns)[number]['key'];
data: ExplorerItem;
} }
const RenderCell: React.FC<{ const Cell = ({ colKey, data }: CellProps) => {
colKey: ColumnKey;
data: ExplorerItem;
}> = ({ colKey, data }) => {
const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; const objectData = data ? (isObject(data) ? data.item : data.item.object) : null;
const { cas_id } = getExplorerItemData(data); const { cas_id } = getExplorerItemData(data);
@ -83,14 +82,8 @@ const RenderCell: React.FC<{
</InfoPill> </InfoPill>
</div> </div>
); );
// case 'meta_integrity_hash':
// return <span className="truncate">{value}</span>;
// case 'tags':
// return renderCellWithIcon(MusicNoteIcon);
default: default:
return <></>; 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 clsx from 'clsx';
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { ExplorerItem } from '@sd/client'; import { ExplorerItem } from '@sd/client';
import { Folder } from '@sd/ui';
import { usePlatform } from '~/util/Platform'; 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'); // const icons = import.meta.glob('../../../../assets/icons/*.svg');
interface FileItemProps { interface Props {
data: ExplorerItem; data: ExplorerItem;
size: number; size: number;
className?: string; className?: string;
} }
export function FileThumb({ data, size, className }: FileItemProps) { export default ({ data, size, className }: Props) => {
const { cas_id, isDir, kind, hasThumbnail, extension } = getExplorerItemData(data); const { cas_id, isDir, kind, hasThumbnail, extension } = getExplorerItemData(data);
// 10 percent of the size // 10 percent of the size
@ -59,14 +59,14 @@ export function FileThumb({ data, size, className }: FileItemProps) {
: {} : {}
} }
/> />
{extension && kind === 'Video' && size > 80 && ( {extension && kind === 'Video' && hasThumbnail && 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"> <div className="absolute bottom-[13%] right-[5%] rounded bg-black/60 py-0.5 px-1 text-[9px] font-semibold uppercase opacity-70">
{extension} {extension}
</div> </div>
)} )}
</div> </div>
); );
} };
interface FileThumbImgProps { interface FileThumbImgProps {
isDir: boolean; isDir: boolean;
cas_id: string | null; cas_id: string | null;

View file

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

View file

@ -2,7 +2,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Barcode, CircleWavyCheck, Clock, Cube, Hash, Link, Lock, Snowflake } from 'phosphor-react'; import { Barcode, CircleWavyCheck, Clock, Cube, Hash, Link, Lock, Snowflake } from 'phosphor-react';
import { useEffect, useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { import {
ExplorerContext, ExplorerContext,
ExplorerItem, ExplorerItem,
@ -11,24 +11,17 @@ import {
isObject, isObject,
useLibraryQuery useLibraryQuery
} from '@sd/client'; } from '@sd/client';
import { Button, tw } from '@sd/ui'; import { Button, Divider, Tooltip, tw } from '@sd/ui';
import { DefaultProps } from '../primitive/types'; import FileThumb from '../File/Thumb';
import { Tooltip } from '../tooltip/Tooltip'; import FavoriteButton from './FavoriteButton';
import { FileThumb } from './FileThumb'; import Note from './Note';
import { Divider } from './inspector/Divider';
import FavoriteButton from './inspector/FavoriteButton';
import Note from './inspector/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 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 MetaContainer = tw.div`flex flex-col px-4 py-1.5`;
export const MetaTitle = tw.h5`text-xs font-bold`; export const MetaTitle = tw.h5`text-xs font-bold`;
export const MetaKeyName = tw.h5`text-xs flex-shrink-0 flex-wrap-0`; 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`; 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`; 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)} /> <Icon weight="bold" {...props} className={clsx('mr-2 shrink-0', props.className)} />
); );
interface Props extends DefaultProps<HTMLDivElement> { interface Props extends ComponentProps<'div'> {
context?: ExplorerContext; context?: ExplorerContext;
data?: ExplorerItem; data?: ExplorerItem;
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,10 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx'; import clsx from 'clsx';
import { DotsThree, Eye, Key as KeyIcon } from 'phosphor-react'; 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 { animated, useTransition } from 'react-spring';
import { useLibraryMutation } from '@sd/client'; import { useLibraryMutation } from '@sd/client';
import { Button } from '@sd/ui'; import { Button, Tooltip } from '@sd/ui';
import { DefaultProps } from '../primitive/types';
import { Tooltip } from '../tooltip/Tooltip';
export type KeyManagerProps = DefaultProps;
// TODO: Replace this with Prisma type when integrating with backend // TODO: Replace this with Prisma type when integrating with backend
export interface Key { export interface Key {
@ -37,10 +33,8 @@ interface Props extends DropdownMenu.MenuContentProps {
export const KeyDropdown = ({ export const KeyDropdown = ({
trigger, trigger,
children, children,
disabled,
transformOrigin, transformOrigin,
className, className
...props
}: PropsWithChildren<Props>) => { }: PropsWithChildren<Props>) => {
const [open, setOpen] = useState(false); 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 mountKey = useLibraryMutation('keys.mount');
const unmountKey = useLibraryMutation('keys.unmount'); const unmountKey = useLibraryMutation('keys.unmount');
const deleteKey = useLibraryMutation('keys.deleteFromLibrary'); 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 { Eye, EyeSlash, Info } from 'phosphor-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Algorithm, useLibraryMutation } from '@sd/client'; import {
import { Button, CategoryHeading, Input, Select, SelectOption, Switch, tw } from '@sd/ui'; Algorithm,
import { getHashingAlgorithmSettings } from '../../screens/settings/library/KeysSetting'; HASHING_ALGOS,
import Slider from '../primitive/Slider'; HashingAlgoSlug,
import { Tooltip } from '../tooltip/Tooltip'; 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`; const KeyHeading = tw(CategoryHeading)`mb-1`;
export const generatePassword = (length: number) => { export default () => {
return cryptoRandomString({ length, type: 'ascii-printable' });
};
export function KeyMounter() {
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [librarySync, setLibrarySync] = useState(true); const [librarySync, setLibrarySync] = useState(true);
@ -23,7 +22,7 @@ export function KeyMounter() {
const [key, setKey] = useState(''); const [key, setKey] = useState('');
const [encryptionAlgo, setEncryptionAlgo] = useState('XChaCha20Poly1305'); const [encryptionAlgo, setEncryptionAlgo] = useState('XChaCha20Poly1305');
const [hashingAlgo, setHashingAlgo] = useState('Argon2id-s'); const [hashingAlgo, setHashingAlgo] = useState<HashingAlgoSlug>('Argon2id-s');
const createKey = useLibraryMutation('keys.add'); const createKey = useLibraryMutation('keys.add');
const CurrentEyeIcon = showKey ? EyeSlash : Eye; const CurrentEyeIcon = showKey ? EyeSlash : Eye;
@ -123,7 +122,11 @@ export function KeyMounter() {
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs font-bold">Hashing</span> <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-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption> <SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption> <SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
@ -140,7 +143,7 @@ export function KeyMounter() {
onClick={() => { onClick={() => {
setKey(''); setKey('');
const hashing_algorithm = getHashingAlgorithmSettings(hashingAlgo); const hashing_algorithm = HASHING_ALGOS[hashingAlgo];
createKey.mutate({ createKey.mutate({
algorithm: encryptionAlgo as Algorithm, algorithm: encryptionAlgo as Algorithm,
@ -155,4 +158,4 @@ export function KeyMounter() {
</Button> </Button>
</div> </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, ArrowsClockwise,
Camera, Camera,
Copy, Copy,
DotsThree,
Eye, Eye,
Fingerprint, Fingerprint,
Folder, Folder,
@ -18,9 +17,7 @@ import {
X X
} from 'phosphor-react'; } from 'phosphor-react';
import { JobReport, useLibraryMutation, useLibraryQuery } from '@sd/client'; import { JobReport, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, CategoryHeading, Popover, PopoverClose, tw } from '@sd/ui'; import { Button, CategoryHeading, PopoverClose, ProgressBar, Tooltip } from '@sd/ui';
import ProgressBar from '../primitive/ProgressBar';
import { Tooltip } from '../tooltip/Tooltip';
interface JobNiceData { interface JobNiceData {
name: string; name: string;
@ -93,12 +90,6 @@ const StatusColors: Record<JobReport['status'], string> = {
Paused: 'text-gray-500' 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() { export function JobsManager() {
const runningJobs = useLibraryQuery(['jobs.getRunning']); const runningJobs = useLibraryQuery(['jobs.getRunning']);
const jobs = useLibraryQuery(['jobs.getHistory']); const jobs = useLibraryQuery(['jobs.getHistory']);
@ -106,7 +97,7 @@ export function JobsManager() {
return ( return (
<div className="h-full overflow-hidden pb-10"> <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> <CategoryHeading className="ml-2">Recent Jobs</CategoryHeading>
<div className="grow" /> <div className="grow" />
@ -122,7 +113,7 @@ export function JobsManager() {
</Tooltip> </Tooltip>
</Button> </Button>
</PopoverClose> </PopoverClose>
</HeaderContainer> </div>
<div className="custom-scroll inspector-scroll mr-1 h-full overflow-x-hidden"> <div className="custom-scroll inspector-scroll mr-1 h-full overflow-x-hidden">
<div className=""> <div className="">
<div className="py-1"> <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 * as ToastPrimitive from '@radix-ui/react-toast';
import clsx from 'clsx'; import clsx from 'clsx';
import { useToasts } from '../../hooks/useToasts'; import { useToasts } from '~/hooks/useToasts';
export function Toasts() { export default () => {
const { toasts, addToast, removeToast } = useToasts(); const { toasts, removeToast } = useToasts();
return ( return (
<div className="fixed right-0 flex"> <div className="fixed right-0 flex">
<ToastPrimitive.Provider> <ToastPrimitive.Provider>
@ -71,4 +71,4 @@ export function Toasts() {
</ToastPrimitive.Provider> </ToastPrimitive.Provider>
</div> </div>
); );
} };

View file

@ -1,13 +1,12 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Suspense } from 'react'; 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 { ClientContextProvider, LibraryContextProvider, useClientContext } from '@sd/client';
import { Sidebar } from '~/components/layout/Sidebar';
import { Toasts } from '~/components/primitive/Toasts';
import { useOperatingSystem } from '~/hooks/useOperatingSystem'; 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 { libraries, library } = useClientContext();
const os = useOperatingSystem(); const os = useOperatingSystem();
@ -15,7 +14,7 @@ function AppLayout() {
if (library === null && libraries.data) { if (library === null && libraries.data) {
const firstLibrary = libraries.data[0]; 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="/" />; else return <Navigate to="/" />;
} }
@ -49,14 +48,14 @@ function AppLayout() {
<Toasts /> <Toasts />
</div> </div>
); );
} };
export default () => { export default () => {
const currentLibraryId = useLibraryId(); const params = useParams<{ libraryId: string }>();
return ( return (
<ClientContextProvider currentLibraryId={currentLibraryId ?? null}> <ClientContextProvider currentLibraryId={params.libraryId ?? null}>
<AppLayout /> <Layout />
</ClientContextProvider> </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 { useBridgeQuery, useLibraryMutation, useLibraryQuery } from '@sd/client';
import CodeBlock from '~/components/primitive/Codeblock'; import { CodeBlock } from '~/components/Codeblock';
import { usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
import { ScreenContainer } from './_Layout';
// TODO: Bring this back with a button in the sidebar near settings at the bottom // TODO: Bring this back with a button in the sidebar near settings at the bottom
export default function DebugScreen() { export default function DebugScreen() {
@ -17,10 +16,9 @@ export default function DebugScreen() {
// }); // });
const { mutate: identifyFiles } = useLibraryMutation('jobs.identifyUniqueFiles'); const { mutate: identifyFiles } = useLibraryMutation('jobs.identifyUniqueFiles');
return ( return (
<ScreenContainer> <div className="flex flex-col space-y-5 p-5 pt-2 pb-7">
<div className="flex flex-col space-y-5 p-5 pt-2 pb-7"> <h1 className="text-lg font-bold ">Developer Debugger</h1>
<h1 className="text-lg font-bold ">Developer Debugger</h1> {/* <div className="flex flex-row pb-4 space-x-2">
{/* <div className="flex flex-row pb-4 space-x-2">
<Button <Button
className="w-40" className="w-40"
variant="gray" variant="gray"
@ -34,15 +32,14 @@ export default function DebugScreen() {
Open data folder Open data folder
</Button> </Button>
</div> */} </div> */}
<h1 className="text-sm font-bold ">Running Jobs</h1> <h1 className="text-sm font-bold ">Running Jobs</h1>
<CodeBlock src={{ ...jobs }} /> <CodeBlock src={{ ...jobs }} />
<h1 className="text-sm font-bold ">Job History</h1> <h1 className="text-sm font-bold ">Job History</h1>
<CodeBlock src={{ ...jobHistory }} /> <CodeBlock src={{ ...jobHistory }} />
<h1 className="text-sm font-bold ">Node State</h1> <h1 className="text-sm font-bold ">Node State</h1>
<CodeBlock src={{ ...nodeState }} /> <CodeBlock src={{ ...nodeState }} />
<h1 className="text-sm font-bold ">Libraries</h1> <h1 className="text-sm font-bold ">Libraries</h1>
<CodeBlock src={{ ...libraryState }} /> <CodeBlock src={{ ...libraryState }} />
</div> </div>
</ScreenContainer>
); );
} }

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 { useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { useLibraryQuery } from '@sd/client'; import { useLibraryQuery } from '@sd/client';
import Explorer from '~/components/explorer/Explorer';
import { getExplorerStore } from '~/hooks/useExplorerStore'; import { getExplorerStore } from '~/hooks/useExplorerStore';
import Explorer from '../Explorer';
export function useExplorerParams() { export function useExplorerParams() {
const { id } = useParams<{ id?: string }>(); const { id } = useParams<{ id?: string }>();
@ -15,7 +15,7 @@ export function useExplorerParams() {
return { location_id, path, limit }; return { location_id, path, limit };
} }
export default function LocationExplorer() { export default () => {
const { location_id, path } = useExplorerParams(); const { location_id, path } = useExplorerParams();
useEffect(() => { useEffect(() => {
@ -39,4 +39,4 @@ export default function LocationExplorer() {
<Explorer data={explorerData.data} /> <Explorer data={explorerData.data} />
</div> </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 byteSize from 'byte-size';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
@ -14,13 +13,10 @@ import {
} from 'phosphor-react'; } from 'phosphor-react';
import Skeleton from 'react-loading-skeleton'; import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css'; 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 { Card } from '@sd/ui';
import useCounter from '~/hooks/useCounter'; import useCounter from '~/hooks/useCounter';
import { useLibraryId } from '~/util';
import { usePlatform } from '~/util/Platform'; import { usePlatform } from '~/util/Platform';
import { ScreenContainer } from './_Layout';
import { useEffect } from 'react';
interface StatItemProps { interface StatItemProps {
title: string; title: string;
@ -91,7 +87,7 @@ const StatItem = (props: StatItemProps) => {
export default function OverviewScreen() { export default function OverviewScreen() {
const platform = usePlatform(); const platform = usePlatform();
const libraryId = useLibraryId(); const { library } = useLibraryContext();
const stats = useLibraryQuery(['library.getStatistics'], { const stats = useLibraryQuery(['library.getStatistics'], {
initialData: { ...EMPTY_STATISTICS } initialData: { ...EMPTY_STATISTICS }
@ -100,45 +96,43 @@ export default function OverviewScreen() {
overviewMounted = true; overviewMounted = true;
return ( return (
<ScreenContainer> <div className="flex h-screen w-full flex-col">
<div className="flex h-screen w-full flex-col"> {/* STAT HEADER */}
{/* STAT HEADER */} <div className="flex w-full">
<div className="flex w-full"> {/* STAT CONTAINER */}
{/* STAT CONTAINER */} <div className="-mb-1 flex h-20 overflow-hidden">
<div className="-mb-1 flex h-20 overflow-hidden"> {Object.entries(stats?.data || []).map(([key, value]) => {
{Object.entries(stats?.data || []).map(([key, value]) => { if (!displayableStatItems.includes(key)) return null;
if (!displayableStatItems.includes(key)) return null; return (
return ( <StatItem
<StatItem key={`${library.uuid} ${key}`}
key={`${libraryId} ${key}`} title={StatItemNames[key as keyof Statistics]!}
title={StatItemNames[key as keyof Statistics]!} bytes={BigInt(value)}
bytes={BigInt(value)} isLoading={platform.demoMode ? false : stats.isLoading}
isLoading={platform.demoMode ? false : stats.isLoading} />
/> );
); })}
})}
</div>
<div className="grow" />
</div> </div>
<div className="mt-4 grid grid-cols-5 gap-3 pb-4"> <div className="grow" />
<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> </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 clsx from 'clsx';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { DefaultProps } from './types';
interface InputContainerProps extends DefaultProps<HTMLDivElement> { interface Props {
title: string; title: string;
description?: string; description?: string;
mini?: boolean; mini?: boolean;
className?: string;
} }
export function InputContainer({ mini, ...props }: PropsWithChildren<InputContainerProps>) { export default ({ mini, ...props }: PropsWithChildren<Props>) => {
return ( return (
<div className="flex flex-row"> <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> <h3 className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">{props.title}</h3>
{!!props.description && <p className="mb-2 text-sm text-gray-400 ">{props.description}</p>} {!!props.description && <p className="mb-2 text-sm text-gray-400 ">{props.description}</p>}
{!mini && props.children} {!mini && props.children}
@ -19,4 +19,4 @@ export function InputContainer({ mini, ...props }: PropsWithChildren<InputContai
{mini && props.children} {mini && props.children}
</div> </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, SearchInput } from '@sd/ui';
import { Button, Card, GridLayout, Input, SearchInput } from '@sd/ui'; import { Heading } from '../Layout';
import { SettingsContainer } from '~/components/settings/SettingsContainer';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
// extensions should cache their logos in the app data folder // extensions should cache their logos in the app data folder
interface ExtensionItemData { interface ExtensionItemData {
@ -59,8 +57,8 @@ export default function ExtensionSettings() {
// const { data: volumes } = useBridgeQuery('GetVolumes'); // const { data: volumes } = useBridgeQuery('GetVolumes');
return ( return (
<SettingsContainer> <>
<SettingsHeader <Heading
title="Extensions" title="Extensions"
description="Install extensions to extend the functionality of this client." description="Install extensions to extend the functionality of this client."
rightArea={<SearchInput outerClassnames="mt-1.5" placeholder="Search extensions" />} rightArea={<SearchInput outerClassnames="mt-1.5" placeholder="Search extensions" />}
@ -71,6 +69,6 @@ export default function ExtensionSettings() {
<ExtensionItem key={extension.uuid} extension={extension} /> <ExtensionItem key={extension.uuid} extension={extension} />
))} ))}
</GridLayout> </GridLayout>
</SettingsContainer> </>
); );
} }

View file

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

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 { useState } from 'react';
import { Switch } from '@sd/ui'; import { Switch } from '@sd/ui';
import { InputContainer } from '~/components/primitive/InputContainer'; import { Heading } from '../Layout';
import { SettingsContainer } from '~/components/settings/SettingsContainer'; import Setting from '../Setting';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
export default function AppearanceSettings() { export default function AppearanceSettings() {
const [syncWithLibrary, setSyncWithLibrary] = useState(true); const [syncWithLibrary, setSyncWithLibrary] = useState(true);
return ( 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" */} {/* 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" /> <Heading title="Keybinds" description="Manage client keybinds" />
<InputContainer <Setting
mini mini
title="Sync with Library" title="Sync with Library"
description="If enabled your keybinds will be synced with library, otherwise they will apply only to this client." 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} onCheckedChange={setSyncWithLibrary}
className="m-2 ml-4" className="m-2 ml-4"
/> />
</InputContainer> </Setting>
</SettingsContainer> </>
); );
} }

View file

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

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

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

View file

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

View file

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

View file

@ -1,22 +1,19 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx'; import clsx from 'clsx';
import { Eye, EyeSlash, Lock, Plus } from 'phosphor-react'; 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 QRCode from 'react-qr-code';
import { animated, useTransition } from 'react-spring'; 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 { Button, Input, dialogManager } from '@sd/ui';
import { BackupRestoreDialog } from '~/components/dialog/BackupRestoreDialog'; import { showAlertDialog } from '~/components/AlertDialog';
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 { usePlatform } from '~/util/Platform'; 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 { interface Props extends DropdownMenu.MenuContentProps {
trigger: React.ReactNode; trigger: React.ReactNode;
@ -75,7 +72,7 @@ export const KeyMounterDropdown = ({
); );
}; };
export default function KeysSettings() { export default () => {
const platform = usePlatform(); const platform = usePlatform();
const isUnlocked = useLibraryQuery(['keys.isUnlocked']); 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 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> </Button>
{!enterSkManually && ( {!enterSkManually && (
<div className="relative flex grow"> <div className="relative flex grow">
<p <p className="text-accent mt-2" onClick={() => setEnterSkManually(true)}>
className="text-accent mt-2"
onClick={(e) => {
setEnterSkManually(true);
}}
>
or enter secret key manually or enter secret key manually
</p> </p>
</div> </div>
@ -180,145 +172,135 @@ export default function KeysSettings() {
} else { } else {
return ( return (
<> <>
<SettingsContainer> <Heading
<SettingsHeader title="Keys"
title="Keys" description="Manage your keys."
description="Manage your keys." rightArea={
rightArea={ <div className="flex flex-row items-center">
<div className="flex flex-row items-center"> <Button
<Button size="icon"
size="icon" onClick={() => {
onClick={() => { unmountAll.mutate(null);
unmountAll.mutate(null); clearMasterPassword.mutate(null);
clearMasterPassword.mutate(null); }}
}} variant="subtle"
variant="subtle" className="text-ink-faint"
className="text-ink-faint" >
> <Lock className="text-ink-faint h-4 w-4" />
<Lock className="text-ink-faint h-4 w-4" /> </Button>
</Button> <KeyMounterDropdown
<KeyMounterDropdown trigger={
trigger={ <Button size="icon" variant="subtle" className="text-ink-faint">
<Button size="icon" variant="subtle" className="text-ink-faint"> <Plus className="text-ink-faint h-4 w-4" />
<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
</Button> </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); <KeyMounter />
}); </KeyMounterDropdown>
}} </div>
> }
Backup />
</Button>
<Button {isUnlocked && (
size="sm" <div className="grid space-y-2">
variant="gray" <KeyList />
className="mr-2"
onClick={() => dialogManager.create((dp) => <BackupRestoreDialog {...dp} />)}
>
Restore
</Button>
</div> </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> = { const Subheading = (props: SubheadingProps) => (
'Argon2id-s': { name: 'Argon2id', params: 'Standard' }, <div className="flex">
'Argon2id-h': { name: 'Argon2id', params: 'Hardened' }, <div className="grow">
'Argon2id-p': { name: 'Argon2id', params: 'Paranoid' }, <h1 className="text-xl font-bold">{props.title}</h1>
'BalloonBlake3-s': { name: 'BalloonBlake3', params: 'Standard' }, </div>
'BalloonBlake3-h': { name: 'BalloonBlake3', params: 'Hardened' }, {props.rightArea}
'BalloonBlake3-p': { name: 'BalloonBlake3', params: 'Paranoid' } </div>
}; );
// 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];
};

View file

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

View file

@ -4,16 +4,14 @@ import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { arraysEqual, useLibraryMutation, useOnlineLocations } from '@sd/client'; import { arraysEqual, useLibraryMutation, useOnlineLocations } from '@sd/client';
import { Location, Node } from '@sd/client'; import { Location, Node } from '@sd/client';
import { Button, Card, Dialog, UseDialogProps, dialogManager, useDialog } from '@sd/ui'; import { Button, Card, Folder, Tooltip, dialogManager } from '@sd/ui';
import { useZodForm, z } from '@sd/ui/src/forms'; import DeleteDialog from './DeleteDialog';
import { Folder } from '../icons/Folder';
import { Tooltip } from '../tooltip/Tooltip';
interface LocationListItemProps { interface Props {
location: Location & { node: Node }; location: Location & { node: Node };
} }
export default function LocationListItem({ location }: LocationListItemProps) { export default ({ location }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [hide, setHide] = useState(false); const [hide, setHide] = useState(false);
@ -26,7 +24,7 @@ export default function LocationListItem({ location }: LocationListItemProps) {
return ( return (
<Card <Card
className="hover:bg-app-box/70 cursor-pointer" className="hover:bg-app-box/70"
onClick={() => { onClick={() => {
navigate(`${location.id}`); navigate(`${location.id}`);
}} }}
@ -59,11 +57,7 @@ export default function LocationListItem({ location }: LocationListItemProps) {
onClick={(e: { stopPropagation: () => void }) => { onClick={(e: { stopPropagation: () => void }) => {
e.stopPropagation(); e.stopPropagation();
dialogManager.create((dp) => ( dialogManager.create((dp) => (
<DeleteLocationDialog <DeleteDialog {...dp} onSuccess={() => setHide(true)} locationId={location.id} />
{...dp}
onSuccess={() => setHide(true)}
locationId={location.id}
/>
)); ));
}} }}
> >
@ -90,31 +84,4 @@ export default function LocationListItem({ location }: LocationListItemProps) {
</div> </div>
</Card> </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 { useLibraryMutation, useLibraryQuery } from '@sd/client';
import { LocationCreateArgs } from '@sd/client'; import { LocationCreateArgs } from '@sd/client';
import { Button, Input, SearchInput, dialogManager } from '@sd/ui'; import { Button, 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 { usePlatform } from '~/util/Platform'; 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 platform = usePlatform();
const locations = useLibraryQuery(['locations.list']); const locations = useLibraryQuery(['locations.list']);
const createLocation = useLibraryMutation('locations.create'); const createLocation = useLibraryMutation('locations.create');
return ( return (
<SettingsContainer> <>
<SettingsHeader <Heading
title="Locations" title="Locations"
description="Manage your storage locations." description="Manage your storage locations."
rightArea={ rightArea={
@ -27,7 +25,7 @@ export default function LocationSettings() {
size="md" size="md"
onClick={() => { onClick={() => {
if (platform.platform === 'web') { if (platform.platform === 'web') {
dialogManager.create((dp) => <AddLocationDialog {...dp} />); dialogManager.create((dp) => <AddDialog {...dp} />);
} else { } else {
if (!platform.openDirectoryPickerDialog) { if (!platform.openDirectoryPickerDialog) {
alert('Opening a dialogue is not supported on this platform!'); alert('Opening a dialogue is not supported on this platform!');
@ -51,9 +49,9 @@ export default function LocationSettings() {
/> />
<div className="grid space-y-2"> <div className="grid space-y-2">
{locations.data?.map((location) => ( {locations.data?.map((location) => (
<LocationListItem key={location.id} location={location} /> <ListItem key={location.id} location={location} />
))} ))}
</div> </div>
</SettingsContainer> </>
); );
} };

View file

@ -1,13 +1,12 @@
import { SettingsContainer } from '~/components/settings/SettingsContainer'; import { Heading } from '../Layout';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
export default function NodesSettings() { export default () => {
return ( return (
<SettingsContainer> <>
<SettingsHeader <Heading
title="Nodes" 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." 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 { useQueryClient } from '@tanstack/react-query';
import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react'; import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react';
import { lazy, useState } from 'react'; import { useState } from 'react';
import { Algorithm, useBridgeMutation } from '@sd/client'; import {
import { Button, Dialog, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui'; 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 { 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; const { Input, z, useZodForm } = forms;
@ -16,12 +27,10 @@ const schema = z.object({
password: z.string(), password: z.string(),
password_validate: z.string(), password_validate: z.string(),
algorithm: z.string(), algorithm: z.string(),
hashing_algorithm: z.string() hashing_algorithm: hashingAlgoSlugSchema
}); });
type Props = UseDialogProps; export default (props: UseDialogProps) => {
export default function CreateLibraryDialog(props: Props) {
const dialog = useDialog(props); const dialog = useDialog(props);
const form = useZodForm({ const form = useZodForm({
@ -58,7 +67,7 @@ export default function CreateLibraryDialog(props: Props) {
await createLibrary.mutateAsync({ await createLibrary.mutateAsync({
...data, ...data,
algorithm: data.algorithm as Algorithm, algorithm: data.algorithm as Algorithm,
hashing_algorithm: getHashingAlgorithmSettings(data.hashing_algorithm), hashing_algorithm: HASHING_ALGOS[data.hashing_algorithm],
auth: { auth: {
type: 'Password', type: 'Password',
value: data.password value: data.password
@ -170,7 +179,7 @@ export default function CreateLibraryDialog(props: Props) {
<Select <Select
className="mt-2" className="mt-2"
value={form.watch('hashing_algorithm')} 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-s">Argon2id (standard)</SelectOption>
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption> <SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
@ -185,4 +194,4 @@ export default function CreateLibraryDialog(props: Props) {
<PasswordMeter password={form.watch('password')} /> <PasswordMeter password={form.watch('password')} />
</Dialog> </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 { Input, Switch } from '@sd/ui';
import { InputContainer } from '~/components/primitive/InputContainer'; import { Heading } from '../Layout';
import { SettingsContainer } from '~/components/settings/SettingsContainer'; import Setting from '../Setting';
import { SettingsHeader } from '~/components/settings/SettingsHeader';
export default function P2PSettings() { export default function P2PSettings() {
return ( return (
<SettingsContainer> <>
<SettingsHeader <Heading
title="P2P Settings" title="P2P Settings"
description="Manage how this node communicates with other nodes." description="Manage how this node communicates with other nodes."
/> />
<InputContainer <Setting
mini mini
title="Enable Node Discovery" title="Enable Node Discovery"
description="Allow or block this node from calling an external server to assist in forming a peer-to-peer connection. " description="Allow or block this node from calling an external server to assist in forming a peer-to-peer connection. "
> >
<Switch checked /> <Switch checked />
</InputContainer> </Setting>
<InputContainer <Setting
title="Discovery Server" 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." 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> <a className="text-accent hover:text-accent p-1 text-sm font-bold">Change</a>
</div> </div>
</div> </div>
</InputContainer> </Setting>
</SettingsContainer> </>
); );
} }

View file

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