mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-06-28 01:03:27 +00:00
[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:
parent
c6455dd439
commit
c65d92ee4c
|
@ -61,6 +61,8 @@ If you are having issues ensure you are using the following versions of Rust and
|
|||
- Rust version: **1.67.0**
|
||||
- Node version: **18**
|
||||
|
||||
Be sure to read the [guidelines](https://spacedrive.com/docs/developers/prerequisites/guidelines) to make sure your code is a similar style to ours.
|
||||
|
||||
##### Mobile app
|
||||
|
||||
To run mobile app
|
||||
|
|
|
@ -6,8 +6,14 @@ import { listen } from '@tauri-apps/api/event';
|
|||
import { convertFileSrc } from '@tauri-apps/api/tauri';
|
||||
import { useEffect } from 'react';
|
||||
import { getDebugState, hooks } from '@sd/client';
|
||||
import SpacedriveInterface, { OperatingSystem, Platform, PlatformProvider } from '@sd/interface';
|
||||
import { KeybindEvent, ErrorPage } from '@sd/interface';
|
||||
import {
|
||||
ErrorPage,
|
||||
KeybindEvent,
|
||||
OperatingSystem,
|
||||
Platform,
|
||||
PlatformProvider,
|
||||
SpacedriveInterface
|
||||
} from '@sd/interface';
|
||||
import '@sd/ui/style';
|
||||
|
||||
const client = hooks.createClient({
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
// WARNING: BE CAREFUL SAVING THIS FILE WITH A FORMATTER ENABLED. The import order is important and goes against prettier's recommendations.
|
||||
import React, { Suspense } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import '@sd/ui/style';
|
||||
// THIS MUST GO BEFORE importing the App
|
||||
import '~/patches';
|
||||
import App from './App';
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/interface"
|
||||
"path": "../../interface"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Heart } from 'phosphor-react-native';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, PressableProps } from 'react-native';
|
||||
import { Object as SDObject, useLibraryMutation } from '@sd/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type Props = {
|
||||
data: SDObject;
|
||||
|
|
|
@ -22,7 +22,7 @@ const Note = (props: Props) => {
|
|||
2000
|
||||
);
|
||||
|
||||
const debouncedNote = useCallback((note: string) => debounce(note), [props.data.id, fileSetNote]);
|
||||
const debouncedNote = useCallback((note: string) => debounce(note), [debounce]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useBridgeMutation } from '@sd/client';
|
||||
import { useRef } from 'react';
|
||||
import { ModalRef, ConfirmModal } from '~/components/layout/Modal';
|
||||
import { useBridgeMutation } from '@sd/client';
|
||||
import { ConfirmModal, ModalRef } from '~/components/layout/Modal';
|
||||
|
||||
type Props = {
|
||||
libraryUuid: string;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import ColorPicker from 'react-native-wheel-color-picker';
|
||||
|
@ -8,7 +9,6 @@ import { Modal, ModalRef } from '~/components/layout/Modal';
|
|||
import { Button } from '~/components/primitive/Button';
|
||||
import useForwardedRef from '~/hooks/useForwardedRef';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const CreateTagModal = forwardRef<ModalRef, unknown>((_, ref) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { Tag, useLibraryMutation } from '@sd/client';
|
||||
|
@ -8,7 +9,6 @@ import { Modal, ModalRef } from '~/components/layout/Modal';
|
|||
import { Button } from '~/components/primitive/Button';
|
||||
import useForwardedRef from '~/hooks/useForwardedRef';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type Props = {
|
||||
tag: Tag;
|
||||
|
|
|
@ -2,7 +2,7 @@ import { createWSClient, loggerLink, wsLink } from '@rspc/client';
|
|||
import { QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { getDebugState, hooks } from '@sd/client';
|
||||
import SpacedriveInterface, { Platform, PlatformProvider } from '@sd/interface';
|
||||
import { Platform, PlatformProvider, SpacedriveInterface } from '@sd/interface';
|
||||
import demoData from './demoData.json';
|
||||
|
||||
globalThis.isDev = import.meta.env.DEV;
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"include": ["src", "src/demoData.json"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/interface"
|
||||
"path": "../../interface"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
26
docs/developers/prerequisites/guidelines.md
Normal file
26
docs/developers/prerequisites/guidelines.md
Normal 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.
|
|
@ -3,18 +3,16 @@ import { FallbackProps } from 'react-error-boundary';
|
|||
import { useDebugState } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
|
||||
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<ErrorPage
|
||||
message={error.message}
|
||||
sendReportBtn={() => {
|
||||
captureException(error);
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
reloadBtn={resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default ({ error, resetErrorBoundary }: FallbackProps) => (
|
||||
<ErrorPage
|
||||
message={error.message}
|
||||
sendReportBtn={() => {
|
||||
captureException(error);
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
reloadBtn={resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
|
||||
export function ErrorPage({
|
||||
reloadBtn,
|
|
@ -1,8 +1,9 @@
|
|||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@sd/ui';
|
||||
|
||||
export default function NotFound() {
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="bg-app/80 w-full">
|
||||
<div
|
||||
|
@ -22,4 +23,4 @@ export default function NotFound() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
139
interface/app/$libraryId/Explorer/ContextMenu.tsx
Normal file
139
interface/app/$libraryId/Explorer/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
288
interface/app/$libraryId/Explorer/File/ContextMenu.tsx
Normal file
288
interface/app/$libraryId/Explorer/File/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,17 +1,11 @@
|
|||
import { RadioGroup } from '@headlessui/react';
|
||||
import { Eye, EyeSlash, Info } from 'phosphor-react';
|
||||
import { useState } from 'react';
|
||||
import { Info } from 'phosphor-react';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { Button, Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { Input, Switch, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { showAlertDialog } from '~/util/dialog';
|
||||
import { usePlatform } from '../../util/Platform';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
|
||||
interface DecryptDialogProps extends UseDialogProps {
|
||||
location_id: number;
|
||||
path_id: number;
|
||||
}
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import { PasswordInput, Switch, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
const schema = z.object({
|
||||
type: z.union([z.literal('password'), z.literal('key')]),
|
||||
|
@ -21,7 +15,12 @@ const schema = z.object({
|
|||
saveToKeyManager: z.boolean()
|
||||
});
|
||||
|
||||
export const DecryptFileDialog = (props: DecryptDialogProps) => {
|
||||
interface Props extends UseDialogProps {
|
||||
location_id: number;
|
||||
path_id: number;
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const platform = usePlatform();
|
||||
const dialog = useDialog(props);
|
||||
|
||||
|
@ -55,10 +54,6 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
|
|||
}
|
||||
});
|
||||
|
||||
const [show, setShow] = useState({ password: false });
|
||||
|
||||
const PasswordCurrentEyeIcon = show.password ? EyeSlash : Eye;
|
||||
|
||||
const form = useZodForm({
|
||||
defaultValues: {
|
||||
type: hasMountedKeys ? 'key' : 'password',
|
||||
|
@ -91,13 +86,13 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
|
|||
loading={decryptFile.isLoading}
|
||||
ctaLabel="Decrypt"
|
||||
>
|
||||
<RadioGroup
|
||||
value={form.watch('type')}
|
||||
onChange={(e: 'key' | 'password') => form.setValue('type', e)}
|
||||
className="mt-2"
|
||||
>
|
||||
<span className="text-xs font-bold">Key Type</span>
|
||||
<div className="mt-2 flex flex-row gap-2">
|
||||
<div className="space-y-2 py-2">
|
||||
<h2 className="text-xs font-bold">Key Type</h2>
|
||||
<RadioGroup
|
||||
value={form.watch('type')}
|
||||
onChange={(e: 'key' | 'password') => form.setValue('type', e)}
|
||||
className="mt-2 flex flex-row gap-2"
|
||||
>
|
||||
<RadioGroup.Option disabled={!hasMountedKeys} value="key">
|
||||
{({ checked }) => (
|
||||
<Button
|
||||
|
@ -117,12 +112,10 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
|
|||
</Button>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</RadioGroup>
|
||||
|
||||
{form.watch('type') === 'key' && (
|
||||
<div className="relative mt-3 mb-2 flex grow">
|
||||
<div className="space-x-2">
|
||||
{form.watch('type') === 'key' && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
className="bg-app-selected"
|
||||
size="sm"
|
||||
|
@ -130,76 +123,58 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => {
|
|||
checked={form.watch('mountAssociatedKey')}
|
||||
onCheckedChange={(e) => form.setValue('mountAssociatedKey', e)}
|
||||
/>
|
||||
<span className="ml-3 mt-0.5 text-xs font-medium">Automatically mount key</span>
|
||||
<Tooltip label="The key linked with the file will be automatically mounted">
|
||||
<Info className="text-ink-faint ml-1.5 mt-0.5 h-4 w-4" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="ml-3 mt-0.5 text-xs font-medium">Automatically mount key</span>
|
||||
<Tooltip label="The key linked with the file will be automatically mounted">
|
||||
<Info className="text-ink-faint ml-1.5 mt-0.5 h-4 w-4" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{form.watch('type') === 'password' && (
|
||||
<>
|
||||
<div className="relative mt-3 mb-2 flex grow">
|
||||
<Input
|
||||
className={`w-max grow !py-0.5`}
|
||||
{form.watch('type') === 'password' && (
|
||||
<>
|
||||
<PasswordInput
|
||||
placeholder="Password"
|
||||
type={show.password ? 'text' : 'password'}
|
||||
size="sm"
|
||||
{...form.register('password', { required: true })}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setShow((old) => ({ ...old, password: !old.password }))}
|
||||
size="icon"
|
||||
className="absolute right-[5px] top-[5px] border-none"
|
||||
type="button"
|
||||
>
|
||||
<PasswordCurrentEyeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-3 mb-2 flex grow">
|
||||
<div className="space-x-2">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
className="bg-app-selected"
|
||||
size="sm"
|
||||
{...form.register('saveToKeyManager')}
|
||||
/>
|
||||
<span className="ml-3 mt-0.5 text-xs font-medium">Save to Key Manager</span>
|
||||
<Tooltip label="This key will be saved to the key manager">
|
||||
<Info className="text-ink-faint ml-1.5 mt-0.5 h-4 w-4" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="ml-3 mt-0.5 text-xs font-medium">Save to Key Manager</span>
|
||||
<Tooltip label="This key will be saved to the key manager">
|
||||
<Info className="text-ink-faint ml-1.5 mt-0.5 h-4 w-4" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4 mb-3 grid w-full grid-cols-2 gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold">Output file</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
|
||||
className="mt-2 h-[23px] text-xs leading-3"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
|
||||
if (!platform.saveFilePickerDialog) {
|
||||
// TODO: Support opening locations on web
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: "System dialogs aren't supported on this platform."
|
||||
});
|
||||
return;
|
||||
}
|
||||
platform.saveFilePickerDialog().then((result) => {
|
||||
if (result) form.setValue('outputPath', result as string);
|
||||
<h2 className="text-xs font-bold">Output file</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
|
||||
className="h-[23px] text-xs leading-3"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
|
||||
if (!platform.saveFilePickerDialog) {
|
||||
// TODO: Support opening locations on web
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: "System dialogs aren't supported on this platform."
|
||||
});
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
return;
|
||||
}
|
||||
platform.saveFilePickerDialog().then((result) => {
|
||||
if (result) form.setValue('outputPath', result as string);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
|
@ -1,20 +1,18 @@
|
|||
import { useLibraryMutation } from '@sd/client';
|
||||
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { useZodForm } from '@sd/ui/src/forms';
|
||||
|
||||
interface DeleteDialogProps extends UseDialogProps {
|
||||
interface Propps extends UseDialogProps {
|
||||
location_id: number;
|
||||
path_id: number;
|
||||
}
|
||||
|
||||
const schema = z.object({});
|
||||
|
||||
export const DeleteFileDialog = (props: DeleteDialogProps) => {
|
||||
export default (props: Propps) => {
|
||||
const dialog = useDialog(props);
|
||||
const deleteFile = useLibraryMutation('files.deleteFiles');
|
||||
const form = useZodForm({
|
||||
schema
|
||||
});
|
||||
|
||||
const form = useZodForm();
|
||||
|
||||
const onSubmit = form.handleSubmit(() =>
|
||||
deleteFile.mutateAsync({
|
||||
location_id: props.location_id,
|
|
@ -1,12 +1,17 @@
|
|||
import { Algorithm, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import {
|
||||
Algorithm,
|
||||
hashingAlgoSlugSchema,
|
||||
slugFromHashingAlgo,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery
|
||||
} from '@sd/client';
|
||||
import { Button, Dialog, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { CheckBox, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { getHashingAlgorithmString } from '~/screens/settings/library/KeysSetting';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { showAlertDialog } from '~/util/dialog';
|
||||
import { SelectOptionKeyList } from '../key/KeyList';
|
||||
import { KeyListSelectOptions } from '../../KeyManager/List';
|
||||
|
||||
interface EncryptDialogProps extends UseDialogProps {
|
||||
interface Props extends UseDialogProps {
|
||||
location_id: number;
|
||||
path_id: number;
|
||||
}
|
||||
|
@ -14,13 +19,13 @@ interface EncryptDialogProps extends UseDialogProps {
|
|||
const schema = z.object({
|
||||
key: z.string(),
|
||||
encryptionAlgo: z.string(),
|
||||
hashingAlgo: z.string(),
|
||||
hashingAlgo: hashingAlgoSlugSchema,
|
||||
metadata: z.boolean(),
|
||||
previewMedia: z.boolean(),
|
||||
outputPath: z.string()
|
||||
});
|
||||
|
||||
export const EncryptFileDialog = ({ ...props }: EncryptDialogProps) => {
|
||||
export default (props: Props) => {
|
||||
const dialog = useDialog(props);
|
||||
const platform = usePlatform();
|
||||
|
||||
|
@ -29,7 +34,7 @@ export const EncryptFileDialog = ({ ...props }: EncryptDialogProps) => {
|
|||
const hashAlg = keys.data?.find((key) => {
|
||||
return key.uuid === uuid;
|
||||
})?.hashing_algorithm;
|
||||
hashAlg && form.setValue('hashingAlgo', getHashingAlgorithmString(hashAlg));
|
||||
hashAlg && form.setValue('hashingAlgo', slugFromHashingAlgo(hashAlg));
|
||||
};
|
||||
|
||||
const keys = useLibraryQuery(['keys.list']);
|
||||
|
@ -44,17 +49,13 @@ export const EncryptFileDialog = ({ ...props }: EncryptDialogProps) => {
|
|||
showAlertDialog({
|
||||
title: 'Success',
|
||||
value:
|
||||
'The encryption job has started successfully. You may track the progress in the job overview panel.',
|
||||
inputBox: false,
|
||||
description: ''
|
||||
'The encryption job has started successfully. You may track the progress in the job overview panel.'
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: 'The encryption job failed to start.',
|
||||
inputBox: false,
|
||||
description: ''
|
||||
value: 'The encryption job failed to start.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -96,7 +97,7 @@ export const EncryptFileDialog = ({ ...props }: EncryptDialogProps) => {
|
|||
UpdateKey(e);
|
||||
}}
|
||||
>
|
||||
{mountedUuids.data && <SelectOptionKeyList keys={mountedUuids.data} />}
|
||||
{mountedUuids.data && <KeyListSelectOptions keys={mountedUuids.data} />}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
|
@ -1,10 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { useLibraryMutation } from '@sd/client';
|
||||
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { Dialog, Slider, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import Slider from '../primitive/Slider';
|
||||
|
||||
interface EraseDialogProps extends UseDialogProps {
|
||||
interface Props extends UseDialogProps {
|
||||
location_id: number;
|
||||
path_id: number;
|
||||
}
|
||||
|
@ -13,7 +12,7 @@ const schema = z.object({
|
|||
passes: z.number()
|
||||
});
|
||||
|
||||
export const EraseFileDialog = (props: EraseDialogProps) => {
|
||||
export default (props: Props) => {
|
||||
const dialog = useDialog(props);
|
||||
|
||||
const eraseFile = useLibraryMutation('files.eraseFiles');
|
|
@ -1,23 +1,9 @@
|
|||
import clsx from 'clsx';
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { ExplorerItem, ObjectKind, isObject } from '@sd/client';
|
||||
import { cva, tw } from '@sd/ui';
|
||||
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { ExplorerItemContextMenu } from './ExplorerContextMenu';
|
||||
import { FileThumb } from './FileThumb';
|
||||
|
||||
const NameArea = tw.div`flex justify-center`;
|
||||
|
||||
const nameContainerStyles = cva(
|
||||
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-xs font-medium',
|
||||
{
|
||||
variants: {
|
||||
selected: {
|
||||
true: 'bg-accent text-white'
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
import ContextMenu from './ContextMenu';
|
||||
import FileThumb from './Thumb';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
data: ExplorerItem;
|
||||
|
@ -26,16 +12,14 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|||
}
|
||||
|
||||
function FileItem({ data, selected, index, ...rest }: Props) {
|
||||
const objectData = data ? (isObject(data) ? data.item : data.item.object) : null;
|
||||
const isVid = ObjectKind[objectData?.kind || 0] === 'Video';
|
||||
const item = data.item;
|
||||
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
return (
|
||||
<ExplorerItemContextMenu data={data}>
|
||||
<ContextMenu data={data}>
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
onContextMenu={() => {
|
||||
if (index != undefined) {
|
||||
getExplorerStore().selectedRowIndex = index;
|
||||
}
|
||||
|
@ -59,14 +43,19 @@ function FileItem({ data, selected, index, ...rest }: Props) {
|
|||
>
|
||||
<FileThumb data={data} size={explorerStore.gridItemSize} />
|
||||
</div>
|
||||
<NameArea>
|
||||
<span className={nameContainerStyles({ selected })}>
|
||||
<div className="flex justify-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-xs font-medium',
|
||||
selected && 'bg-accent text-white'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{item.extension && `.${item.extension}`}
|
||||
</span>
|
||||
</NameArea>
|
||||
</div>
|
||||
</div>
|
||||
</ExplorerItemContextMenu>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,12 +3,11 @@ import clsx from 'clsx';
|
|||
import dayjs from 'dayjs';
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { ExplorerItem, ObjectKind, isObject, isPath } from '@sd/client';
|
||||
import { getExplorerStore } from '../../hooks/useExplorerStore';
|
||||
import { ExplorerItemContextMenu } from './ExplorerContextMenu';
|
||||
import { ColumnKey, columns } from './FileColumns';
|
||||
import { FileThumb } from './FileThumb';
|
||||
import { InfoPill } from './Inspector';
|
||||
import { getExplorerItemData } from './util';
|
||||
import { InfoPill } from '../Inspector';
|
||||
import { getExplorerItemData } from '../util';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import { columns } from './RowHeader';
|
||||
import FileThumb from './Thumb';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
data: ExplorerItem;
|
||||
|
@ -16,35 +15,35 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|||
selected: boolean;
|
||||
}
|
||||
|
||||
function FileRow({ data, index, selected, ...props }: Props) {
|
||||
return (
|
||||
<ExplorerItemContextMenu className="w-full" data={data}>
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
'table-body-row mr-2 flex w-full flex-row rounded-lg border-2',
|
||||
selected ? 'border-accent' : 'border-transparent',
|
||||
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="table-body-cell flex items-center px-4 py-2 pr-2"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
<RenderCell data={data} colKey={col.key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ExplorerItemContextMenu>
|
||||
);
|
||||
export default ({ data, index, selected, ...props }: Props) => (
|
||||
<ContextMenu data={data}>
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
'table-body-row mr-2 flex w-full flex-row rounded-lg border-2',
|
||||
selected ? 'border-accent' : 'border-transparent',
|
||||
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="table-body-cell flex items-center px-4 py-2 pr-2"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
<Cell data={data} colKey={col.key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
interface CellProps {
|
||||
colKey: (typeof columns)[number]['key'];
|
||||
data: ExplorerItem;
|
||||
}
|
||||
|
||||
const RenderCell: React.FC<{
|
||||
colKey: ColumnKey;
|
||||
data: ExplorerItem;
|
||||
}> = ({ colKey, data }) => {
|
||||
const Cell = ({ colKey, data }: CellProps) => {
|
||||
const objectData = data ? (isObject(data) ? data.item : data.item.object) : null;
|
||||
const { cas_id } = getExplorerItemData(data);
|
||||
|
||||
|
@ -83,14 +82,8 @@ const RenderCell: React.FC<{
|
|||
</InfoPill>
|
||||
</div>
|
||||
);
|
||||
// case 'meta_integrity_hash':
|
||||
// return <span className="truncate">{value}</span>;
|
||||
// case 'tags':
|
||||
// return renderCellWithIcon(MusicNoteIcon);
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export default FileRow;
|
32
interface/app/$libraryId/Explorer/File/RowHeader.tsx
Normal file
32
interface/app/$libraryId/Explorer/File/RowHeader.tsx
Normal 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>
|
||||
);
|
|
@ -8,18 +8,18 @@ import Video from '@sd/assets/images/Video.png';
|
|||
import clsx from 'clsx';
|
||||
import { CSSProperties } from 'react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { Folder } from '@sd/ui';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { Folder } from '../icons/Folder';
|
||||
import { getExplorerItemData } from './util';
|
||||
import { getExplorerItemData } from '../util';
|
||||
|
||||
// const icons = import.meta.glob('../../../../assets/icons/*.svg');
|
||||
interface FileItemProps {
|
||||
interface Props {
|
||||
data: ExplorerItem;
|
||||
size: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileThumb({ data, size, className }: FileItemProps) {
|
||||
export default ({ data, size, className }: Props) => {
|
||||
const { cas_id, isDir, kind, hasThumbnail, extension } = getExplorerItemData(data);
|
||||
|
||||
// 10 percent of the size
|
||||
|
@ -59,14 +59,14 @@ export function FileThumb({ data, size, className }: FileItemProps) {
|
|||
: {}
|
||||
}
|
||||
/>
|
||||
{extension && kind === 'Video' && size > 80 && (
|
||||
<div className="absolute bottom-[22%] right-2 rounded bg-black/60 py-0.5 px-1 text-[9px] font-semibold uppercase opacity-70">
|
||||
{extension && kind === 'Video' && hasThumbnail && size > 80 && (
|
||||
<div className="absolute bottom-[13%] right-[5%] rounded bg-black/60 py-0.5 px-1 text-[9px] font-semibold uppercase opacity-70">
|
||||
{extension}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
interface FileThumbImgProps {
|
||||
isDir: boolean;
|
||||
cas_id: string | null;
|
|
@ -1,10 +1,8 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useLibraryMutation } from '@sd/client';
|
||||
import { Object as SDObject } from '@sd/client';
|
||||
import { TextArea } from '@sd/ui';
|
||||
import { Object as SDObject, useLibraryMutation } from '@sd/client';
|
||||
import { Divider, TextArea } from '@sd/ui';
|
||||
import { MetaContainer, MetaTitle } from '../Inspector';
|
||||
import { Divider } from './Divider';
|
||||
|
||||
interface Props {
|
||||
data: SDObject;
|
|
@ -2,7 +2,7 @@
|
|||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import { Barcode, CircleWavyCheck, Clock, Cube, Hash, Link, Lock, Snowflake } from 'phosphor-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ComponentProps, useEffect, useState } from 'react';
|
||||
import {
|
||||
ExplorerContext,
|
||||
ExplorerItem,
|
||||
|
@ -11,24 +11,17 @@ import {
|
|||
isObject,
|
||||
useLibraryQuery
|
||||
} from '@sd/client';
|
||||
import { Button, tw } from '@sd/ui';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import { FileThumb } from './FileThumb';
|
||||
import { Divider } from './inspector/Divider';
|
||||
import FavoriteButton from './inspector/FavoriteButton';
|
||||
import Note from './inspector/Note';
|
||||
import { Button, Divider, Tooltip, tw } from '@sd/ui';
|
||||
import FileThumb from '../File/Thumb';
|
||||
import FavoriteButton from './FavoriteButton';
|
||||
import Note from './Note';
|
||||
|
||||
export const InfoPill = tw.span`inline border border-transparent px-1 text-[11px] font-medium shadow shadow-app-shade/5 bg-app-selected rounded-md text-ink-dull`;
|
||||
|
||||
export const PlaceholderPill = tw.span`inline border px-1 text-[11px] shadow shadow-app-shade/10 rounded-md bg-transparent border-dashed border-app-active transition hover:text-ink-faint hover:border-ink-faint font-medium text-ink-faint/70`;
|
||||
export const PlaceholderPill = tw.span`inline border px-1 text-[11px] shadow shadow-app-shade/10 rounded-md bg-transparent border-dashed border-app-active transition hover:text-ink-faint hover:border-ink-faint font-medium text-ink-faint/70`;
|
||||
|
||||
export const MetaContainer = tw.div`flex flex-col px-4 py-1.5`;
|
||||
|
||||
export const MetaTitle = tw.h5`text-xs font-bold`;
|
||||
|
||||
export const MetaKeyName = tw.h5`text-xs flex-shrink-0 flex-wrap-0`;
|
||||
|
||||
export const MetaValue = tw.p`text-xs break-all text-ink truncate`;
|
||||
|
||||
const MetaTextLine = tw.div`flex items-center my-0.5 text-xs text-ink-dull`;
|
||||
|
@ -37,7 +30,7 @@ const InspectorIcon = ({ component: Icon, ...props }: any) => (
|
|||
<Icon weight="bold" {...props} className={clsx('mr-2 shrink-0', props.className)} />
|
||||
);
|
||||
|
||||
interface Props extends DefaultProps<HTMLDivElement> {
|
||||
interface Props extends ComponentProps<'div'> {
|
||||
context?: ExplorerContext;
|
||||
data?: ExplorerItem;
|
||||
}
|
|
@ -1,15 +1,9 @@
|
|||
import { PropsWithChildren, useState } from 'react';
|
||||
import { Select, SelectOption } from '@sd/ui';
|
||||
import { getExplorerStore, useExplorerStore } from '../../hooks/useExplorerStore';
|
||||
import Slider from '../primitive/Slider';
|
||||
import { useState } from 'react';
|
||||
import { Select, SelectOption, Slider, tw } from '@sd/ui';
|
||||
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
|
||||
function Heading({ children }: PropsWithChildren) {
|
||||
return <div className="text-ink-dull text-xs font-semibold">{children}</div>;
|
||||
}
|
||||
|
||||
function SubHeading({ children }: PropsWithChildren) {
|
||||
return <div className="text-ink-dull mb-1 text-xs font-medium">{children}</div>;
|
||||
}
|
||||
const Heading = tw.div`text-ink-dull text-xs font-semibold`;
|
||||
const Subheading = tw.div`text-ink-dull mb-1 text-xs font-medium`;
|
||||
|
||||
const sortOptions = {
|
||||
name: 'Name',
|
||||
|
@ -20,7 +14,7 @@ const sortOptions = {
|
|||
date_last_opened: 'Date Last Opened'
|
||||
};
|
||||
|
||||
export function ExplorerOptionsPanel() {
|
||||
export default () => {
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const [stackBy, setStackBy] = useState('kind');
|
||||
|
||||
|
@ -29,7 +23,7 @@ export function ExplorerOptionsPanel() {
|
|||
return (
|
||||
<div className="p-4 ">
|
||||
{/* <Heading>Explorer Appearance</Heading> */}
|
||||
<SubHeading>Item size</SubHeading>
|
||||
<Subheading>Item size</Subheading>
|
||||
<Slider
|
||||
onValueChange={(value) => {
|
||||
getExplorerStore().gridItemSize = value[0] || 100;
|
||||
|
@ -42,7 +36,7 @@ export function ExplorerOptionsPanel() {
|
|||
/>
|
||||
<div className="my-2 mt-4 grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<SubHeading>Sort by</SubHeading>
|
||||
<Subheading>Sort by</Subheading>
|
||||
<Select value={sortBy} size="sm" onChange={setSortBy}>
|
||||
{Object.entries(sortOptions).map(([value, text]) => (
|
||||
<SelectOption key={value} value={value}>
|
||||
|
@ -52,7 +46,7 @@ export function ExplorerOptionsPanel() {
|
|||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<SubHeading>Stack by</SubHeading>
|
||||
<Subheading>Stack by</Subheading>
|
||||
<Select value={stackBy} size="sm" onChange={setStackBy}>
|
||||
<SelectOption value="kind">Kind</SelectOption>
|
||||
<SelectOption value="location">Location</SelectOption>
|
||||
|
@ -62,4 +56,4 @@ export function ExplorerOptionsPanel() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -12,19 +12,15 @@ import {
|
|||
SquaresFour,
|
||||
Tag
|
||||
} from 'phosphor-react';
|
||||
import { forwardRef, useEffect, useRef } from 'react';
|
||||
import { ComponentProps, forwardRef, useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Input, Popover, cva } from '@sd/ui';
|
||||
import DragRegion from '~/components/layout/DragRegion';
|
||||
import { getExplorerStore, useExplorerStore } from '../../hooks/useExplorerStore';
|
||||
import { useOperatingSystem } from '../../hooks/useOperatingSystem';
|
||||
import { KeybindEvent } from '../../util/keybind';
|
||||
import { KeyManager } from '../key/KeyManager';
|
||||
import { Shortcut } from '../primitive/Shortcut';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import { ExplorerOptionsPanel } from './ExplorerOptionsPanel';
|
||||
import { Button, Input, Popover, Shortcut, Tooltip, cva } from '@sd/ui';
|
||||
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
|
||||
import { KeybindEvent } from '~/util/keybind';
|
||||
import { KeyManager } from '../KeyManager';
|
||||
import OptionsPanel from './OptionsPanel';
|
||||
|
||||
export interface TopBarButtonProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -75,59 +71,61 @@ const TopBarButton = forwardRef<HTMLButtonElement, TopBarButtonProps>(
|
|||
}
|
||||
);
|
||||
|
||||
export const SearchBar = forwardRef<HTMLInputElement, DefaultProps>((props, forwardedRef) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty, dirtyFields }
|
||||
} = useForm();
|
||||
export const SearchBar = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
||||
(props, forwardedRef) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { dirtyFields }
|
||||
} = useForm();
|
||||
|
||||
const { ref, ...searchField } = register('searchField', {
|
||||
onBlur: (e) => {
|
||||
// if there's no text in the search bar, don't mark it as dirty so the key hint shows
|
||||
if (!dirtyFields.searchField) reset();
|
||||
}
|
||||
});
|
||||
const { ref, ...searchField } = register('searchField', {
|
||||
onBlur: () => {
|
||||
// if there's no text in the search bar, don't mark it as dirty so the key hint shows
|
||||
if (!dirtyFields.searchField) reset();
|
||||
}
|
||||
});
|
||||
|
||||
const platform = useOperatingSystem(false);
|
||||
const os = useOperatingSystem(true);
|
||||
const platform = useOperatingSystem(false);
|
||||
const os = useOperatingSystem(true);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(() => null)} className="relative flex h-7">
|
||||
<Input
|
||||
ref={(el) => {
|
||||
ref(el);
|
||||
return (
|
||||
<form onSubmit={handleSubmit(() => null)} className="relative flex h-7">
|
||||
<Input
|
||||
ref={(el) => {
|
||||
ref(el);
|
||||
|
||||
if (typeof forwardedRef === 'function') forwardedRef(el);
|
||||
else if (forwardedRef) forwardedRef.current = el;
|
||||
}}
|
||||
placeholder="Search"
|
||||
className={clsx('w-32 transition-all focus:w-52', props.className)}
|
||||
{...searchField}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute right-1 flex h-7 items-center space-x-1 opacity-70 peer-focus:invisible'
|
||||
)}
|
||||
>
|
||||
{platform === 'browser' ? (
|
||||
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
|
||||
) : os === 'macOS' ? (
|
||||
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
|
||||
) : (
|
||||
<Shortcut chars="CTRL+F" aria-label={'Press CTRL-F to focus search bar'} />
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
if (typeof forwardedRef === 'function') forwardedRef(el);
|
||||
else if (forwardedRef) forwardedRef.current = el;
|
||||
}}
|
||||
placeholder="Search"
|
||||
className={clsx('w-32 transition-all focus:w-52', props.className)}
|
||||
{...searchField}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute right-1 flex h-7 items-center space-x-1 opacity-70 peer-focus:invisible'
|
||||
)}
|
||||
>
|
||||
{platform === 'browser' ? (
|
||||
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
|
||||
) : os === 'macOS' ? (
|
||||
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
|
||||
) : (
|
||||
<Shortcut chars="CTRL+F" aria-label={'Press CTRL-F to focus search bar'} />
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type TopBarProps = DefaultProps & {
|
||||
export type TopBarProps = {
|
||||
showSeparator?: boolean;
|
||||
};
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = (props) => {
|
||||
export default (props: TopBarProps) => {
|
||||
const platform = useOperatingSystem(false);
|
||||
const os = useOperatingSystem(true);
|
||||
|
||||
|
@ -235,8 +233,8 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
<Tooltip label="List view">
|
||||
<TopBarButton
|
||||
rounding="none"
|
||||
active={store.layoutMode === 'list'}
|
||||
onClick={() => (getExplorerStore().layoutMode = 'list')}
|
||||
active={store.layoutMode === 'rows'}
|
||||
onClick={() => (getExplorerStore().layoutMode = 'rows')}
|
||||
>
|
||||
<Rows className={TOP_BAR_ICON_STYLE} />
|
||||
</TopBarButton>
|
||||
|
@ -326,7 +324,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
}
|
||||
>
|
||||
<div className="block w-[250px] ">
|
||||
<ExplorerOptionsPanel />
|
||||
<OptionsPanel />
|
||||
</div>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
|
@ -342,7 +340,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
>
|
||||
<SidebarSimple
|
||||
weight={store.showInspector ? 'fill' : 'regular'}
|
||||
className={clsx(TOP_BAR_ICON_STYLE, 'scale-x-[-1] transform')}
|
||||
className={clsx(TOP_BAR_ICON_STYLE, 'scale-x-[-1]')}
|
||||
/>
|
||||
</TopBarButton>
|
||||
</Tooltip>
|
|
@ -4,12 +4,11 @@ import { useSearchParams } from 'react-router-dom';
|
|||
import { useKey, useOnWindowResize } from 'rooks';
|
||||
import { ExplorerContext, ExplorerItem, isPath } from '@sd/client';
|
||||
import { ExplorerLayoutMode, getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { LIST_VIEW_HEADER_HEIGHT, ListViewHeader } from './FileColumns';
|
||||
import FileItem from './FileItem';
|
||||
import FileRow from './FileRow';
|
||||
import FileItem from './File/Item';
|
||||
import FileRow from './File/Row';
|
||||
import { ROW_HEADER_HEIGHT, RowHeader } from './File/RowHeader';
|
||||
|
||||
const TOP_BAR_HEIGHT = 46;
|
||||
// const GRID_TEXT_AREA_HEIGHT = 25;
|
||||
|
||||
interface Props {
|
||||
context: ExplorerContext;
|
||||
|
@ -17,7 +16,7 @@ interface Props {
|
|||
onScroll?: (posY: number) => void;
|
||||
}
|
||||
|
||||
export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
|
||||
export const VirtualizedList = memo(({ data, onScroll }: Props) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
@ -64,7 +63,7 @@ export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
|
|||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 200,
|
||||
estimateSize: () => itemSize,
|
||||
measureElement: (index) => itemSize
|
||||
measureElement: () => itemSize
|
||||
});
|
||||
|
||||
// TODO: Make scroll adjustment work with both list and grid layout, currently top bar offset disrupts positioning of list, and grid just doesn't work
|
||||
|
@ -101,20 +100,17 @@ export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
|
|||
<div
|
||||
ref={scrollRef}
|
||||
className="custom-scroll explorer-scroll h-screen"
|
||||
onClick={(e) => {
|
||||
getExplorerStore().selectedRowIndex = -1;
|
||||
}}
|
||||
onClick={() => (getExplorerStore().selectedRowIndex = -1)}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
marginTop:
|
||||
layoutMode === 'list' ? TOP_BAR_HEIGHT + LIST_VIEW_HEADER_HEIGHT : TOP_BAR_HEIGHT
|
||||
marginTop: layoutMode === 'rows' ? TOP_BAR_HEIGHT + ROW_HEADER_HEIGHT : TOP_BAR_HEIGHT
|
||||
}}
|
||||
>
|
||||
{layoutMode === 'list' && <ListViewHeader />}
|
||||
{layoutMode === 'rows' && <RowHeader />}
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
|
@ -124,9 +120,9 @@ export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
|
|||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
>
|
||||
{layoutMode === 'list' && (
|
||||
{layoutMode === 'rows' && (
|
||||
<WrappedItem
|
||||
kind="list"
|
||||
kind="rows"
|
||||
isSelected={explorerStore.selectedRowIndex === virtualRow.index}
|
||||
index={virtualRow.index}
|
||||
item={data[virtualRow.index]!}
|
||||
|
@ -181,7 +177,7 @@ const WrappedItem = memo(({ item, index, isSelected, kind }: WrappedItemProps) =
|
|||
[isSelected, index]
|
||||
);
|
||||
|
||||
const ItemComponent = kind === 'list' ? FileRow : FileItem;
|
||||
const ItemComponent = kind === 'rows' ? FileRow : FileItem;
|
||||
|
||||
return (
|
||||
<ItemComponent
|
|
@ -1,9 +1,9 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ExplorerData, rspc, useLibraryContext } from '@sd/client';
|
||||
import { useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { Inspector } from '../explorer/Inspector';
|
||||
import { ExplorerContextMenu } from './ExplorerContextMenu';
|
||||
import { TopBar } from './ExplorerTopBar';
|
||||
import { Inspector } from '../Explorer/Inspector';
|
||||
import ExplorerContextMenu from './ContextMenu';
|
||||
import TopBar from './TopBar';
|
||||
import { VirtualizedList } from './VirtualizedList';
|
||||
|
||||
interface Props {
|
|
@ -1,14 +1,10 @@
|
|||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import clsx from 'clsx';
|
||||
import { DotsThree, Eye, Key as KeyIcon } from 'phosphor-react';
|
||||
import { MutableRefObject, PropsWithChildren, useState } from 'react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { animated, useTransition } from 'react-spring';
|
||||
import { useLibraryMutation } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
|
||||
export type KeyManagerProps = DefaultProps;
|
||||
import { Button, Tooltip } from '@sd/ui';
|
||||
|
||||
// TODO: Replace this with Prisma type when integrating with backend
|
||||
export interface Key {
|
||||
|
@ -37,10 +33,8 @@ interface Props extends DropdownMenu.MenuContentProps {
|
|||
export const KeyDropdown = ({
|
||||
trigger,
|
||||
children,
|
||||
disabled,
|
||||
transformOrigin,
|
||||
className,
|
||||
...props
|
||||
className
|
||||
}: PropsWithChildren<Props>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
@ -87,7 +81,7 @@ export const KeyDropdown = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const Key: React.FC<{ data: Key; index: number }> = ({ data, index }) => {
|
||||
export const Key = ({ data }: { data: Key }) => {
|
||||
const mountKey = useLibraryMutation('keys.mount');
|
||||
const unmountKey = useLibraryMutation('keys.unmount');
|
||||
const deleteKey = useLibraryMutation('keys.deleteFromLibrary');
|
56
interface/app/$libraryId/KeyManager/List.tsx
Normal file
56
interface/app/$libraryId/KeyManager/List.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,19 +1,18 @@
|
|||
import cryptoRandomString from 'crypto-random-string';
|
||||
import { Eye, EyeSlash, Info } from 'phosphor-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Algorithm, useLibraryMutation } from '@sd/client';
|
||||
import { Button, CategoryHeading, Input, Select, SelectOption, Switch, tw } from '@sd/ui';
|
||||
import { getHashingAlgorithmSettings } from '../../screens/settings/library/KeysSetting';
|
||||
import Slider from '../primitive/Slider';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import {
|
||||
Algorithm,
|
||||
HASHING_ALGOS,
|
||||
HashingAlgoSlug,
|
||||
generatePassword,
|
||||
useLibraryMutation
|
||||
} from '@sd/client';
|
||||
import { Button, CategoryHeading, Input, Select, SelectOption, Slider, Switch, tw } from '@sd/ui';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
|
||||
const KeyHeading = tw(CategoryHeading)`mb-1`;
|
||||
|
||||
export const generatePassword = (length: number) => {
|
||||
return cryptoRandomString({ length, type: 'ascii-printable' });
|
||||
};
|
||||
|
||||
export function KeyMounter() {
|
||||
export default () => {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [librarySync, setLibrarySync] = useState(true);
|
||||
|
@ -23,7 +22,7 @@ export function KeyMounter() {
|
|||
|
||||
const [key, setKey] = useState('');
|
||||
const [encryptionAlgo, setEncryptionAlgo] = useState('XChaCha20Poly1305');
|
||||
const [hashingAlgo, setHashingAlgo] = useState('Argon2id-s');
|
||||
const [hashingAlgo, setHashingAlgo] = useState<HashingAlgoSlug>('Argon2id-s');
|
||||
|
||||
const createKey = useLibraryMutation('keys.add');
|
||||
const CurrentEyeIcon = showKey ? EyeSlash : Eye;
|
||||
|
@ -123,7 +122,11 @@ export function KeyMounter() {
|
|||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold">Hashing</span>
|
||||
<Select className="mt-2" onChange={setHashingAlgo} value={hashingAlgo}>
|
||||
<Select
|
||||
className="mt-2"
|
||||
onChange={(s) => setHashingAlgo(s as HashingAlgoSlug)}
|
||||
value={hashingAlgo}
|
||||
>
|
||||
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
|
||||
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
|
||||
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
|
||||
|
@ -140,7 +143,7 @@ export function KeyMounter() {
|
|||
onClick={() => {
|
||||
setKey('');
|
||||
|
||||
const hashing_algorithm = getHashingAlgorithmSettings(hashingAlgo);
|
||||
const hashing_algorithm = HASHING_ALGOS[hashingAlgo];
|
||||
|
||||
createKey.mutate({
|
||||
algorithm: encryptionAlgo as Algorithm,
|
||||
|
@ -155,4 +158,4 @@ export function KeyMounter() {
|
|||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
68
interface/app/$libraryId/KeyManager/NotUnlocked.tsx
Normal file
68
interface/app/$libraryId/KeyManager/NotUnlocked.tsx
Normal 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>
|
||||
);
|
||||
};
|
95
interface/app/$libraryId/KeyManager/index.tsx
Normal file
95
interface/app/$libraryId/KeyManager/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
85
interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx
Normal file
85
interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx
Normal 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>
|
||||
);
|
||||
};
|
5
interface/app/$libraryId/Layout/Sidebar/Icon.tsx
Normal file
5
interface/app/$libraryId/Layout/Sidebar/Icon.tsx
Normal 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)} />
|
||||
);
|
|
@ -4,7 +4,6 @@ import {
|
|||
ArrowsClockwise,
|
||||
Camera,
|
||||
Copy,
|
||||
DotsThree,
|
||||
Eye,
|
||||
Fingerprint,
|
||||
Folder,
|
||||
|
@ -18,9 +17,7 @@ import {
|
|||
X
|
||||
} from 'phosphor-react';
|
||||
import { JobReport, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { Button, CategoryHeading, Popover, PopoverClose, tw } from '@sd/ui';
|
||||
import ProgressBar from '../primitive/ProgressBar';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import { Button, CategoryHeading, PopoverClose, ProgressBar, Tooltip } from '@sd/ui';
|
||||
|
||||
interface JobNiceData {
|
||||
name: string;
|
||||
|
@ -93,12 +90,6 @@ const StatusColors: Record<JobReport['status'], string> = {
|
|||
Paused: 'text-gray-500'
|
||||
};
|
||||
|
||||
function elapsed(seconds: number) {
|
||||
return new Date(seconds * 1000).toUTCString().match(/(\d\d:\d\d:\d\d)/)?.[0];
|
||||
}
|
||||
|
||||
const HeaderContainer = tw.div`z-20 flex items-center w-full h-10 px-2 border-b border-app-line/50 rounded-t-md bg-app-button/70`;
|
||||
|
||||
export function JobsManager() {
|
||||
const runningJobs = useLibraryQuery(['jobs.getRunning']);
|
||||
const jobs = useLibraryQuery(['jobs.getHistory']);
|
||||
|
@ -106,7 +97,7 @@ export function JobsManager() {
|
|||
|
||||
return (
|
||||
<div className="h-full overflow-hidden pb-10">
|
||||
<HeaderContainer>
|
||||
<div className="border-app-line/50 bg-app-button/70 z-20 flex h-10 w-full items-center rounded-t-md border-b px-2">
|
||||
<CategoryHeading className="ml-2">Recent Jobs</CategoryHeading>
|
||||
<div className="grow" />
|
||||
|
||||
|
@ -122,7 +113,7 @@ export function JobsManager() {
|
|||
</Tooltip>
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</HeaderContainer>
|
||||
</div>
|
||||
<div className="custom-scroll inspector-scroll mr-1 h-full overflow-x-hidden">
|
||||
<div className="">
|
||||
<div className="py-1">
|
|
@ -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>
|
||||
);
|
||||
};
|
36
interface/app/$libraryId/Layout/Sidebar/Link.tsx
Normal file
36
interface/app/$libraryId/Layout/Sidebar/Link.tsx
Normal 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>
|
||||
);
|
||||
};
|
19
interface/app/$libraryId/Layout/Sidebar/Section.tsx
Normal file
19
interface/app/$libraryId/Layout/Sidebar/Section.tsx
Normal 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>
|
||||
);
|
259
interface/app/$libraryId/Layout/Sidebar/index.tsx
Normal file
259
interface/app/$libraryId/Layout/Sidebar/index.tsx
Normal 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 : '';
|
|
@ -1,9 +1,9 @@
|
|||
import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||
import clsx from 'clsx';
|
||||
import { useToasts } from '../../hooks/useToasts';
|
||||
import { useToasts } from '~/hooks/useToasts';
|
||||
|
||||
export function Toasts() {
|
||||
const { toasts, addToast, removeToast } = useToasts();
|
||||
export default () => {
|
||||
const { toasts, removeToast } = useToasts();
|
||||
return (
|
||||
<div className="fixed right-0 flex">
|
||||
<ToastPrimitive.Provider>
|
||||
|
@ -71,4 +71,4 @@ export function Toasts() {
|
|||
</ToastPrimitive.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,13 +1,12 @@
|
|||
import clsx from 'clsx';
|
||||
import { Suspense } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { Navigate, Outlet, useParams } from 'react-router-dom';
|
||||
import { ClientContextProvider, LibraryContextProvider, useClientContext } from '@sd/client';
|
||||
import { Sidebar } from '~/components/layout/Sidebar';
|
||||
import { Toasts } from '~/components/primitive/Toasts';
|
||||
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
|
||||
import { useLibraryId } from './util';
|
||||
import Sidebar from './Sidebar';
|
||||
import Toasts from './Toasts';
|
||||
|
||||
function AppLayout() {
|
||||
const Layout = () => {
|
||||
const { libraries, library } = useClientContext();
|
||||
|
||||
const os = useOperatingSystem();
|
||||
|
@ -15,7 +14,7 @@ function AppLayout() {
|
|||
if (library === null && libraries.data) {
|
||||
const firstLibrary = libraries.data[0];
|
||||
|
||||
if (firstLibrary) return <Navigate to={`${firstLibrary.uuid}/overview`} />;
|
||||
if (firstLibrary) return <Navigate to={`/${firstLibrary.uuid}/overview`} />;
|
||||
else return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
|
@ -49,14 +48,14 @@ function AppLayout() {
|
|||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default () => {
|
||||
const currentLibraryId = useLibraryId();
|
||||
const params = useParams<{ libraryId: string }>();
|
||||
|
||||
return (
|
||||
<ClientContextProvider currentLibraryId={currentLibraryId ?? null}>
|
||||
<AppLayout />
|
||||
<ClientContextProvider currentLibraryId={params.libraryId ?? null}>
|
||||
<Layout />
|
||||
</ClientContextProvider>
|
||||
);
|
||||
};
|
36
interface/app/$libraryId/PageLayout.tsx
Normal file
36
interface/app/$libraryId/PageLayout.tsx
Normal 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);
|
||||
};
|
|
@ -1,7 +1,6 @@
|
|||
import { useBridgeQuery, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import CodeBlock from '~/components/primitive/Codeblock';
|
||||
import { CodeBlock } from '~/components/Codeblock';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { ScreenContainer } from './_Layout';
|
||||
|
||||
// TODO: Bring this back with a button in the sidebar near settings at the bottom
|
||||
export default function DebugScreen() {
|
||||
|
@ -17,10 +16,9 @@ export default function DebugScreen() {
|
|||
// });
|
||||
const { mutate: identifyFiles } = useLibraryMutation('jobs.identifyUniqueFiles');
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<div className="flex flex-col space-y-5 p-5 pt-2 pb-7">
|
||||
<h1 className="text-lg font-bold ">Developer Debugger</h1>
|
||||
{/* <div className="flex flex-row pb-4 space-x-2">
|
||||
<div className="flex flex-col space-y-5 p-5 pt-2 pb-7">
|
||||
<h1 className="text-lg font-bold ">Developer Debugger</h1>
|
||||
{/* <div className="flex flex-row pb-4 space-x-2">
|
||||
<Button
|
||||
className="w-40"
|
||||
variant="gray"
|
||||
|
@ -34,15 +32,14 @@ export default function DebugScreen() {
|
|||
Open data folder
|
||||
</Button>
|
||||
</div> */}
|
||||
<h1 className="text-sm font-bold ">Running Jobs</h1>
|
||||
<CodeBlock src={{ ...jobs }} />
|
||||
<h1 className="text-sm font-bold ">Job History</h1>
|
||||
<CodeBlock src={{ ...jobHistory }} />
|
||||
<h1 className="text-sm font-bold ">Node State</h1>
|
||||
<CodeBlock src={{ ...nodeState }} />
|
||||
<h1 className="text-sm font-bold ">Libraries</h1>
|
||||
<CodeBlock src={{ ...libraryState }} />
|
||||
</div>
|
||||
</ScreenContainer>
|
||||
<h1 className="text-sm font-bold ">Running Jobs</h1>
|
||||
<CodeBlock src={{ ...jobs }} />
|
||||
<h1 className="text-sm font-bold ">Job History</h1>
|
||||
<CodeBlock src={{ ...jobHistory }} />
|
||||
<h1 className="text-sm font-bold ">Node State</h1>
|
||||
<CodeBlock src={{ ...nodeState }} />
|
||||
<h1 className="text-sm font-bold ">Libraries</h1>
|
||||
<CodeBlock src={{ ...libraryState }} />
|
||||
</div>
|
||||
);
|
||||
}
|
28
interface/app/$libraryId/index.tsx
Normal file
28
interface/app/$libraryId/index.tsx
Normal 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[];
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import Explorer from '~/components/explorer/Explorer';
|
||||
import { getExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import Explorer from '../Explorer';
|
||||
|
||||
export function useExplorerParams() {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
|
@ -15,7 +15,7 @@ export function useExplorerParams() {
|
|||
return { location_id, path, limit };
|
||||
}
|
||||
|
||||
export default function LocationExplorer() {
|
||||
export default () => {
|
||||
const { location_id, path } = useExplorerParams();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -39,4 +39,4 @@ export default function LocationExplorer() {
|
|||
<Explorer data={explorerData.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
5
interface/app/$libraryId/media.tsx
Normal file
5
interface/app/$libraryId/media.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { ScreenHeading } from '@sd/ui';
|
||||
|
||||
export default function MediaScreen() {
|
||||
return <ScreenHeading>Media</ScreenHeading>;
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import byteSize from 'byte-size';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
|
@ -14,13 +13,10 @@ import {
|
|||
} from 'phosphor-react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import 'react-loading-skeleton/dist/skeleton.css';
|
||||
import { Statistics, useLibraryQuery } from '@sd/client';
|
||||
import { Statistics, useLibraryContext, useLibraryQuery } from '@sd/client';
|
||||
import { Card } from '@sd/ui';
|
||||
import useCounter from '~/hooks/useCounter';
|
||||
import { useLibraryId } from '~/util';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { ScreenContainer } from './_Layout';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface StatItemProps {
|
||||
title: string;
|
||||
|
@ -91,7 +87,7 @@ const StatItem = (props: StatItemProps) => {
|
|||
|
||||
export default function OverviewScreen() {
|
||||
const platform = usePlatform();
|
||||
const libraryId = useLibraryId();
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
const stats = useLibraryQuery(['library.getStatistics'], {
|
||||
initialData: { ...EMPTY_STATISTICS }
|
||||
|
@ -100,45 +96,43 @@ export default function OverviewScreen() {
|
|||
overviewMounted = true;
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
{/* STAT HEADER */}
|
||||
<div className="flex w-full">
|
||||
{/* STAT CONTAINER */}
|
||||
<div className="-mb-1 flex h-20 overflow-hidden">
|
||||
{Object.entries(stats?.data || []).map(([key, value]) => {
|
||||
if (!displayableStatItems.includes(key)) return null;
|
||||
return (
|
||||
<StatItem
|
||||
key={`${libraryId} ${key}`}
|
||||
title={StatItemNames[key as keyof Statistics]!}
|
||||
bytes={BigInt(value)}
|
||||
isLoading={platform.demoMode ? false : stats.isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grow" />
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
{/* STAT HEADER */}
|
||||
<div className="flex w-full">
|
||||
{/* STAT CONTAINER */}
|
||||
<div className="-mb-1 flex h-20 overflow-hidden">
|
||||
{Object.entries(stats?.data || []).map(([key, value]) => {
|
||||
if (!displayableStatItems.includes(key)) return null;
|
||||
return (
|
||||
<StatItem
|
||||
key={`${library.uuid} ${key}`}
|
||||
title={StatItemNames[key as keyof Statistics]!}
|
||||
bytes={BigInt(value)}
|
||||
isLoading={platform.demoMode ? false : stats.isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-5 gap-3 pb-4">
|
||||
<CategoryButton icon={Heart} category="Favorites" />
|
||||
<CategoryButton icon={FileText} category="Documents" />
|
||||
<CategoryButton icon={Camera} category="Movies" />
|
||||
<CategoryButton icon={FrameCorners} category="Screenshots" />
|
||||
<CategoryButton icon={AppWindow} category="Applications" />
|
||||
<CategoryButton icon={Wrench} category="Projects" />
|
||||
<CategoryButton icon={CloudArrowDown} category="Downloads" />
|
||||
<CategoryButton icon={MusicNote} category="Music" />
|
||||
<CategoryButton icon={Image} category="Albums" />
|
||||
<CategoryButton icon={Heart} category="Favorites" />
|
||||
</div>
|
||||
<Card className="text-ink-dull">
|
||||
<b>Note: </b> This is a pre-alpha build of Spacedrive, many features are yet to be
|
||||
functional.
|
||||
</Card>
|
||||
<div className="flex h-4 w-full shrink-0" />
|
||||
<div className="grow" />
|
||||
</div>
|
||||
</ScreenContainer>
|
||||
<div className="mt-4 grid grid-cols-5 gap-3 pb-4">
|
||||
<CategoryButton icon={Heart} category="Favorites" />
|
||||
<CategoryButton icon={FileText} category="Documents" />
|
||||
<CategoryButton icon={Camera} category="Movies" />
|
||||
<CategoryButton icon={FrameCorners} category="Screenshots" />
|
||||
<CategoryButton icon={AppWindow} category="Applications" />
|
||||
<CategoryButton icon={Wrench} category="Projects" />
|
||||
<CategoryButton icon={CloudArrowDown} category="Downloads" />
|
||||
<CategoryButton icon={MusicNote} category="Music" />
|
||||
<CategoryButton icon={Image} category="Albums" />
|
||||
<CategoryButton icon={Heart} category="Favorites" />
|
||||
</div>
|
||||
<Card className="text-ink-dull">
|
||||
<b>Note: </b> 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>
|
||||
);
|
||||
}
|
||||
|
5
interface/app/$libraryId/people.tsx
Normal file
5
interface/app/$libraryId/people.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { ScreenHeading } from '@sd/ui';
|
||||
|
||||
export default () => {
|
||||
return <ScreenHeading>People</ScreenHeading>;
|
||||
};
|
46
interface/app/$libraryId/settings/Layout.tsx
Normal file
46
interface/app/$libraryId/settings/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
45
interface/app/$libraryId/settings/ModalLayout.tsx
Normal file
45
interface/app/$libraryId/settings/ModalLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
10
interface/app/$libraryId/settings/OverviewLayout.tsx
Normal file
10
interface/app/$libraryId/settings/OverviewLayout.tsx
Normal 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>
|
||||
);
|
|
@ -1,17 +1,17 @@
|
|||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { DefaultProps } from './types';
|
||||
|
||||
interface InputContainerProps extends DefaultProps<HTMLDivElement> {
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
mini?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InputContainer({ mini, ...props }: PropsWithChildren<InputContainerProps>) {
|
||||
export default ({ mini, ...props }: PropsWithChildren<Props>) => {
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<div {...props} className={clsx('flex w-full flex-col', !mini && 'pb-6', props.className)}>
|
||||
<div className={clsx('flex w-full flex-col', !mini && 'pb-6', props.className)}>
|
||||
<h3 className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-100">{props.title}</h3>
|
||||
{!!props.description && <p className="mb-2 text-sm text-gray-400 ">{props.description}</p>}
|
||||
{!mini && props.children}
|
||||
|
@ -19,4 +19,4 @@ export function InputContainer({ mini, ...props }: PropsWithChildren<InputContai
|
|||
{mini && props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
108
interface/app/$libraryId/settings/Sidebar.tsx
Normal file
108
interface/app/$libraryId/settings/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
55
interface/app/$libraryId/settings/client/appearance.tsx
Normal file
55
interface/app/$libraryId/settings/client/appearance.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
import { MagnifyingGlass } from 'phosphor-react';
|
||||
import { Button, Card, GridLayout, Input, SearchInput } from '@sd/ui';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { Button, Card, GridLayout, SearchInput } from '@sd/ui';
|
||||
import { Heading } from '../Layout';
|
||||
|
||||
// extensions should cache their logos in the app data folder
|
||||
interface ExtensionItemData {
|
||||
|
@ -59,8 +57,8 @@ export default function ExtensionSettings() {
|
|||
// const { data: volumes } = useBridgeQuery('GetVolumes');
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsHeader
|
||||
<>
|
||||
<Heading
|
||||
title="Extensions"
|
||||
description="Install extensions to extend the functionality of this client."
|
||||
rightArea={<SearchInput outerClassnames="mt-1.5" placeholder="Search extensions" />}
|
||||
|
@ -71,6 +69,6 @@ export default function ExtensionSettings() {
|
|||
<ExtensionItem key={extension.uuid} extension={extension} />
|
||||
))}
|
||||
</GridLayout>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,25 +1,21 @@
|
|||
import { Database } from 'phosphor-react';
|
||||
import { getDebugState, useBridgeQuery, useDebugState } from '@sd/client';
|
||||
import { Card, Input, Switch, tw } from '@sd/ui';
|
||||
import { InputContainer } from '~/components/primitive/InputContainer';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { Heading } from '../Layout';
|
||||
import Setting from '../Setting';
|
||||
|
||||
const NodePill = tw.div`px-1.5 py-[2px] rounded text-xs font-medium bg-app-selected`;
|
||||
const NodeSettingLabel = tw.div`mb-1 text-xs font-medium`;
|
||||
|
||||
export default function GeneralSettings() {
|
||||
const { data: node } = useBridgeQuery(['nodeState']);
|
||||
export default () => {
|
||||
const node = useBridgeQuery(['nodeState']);
|
||||
const platform = usePlatform();
|
||||
const debugState = useDebugState();
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsHeader
|
||||
title="General Settings"
|
||||
description="General settings related to this client."
|
||||
/>
|
||||
<>
|
||||
<Heading title="General Settings" description="General settings related to this client." />
|
||||
<Card className="px-5">
|
||||
<div className="my-2 flex w-full flex-col">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
|
@ -34,11 +30,22 @@ export default function GeneralSettings() {
|
|||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<NodeSettingLabel>Node Name</NodeSettingLabel>
|
||||
<Input value={node?.name} />
|
||||
<Input
|
||||
value={node.data?.name}
|
||||
onChange={() => {
|
||||
/* TODO */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<NodeSettingLabel>Node Port</NodeSettingLabel>
|
||||
<Input contentEditable={false} value={node?.p2p_port || 5795} />
|
||||
<Input
|
||||
contentEditable={false}
|
||||
value={node.data?.p2p_port || 5795}
|
||||
onChange={() => {
|
||||
/* TODO */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center space-x-3">
|
||||
|
@ -48,8 +55,8 @@ export default function GeneralSettings() {
|
|||
<div className="mt-3">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (node && platform?.openLink) {
|
||||
platform.openLink(node.data_path);
|
||||
if (node.data && platform?.openLink) {
|
||||
platform.openLink(node.data.data_path);
|
||||
}
|
||||
}}
|
||||
className="text-ink-faint text-sm font-medium"
|
||||
|
@ -57,12 +64,12 @@ export default function GeneralSettings() {
|
|||
<b className="mr-2 inline truncate">
|
||||
<Database className="mr-1 mt-[-2px] inline h-4 w-4" /> Data Folder
|
||||
</b>
|
||||
<span className="select-text">{node?.data_path}</span>
|
||||
<span className="select-text">{node.data?.data_path}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<InputContainer
|
||||
<Setting
|
||||
mini
|
||||
title="Debug mode"
|
||||
description="Enable extra debugging features within the app."
|
||||
|
@ -71,7 +78,7 @@ export default function GeneralSettings() {
|
|||
checked={debugState.enabled}
|
||||
onClick={() => (getDebugState().enabled = !debugState.enabled)}
|
||||
/>
|
||||
</InputContainer>
|
||||
</SettingsContainer>
|
||||
</Setting>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
10
interface/app/$libraryId/settings/client/index.ts
Normal file
10
interface/app/$libraryId/settings/client/index.ts
Normal 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[]
|
|
@ -1,16 +1,15 @@
|
|||
import { useState } from 'react';
|
||||
import { Switch } from '@sd/ui';
|
||||
import { InputContainer } from '~/components/primitive/InputContainer';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { Heading } from '../Layout';
|
||||
import Setting from '../Setting';
|
||||
|
||||
export default function AppearanceSettings() {
|
||||
const [syncWithLibrary, setSyncWithLibrary] = useState(true);
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<>
|
||||
{/* I don't care what you think the "right" way to write "keybinds" is, I simply refuse to refer to it as "keybindings" */}
|
||||
<SettingsHeader title="Keybinds" description="Manage client keybinds" />
|
||||
<InputContainer
|
||||
<Heading title="Keybinds" description="Manage client keybinds" />
|
||||
<Setting
|
||||
mini
|
||||
title="Sync with Library"
|
||||
description="If enabled your keybinds will be synced with library, otherwise they will apply only to this client."
|
||||
|
@ -20,7 +19,7 @@ export default function AppearanceSettings() {
|
|||
onCheckedChange={setSyncWithLibrary}
|
||||
className="m-2 ml-4"
|
||||
/>
|
||||
</InputContainer>
|
||||
</SettingsContainer>
|
||||
</Setting>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,22 +1,22 @@
|
|||
import { useState } from 'react';
|
||||
import { Switch } from '@sd/ui';
|
||||
import { InputContainer } from '~/components/primitive/InputContainer';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { Heading } from '../Layout';
|
||||
import Setting from '../Setting';
|
||||
|
||||
export default function PrivacySettings() {
|
||||
const [shareUsageData, setShareUsageData] = useState(true);
|
||||
const [blurEffects, setBlurEffects] = useState(true);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsHeader title="Privacy" description="" />
|
||||
<InputContainer
|
||||
<>
|
||||
<Heading title="Privacy" description="" />
|
||||
<Setting
|
||||
mini
|
||||
title="Share Usage Data"
|
||||
description="Share anonymous usage data to help us improve the app."
|
||||
>
|
||||
<Switch checked={shareUsageData} onCheckedChange={setShareUsageData} className="m-2 ml-4" />
|
||||
</InputContainer>
|
||||
</SettingsContainer>
|
||||
</Setting>
|
||||
</>
|
||||
);
|
||||
}
|
28
interface/app/$libraryId/settings/index.tsx
Normal file
28
interface/app/$libraryId/settings/index.tsx
Normal 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[];
|
9
interface/app/$libraryId/settings/library/backups.tsx
Normal file
9
interface/app/$libraryId/settings/library/backups.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Heading } from '../Layout';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<>
|
||||
<Heading title="Backups" description="Manage database backups." />
|
||||
</>
|
||||
);
|
||||
};
|
9
interface/app/$libraryId/settings/library/contacts.tsx
Normal file
9
interface/app/$libraryId/settings/library/contacts.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Heading } from '../Layout';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<>
|
||||
<Heading title="Contacts" description="Manage your contacts in Spacedrive." />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,12 +1,11 @@
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { useBridgeMutation, useLibraryContext } from '@sd/client';
|
||||
import { Button, Input, Switch } from '@sd/ui';
|
||||
import { InputContainer } from '~/components/primitive/InputContainer';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { useDebouncedFormWatch } from '~/hooks/useDebouncedForm';
|
||||
import { Heading } from '../Layout';
|
||||
import Setting from '../Setting';
|
||||
|
||||
export default function LibraryGeneralSettings() {
|
||||
export default () => {
|
||||
const { library } = useLibraryContext();
|
||||
const editLibrary = useBridgeMutation('library.edit');
|
||||
|
||||
|
@ -23,8 +22,8 @@ export default function LibraryGeneralSettings() {
|
|||
);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsHeader
|
||||
<>
|
||||
<Heading
|
||||
title="Library Settings"
|
||||
description="General settings related to the currently active library."
|
||||
/>
|
||||
|
@ -43,7 +42,7 @@ export default function LibraryGeneralSettings() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<InputContainer
|
||||
<Setting
|
||||
mini
|
||||
title="Encrypt Library"
|
||||
description="Enable encryption for this library, this will only encrypt the Spacedrive database, not the files themselves."
|
||||
|
@ -51,15 +50,15 @@ export default function LibraryGeneralSettings() {
|
|||
<div className="ml-3 flex items-center">
|
||||
<Switch checked={false} />
|
||||
</div>
|
||||
</InputContainer>
|
||||
<InputContainer mini title="Export Library" description="Export this library to a file.">
|
||||
</Setting>
|
||||
<Setting mini title="Export Library" description="Export this library to a file.">
|
||||
<div className="mt-2">
|
||||
<Button size="sm" variant="gray">
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</InputContainer>
|
||||
<InputContainer
|
||||
</Setting>
|
||||
<Setting
|
||||
mini
|
||||
title="Delete Library"
|
||||
description="This is permanent, your files will not be deleted, only the Spacedrive library."
|
||||
|
@ -69,7 +68,7 @@ export default function LibraryGeneralSettings() {
|
|||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</InputContainer>
|
||||
</SettingsContainer>
|
||||
</Setting>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
21
interface/app/$libraryId/settings/library/index.tsx
Normal file
21
interface/app/$libraryId/settings/library/index.tsx
Normal 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[];
|
|
@ -3,8 +3,8 @@ import { useState } from 'react';
|
|||
import { useLibraryMutation } from '@sd/client';
|
||||
import { Button, Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { forms } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { showAlertDialog } from '~/util/dialog';
|
||||
|
||||
const { Input, useZodForm, z } = forms;
|
||||
|
||||
|
@ -14,9 +14,7 @@ const schema = z.object({
|
|||
filePath: z.string()
|
||||
});
|
||||
|
||||
export type BackupRestorationDialogProps = UseDialogProps;
|
||||
|
||||
export const BackupRestoreDialog = (props: BackupRestorationDialogProps) => {
|
||||
export default (props: UseDialogProps) => {
|
||||
const platform = usePlatform();
|
||||
|
||||
const restoreKeystoreMutation = useLibraryMutation('keys.restoreKeystore', {
|
|
@ -1,13 +1,10 @@
|
|||
import { Buffer } from 'buffer';
|
||||
import { Clipboard } from 'phosphor-react';
|
||||
import { useState } from 'react';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import { slugFromHashingAlgo, useLibraryQuery } from '@sd/client';
|
||||
import { Button, Dialog, Input, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { getHashingAlgorithmString } from '~/screens/settings/library/KeysSetting';
|
||||
import { SelectOptionKeyList } from '../key/KeyList';
|
||||
|
||||
type KeyViewerDialogProps = UseDialogProps;
|
||||
import { useZodForm } from '@sd/ui/src/forms';
|
||||
import { KeyListSelectOptions } from '~/app/$libraryId/KeyManager/List';
|
||||
|
||||
export const KeyUpdater = (props: {
|
||||
uuid: string;
|
||||
|
@ -25,17 +22,18 @@ export const KeyUpdater = (props: {
|
|||
const keys = useLibraryQuery(['keys.list']);
|
||||
|
||||
const key = keys.data?.find((key) => key.uuid == props.uuid);
|
||||
key && props.setEncryptionAlgo(key?.algorithm);
|
||||
key && props.setHashingAlgo(getHashingAlgorithmString(key?.hashing_algorithm));
|
||||
key && props.setContentSalt(Buffer.from(key.content_salt).toString('hex'));
|
||||
|
||||
if (key) {
|
||||
props.setEncryptionAlgo(key?.algorithm);
|
||||
props.setHashingAlgo(slugFromHashingAlgo(key?.hashing_algorithm));
|
||||
props.setContentSalt(Buffer.from(key.content_salt).toString('hex'));
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const schema = z.object({});
|
||||
|
||||
export const KeyViewerDialog = (props: KeyViewerDialogProps) => {
|
||||
const form = useZodForm({ schema });
|
||||
export default (props: UseDialogProps) => {
|
||||
const form = useZodForm();
|
||||
const dialog = useDialog(props);
|
||||
|
||||
const keys = useLibraryQuery(['keys.list'], {
|
||||
|
@ -79,7 +77,7 @@ export const KeyViewerDialog = (props: KeyViewerDialogProps) => {
|
|||
setKey(e);
|
||||
}}
|
||||
>
|
||||
{keys.data && <SelectOptionKeyList keys={keys.data.map((key) => key.uuid)} />}
|
||||
{keys.data && <KeyListSelectOptions keys={keys.data.map((key) => key.uuid)} />}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
|
@ -1,24 +1,34 @@
|
|||
import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react';
|
||||
import { lazy, useState } from 'react';
|
||||
import { Algorithm, useLibraryMutation } from '@sd/client';
|
||||
import { Button, Dialog, Input, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Algorithm,
|
||||
HASHING_ALGOS,
|
||||
HashingAlgoSlug,
|
||||
generatePassword,
|
||||
hashingAlgoSlugSchema,
|
||||
useLibraryMutation
|
||||
} from '@sd/client';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
Input,
|
||||
PasswordMeter,
|
||||
Select,
|
||||
SelectOption,
|
||||
UseDialogProps,
|
||||
useDialog
|
||||
} from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { getHashingAlgorithmSettings } from '~/screens/settings/library/KeysSetting';
|
||||
import { showAlertDialog } from '~/util/dialog';
|
||||
import { generatePassword } from '../key/KeyMounter';
|
||||
|
||||
const PasswordMeter = lazy(() => import('../key/PasswordMeter'));
|
||||
|
||||
export type MasterPasswordChangeDialogProps = UseDialogProps;
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
|
||||
const schema = z.object({
|
||||
masterPassword: z.string(),
|
||||
masterPassword2: z.string(),
|
||||
encryptionAlgo: z.string(),
|
||||
hashingAlgo: z.string()
|
||||
hashingAlgo: hashingAlgoSlugSchema
|
||||
});
|
||||
|
||||
export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProps) => {
|
||||
export default (props: UseDialogProps) => {
|
||||
const changeMasterPassword = useLibraryMutation('keys.changeMasterPassword', {
|
||||
onSuccess: () => {
|
||||
showAlertDialog({
|
||||
|
@ -62,7 +72,8 @@ export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProp
|
|||
value: 'Passwords are not the same, please try again.'
|
||||
});
|
||||
} else {
|
||||
const hashing_algorithm = getHashingAlgorithmSettings(data.hashingAlgo);
|
||||
const hashing_algorithm = HASHING_ALGOS[data.hashingAlgo];
|
||||
|
||||
return changeMasterPassword.mutateAsync({
|
||||
algorithm: data.encryptionAlgo as Algorithm,
|
||||
hashing_algorithm,
|
||||
|
@ -160,7 +171,7 @@ export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProp
|
|||
<Select
|
||||
className="mt-2"
|
||||
value={form.watch('hashingAlgo')}
|
||||
onChange={(e) => form.setValue('hashingAlgo', e)}
|
||||
onChange={(e) => form.setValue('hashingAlgo', e as HashingAlgoSlug)}
|
||||
>
|
||||
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
|
||||
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
|
|
@ -1,22 +1,19 @@
|
|||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import clsx from 'clsx';
|
||||
import { Eye, EyeSlash, Lock, Plus } from 'phosphor-react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { PropsWithChildren, ReactNode, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { animated, useTransition } from 'react-spring';
|
||||
import { HashingAlgorithm, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { Button, Input, dialogManager } from '@sd/ui';
|
||||
import { BackupRestoreDialog } from '~/components/dialog/BackupRestoreDialog';
|
||||
import { KeyViewerDialog } from '~/components/dialog/KeyViewerDialog';
|
||||
import { MasterPasswordChangeDialog } from '~/components/dialog/MasterPasswordChangeDialog';
|
||||
import { ListOfKeys } from '~/components/key/KeyList';
|
||||
import { KeyMounter } from '~/components/key/KeyMounter';
|
||||
import { DefaultProps } from '~/components/primitive/types';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { SettingsSubHeader } from '~/components/settings/SettingsSubHeader';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { showAlertDialog } from '~/util/dialog';
|
||||
import KeyList from '../../../KeyManager/List';
|
||||
import KeyMounter from '../../../KeyManager/Mounter';
|
||||
import { Heading } from '../../Layout';
|
||||
import BackupRestoreDialog from './BackupRestoreDialog';
|
||||
import KeyViewerDialog from './KeyViewerDialog';
|
||||
import MasterPasswordDialog from './MasterPasswordDialog';
|
||||
|
||||
interface Props extends DropdownMenu.MenuContentProps {
|
||||
trigger: React.ReactNode;
|
||||
|
@ -75,7 +72,7 @@ export const KeyMounterDropdown = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default function KeysSettings() {
|
||||
export default () => {
|
||||
const platform = usePlatform();
|
||||
const isUnlocked = useLibraryQuery(['keys.isUnlocked']);
|
||||
const keyringSk = useLibraryQuery(['keys.getSecretKey'], { initialData: '' }); // assume true by default, as it will often be the case. need to fix this with an rspc subscription+such
|
||||
|
@ -165,12 +162,7 @@ export default function KeysSettings() {
|
|||
</Button>
|
||||
{!enterSkManually && (
|
||||
<div className="relative flex grow">
|
||||
<p
|
||||
className="text-accent mt-2"
|
||||
onClick={(e) => {
|
||||
setEnterSkManually(true);
|
||||
}}
|
||||
>
|
||||
<p className="text-accent mt-2" onClick={() => setEnterSkManually(true)}>
|
||||
or enter secret key manually
|
||||
</p>
|
||||
</div>
|
||||
|
@ -180,145 +172,135 @@ export default function KeysSettings() {
|
|||
} else {
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
<SettingsHeader
|
||||
title="Keys"
|
||||
description="Manage your keys."
|
||||
rightArea={
|
||||
<div className="flex flex-row items-center">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
unmountAll.mutate(null);
|
||||
clearMasterPassword.mutate(null);
|
||||
}}
|
||||
variant="subtle"
|
||||
className="text-ink-faint"
|
||||
>
|
||||
<Lock className="text-ink-faint h-4 w-4" />
|
||||
</Button>
|
||||
<KeyMounterDropdown
|
||||
trigger={
|
||||
<Button size="icon" variant="subtle" className="text-ink-faint">
|
||||
<Plus className="text-ink-faint h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<KeyMounter />
|
||||
</KeyMounterDropdown>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{isUnlocked && (
|
||||
<div className="grid space-y-2">
|
||||
<ListOfKeys />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keyringSk?.data && (
|
||||
<>
|
||||
<SettingsSubHeader title="Secret key" />
|
||||
{!viewSecretKey && (
|
||||
<div className="flex flex-row">
|
||||
<Button size="sm" variant="gray" onClick={() => setViewSecretKey(true)}>
|
||||
View Secret Key
|
||||
<Heading
|
||||
title="Keys"
|
||||
description="Manage your keys."
|
||||
rightArea={
|
||||
<div className="flex flex-row items-center">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
unmountAll.mutate(null);
|
||||
clearMasterPassword.mutate(null);
|
||||
}}
|
||||
variant="subtle"
|
||||
className="text-ink-faint"
|
||||
>
|
||||
<Lock className="text-ink-faint h-4 w-4" />
|
||||
</Button>
|
||||
<KeyMounterDropdown
|
||||
trigger={
|
||||
<Button size="icon" variant="subtle" className="text-ink-faint">
|
||||
<Plus className="text-ink-faint h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{viewSecretKey && (
|
||||
<div
|
||||
className="flex flex-row"
|
||||
onClick={() => {
|
||||
keyringSk.data && navigator.clipboard.writeText(keyringSk.data);
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<QRCode size={128} value={keyringSk.data} />
|
||||
<p className="mt-14 ml-6 text-xl font-bold">{keyringSk.data}</p>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsSubHeader title="Password Options" />
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="gray"
|
||||
className="mr-2"
|
||||
onClick={() => dialogManager.create((dp) => <MasterPasswordChangeDialog {...dp} />)}
|
||||
>
|
||||
Change Master Password
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="gray"
|
||||
className="mr-2"
|
||||
hidden={keys.data?.length === 0}
|
||||
onClick={() => dialogManager.create((dp) => <KeyViewerDialog {...dp} />)}
|
||||
>
|
||||
View Key Values
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsSubHeader title="Data Recovery" />
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="gray"
|
||||
className="mr-2"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!platform.saveFilePickerDialog) {
|
||||
// TODO: Support opening locations on web
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: "System dialogs aren't supported on this platform."
|
||||
});
|
||||
return;
|
||||
}
|
||||
platform.saveFilePickerDialog().then((result) => {
|
||||
if (result) backupKeystore.mutate(result as string);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Backup
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="gray"
|
||||
className="mr-2"
|
||||
onClick={() => dialogManager.create((dp) => <BackupRestoreDialog {...dp} />)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
>
|
||||
<KeyMounter />
|
||||
</KeyMounterDropdown>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{isUnlocked && (
|
||||
<div className="grid space-y-2">
|
||||
<KeyList />
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
)}
|
||||
|
||||
{keyringSk?.data && (
|
||||
<>
|
||||
<Subheading title="Secret key" />
|
||||
{!viewSecretKey && (
|
||||
<div className="flex flex-row">
|
||||
<Button size="sm" variant="gray" onClick={() => setViewSecretKey(true)}>
|
||||
View Secret Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{viewSecretKey && (
|
||||
<div
|
||||
className="flex flex-row"
|
||||
onClick={() => {
|
||||
keyringSk.data && navigator.clipboard.writeText(keyringSk.data);
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<QRCode size={128} value={keyringSk.data} />
|
||||
<p className="mt-14 ml-6 text-xl font-bold">{keyringSk.data}</p>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Subheading title="Password Options" />
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="gray"
|
||||
className="mr-2"
|
||||
onClick={() => dialogManager.create((dp) => <MasterPasswordDialog {...dp} />)}
|
||||
>
|
||||
Change Master Password
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="gray"
|
||||
className="mr-2"
|
||||
hidden={keys.data?.length === 0}
|
||||
onClick={() => dialogManager.create((dp) => <KeyViewerDialog {...dp} />)}
|
||||
>
|
||||
View Key Values
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Subheading title="Data Recovery" />
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="gray"
|
||||
className="mr-2"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!platform.saveFilePickerDialog) {
|
||||
// TODO: Support opening locations on web
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: "System dialogs aren't supported on this platform."
|
||||
});
|
||||
return;
|
||||
}
|
||||
platform.saveFilePickerDialog().then((result) => {
|
||||
if (result) backupKeystore.mutate(result as string);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Backup
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="gray"
|
||||
className="mr-2"
|
||||
onClick={() => dialogManager.create((dp) => <BackupRestoreDialog {...dp} />)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface SubheadingProps {
|
||||
title: string;
|
||||
rightArea?: ReactNode;
|
||||
}
|
||||
|
||||
const table: Record<string, HashingAlgorithm> = {
|
||||
'Argon2id-s': { name: 'Argon2id', params: 'Standard' },
|
||||
'Argon2id-h': { name: 'Argon2id', params: 'Hardened' },
|
||||
'Argon2id-p': { name: 'Argon2id', params: 'Paranoid' },
|
||||
'BalloonBlake3-s': { name: 'BalloonBlake3', params: 'Standard' },
|
||||
'BalloonBlake3-h': { name: 'BalloonBlake3', params: 'Hardened' },
|
||||
'BalloonBlake3-p': { name: 'BalloonBlake3', params: 'Paranoid' }
|
||||
};
|
||||
|
||||
// not sure of a suitable place for this function
|
||||
export const getHashingAlgorithmSettings = (hashingAlgorithm: string): HashingAlgorithm => {
|
||||
return table[hashingAlgorithm] || { name: 'Argon2id', params: 'Standard' };
|
||||
};
|
||||
|
||||
// not sure of a suitable place for this function
|
||||
export const getHashingAlgorithmString = (hashingAlgorithm: HashingAlgorithm): string => {
|
||||
return Object.entries(table).find(
|
||||
([_, hashAlg]) =>
|
||||
hashAlg.name === hashingAlgorithm.name && hashAlg.params === hashingAlgorithm.params
|
||||
)![0];
|
||||
};
|
||||
const Subheading = (props: SubheadingProps) => (
|
||||
<div className="flex">
|
||||
<div className="grow">
|
||||
<h1 className="text-xl font-bold">{props.title}</h1>
|
||||
</div>
|
||||
{props.rightArea}
|
||||
</div>
|
||||
);
|
|
@ -3,10 +3,9 @@ import { Archive, ArrowsClockwise, Info, Trash } from 'phosphor-react';
|
|||
import { useFormState } from 'react-hook-form';
|
||||
import { useParams } from 'react-router';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { Button, forms, tw } from '@sd/ui';
|
||||
import { Divider } from '~/components/explorer/inspector/Divider';
|
||||
import { SettingsSubPage } from '~/components/settings/SettingsSubPage';
|
||||
import { Tooltip } from '~/components/tooltip/Tooltip';
|
||||
import { Button, Divider, forms, tw } from '@sd/ui';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import ModalLayout from '../../ModalLayout';
|
||||
import { IndexerRuleEditor } from './IndexerRuleEditor';
|
||||
|
||||
const InfoText = tw.p`mt-2 text-xs text-ink-faint`;
|
||||
|
@ -16,10 +15,6 @@ const ToggleSection = tw.label`flex flex-row w-full`;
|
|||
|
||||
const { Form, Input, Switch, useZodForm, z } = forms;
|
||||
|
||||
export type EditLocationParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
displayName: z.string(),
|
||||
localPath: z.string(),
|
||||
|
@ -31,7 +26,9 @@ const schema = z.object({
|
|||
|
||||
export default function EditLocation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { id } = useParams<keyof EditLocationParams>() as EditLocationParams;
|
||||
const { id } = useParams<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
useLibraryQuery(['locations.getById', Number(id)], {
|
||||
onSuccess: (data) => {
|
||||
|
@ -53,13 +50,13 @@ export default function EditLocation() {
|
|||
|
||||
const updateLocation = useLibraryMutation('locations.update', {
|
||||
onError: (e) => console.log({ e }),
|
||||
onSuccess: (e) => {
|
||||
onSuccess: () => {
|
||||
form.reset(form.getValues());
|
||||
queryClient.invalidateQueries(['locations.list']);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) =>
|
||||
const onSubmit = form.handleSubmit((data) =>
|
||||
updateLocation.mutateAsync({
|
||||
id: Number(id),
|
||||
name: data.displayName,
|
||||
|
@ -75,8 +72,8 @@ export default function EditLocation() {
|
|||
const { isDirty } = useFormState({ control: form.control });
|
||||
|
||||
return (
|
||||
<Form form={form} onSubmit={onSubmit}>
|
||||
<SettingsSubPage
|
||||
<Form form={form} onSubmit={onSubmit} className="h-full w-full">
|
||||
<ModalLayout
|
||||
title="Edit Location"
|
||||
topRight={
|
||||
<div className="flex flex-row space-x-3">
|
||||
|
@ -114,7 +111,6 @@ export default function EditLocation() {
|
|||
</FlexCol>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="space-y-2">
|
||||
<ToggleSection>
|
||||
<Label className="grow">Generate preview media for this Location</Label>
|
||||
|
@ -140,7 +136,7 @@ export default function EditLocation() {
|
|||
<InfoText className="mt-0 mb-1">
|
||||
Indexer rules allow you to specify paths to ignore using RegEx.
|
||||
</InfoText>
|
||||
<IndexerRuleEditor locationId={id} />
|
||||
<IndexerRuleEditor locationId={id!} />
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex space-x-5">
|
||||
|
@ -183,7 +179,7 @@ export default function EditLocation() {
|
|||
</div>
|
||||
<Divider />
|
||||
<div className="h-6" />
|
||||
</SettingsSubPage>
|
||||
</ModalLayout>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
import { useLibraryQuery } from '@sd/client';
|
||||
import { Card, Input, tw } from '@sd/ui';
|
||||
import { Card, tw } from '@sd/ui';
|
||||
|
||||
interface Props {
|
||||
locationId: string;
|
|
@ -4,16 +4,14 @@ import { useState } from 'react';
|
|||
import { useNavigate } from 'react-router';
|
||||
import { arraysEqual, useLibraryMutation, useOnlineLocations } from '@sd/client';
|
||||
import { Location, Node } from '@sd/client';
|
||||
import { Button, Card, Dialog, UseDialogProps, dialogManager, useDialog } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { Folder } from '../icons/Folder';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import { Button, Card, Folder, Tooltip, dialogManager } from '@sd/ui';
|
||||
import DeleteDialog from './DeleteDialog';
|
||||
|
||||
interface LocationListItemProps {
|
||||
interface Props {
|
||||
location: Location & { node: Node };
|
||||
}
|
||||
|
||||
export default function LocationListItem({ location }: LocationListItemProps) {
|
||||
export default ({ location }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const [hide, setHide] = useState(false);
|
||||
|
||||
|
@ -26,7 +24,7 @@ export default function LocationListItem({ location }: LocationListItemProps) {
|
|||
|
||||
return (
|
||||
<Card
|
||||
className="hover:bg-app-box/70 cursor-pointer"
|
||||
className="hover:bg-app-box/70"
|
||||
onClick={() => {
|
||||
navigate(`${location.id}`);
|
||||
}}
|
||||
|
@ -59,11 +57,7 @@ export default function LocationListItem({ location }: LocationListItemProps) {
|
|||
onClick={(e: { stopPropagation: () => void }) => {
|
||||
e.stopPropagation();
|
||||
dialogManager.create((dp) => (
|
||||
<DeleteLocationDialog
|
||||
{...dp}
|
||||
onSuccess={() => setHide(true)}
|
||||
locationId={location.id}
|
||||
/>
|
||||
<DeleteDialog {...dp} onSuccess={() => setHide(true)} locationId={location.id} />
|
||||
));
|
||||
}}
|
||||
>
|
||||
|
@ -90,31 +84,4 @@ export default function LocationListItem({ location }: LocationListItemProps) {
|
|||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteLocationDialogProps extends UseDialogProps {
|
||||
onSuccess: () => void;
|
||||
locationId: number;
|
||||
}
|
||||
|
||||
function DeleteLocationDialog(props: DeleteLocationDialogProps) {
|
||||
const dialog = useDialog(props);
|
||||
|
||||
const form = useZodForm({ schema: z.object({}) });
|
||||
|
||||
const deleteLocation = useLibraryMutation('locations.delete', {
|
||||
onSuccess: props.onSuccess
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
form={form}
|
||||
onSubmit={form.handleSubmit(() => deleteLocation.mutateAsync(props.locationId))}
|
||||
dialog={dialog}
|
||||
title="Delete Location"
|
||||
description="Deleting a location will also remove all files associated with it from the Spacedrive database, the files themselves will not be deleted."
|
||||
ctaDanger
|
||||
ctaLabel="Delete"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,21 +1,19 @@
|
|||
import { MagnifyingGlass } from 'phosphor-react';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { LocationCreateArgs } from '@sd/client';
|
||||
import { Button, Input, SearchInput, dialogManager } from '@sd/ui';
|
||||
import AddLocationDialog from '~/components/dialog/AddLocationDialog';
|
||||
import LocationListItem from '~/components/location/LocationListItem';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { Button, SearchInput, dialogManager } from '@sd/ui';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { Heading } from '../../Layout';
|
||||
import AddDialog from './AddDialog';
|
||||
import ListItem from './ListItem';
|
||||
|
||||
export default function LocationSettings() {
|
||||
export default () => {
|
||||
const platform = usePlatform();
|
||||
const locations = useLibraryQuery(['locations.list']);
|
||||
const createLocation = useLibraryMutation('locations.create');
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsHeader
|
||||
<>
|
||||
<Heading
|
||||
title="Locations"
|
||||
description="Manage your storage locations."
|
||||
rightArea={
|
||||
|
@ -27,7 +25,7 @@ export default function LocationSettings() {
|
|||
size="md"
|
||||
onClick={() => {
|
||||
if (platform.platform === 'web') {
|
||||
dialogManager.create((dp) => <AddLocationDialog {...dp} />);
|
||||
dialogManager.create((dp) => <AddDialog {...dp} />);
|
||||
} else {
|
||||
if (!platform.openDirectoryPickerDialog) {
|
||||
alert('Opening a dialogue is not supported on this platform!');
|
||||
|
@ -51,9 +49,9 @@ export default function LocationSettings() {
|
|||
/>
|
||||
<div className="grid space-y-2">
|
||||
{locations.data?.map((location) => (
|
||||
<LocationListItem key={location.id} location={location} />
|
||||
<ListItem key={location.id} location={location} />
|
||||
))}
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,13 +1,12 @@
|
|||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { Heading } from '../Layout';
|
||||
|
||||
export default function NodesSettings() {
|
||||
export default () => {
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsHeader
|
||||
<>
|
||||
<Heading
|
||||
title="Nodes"
|
||||
description="Manage the nodes connected to this library. A node is an instance of Spacedrive's backend, running on a device or server. Each node carries a copy of the database and synchronizes via peer-to-peer connections in realtime."
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
9
interface/app/$libraryId/settings/library/security.tsx
Normal file
9
interface/app/$libraryId/settings/library/security.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Heading } from '../Layout';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<>
|
||||
<Heading title="Security" description="Keep your client safe." />
|
||||
</>
|
||||
);
|
||||
};
|
9
interface/app/$libraryId/settings/library/sharing.tsx
Normal file
9
interface/app/$libraryId/settings/library/sharing.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Heading } from '../Layout';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<>
|
||||
<Heading title="Sharing" description="Manage who has access to your libraries." />
|
||||
</>
|
||||
);
|
||||
};
|
9
interface/app/$libraryId/settings/library/sync.tsx
Normal file
9
interface/app/$libraryId/settings/library/sync.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Heading } from '../Layout';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<>
|
||||
<Heading title="Sync" description="Manage how Spacedrive syncs." />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
70
interface/app/$libraryId/settings/library/tags/EditForm.tsx
Normal file
70
interface/app/$libraryId/settings/library/tags/EditForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
57
interface/app/$libraryId/settings/library/tags/index.tsx
Normal file
57
interface/app/$libraryId/settings/library/tags/index.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
7
interface/app/$libraryId/settings/node/index.tsx
Normal file
7
interface/app/$libraryId/settings/node/index.tsx
Normal 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[]
|
|
@ -1,13 +1,24 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react';
|
||||
import { lazy, useState } from 'react';
|
||||
import { Algorithm, useBridgeMutation } from '@sd/client';
|
||||
import { Button, Dialog, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Algorithm,
|
||||
HASHING_ALGOS,
|
||||
HashingAlgoSlug,
|
||||
generatePassword,
|
||||
hashingAlgoSlugSchema,
|
||||
useBridgeMutation
|
||||
} from '@sd/client';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
PasswordMeter,
|
||||
Select,
|
||||
SelectOption,
|
||||
UseDialogProps,
|
||||
useDialog
|
||||
} from '@sd/ui';
|
||||
import { forms } from '@sd/ui';
|
||||
import { getHashingAlgorithmSettings } from '~/screens/settings/library/KeysSetting';
|
||||
import { generatePassword } from '../key/KeyMounter';
|
||||
|
||||
const PasswordMeter = lazy(() => import('../key/PasswordMeter'));
|
||||
|
||||
const { Input, z, useZodForm } = forms;
|
||||
|
||||
|
@ -16,12 +27,10 @@ const schema = z.object({
|
|||
password: z.string(),
|
||||
password_validate: z.string(),
|
||||
algorithm: z.string(),
|
||||
hashing_algorithm: z.string()
|
||||
hashing_algorithm: hashingAlgoSlugSchema
|
||||
});
|
||||
|
||||
type Props = UseDialogProps;
|
||||
|
||||
export default function CreateLibraryDialog(props: Props) {
|
||||
export default (props: UseDialogProps) => {
|
||||
const dialog = useDialog(props);
|
||||
|
||||
const form = useZodForm({
|
||||
|
@ -58,7 +67,7 @@ export default function CreateLibraryDialog(props: Props) {
|
|||
await createLibrary.mutateAsync({
|
||||
...data,
|
||||
algorithm: data.algorithm as Algorithm,
|
||||
hashing_algorithm: getHashingAlgorithmSettings(data.hashing_algorithm),
|
||||
hashing_algorithm: HASHING_ALGOS[data.hashing_algorithm],
|
||||
auth: {
|
||||
type: 'Password',
|
||||
value: data.password
|
||||
|
@ -170,7 +179,7 @@ export default function CreateLibraryDialog(props: Props) {
|
|||
<Select
|
||||
className="mt-2"
|
||||
value={form.watch('hashing_algorithm')}
|
||||
onChange={(e) => form.setValue('hashing_algorithm', e)}
|
||||
onChange={(e) => form.setValue('hashing_algorithm', e as HashingAlgoSlug)}
|
||||
>
|
||||
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
|
||||
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
|
||||
|
@ -185,4 +194,4 @@ export default function CreateLibraryDialog(props: Props) {
|
|||
<PasswordMeter password={form.watch('password')} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
);
|
49
interface/app/$libraryId/settings/node/libraries/index.tsx
Normal file
49
interface/app/$libraryId/settings/node/libraries/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,25 +1,24 @@
|
|||
import { Input, Switch } from '@sd/ui';
|
||||
import { InputContainer } from '~/components/primitive/InputContainer';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsHeader } from '~/components/settings/SettingsHeader';
|
||||
import { Heading } from '../Layout';
|
||||
import Setting from '../Setting';
|
||||
|
||||
export default function P2PSettings() {
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsHeader
|
||||
<>
|
||||
<Heading
|
||||
title="P2P Settings"
|
||||
description="Manage how this node communicates with other nodes."
|
||||
/>
|
||||
|
||||
<InputContainer
|
||||
<Setting
|
||||
mini
|
||||
title="Enable Node Discovery"
|
||||
description="Allow or block this node from calling an external server to assist in forming a peer-to-peer connection. "
|
||||
>
|
||||
<Switch checked />
|
||||
</InputContainer>
|
||||
</Setting>
|
||||
|
||||
<InputContainer
|
||||
<Setting
|
||||
title="Discovery Server"
|
||||
description="Configuration server to aid with establishing peer-to-peer to connections between nodes over the internet. Disabling will result in nodes only being accessible over LAN and direct IP connections."
|
||||
>
|
||||
|
@ -29,7 +28,7 @@ export default function P2PSettings() {
|
|||
<a className="text-accent hover:text-accent p-1 text-sm font-bold">Change</a>
|
||||
</div>
|
||||
</div>
|
||||
</InputContainer>
|
||||
</SettingsContainer>
|
||||
</Setting>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import Logo from '@sd/assets/images/logo.png';
|
||||
import { useBridgeQuery } from '@sd/client';
|
||||
import { SettingsContainer } from '~/components/settings/SettingsContainer';
|
||||
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
|
||||
|
||||
export default function AboutSpacedrive() {
|
||||
|
@ -12,7 +11,7 @@ export default function AboutSpacedrive() {
|
|||
os === 'browser' ? 'Web' : os == 'macOS' ? os : os.charAt(0).toUpperCase() + os.slice(1);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<>
|
||||
<div className="flex flex-row items-center">
|
||||
<img src={Logo} className="mr-8 h-[88px] w-[88px]" />
|
||||
<div className="flex flex-col">
|
||||
|
@ -25,6 +24,6 @@ export default function AboutSpacedrive() {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
9
interface/app/$libraryId/settings/resources/index.tsx
Normal file
9
interface/app/$libraryId/settings/resources/index.tsx
Normal 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[]
|
9
interface/app/$libraryId/settings/resources/support.tsx
Normal file
9
interface/app/$libraryId/settings/resources/support.tsx
Normal 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
Loading…
Reference in a new issue