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