mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
Update frontend base
This commit is contained in:
@@ -1,85 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Modal,
|
|
||||||
ModalProps,
|
|
||||||
PasswordInput,
|
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
|
||||||
TextInput,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useForm } from '@mantine/form';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useCreateServer, validateServer } from '../queries/useCreateServer';
|
|
||||||
|
|
||||||
export const AddServerModal = ({ ...rest }: ModalProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
legacyAuth: false,
|
|
||||||
name: '',
|
|
||||||
password: '',
|
|
||||||
serverType: 'jellyfin',
|
|
||||||
url: 'http://',
|
|
||||||
username: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createServerMutation = useCreateServer();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal centered title={t('server.add.title')} {...rest}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.onSubmit(async (values) => {
|
|
||||||
const res = await validateServer(values);
|
|
||||||
|
|
||||||
if (res?.token) {
|
|
||||||
createServerMutation.mutate({
|
|
||||||
...values,
|
|
||||||
remoteUserId: res.userId,
|
|
||||||
token: res.token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<SegmentedControl
|
|
||||||
data={[
|
|
||||||
{ label: 'Jellyfin', value: 'jellyfin' },
|
|
||||||
{ label: 'Subsonic', value: 'subsonic' },
|
|
||||||
]}
|
|
||||||
{...form.getInputProps('serverType')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
label={t('server.name')}
|
|
||||||
{...form.getInputProps('name')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
label={t('server.url')}
|
|
||||||
{...form.getInputProps('url')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
label={t('server.username')}
|
|
||||||
{...form.getInputProps('username')}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
required
|
|
||||||
label={t('server.password')}
|
|
||||||
{...form.getInputProps('password')}
|
|
||||||
/>
|
|
||||||
{form.getInputProps('serverType').value === 'subsonic' && (
|
|
||||||
<Checkbox
|
|
||||||
label={t('server.legacyauth')}
|
|
||||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button type="submit">{t('server.submit')}</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
ModalProps,
|
|
||||||
PasswordInput,
|
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
|
||||||
TextInput,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useForm } from '@mantine/form';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ServerResponse } from '../../../api/types';
|
|
||||||
|
|
||||||
interface EditServerModalProps extends ModalProps {
|
|
||||||
server: ServerResponse | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditServerModal = ({ server }: EditServerModalProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
legacyAuth: false,
|
|
||||||
name: server?.name,
|
|
||||||
password: '',
|
|
||||||
serverType: server?.serverType,
|
|
||||||
url: server?.url,
|
|
||||||
username: server?.username,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(async () => {})}>
|
|
||||||
<Stack>
|
|
||||||
<SegmentedControl
|
|
||||||
disabled
|
|
||||||
data={[
|
|
||||||
{ label: 'Jellyfin', value: 'jellyfin' },
|
|
||||||
{ label: 'Subsonic', value: 'subsonic' },
|
|
||||||
]}
|
|
||||||
{...form.getInputProps('serverType')}
|
|
||||||
/>
|
|
||||||
<TextInput label={t('server.name')} {...form.getInputProps('name')} />
|
|
||||||
<TextInput label={t('server.url')} {...form.getInputProps('url')} />
|
|
||||||
<TextInput
|
|
||||||
label={t('server.username')}
|
|
||||||
{...form.getInputProps('username')}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={t('server.password')}
|
|
||||||
{...form.getInputProps('password')}
|
|
||||||
/>
|
|
||||||
{form.getInputProps('serverType').value === 'subsonic' && (
|
|
||||||
<Checkbox
|
|
||||||
label={t('server.legacyauth')}
|
|
||||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button type="submit">{t('server.submit')}</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
max-width: 50vw;
|
|
||||||
margin: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
outline: 1px #fff solid;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Button, Text } from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
|
||||||
import { ServerResponse } from '../../../api/types';
|
|
||||||
import { useServers } from '../queries/useServers';
|
|
||||||
import { EditServerModal } from './EditServerModal';
|
|
||||||
import styles from './ServerList.module.scss';
|
|
||||||
|
|
||||||
export const ServerList = () => {
|
|
||||||
const { data: servers } = useServers();
|
|
||||||
const [editServerModal, editServerHandlers] = useDisclosure(false);
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
|
|
||||||
const [selectedServer, setSelectedServer] = useState<
|
|
||||||
ServerResponse | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const handleClickServer = (server: ServerResponse) => {
|
|
||||||
setSelectedServer(server);
|
|
||||||
editServerHandlers.open();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDownServer = (
|
|
||||||
e: React.KeyboardEvent<HTMLDivElement>,
|
|
||||||
server: ServerResponse
|
|
||||||
) => {
|
|
||||||
if (e.key === ' ' || e.key === 'Enter') {
|
|
||||||
setSelectedServer(server);
|
|
||||||
editServerHandlers.open();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setOpened(false);
|
|
||||||
editServerHandlers.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editServerModal === true) {
|
|
||||||
setTimeout(() => setOpened(true));
|
|
||||||
} else {
|
|
||||||
setTimeout(() => setSelectedServer(undefined), 100);
|
|
||||||
}
|
|
||||||
}, [editServerModal]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{servers &&
|
|
||||||
servers.data.map((server) => (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={styles.item}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => handleClickServer(server)}
|
|
||||||
onKeyDown={(e) => handleKeyDownServer(e, server)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{server.name}
|
|
||||||
<Text>Hello</Text>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => editServerHandlers.toggle()}>Edit</Button>
|
|
||||||
</div>
|
|
||||||
{selectedServer && (
|
|
||||||
<EditServerModal
|
|
||||||
opened={opened}
|
|
||||||
server={selectedServer}
|
|
||||||
onClose={handleCloseModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Checkbox, Stack, Group } from '@mantine/core';
|
||||||
|
import { useForm, zodResolver } from '@mantine/form';
|
||||||
|
import { useFocusTrap } from '@mantine/hooks';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { remoteServerLogin } from '@/renderer/api/shared.api';
|
||||||
|
import { Server, ServerType } from '@/renderer/api/types';
|
||||||
|
import { Button, PasswordInput, TextInput, toast } from '@/renderer/components';
|
||||||
|
import { useAuthStore } from '@/renderer/store';
|
||||||
|
import { randomString } from '@/renderer/utils';
|
||||||
|
|
||||||
|
interface AddServerCredentialFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
server: Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
legacyAuth: z.boolean().optional(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
username: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddServerCredentialForm = ({
|
||||||
|
onCancel,
|
||||||
|
server,
|
||||||
|
}: AddServerCredentialFormProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const focusTrapRef = useFocusTrap(true);
|
||||||
|
const addServerCredential = useAuthStore(
|
||||||
|
(state) => state.addServerCredential
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
legacy: false,
|
||||||
|
password: '',
|
||||||
|
url: 'http://',
|
||||||
|
username: '',
|
||||||
|
},
|
||||||
|
validate: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const auth = await remoteServerLogin({
|
||||||
|
...values,
|
||||||
|
type: server.type,
|
||||||
|
url: server.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auth.type === 'error') {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return toast.show({
|
||||||
|
message: auth.message,
|
||||||
|
title: 'Failed to add credential',
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addServerCredential({
|
||||||
|
enabled: false,
|
||||||
|
id: randomString(),
|
||||||
|
serverId: server.id,
|
||||||
|
token: auth.token,
|
||||||
|
username: auth.username!,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return onCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack ref={focusTrapRef}>
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
required
|
||||||
|
label="username"
|
||||||
|
{...form.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
required
|
||||||
|
label="password"
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
{server.type === ServerType.SUBSONIC && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('modal.add_server.legacy_label')}
|
||||||
|
{...form.getInputProps('legacy', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Group position="right">
|
||||||
|
<Button disabled={isSubmitting} variant="subtle" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button loading={isSubmitting} type="submit" variant="filled">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Checkbox, Stack, Group } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { useFocusTrap } from '@mantine/hooks';
|
||||||
|
import { closeAllModals } from '@mantine/modals';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ServerType } from '@/renderer/api/types';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
PasswordInput,
|
||||||
|
TextInput,
|
||||||
|
SegmentedControl,
|
||||||
|
} from '@/renderer/components';
|
||||||
|
import { useCreateServer } from '@/renderer/features/servers/mutations/use-create-server';
|
||||||
|
|
||||||
|
const SERVER_TYPES = [
|
||||||
|
{ label: 'Jellyfin', value: ServerType.JELLYFIN },
|
||||||
|
{ label: 'Navidrome', value: ServerType.NAVIDROME },
|
||||||
|
{ label: 'Subsonic', value: ServerType.SUBSONIC },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AddServerFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const focusTrapRef = useFocusTrap(true);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
legacyAuth: false,
|
||||||
|
name: '',
|
||||||
|
password: '',
|
||||||
|
type: ServerType.JELLYFIN,
|
||||||
|
url: 'http://',
|
||||||
|
username: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createServerMutation = useCreateServer();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(async (values) => {
|
||||||
|
createServerMutation.mutate(
|
||||||
|
{ body: values },
|
||||||
|
{ onSuccess: () => closeAllModals() }
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack ref={focusTrapRef}>
|
||||||
|
<SegmentedControl data={SERVER_TYPES} {...form.getInputProps('type')} />
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
required
|
||||||
|
label={t('modal.add_server.name_label')}
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label={t('modal.add_server.url_label')}
|
||||||
|
{...form.getInputProps('url')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label={t('modal.add_server.username_label')}
|
||||||
|
{...form.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
required
|
||||||
|
label={t('modal.add_server.password_label')}
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
{form.values.type === ServerType.SUBSONIC && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('modal.add_server.legacy_label')}
|
||||||
|
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Group position="right">
|
||||||
|
<Button variant="subtle" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={createServerMutation.isLoading}
|
||||||
|
type="submit"
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
{t('modal.add_server.submit_label')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Stack, Group } from '@mantine/core';
|
||||||
|
import { useForm, zodResolver } from '@mantine/form';
|
||||||
|
import { useFocusTrap } from '@mantine/hooks';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Button, TextInput } from '@/renderer/components';
|
||||||
|
import { useCreateServerUrl } from '@/renderer/features/servers/mutations/use-create-server-url';
|
||||||
|
|
||||||
|
interface AddServerUrlFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
url: z.string().url({ message: 'Invalid URL' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddServerUrlForm = ({
|
||||||
|
serverId,
|
||||||
|
onCancel,
|
||||||
|
}: AddServerUrlFormProps) => {
|
||||||
|
const focusTrapRef = useFocusTrap(true);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
url: 'http://',
|
||||||
|
},
|
||||||
|
validate: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useCreateServerUrl();
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit((values) => {
|
||||||
|
mutation.mutate(
|
||||||
|
{ body: values, query: { serverId } },
|
||||||
|
{ onSuccess: () => onCancel() }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack ref={focusTrapRef}>
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
required
|
||||||
|
label="URL"
|
||||||
|
{...form.getInputProps('url')}
|
||||||
|
/>
|
||||||
|
<Group position="right">
|
||||||
|
<Button variant="subtle" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="filled">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Checkbox, Stack, Group } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RiInformationLine } from 'react-icons/ri';
|
||||||
|
import { Server, ServerType } from '@/renderer/api/types';
|
||||||
|
import { Button, PasswordInput, TextInput } from '@/renderer/components';
|
||||||
|
import { useUpdateServer } from '@/renderer/features/servers/mutations/use-update-server';
|
||||||
|
|
||||||
|
interface EditServerFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
server: Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditServerForm = ({ server, onCancel }: EditServerFormProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const updateServer = useUpdateServer();
|
||||||
|
|
||||||
|
const serverDetailsForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
legacyAuth: false,
|
||||||
|
name: server?.name,
|
||||||
|
password: '',
|
||||||
|
type: server?.type,
|
||||||
|
url: server?.url,
|
||||||
|
username: server?.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubsonic = serverDetailsForm.values.type === ServerType.SUBSONIC;
|
||||||
|
|
||||||
|
const handleSubmit = serverDetailsForm.onSubmit(async (values) => {
|
||||||
|
updateServer.mutate(
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
name: values.name,
|
||||||
|
password: values.password,
|
||||||
|
type: values.type,
|
||||||
|
url: values.url,
|
||||||
|
username: values.username,
|
||||||
|
},
|
||||||
|
query: { serverId: server.id },
|
||||||
|
},
|
||||||
|
{ onSuccess: onCancel }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label={t('generic.name')}
|
||||||
|
rightSection={
|
||||||
|
serverDetailsForm.isDirty('name') && (
|
||||||
|
<RiInformationLine color="red" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{...serverDetailsForm.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label={t('generic.url')}
|
||||||
|
rightSection={
|
||||||
|
serverDetailsForm.isDirty('url') && (
|
||||||
|
<RiInformationLine color="red" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{...serverDetailsForm.getInputProps('url')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t('generic.username')}
|
||||||
|
rightSection={
|
||||||
|
serverDetailsForm.isDirty('username') && (
|
||||||
|
<RiInformationLine color="red" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{...serverDetailsForm.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t('generic.password')}
|
||||||
|
{...serverDetailsForm.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
{isSubsonic && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('generic.legacyauth')}
|
||||||
|
{...serverDetailsForm.getInputProps('legacyAuth', {
|
||||||
|
type: 'checkbox',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Group position="right">
|
||||||
|
<Button variant="subtle" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="filled">
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { Stack, Group } from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { RiDeleteBin2Fill, RiEdit2Fill } from 'react-icons/ri';
|
||||||
|
import { Server } from '@/renderer/api/types';
|
||||||
|
import { Button, Text } from '@/renderer/components';
|
||||||
|
import { AddServerCredentialForm } from '@/renderer/features/servers/components/add-server-credential-form';
|
||||||
|
import { AddServerUrlForm } from '@/renderer/features/servers/components/add-server-url-form';
|
||||||
|
import { EditServerForm } from '@/renderer/features/servers/components/edit-server-form';
|
||||||
|
import { ServerSection } from '@/renderer/features/servers/components/server-section';
|
||||||
|
import { useDeleteServerUrl } from '@/renderer/features/servers/mutations/use-delete-server-url';
|
||||||
|
import { useDisableServerUrl } from '@/renderer/features/servers/mutations/use-disable-server-url';
|
||||||
|
import { useEnableServerUrl } from '@/renderer/features/servers/mutations/use-enable-server-url';
|
||||||
|
import { usePermissions } from '@/renderer/features/shared';
|
||||||
|
import { useAuthStore } from '@/renderer/store';
|
||||||
|
|
||||||
|
interface ServerListItemProps {
|
||||||
|
server: Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerListItem = ({ server }: ServerListItemProps) => {
|
||||||
|
const [edit, editHandlers] = useDisclosure(false);
|
||||||
|
const [addUrl, addUrlHandlers] = useDisclosure(false);
|
||||||
|
const [addCredential, addCredentialHandlers] = useDisclosure(false);
|
||||||
|
|
||||||
|
const permissions = usePermissions();
|
||||||
|
const enableServerUrl = useEnableServerUrl();
|
||||||
|
const disableServerUrl = useDisableServerUrl();
|
||||||
|
const deleteServerUrl = useDeleteServerUrl();
|
||||||
|
const serverCredentials = useAuthStore((state) => state.serverCredentials);
|
||||||
|
|
||||||
|
const enableServerCredential = useAuthStore(
|
||||||
|
(state) => state.enableServerCredential
|
||||||
|
);
|
||||||
|
|
||||||
|
const disableServerCredential = useAuthStore(
|
||||||
|
(state) => state.disableServerCredential
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteServerCredential = useAuthStore(
|
||||||
|
(state) => state.deleteServerCredential
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleCredential = (credentialId: string, enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
return disableServerCredential({ id: credentialId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return enableServerCredential({ id: credentialId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCredential = (credentialId: string) => {
|
||||||
|
deleteServerCredential({ id: credentialId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleUrl = (urlId: string, enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
return disableServerUrl.mutate({ query: { serverId: server.id, urlId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return enableServerUrl.mutate({ query: { serverId: server.id, urlId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUrl = (urlId: string) => {
|
||||||
|
deleteServerUrl.mutate({
|
||||||
|
query: {
|
||||||
|
serverId: server.id,
|
||||||
|
urlId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack spacing="xl">
|
||||||
|
<ServerSection title="Server details">
|
||||||
|
{edit ? (
|
||||||
|
<EditServerForm
|
||||||
|
server={server}
|
||||||
|
onCancel={() => editHandlers.toggle()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<Stack>
|
||||||
|
<Text>URL</Text>
|
||||||
|
<Text>Username</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Text>{server.url}</Text>
|
||||||
|
<Text>{server.username}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
{permissions.editServer && (
|
||||||
|
<Button
|
||||||
|
tooltip={{ label: 'Edit server details' }}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => editHandlers.toggle()}
|
||||||
|
>
|
||||||
|
<RiEdit2Fill color="white" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</ServerSection>
|
||||||
|
<ServerSection title="Server URLs">
|
||||||
|
{addUrl ? (
|
||||||
|
<AddServerUrlForm
|
||||||
|
serverId={server.id}
|
||||||
|
onCancel={() => addUrlHandlers.close()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack>
|
||||||
|
{server.serverUrls?.map((serverUrl) => (
|
||||||
|
<Group key={serverUrl.id} position="apart">
|
||||||
|
<Text>{serverUrl.url}</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
px={10}
|
||||||
|
radius="lg"
|
||||||
|
variant={serverUrl.enabled ? 'filled' : 'subtle'}
|
||||||
|
onClick={() =>
|
||||||
|
handleToggleUrl(serverUrl.id, serverUrl.enabled)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{serverUrl.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
{permissions.deleteServerUrl && (
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
radius="xl"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => handleDeleteUrl(serverUrl.id)}
|
||||||
|
>
|
||||||
|
<RiDeleteBin2Fill />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{permissions.createServerUrl && (
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
mt={10}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => addUrlHandlers.open()}
|
||||||
|
>
|
||||||
|
Add URL
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ServerSection>
|
||||||
|
<ServerSection title="Server Credentials">
|
||||||
|
{addCredential ? (
|
||||||
|
<AddServerCredentialForm
|
||||||
|
server={server}
|
||||||
|
onCancel={() => addCredentialHandlers.close()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack>
|
||||||
|
{serverCredentials?.map((credential) => (
|
||||||
|
<Group key={credential.id} position="apart">
|
||||||
|
<Text>{credential.username}</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
px={10}
|
||||||
|
radius="lg"
|
||||||
|
variant={credential.enabled ? 'filled' : 'subtle'}
|
||||||
|
onClick={() =>
|
||||||
|
handleToggleCredential(
|
||||||
|
credential.id,
|
||||||
|
credential.enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{credential.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
{permissions.deleteServerCredential && (
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
radius="xl"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => handleDeleteCredential(credential.id)}
|
||||||
|
>
|
||||||
|
<RiDeleteBin2Fill />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{permissions.createServerCredential && (
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
mt={10}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => addCredentialHandlers.open()}
|
||||||
|
>
|
||||||
|
Add credential
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ServerSection>
|
||||||
|
<ServerSection title="Danger zone">
|
||||||
|
{permissions.deleteServer && (
|
||||||
|
<Button compact color="red" leftIcon={<RiDeleteBin2Fill />}>
|
||||||
|
Delete server
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ServerSection>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Accordion, Group } from '@mantine/core';
|
||||||
|
import { RiRefreshLine, RiRestartLine, RiServerFill } from 'react-icons/ri';
|
||||||
|
import { Button, Text } from '@/renderer/components';
|
||||||
|
import { ServerListItem } from '@/renderer/features/servers/components/server-list-item';
|
||||||
|
import { useServerList } from '@/renderer/features/servers/queries/use-server-list';
|
||||||
|
import { Font } from '@/renderer/styles';
|
||||||
|
import { titleCase } from '@/renderer/utils';
|
||||||
|
|
||||||
|
export const ServerList = () => {
|
||||||
|
const { data: servers } = useServerList();
|
||||||
|
|
||||||
|
const handleQuickScan = (
|
||||||
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFullScan = (
|
||||||
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Accordion variant="separated">
|
||||||
|
{servers?.data?.map((s) => (
|
||||||
|
<Accordion.Item key={s.id} value={s.name}>
|
||||||
|
<Accordion.Control icon={<RiServerFill size={15} />}>
|
||||||
|
<Group position="apart">
|
||||||
|
<Text font={Font.GOTHAM}>
|
||||||
|
{titleCase(s.type)} - {s.name}
|
||||||
|
</Text>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Button
|
||||||
|
component="div"
|
||||||
|
px={10}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
tooltip={{ label: 'Full scan' }}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleFullScan}
|
||||||
|
>
|
||||||
|
<RiRefreshLine color="white" size={20} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
px={10}
|
||||||
|
tooltip={{ label: 'Quick scan' }}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleQuickScan}
|
||||||
|
>
|
||||||
|
<RiRestartLine color="white" size={20} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<ServerListItem server={s} />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Text } from '@/renderer/components';
|
||||||
|
import { Font } from '@/renderer/styles';
|
||||||
|
|
||||||
|
interface ServerSectionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string | React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div``;
|
||||||
|
|
||||||
|
const Section = styled.div`
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--generic-border-color);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ServerSection = ({ title, children }: ServerSectionProps) => {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Text font={Font.EPILOGUE} size="sm">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Section>{children}</Section>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export * from './routes/ServersRoute';
|
export * from './mutations/use-create-server';
|
||||||
export * from './queries/useCreateServer';
|
export * from './components/add-server-form';
|
||||||
export * from './queries/useServers';
|
export * from './components/server-list';
|
||||||
export * from './components/AddServerModal';
|
export * from './queries/use-server-list';
|
||||||
export * from './components/ServerList';
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { api } from '@/renderer/api';
|
||||||
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
|
import { ServerListResponse, UrlResponse } from '@/renderer/api/servers.api';
|
||||||
|
import { ApiError } from '@/renderer/api/types';
|
||||||
|
import { toast } from '@/renderer/components';
|
||||||
|
|
||||||
|
export const useCreateServerUrl = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation<
|
||||||
|
UrlResponse,
|
||||||
|
AxiosError<ApiError>,
|
||||||
|
{
|
||||||
|
body: { url: string };
|
||||||
|
query: { serverId: string };
|
||||||
|
},
|
||||||
|
{ previous: ServerListResponse | undefined }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ query, body }) => api.servers.createUrl(query, body),
|
||||||
|
onError: (err) => {
|
||||||
|
toast.show({
|
||||||
|
message: `${err.response?.data?.error?.message || err.message}`,
|
||||||
|
title: 'Failed to add server URL',
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.servers.list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { api } from '@/renderer/api';
|
||||||
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
|
import { CreateServerBody, ServerResponse } from '@/renderer/api/servers.api';
|
||||||
|
import { ApiError } from '@/renderer/api/types';
|
||||||
|
import { toast } from '@/renderer/components';
|
||||||
|
|
||||||
|
export const useCreateServer = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
|
ServerResponse,
|
||||||
|
AxiosError<ApiError>,
|
||||||
|
{ body: CreateServerBody },
|
||||||
|
null
|
||||||
|
>({
|
||||||
|
mutationFn: ({ body }) => api.servers.createServer(body),
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.show({
|
||||||
|
message: `${err.response?.data?.error?.message || err.message}`,
|
||||||
|
title: 'Failed to add server',
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.servers.list());
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.show({
|
||||||
|
message: `${data.data.name} was added successfully`,
|
||||||
|
title: 'Server added',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { api } from '@/renderer/api';
|
||||||
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
|
import { ServerListResponse } from '@/renderer/api/servers.api';
|
||||||
|
import { ApiError, NullResponse } from '@/renderer/api/types';
|
||||||
|
import { toast } from '@/renderer/components';
|
||||||
|
|
||||||
|
export const useDeleteServerUrl = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation<
|
||||||
|
NullResponse,
|
||||||
|
AxiosError<ApiError>,
|
||||||
|
{ query: { serverId: string; urlId: string } },
|
||||||
|
{ previous: ServerListResponse | undefined }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ query }) => api.servers.deleteUrl(query),
|
||||||
|
onError: (err, _variables, context) => {
|
||||||
|
toast.show({
|
||||||
|
message: `${err.response?.data?.error?.message || err.message}`,
|
||||||
|
title: 'Failed to delete server URL',
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!context?.previous) return;
|
||||||
|
queryClient.setQueryData(queryKeys.servers.list(), context.previous);
|
||||||
|
},
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
const queryKey = queryKeys.servers.list();
|
||||||
|
|
||||||
|
await queryClient.cancelQueries(queryKey);
|
||||||
|
const previous = queryClient.getQueryData<ServerListResponse>(queryKey);
|
||||||
|
|
||||||
|
if (!previous) return undefined;
|
||||||
|
|
||||||
|
const data = previous.data.map((server) => {
|
||||||
|
if (server.id === variables.query.serverId) {
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
serverUrls: server.serverUrls?.filter(
|
||||||
|
(url) => url.id !== variables.query.urlId
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setQueryData(queryKey, { ...previous, data });
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.servers.list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { api } from '@/renderer/api';
|
||||||
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
|
import { ServerListResponse } from '@/renderer/api/servers.api';
|
||||||
|
import { ApiError, NullResponse } from '@/renderer/api/types';
|
||||||
|
|
||||||
|
export const useDisableServerUrl = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation<
|
||||||
|
NullResponse,
|
||||||
|
AxiosError<ApiError>,
|
||||||
|
{ query: { serverId: string; urlId: string } },
|
||||||
|
{ previous: ServerListResponse | undefined }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ query }) => api.servers.disableUrl(query),
|
||||||
|
onError: (_err, _variables, context) => {
|
||||||
|
if (!context?.previous) return;
|
||||||
|
queryClient.setQueryData(queryKeys.servers.list(), context.previous);
|
||||||
|
},
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
const queryKey = queryKeys.servers.list();
|
||||||
|
|
||||||
|
await queryClient.cancelQueries(queryKey);
|
||||||
|
const previous = queryClient.getQueryData<ServerListResponse>(queryKey);
|
||||||
|
|
||||||
|
if (!previous) return undefined;
|
||||||
|
|
||||||
|
const data = previous.data.map((server) => {
|
||||||
|
if (server.id === variables.query.serverId) {
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
serverUrls: server.serverUrls?.map((url) =>
|
||||||
|
url.id === variables.query.urlId
|
||||||
|
? { ...url, enabled: false }
|
||||||
|
: url
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setQueryData(queryKey, { ...previous, data });
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.servers.list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { api } from '@/renderer/api';
|
||||||
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
|
import { ServerListResponse } from '@/renderer/api/servers.api';
|
||||||
|
import { ApiError, NullResponse } from '@/renderer/api/types';
|
||||||
|
|
||||||
|
export const useEnableServerUrl = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation<
|
||||||
|
NullResponse,
|
||||||
|
AxiosError<ApiError>,
|
||||||
|
{ query: { serverId: string; urlId: string } },
|
||||||
|
{ previous: ServerListResponse | undefined }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ query }) => api.servers.enableUrl(query),
|
||||||
|
onError: (_err, _variables, context) => {
|
||||||
|
if (!context?.previous) return;
|
||||||
|
queryClient.setQueryData(queryKeys.servers.list(), context.previous);
|
||||||
|
},
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
const queryKey = queryKeys.servers.list();
|
||||||
|
|
||||||
|
await queryClient.cancelQueries(queryKey);
|
||||||
|
const previous = queryClient.getQueryData<ServerListResponse>(queryKey);
|
||||||
|
|
||||||
|
if (!previous) return undefined;
|
||||||
|
|
||||||
|
const data = previous.data.map((server) => {
|
||||||
|
if (server.id === variables.query.serverId) {
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
serverUrls: server.serverUrls?.map((url) =>
|
||||||
|
url.id === variables.query.urlId
|
||||||
|
? { ...url, enabled: true }
|
||||||
|
: { ...url, enabled: false }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setQueryData(queryKey, { ...previous, data });
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.servers.list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { api } from '@/renderer/api';
|
||||||
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
|
import {
|
||||||
|
ServerResponse,
|
||||||
|
ServerListResponse,
|
||||||
|
CreateServerBody,
|
||||||
|
} from '@/renderer/api/servers.api';
|
||||||
|
import { ApiError } from '@/renderer/api/types';
|
||||||
|
import { toast } from '@/renderer/components';
|
||||||
|
|
||||||
|
export const useUpdateServer = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation<
|
||||||
|
ServerResponse,
|
||||||
|
AxiosError<ApiError>,
|
||||||
|
{ body: Partial<CreateServerBody>; query: { serverId: string } },
|
||||||
|
{ previous: ServerListResponse | undefined }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ query, body }) => api.servers.updateServer(query, body),
|
||||||
|
onError: (err, _variables, context) => {
|
||||||
|
toast.show({
|
||||||
|
message: `${err.response?.data.error.message}`,
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(queryKeys.servers.list(), context.previous);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
const queryKey = queryKeys.servers.list();
|
||||||
|
|
||||||
|
await queryClient.cancelQueries(queryKey);
|
||||||
|
const previous = queryClient.getQueryData<ServerListResponse>(queryKey);
|
||||||
|
|
||||||
|
if (!previous) return undefined;
|
||||||
|
const data = previous.data.map((server) => {
|
||||||
|
if (server.id === variables.query.serverId) {
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
name: variables.body.name,
|
||||||
|
username: variables.body.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setQueryData(queryKey, { ...previous, data });
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.servers.list());
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.show({
|
||||||
|
message: `Server "${data.data.name}" updated`,
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries(queryKeys.servers.list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/renderer/api';
|
||||||
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
|
import { ServerListResponse } from '@/renderer/api/servers.api';
|
||||||
|
import { QueryOptions } from '@/renderer/lib/react-query';
|
||||||
|
|
||||||
|
export const useServerList = (options?: QueryOptions<ServerListResponse>) => {
|
||||||
|
// return useQuery({
|
||||||
|
// // onSuccess: (servers) => {
|
||||||
|
// // const { serverUrl } = JSON.parse(
|
||||||
|
// // localStorage.getItem('authentication') || '{}'
|
||||||
|
// // );
|
||||||
|
// // const storedServersKey = `servers_${md5(serverUrl)}`;
|
||||||
|
// // const serversFromLocalStorage = localStorage.getItem(storedServersKey);
|
||||||
|
// // // If a custom account/token is set for a server, use that instead of the default one
|
||||||
|
// // if (serversFromLocalStorage) {
|
||||||
|
// // const existingServers = JSON.parse(serversFromLocalStorage);
|
||||||
|
// // // The 'locked' property determines whether or not to skip updating the server auth
|
||||||
|
// // const skipped = existingServers.filter(
|
||||||
|
// // (server: ServerFolderAuth) => server.locked
|
||||||
|
// // );
|
||||||
|
// // const store = servers?.data?.flatMap((server) =>
|
||||||
|
// // server.serverFolders?.map((serverFolder: ServerFolder) => {
|
||||||
|
// // if (skipped.includes(serverFolder.id)) {
|
||||||
|
// // return existingServers.find(
|
||||||
|
// // (s: ServerFolderAuth) => s.id === serverFolder.id
|
||||||
|
// // );
|
||||||
|
// // }
|
||||||
|
// // return {
|
||||||
|
// // id: serverFolder.id,
|
||||||
|
// // locked: false,
|
||||||
|
// // serverId: server.id,
|
||||||
|
// // token: server.token,
|
||||||
|
// // type: server.type,
|
||||||
|
// // url: server.url,
|
||||||
|
// // userId: server.remoteUserId,
|
||||||
|
// // username: server.username,
|
||||||
|
// // };
|
||||||
|
// // })
|
||||||
|
// // );
|
||||||
|
// // return localStorage.setItem(storedServersKey, JSON.stringify(store));
|
||||||
|
// // }
|
||||||
|
// // const store = servers?.data?.flatMap((server) =>
|
||||||
|
// // server.serverFolders?.map((serverFolder: ServerFolder) => ({
|
||||||
|
// // id: serverFolder.id,
|
||||||
|
// // locked: false,
|
||||||
|
// // serverId: server.id,
|
||||||
|
// // token: server.token,
|
||||||
|
// // type: server.type,
|
||||||
|
// // url: server.url,
|
||||||
|
// // userId: server.remoteUserId,
|
||||||
|
// // username: server.username,
|
||||||
|
// // }))
|
||||||
|
// // );
|
||||||
|
// // return localStorage.setItem(storedServersKey, JSON.stringify(store));
|
||||||
|
// // },
|
||||||
|
// queryFn: () => api.servers.getServerList(),
|
||||||
|
// queryKey: queryKeys.server.list,
|
||||||
|
// ...options,
|
||||||
|
// });
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryFn: () => api.servers.getServerList(),
|
||||||
|
queryKey: queryKeys.servers.list(),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import md5 from 'md5';
|
|
||||||
import { useQuery } from 'react-query';
|
|
||||||
import { ServerFolderAuth } from '../../../../types';
|
|
||||||
import { queryKeys } from '../../../api/queryKeys';
|
|
||||||
import { serversApi } from '../../../api/serversApi';
|
|
||||||
import { ServerFolderResponse } from '../../../api/types';
|
|
||||||
|
|
||||||
export const useServers = () => {
|
|
||||||
return useQuery({
|
|
||||||
onSuccess: (servers) => {
|
|
||||||
const { serverUrl } = JSON.parse(
|
|
||||||
localStorage.getItem('authentication') || '{}'
|
|
||||||
);
|
|
||||||
const storedServersKey = `servers_${md5(serverUrl)}`;
|
|
||||||
const serversFromLocalStorage = localStorage.getItem(storedServersKey);
|
|
||||||
|
|
||||||
// If a custom account/token is set for a server, use that instead of the default one
|
|
||||||
if (serversFromLocalStorage) {
|
|
||||||
const existingServers = JSON.parse(serversFromLocalStorage);
|
|
||||||
|
|
||||||
// The 'locked' property determines whether or not to skip updating the server auth
|
|
||||||
const skipped = existingServers.filter(
|
|
||||||
(server: ServerFolderAuth) => server.locked
|
|
||||||
);
|
|
||||||
|
|
||||||
const store = servers?.data?.flatMap((server) =>
|
|
||||||
server.serverFolder?.map((serverFolder: ServerFolderResponse) => {
|
|
||||||
if (skipped.includes(serverFolder.id)) {
|
|
||||||
return existingServers.find(
|
|
||||||
(s: ServerFolderAuth) => s.id === serverFolder.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: serverFolder.id,
|
|
||||||
locked: false,
|
|
||||||
serverId: server.id,
|
|
||||||
token: server.token,
|
|
||||||
type: server.serverType,
|
|
||||||
url: server.url,
|
|
||||||
userId: server.remoteUserId,
|
|
||||||
username: server.username,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return localStorage.setItem(storedServersKey, JSON.stringify(store));
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = servers?.data?.flatMap((server) =>
|
|
||||||
server.serverFolder?.map((serverFolder: ServerFolderResponse) => ({
|
|
||||||
id: serverFolder.id,
|
|
||||||
locked: false,
|
|
||||||
serverId: server.id,
|
|
||||||
token: server.token,
|
|
||||||
type: server.serverType,
|
|
||||||
url: server.url,
|
|
||||||
userId: server.remoteUserId,
|
|
||||||
username: server.username,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
return localStorage.setItem(storedServersKey, JSON.stringify(store));
|
|
||||||
},
|
|
||||||
queryFn: () => serversApi.getServers(),
|
|
||||||
queryKey: queryKeys.servers,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Title } from '@mantine/core';
|
|
||||||
import { ServerList } from '../components/ServerList';
|
|
||||||
|
|
||||||
export const ServersRoute = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Title>Servers</Title>
|
|
||||||
<ServerList />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+6
-15
@@ -1,21 +1,20 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import md5 from 'md5';
|
import md5 from 'md5';
|
||||||
import { useMutation } from 'react-query';
|
import { ServerType } from '@/renderer/api/types';
|
||||||
import { serversApi } from '../../../api/serversApi';
|
import { randomString } from '@/renderer/utils';
|
||||||
import { randomString } from '../../../utils';
|
|
||||||
|
|
||||||
export const validateServer = async (options: {
|
export const validateServerCredential = async (options: {
|
||||||
legacyAuth: boolean;
|
legacyAuth: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
serverType: string;
|
type: ServerType;
|
||||||
url: string;
|
url: string;
|
||||||
username: string;
|
username: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { serverType, url, username, password, legacyAuth } = options;
|
const { type, url, username, password, legacyAuth } = options;
|
||||||
const cleanServerUrl = url.replace(/\/$/, '');
|
const cleanServerUrl = url.replace(/\/$/, '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (serverType === 'subsonic') {
|
if (type === ServerType.SUBSONIC) {
|
||||||
let testConnection;
|
let testConnection;
|
||||||
let token;
|
let token;
|
||||||
if (legacyAuth) {
|
if (legacyAuth) {
|
||||||
@@ -60,11 +59,3 @@ export const validateServer = async (options: {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateServer = () => {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: serversApi.createServer,
|
|
||||||
onError: (e) => console.log(e),
|
|
||||||
onSuccess: (e) => console.log(e),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
import { Group } from '@mantine/core';
|
import { Group } from '@mantine/core';
|
||||||
|
import { FiActivity } from 'react-icons/fi';
|
||||||
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import { AppMenu } from '@/renderer/features/titlebar/components/app-menu';
|
||||||
import { IconButton } from '../../../components';
|
import { useAuthStore } from '@/renderer/store';
|
||||||
|
import { Button } from '../../../components';
|
||||||
import { WindowControls } from '../../window-controls';
|
import { WindowControls } from '../../window-controls';
|
||||||
|
|
||||||
interface TitlebarProps {
|
interface TitlebarProps {
|
||||||
@@ -18,11 +21,6 @@ const TitlebarContainer = styled.div`
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
-webkit-app-region: drag;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Left = styled.div`
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
@@ -33,35 +31,74 @@ const Left = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Right = styled.div`
|
const Left = styled.div`
|
||||||
|
flex: 1/3;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Center = styled.div`
|
||||||
|
flex: 1/3;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Right = styled.div`
|
||||||
|
flex: 1/3;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Titlebar = ({ children }: TitlebarProps) => {
|
export const Titlebar = ({ children }: TitlebarProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isAuthenticated = useAuthStore((state) => !!state.accessToken);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TitlebarContainer>
|
<TitlebarContainer>
|
||||||
<Left>
|
<Left>
|
||||||
<Group spacing="xs">
|
<Group spacing="xs">
|
||||||
<IconButton
|
{isAuthenticated && (
|
||||||
icon={<RiArrowLeftSLine size={25} />}
|
<>
|
||||||
size={25}
|
<Button
|
||||||
onClick={() => navigate(-1)}
|
px={5}
|
||||||
/>
|
size="xs"
|
||||||
<IconButton
|
sx={{ color: 'var(--titlebar-fg)' }}
|
||||||
icon={<RiArrowRightSLine size={25} />}
|
variant="subtle"
|
||||||
size={25}
|
onClick={() => navigate(-1)}
|
||||||
onClick={() => navigate(1)}
|
>
|
||||||
/>
|
<RiArrowLeftSLine size={20} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
px={5}
|
||||||
|
size="xs"
|
||||||
|
sx={{ color: 'var(--titlebar-fg)' }}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => navigate(1)}
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine size={20} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Left>
|
</Left>
|
||||||
|
<Center />
|
||||||
<Right>
|
<Right>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<WindowControls />
|
<Group spacing="xs">
|
||||||
|
{isAuthenticated && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
px={5}
|
||||||
|
size="xs"
|
||||||
|
sx={{ color: 'var(--titlebar-fg)' }}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<FiActivity size={15} />
|
||||||
|
</Button>
|
||||||
|
<AppMenu />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<WindowControls />
|
||||||
|
</Group>
|
||||||
</Right>
|
</Right>
|
||||||
</TitlebarContainer>
|
</TitlebarContainer>
|
||||||
</>
|
</>
|
||||||
@@ -69,5 +106,5 @@ export const Titlebar = ({ children }: TitlebarProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Titlebar.defaultProps = {
|
Titlebar.defaultProps = {
|
||||||
children: <></>,
|
children: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { openModal, closeAllModals } from '@mantine/modals';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RiArrowLeftLine, RiLogoutBoxLine, RiMenu3Fill } from 'react-icons/ri';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { Button, DropdownMenu } from '@/renderer/components';
|
||||||
|
import {
|
||||||
|
AddServerForm,
|
||||||
|
ServerList,
|
||||||
|
useServerList,
|
||||||
|
} from '@/renderer/features/servers';
|
||||||
|
import { useAuthStore } from '@/renderer/store';
|
||||||
|
|
||||||
|
export const AppMenu = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
const currentServer = useAuthStore((state) => state.currentServer);
|
||||||
|
const setCurrentServer = useAuthStore((state) => state.setCurrentServer);
|
||||||
|
const { data: servers } = useServerList();
|
||||||
|
|
||||||
|
const serverList =
|
||||||
|
servers?.data?.map((s) => ({ id: s.id, label: `${s.name} - ${s.url}` })) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
localStorage.removeItem('authentication');
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddServerModal = () => {
|
||||||
|
openModal({
|
||||||
|
centered: true,
|
||||||
|
children: <AddServerForm onCancel={closeAllModals} />,
|
||||||
|
title: t('modal.add_server.title'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageServersModal = () => {
|
||||||
|
openModal({
|
||||||
|
centered: true,
|
||||||
|
children: <ServerList />,
|
||||||
|
title: t('modal.manage_servers.title'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetCurrentServer = (serverId: string) => {
|
||||||
|
const server = servers?.data.find((s) => s.id === serverId);
|
||||||
|
if (!server) return;
|
||||||
|
setCurrentServer(server);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu withinPortal position="bottom-start">
|
||||||
|
<DropdownMenu.Target>
|
||||||
|
<Button
|
||||||
|
px={5}
|
||||||
|
size="xs"
|
||||||
|
sx={{ color: 'var(--titlebar-fg)' }}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<RiMenu3Fill size={15} />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Target>
|
||||||
|
<DropdownMenu.Dropdown>
|
||||||
|
<DropdownMenu.Label>Server switcher</DropdownMenu.Label>
|
||||||
|
{serverList.map((s) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={`server-${s.id}`}
|
||||||
|
rightSection={
|
||||||
|
s.id === currentServer?.id ? <RiArrowLeftLine /> : undefined
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
color:
|
||||||
|
s.id === currentServer?.id ? 'var(--primary-color)' : undefined,
|
||||||
|
}}
|
||||||
|
onClick={() => handleSetCurrentServer(s.id)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
<DropdownMenu.Divider />
|
||||||
|
<DropdownMenu.Item>{t('global.menu.search_label')}</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
{t('global.menu.configure_label')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Divider />
|
||||||
|
<DropdownMenu.Item onClick={handleAddServerModal}>
|
||||||
|
{t('global.menu.label_add_server_label')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onClick={handleManageServersModal}>
|
||||||
|
{t('global.menu.label_manage_servers_label')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item disabled>
|
||||||
|
{t('global.menu.label_manage_users_label')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Divider />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
rightSection={<RiLogoutBoxLine />}
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
{t('global.menu.log_out_label')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Dropdown>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
-5
@@ -3,7 +3,6 @@ import { useDisclosure } from '@mantine/hooks';
|
|||||||
import { RiLogoutBoxLine, RiServerFill, RiSettings3Fill } from 'react-icons/ri';
|
import { RiLogoutBoxLine, RiServerFill, RiSettings3Fill } from 'react-icons/ri';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useAuthStore } from '../../../store';
|
import { useAuthStore } from '../../../store';
|
||||||
import { AddServerModal } from '../../servers';
|
|
||||||
|
|
||||||
export const UserMenu = () => {
|
export const UserMenu = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -37,10 +36,6 @@ export const UserMenu = () => {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
<AddServerModal
|
|
||||||
opened={addServerModal}
|
|
||||||
onClose={() => addServerHandlers.close()}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './components/Titlebar';
|
export * from './components/titlebar';
|
||||||
|
export * from './components/user-menu';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/renderer/api';
|
||||||
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
|
|
||||||
|
export const useUserDetail = (options: { userId: string }) => {
|
||||||
|
const { data, error, isLoading } = useQuery({
|
||||||
|
queryFn: () => api.users.getUserDetail({ userId: options.userId }),
|
||||||
|
queryKey: queryKeys.users.detail(options.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
};
|
||||||
+33
-28
@@ -1,18 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import styled from 'styled-components';
|
import {
|
||||||
import windowsClose from '../assets/close-w-10.png';
|
RiCheckboxBlankLine,
|
||||||
import windowsMax from '../assets/max-w-10.png';
|
RiCloseLine,
|
||||||
import windowsMin from '../assets/min-w-10.png';
|
RiSubtractLine,
|
||||||
|
} from 'react-icons/ri';
|
||||||
|
|
||||||
interface WindowControlsProps {
|
interface WindowControlsProps {
|
||||||
style?: 'macos' | 'windows' | 'linux';
|
style?: 'macos' | 'windows' | 'linux';
|
||||||
}
|
}
|
||||||
|
|
||||||
const WindowControlsContainer = styled.div`
|
|
||||||
height: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const WindowsButtonGroup = styled.div`
|
const WindowsButtonGroup = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 130px;
|
width: 130px;
|
||||||
@@ -20,14 +18,23 @@ const WindowsButtonGroup = styled.div`
|
|||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const WindowsButton = styled.div`
|
export const WindowsButton = styled.div<{ $exit?: boolean }>`
|
||||||
flex-grow: 1;
|
display: flex;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
text-align: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 35%;
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(125, 125, 125, 30%);
|
background: ${({ $exit }) =>
|
||||||
|
$exit ? 'rgba(200, 50, 0, 30%)' : `rgba(125, 125, 125, 30%)`};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -56,27 +63,25 @@ export const WindowControls = ({ style }: WindowControlsProps) => {
|
|||||||
const handleClose = () => close();
|
const handleClose = () => close();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WindowControlsContainer>
|
<>
|
||||||
{isElectron() && (
|
{isElectron() && (
|
||||||
<>
|
<>
|
||||||
{style === 'windows' && (
|
{style === 'windows' && (
|
||||||
<>
|
<WindowsButtonGroup>
|
||||||
<WindowsButtonGroup>
|
<WindowsButton role="button" onClick={handleMinimize}>
|
||||||
<WindowsButton role="button" onClick={handleMinimize}>
|
<RiSubtractLine size={20} />
|
||||||
<img alt="minimize" src={windowsMin} />
|
</WindowsButton>
|
||||||
</WindowsButton>
|
<WindowsButton role="button" onClick={handleMaximize}>
|
||||||
<WindowsButton role="button" onClick={handleMaximize}>
|
<RiCheckboxBlankLine size={15} />
|
||||||
<img alt="maximize" src={windowsMax} />
|
</WindowsButton>
|
||||||
</WindowsButton>
|
<WindowsButton $exit role="button" onClick={handleClose}>
|
||||||
<WindowsButton role="button" onClick={handleClose}>
|
<RiCloseLine size={20} />
|
||||||
<img alt="exit" src={windowsClose} />
|
</WindowsButton>
|
||||||
</WindowsButton>
|
</WindowsButtonGroup>
|
||||||
</WindowsButtonGroup>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</WindowControlsContainer>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1 +1 @@
|
|||||||
export * from './components/WindowControls';
|
export * from './components/window-controls';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import { Titlebar } from '../features/titlebar';
|
||||||
import { Titlebar } from '../../features/titlebar';
|
|
||||||
|
|
||||||
const WindowsTitlebarContainer = styled.div`
|
const WindowsTitlebarContainer = styled.div`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Menu, Button } from '@mantine/core';
|
||||||
|
import { Outlet } from 'react-router';
|
||||||
|
import { Titlebar } from '@/renderer/features/titlebar';
|
||||||
|
import { constrainSidebarWidth } from '@/renderer/utils';
|
||||||
|
import { Playerbar } from '../features/player';
|
||||||
|
import { Sidebar } from '../features/sidebar';
|
||||||
|
|
||||||
|
const Layout = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
'header'
|
||||||
|
'main'
|
||||||
|
'player';
|
||||||
|
grid-template-rows: 30px 1fr 90px;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TitlebarContainer = styled.header`
|
||||||
|
grid-area: header;
|
||||||
|
background: var(--titlebar-bg);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MainContainer = styled.main<{ leftSidebarWidth: string }>`
|
||||||
|
display: grid;
|
||||||
|
grid-area: main;
|
||||||
|
grid-template-areas: 'sidebar .';
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
grid-template-columns: ${(props) => props.leftSidebarWidth} 1fr;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--main-bg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SidebarContainer = styled.div`
|
||||||
|
position: relative;
|
||||||
|
grid-area: sidebar;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlayerbarContainer = styled.footer`
|
||||||
|
grid-area: player;
|
||||||
|
background: var(--playerbar-bg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResizeHandle = styled.div<{
|
||||||
|
isResizing: boolean;
|
||||||
|
placement: 'top' | 'left' | 'bottom' | 'right';
|
||||||
|
}>`
|
||||||
|
position: absolute;
|
||||||
|
width: 3px;
|
||||||
|
height: 100%;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--sidebar-handle-bg);
|
||||||
|
/* border-top: ${({ placement }) =>
|
||||||
|
placement === 'top' && '1px var(--sidebar-handle-bg) solid'};
|
||||||
|
border-right: ${({ placement }) =>
|
||||||
|
placement === 'right' && '1px var(--sidebar-handle-bg) solid'};
|
||||||
|
border-bottom: ${({ placement }) =>
|
||||||
|
placement === 'bottom' && '1px var(--sidebar-handle-bg) solid'};
|
||||||
|
border-left: ${({ placement }) =>
|
||||||
|
placement === 'left' && '1px var(--sidebar-handle-bg) solid'}; */
|
||||||
|
opacity: ${(props) => (props.isResizing ? 1 : 0)};
|
||||||
|
cursor: ew-resize;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DefaultLayout = () => {
|
||||||
|
const [leftSidebarWidth, setLeftSidebarWidth] = useState('170px');
|
||||||
|
|
||||||
|
const sidebarRef = useRef(null);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
||||||
|
const startResizing = useCallback(() => {
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopResizing = useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resize = useCallback(
|
||||||
|
(mouseMoveEvent) => {
|
||||||
|
if (isResizing) {
|
||||||
|
setLeftSidebarWidth(
|
||||||
|
`${constrainSidebarWidth(mouseMoveEvent.clientX)}px`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isResizing]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('mousemove', resize);
|
||||||
|
window.addEventListener('mouseup', stopResizing);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', resize);
|
||||||
|
window.removeEventListener('mouseup', stopResizing);
|
||||||
|
};
|
||||||
|
}, [resize, stopResizing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* <LayoutContainer>
|
||||||
|
<TitlebarContainer size={30}>
|
||||||
|
<Titlebar />
|
||||||
|
</TitlebarContainer>
|
||||||
|
<Space.Fill>
|
||||||
|
<LeftSidebar
|
||||||
|
handleRender={(props) => (
|
||||||
|
<ResizeHandle placement="right" {...props} />
|
||||||
|
)}
|
||||||
|
maximumSize={400}
|
||||||
|
minimumSize={175}
|
||||||
|
size={175}
|
||||||
|
>
|
||||||
|
<Sidebar />
|
||||||
|
</LeftSidebar>
|
||||||
|
<RightSidebar
|
||||||
|
scrollable
|
||||||
|
handleRender={(props) => (
|
||||||
|
<ResizeHandle placement="left" {...props} />
|
||||||
|
)}
|
||||||
|
maximumSize={800}
|
||||||
|
size={300}
|
||||||
|
>
|
||||||
|
<QueueTable />
|
||||||
|
</RightSidebar>
|
||||||
|
<Space.Fill scrollable>
|
||||||
|
<AnimatePresence exitBeforeEnter>
|
||||||
|
<ContentContainer key={location.pathname}>
|
||||||
|
<Outlet />
|
||||||
|
</ContentContainer>
|
||||||
|
</AnimatePresence>
|
||||||
|
</Space.Fill>
|
||||||
|
</Space.Fill>
|
||||||
|
<PlayerbarContainer size={90}>
|
||||||
|
<Playerbar />
|
||||||
|
</PlayerbarContainer>
|
||||||
|
</LayoutContainer> */}
|
||||||
|
<Layout>
|
||||||
|
<TitlebarContainer>
|
||||||
|
<Titlebar />
|
||||||
|
<Menu withinPortal zIndex="999999">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button />
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item>Hello</Menu.Item>
|
||||||
|
<Menu.Item>Hello</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</TitlebarContainer>
|
||||||
|
<MainContainer leftSidebarWidth={leftSidebarWidth}>
|
||||||
|
<SidebarContainer>
|
||||||
|
<ResizeHandle
|
||||||
|
ref={sidebarRef}
|
||||||
|
isResizing={isResizing}
|
||||||
|
placement="left"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startResizing();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Sidebar />
|
||||||
|
</SidebarContainer>
|
||||||
|
<Outlet />
|
||||||
|
</MainContainer>
|
||||||
|
<PlayerbarContainer>
|
||||||
|
<Playerbar />
|
||||||
|
</PlayerbarContainer>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { AnimatePresence } from 'framer-motion';
|
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
|
||||||
import * as Space from 'react-spaces';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import { Playerbar } from '../../features/player';
|
|
||||||
import { Sidebar } from '../../features/sidebar';
|
|
||||||
import { Titlebar } from '../../features/titlebar';
|
|
||||||
|
|
||||||
const LayoutContainer = styled(Space.ViewPort)``;
|
|
||||||
|
|
||||||
const LeftSidebar = styled(Space.LeftResizable)`
|
|
||||||
background: var(--sidebar-bg);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const RightSidebar = styled(Space.RightResizable)`
|
|
||||||
background: var(--sidebar-bg);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TitlebarContainer = styled(Space.Top)`
|
|
||||||
position: sticky;
|
|
||||||
background: var(--titlebar-bg);
|
|
||||||
border-bottom: var(--playerbar-border-top);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ContentContainer = styled(Space.Fill)``;
|
|
||||||
|
|
||||||
const PlayerbarContainer = styled(Space.Bottom)``;
|
|
||||||
|
|
||||||
const ResizeHandle = styled.span<{
|
|
||||||
placement: 'top' | 'left' | 'bottom' | 'right';
|
|
||||||
}>`
|
|
||||||
position: absolute;
|
|
||||||
width: 3px;
|
|
||||||
height: 100%;
|
|
||||||
border-top: ${({ placement }) =>
|
|
||||||
placement === 'top' && '1px var(--sidebar-handle-bg) solid'};
|
|
||||||
border-right: ${({ placement }) =>
|
|
||||||
placement === 'right' && '1px var(--sidebar-handle-bg) solid'};
|
|
||||||
border-bottom: ${({ placement }) =>
|
|
||||||
placement === 'bottom' && '1px var(--sidebar-handle-bg) solid'};
|
|
||||||
border-left: ${({ placement }) =>
|
|
||||||
placement === 'left' && '1px var(--sidebar-handle-bg) solid'};
|
|
||||||
opacity: 0;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DefaultLayout = () => {
|
|
||||||
const location = useLocation();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<LayoutContainer>
|
|
||||||
<TitlebarContainer size={30}>
|
|
||||||
<Titlebar />
|
|
||||||
</TitlebarContainer>
|
|
||||||
<Space.Fill>
|
|
||||||
<LeftSidebar
|
|
||||||
handleRender={(props) => (
|
|
||||||
<ResizeHandle placement="right" {...props} />
|
|
||||||
)}
|
|
||||||
maximumSize={400}
|
|
||||||
minimumSize={175}
|
|
||||||
size={175}
|
|
||||||
>
|
|
||||||
<Sidebar />
|
|
||||||
</LeftSidebar>
|
|
||||||
<RightSidebar
|
|
||||||
handleRender={(props) => (
|
|
||||||
<ResizeHandle placement="left" {...props} />
|
|
||||||
)}
|
|
||||||
maximumSize={400}
|
|
||||||
size={300}
|
|
||||||
/>
|
|
||||||
<Space.Fill scrollable>
|
|
||||||
<AnimatePresence exitBeforeEnter>
|
|
||||||
<ContentContainer key={location.pathname}>
|
|
||||||
<Outlet />
|
|
||||||
</ContentContainer>
|
|
||||||
</AnimatePresence>
|
|
||||||
</Space.Fill>
|
|
||||||
</Space.Fill>
|
|
||||||
<PlayerbarContainer size={90}>
|
|
||||||
<Playerbar />
|
|
||||||
</PlayerbarContainer>
|
|
||||||
</LayoutContainer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export const constrainSidebarWidth = (num: number) => {
|
|
||||||
if (num < 165) {
|
|
||||||
return 165;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (num > 400) {
|
|
||||||
return 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
return num;
|
|
||||||
};
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './auth/AuthLayout';
|
export * from './auth-layout';
|
||||||
export * from './default/DefaultLayout';
|
export * from './default-layout';
|
||||||
|
|||||||
Reference in New Issue
Block a user