Merge branch 'gridstack' of https://github.com/manuel-rw/homarr into gridstack

This commit is contained in:
Meierschlumpf
2022-12-10 22:17:48 +01:00
59 changed files with 962 additions and 1009 deletions

View File

@@ -1,4 +1,3 @@
import Image from 'next/image';
import {
ActionIcon,
Badge,
@@ -18,7 +17,9 @@ import {
IconVocabulary,
IconWorldWww,
} from '@tabler/icons';
import { InitOptions } from 'i18next';
import { i18n } from 'next-i18next';
import Image from 'next/image';
import { ReactNode } from 'react';
import { CURRENT_VERSION } from '../../../data/constants';
import { usePrimaryGradient } from '../layout/useGradient';
@@ -118,13 +119,44 @@ interface InformationTableItem {
content: ReactNode;
}
interface ExtendedInitOptions extends InitOptions {
locales: string[];
}
const useInformationTableItems = (): InformationTableItem[] => {
const colorGradiant = usePrimaryGradient();
const usedI18nNamespaces = i18n?.reportNamespaces?.getUsedNamespaces();
const configuredi18nLocales: string[] = i18n?.options.locales;
let items: InformationTableItem[] = [];
return [
if (i18n !== null) {
const usedI18nNamespaces = i18n.reportNamespaces.getUsedNamespaces();
const initOptions = i18n.options as ExtendedInitOptions;
items = [
...items,
{
icon: <IconLanguage size={20} />,
label: 'Loaded I18n translation namespaces',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{usedI18nNamespaces.length}
</Badge>
),
},
{
icon: <IconVocabulary size={20} />,
label: 'Configured I18n locales',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{initOptions.locales.length}
</Badge>
),
},
];
}
items = [
...items,
{
icon: <IconVersions size={20} />,
label: 'Homarr version',
@@ -134,25 +166,9 @@ const useInformationTableItems = (): InformationTableItem[] => {
</Badge>
),
},
{
icon: <IconLanguage size={20} />,
label: 'Loaded I18n translation namespaces',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{usedI18nNamespaces?.length ?? 'loading'}
</Badge>
),
},
{
icon: <IconVocabulary size={20} />,
label: 'Configured I18n locales',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{configuredi18nLocales?.length ?? 'loading'}
</Badge>
),
},
];
return items;
};
const useStyles = createStyles(() => ({

View File

@@ -1,460 +0,0 @@
import {
ActionIcon,
Anchor,
Button,
Center,
Group,
Image,
LoadingOverlay,
Modal,
PasswordInput,
Select,
Space,
Stack,
Switch,
Tabs,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { IconApps } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useConfig } from '../../tools/state';
import { tryMatchPort, ServiceTypeList, Config } from '../../tools/types';
import apiKeyPaths from './apiKeyPaths.json';
import Tip from '../layout/Tip';
export function AddItemShelfButton(props: any) {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
const { t } = useTranslation('layout/add-service-app-shelf');
return (
<>
<Modal
size="xl"
radius="md"
title={<Title order={3}>{t('modal.title')}</Title>}
opened={props.opened || opened}
onClose={() => setOpened(false)}
>
<AddAppShelfItemForm config={config} setConfig={setConfig} setOpened={setOpened} />
</Modal>
<Tooltip withinPortal label={t('actionIcon.tooltip')}>
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
style={props.style}
onClick={() => setOpened(true)}
>
<IconApps />
</ActionIcon>
</Tooltip>
</>
);
}
function MatchIcon(name: string | undefined, form: any) {
if (name === undefined || name === '') return null;
fetch(
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()
.replace(/^dash\.$/, 'dashdot')}.png`
).then((res) => {
if (res.ok) {
form.setFieldValue('icon', res.url);
}
});
return false;
}
function MatchService(name: string, form: any) {
const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase());
if (service) {
form.setFieldValue('type', service);
}
}
const DEFAULT_ICON = '/imgs/favicon/favicon.png';
interface AddAppShelfItemFormProps {
setOpened: (b: boolean) => void;
config: Config;
setConfig: (config: Config) => void;
// Any other props you want to pass to the form
[key: string]: any;
}
export function AddAppShelfItemForm(props: AddAppShelfItemFormProps) {
const { setOpened, config, setConfig } = props;
// Only get config and setConfig from useCOnfig if they are not present in props
const [isLoading, setLoading] = useState(false);
const { t } = useTranslation('layout/add-service-app-shelf');
// Extract all the categories from the services in config
const InitialCategories = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
const [categories, setCategories] = useState<string[]>(InitialCategories);
const form = useForm({
initialValues: {
id: props.id ?? uuidv4(),
type: props.type ?? 'Other',
category: props.category ?? null,
name: props.name ?? '',
icon: props.icon ?? DEFAULT_ICON,
url: props.url ?? '',
apiKey: props.apiKey ?? undefined,
username: props.username ?? undefined,
password: props.password ?? undefined,
openedUrl: props.openedUrl ?? undefined,
ping: props.ping ?? true,
newTab: props.newTab ?? true,
},
validate: {
apiKey: () => null,
// Validate icon with a regex
icon: (value: string) =>
// Disable matching to allow any values
null,
// Validate url with a regex http/https
url: (value: string) => {
try {
const _isValid = new URL(value);
} catch (e) {
return t('modal.form.validation.invalidUrl');
}
return null;
},
},
});
const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
useEffect(() => {
if (
form.values.name !== debounced ||
form.values.icon !== DEFAULT_ICON ||
form.values.type !== 'Other'
) {
return;
}
MatchIcon(form.values.name, form);
MatchService(form.values.name, form);
tryMatchPort(form.values.name, form);
}, [debounced]);
// Try to set const hostname to new URL(form.values.url).hostname)
// If it fails, set it to the form.values.url
let hostname = form.values.url;
try {
hostname = new URL(form.values.url).origin;
} catch (e) {
// Do nothing
}
return (
<>
<Center mb="lg">
<Image
height={120}
width={120}
fit="contain"
src={form.values.icon}
alt="Placeholder"
withPlaceholder
/>
</Center>
<form
onSubmit={form.onSubmit(() => {
const newForm = { ...form.values };
if (newForm.newTab === true) newForm.newTab = undefined;
if (newForm.openedUrl === '') newForm.openedUrl = undefined;
if (newForm.category === null) newForm.category = undefined;
if (newForm.ping === true) newForm.ping = undefined;
// If service already exists, update it.
if (config.services && config.services.find((s) => s.id === newForm.id)) {
setConfig({
...config,
// replace the found item by matching ID
services: config.services.map((s) => {
if (s.id === newForm.id) {
return {
...newForm,
};
}
return s;
}),
});
} else {
setConfig({
...config,
services: [...config.services, newForm],
});
}
setOpened(false);
form.reset();
})}
>
<Tabs defaultValue="Options">
<Tabs.List grow>
<Tabs.Tab value="Options">{t('modal.tabs.options.title')}</Tabs.Tab>
<Tabs.Tab value="Advanced Options">{t('modal.tabs.advancedOptions.title')}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="Options">
<Space h="sm" />
<Stack>
<TextInput
required
label={t('modal.tabs.options.form.serviceName.label')}
placeholder={t('modal.tabs.options.form.serviceName.placeholder')}
{...form.getInputProps('name')}
/>
<TextInput
required
label={t('modal.tabs.options.form.iconUrl.label')}
placeholder={DEFAULT_ICON}
{...form.getInputProps('icon')}
/>
<TextInput
required
label={t('modal.tabs.options.form.serviceUrl.label')}
placeholder="http://localhost:7575"
{...form.getInputProps('url')}
/>
<TextInput
label={t('modal.tabs.options.form.onClickUrl.label')}
placeholder="http://sonarr.example.com"
{...form.getInputProps('openedUrl')}
/>
<Select
label={t('modal.tabs.options.form.serviceType.label')}
defaultValue={t('modal.tabs.options.form.serviceType.defaultValue')}
placeholder={t('modal.tabs.options.form.serviceType.placeholder')}
required
searchable
data={ServiceTypeList}
{...form.getInputProps('type')}
/>
<Select
label={t('modal.tabs.options.form.category.label')}
data={categories}
placeholder={t('modal.tabs.options.form.category.placeholder')}
nothingFound={t('modal.tabs.options.form.category.nothingFound')}
searchable
clearable
creatable
onCreate={(query) => {
const item = { value: query, label: query };
setCategories([...InitialCategories, query]);
return item;
}}
getCreateLabel={(query) =>
t('modal.tabs.options.form.category.createLabel', {
query,
})
}
{...form.getInputProps('category')}
/>
<LoadingOverlay visible={isLoading} />
{(form.values.type === 'Sonarr' ||
form.values.type === 'Radarr' ||
form.values.type === 'Lidarr' ||
form.values.type === 'Overseerr' ||
form.values.type === 'Jellyseerr' ||
form.values.type === 'Readarr' ||
form.values.type === 'Sabnzbd') && (
<>
<TextInput
required
label={t('modal.tabs.options.form.integrations.apiKey.label')}
placeholder={t('modal.tabs.options.form.integrations.apiKey.placeholder')}
value={form.values.apiKey}
onChange={(event) => {
form.setFieldValue('apiKey', event.currentTarget.value);
}}
error={
form.errors.apiKey &&
t('modal.tabs.options.form.integrations.apiKey.validation.noKey')
}
/>
<Tip>
{t('modal.tabs.options.form.integrations.apiKey.tip.text')}{' '}
<Anchor
target="_blank"
weight="bold"
style={{ fontStyle: 'inherit', fontSize: 'inherit' }}
href={`${hostname}/${
apiKeyPaths[form.values.type as keyof typeof apiKeyPaths]
}`}
>
{t('modal.tabs.options.form.integrations.apiKey.tip.link')}
</Anchor>
</Tip>
</>
)}
{form.values.type === 'qBittorrent' && (
<>
<TextInput
label={t('modal.tabs.options.form.integrations.qBittorrent.username.label')}
placeholder={t(
'modal.tabs.options.form.integrations.qBittorrent.username.placeholder'
)}
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={
form.errors.username &&
t(
'modal.tabs.options.form.integrations.qBittorrent.username.validation.invalidUsername'
)
}
/>
<PasswordInput
label={t('modal.tabs.options.form.integrations.qBittorrent.password.label')}
placeholder={t(
'modal.tabs.options.form.integrations.qBittorrent.password.placeholder'
)}
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={
form.errors.password &&
t(
'modal.tabs.options.form.integrations.qBittorrent.password.validation.invalidPassword'
)
}
/>
</>
)}
{form.values.type === 'Deluge' && (
<>
<PasswordInput
label={t('modal.tabs.options.form.integrations.deluge.password.label')}
placeholder={t(
'modal.tabs.options.form.integrations.deluge.password.placeholder'
)}
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={
form.errors.password &&
t(
'modal.tabs.options.form.integrations.deluge.password.validation.invalidPassword'
)
}
/>
</>
)}
{form.values.type === 'Transmission' && (
<>
<TextInput
label={t('modal.tabs.options.form.integrations.transmission.username.label')}
placeholder={t(
'modal.tabs.options.form.integrations.transmission.username.placeholder'
)}
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={
form.errors.username &&
t(
'modal.tabs.options.form.integrations.transmission.username.validation.invalidUsername'
)
}
/>
<PasswordInput
label={t('modal.tabs.options.form.integrations.transmission.password.label')}
placeholder={t(
'modal.tabs.options.form.integrations.transmission.password.placeholder'
)}
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={
form.errors.password &&
t(
'modal.tabs.options.form.integrations.transmission.password.validation.invalidPassword'
)
}
/>
</>
)}
{form.values.type === 'NZBGet' && (
<>
<TextInput
label={t('modal.tabs.options.form.integrations.nzbget.username.label')}
placeholder={t(
'modal.tabs.options.form.integrations.nzbget.username.placeholder'
)}
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={
form.errors.username &&
t(
'modal.tabs.options.form.integrations.nzbget.username.validation.invalidUsername'
)
}
/>
<PasswordInput
label={t('modal.tabs.options.form.integrations.nzbget.password.label')}
placeholder={t(
'modal.tabs.options.form.integrations.nzbget.password.placeholder'
)}
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={
form.errors.password &&
t(
'modal.tabs.options.form.integrations.nzbget.password.validation.invalidPassword'
)
}
/>
</>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="Advanced Options">
<Space h="sm" />
<Stack>
<Switch
label="Ping service"
defaultChecked={form.values.ping}
{...form.getInputProps('ping')}
/>
<Switch
label={t('modal.tabs.advancedOptions.form.openServiceInNewTab.label')}
defaultChecked={form.values.newTab}
{...form.getInputProps('newTab')}
/>
</Stack>
</Tabs.Panel>
</Tabs>
<Group grow position="center" mt="xl">
<Button type="submit">
{props.message ?? t('modal.tabs.advancedOptions.form.buttons.submit.content')}
</Button>
</Group>
</form>
</>
);
}

View File

@@ -15,7 +15,7 @@ import { useState } from 'react';
import PingComponent from '../../modules/ping/PingModule';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import AppShelfMenu from './AppShelfMenu';
import { TileMenu } from '../Dashboard/Menu/TileMenu';
const useStyles = createStyles((theme) => ({
item: {
@@ -104,7 +104,7 @@ export function AppShelfItem(props: any) {
opacity: hovering ? 1 : 0,
}}
>
<AppShelfMenu service={service} />
{ /* <TileMenu service={service} /> TODO: Remove this component */ }
</motion.div>
</Card.Section>
<Card.Section>

View File

@@ -1,86 +0,0 @@
import { ActionIcon, Menu, Modal, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { useState } from 'react';
import { IconCheck as Check, IconEdit as Edit, IconMenu, IconTrash as Trash } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { AddAppShelfItemForm } from './AddAppShelfItem';
import { useColorTheme } from '../../tools/color';
export default function AppShelfMenu(props: any) {
const { service }: { service: serviceItem } = props;
const { config, setConfig } = useConfig();
const { secondaryColor } = useColorTheme();
const { t } = useTranslation('layout/app-shelf-menu');
const [opened, setOpened] = useState(false);
return (
<>
<Modal
size="xl"
radius="md"
opened={props.opened || opened}
onClose={() => setOpened(false)}
title={t('modal.title')}
>
<AddAppShelfItemForm
config={config}
setConfig={setConfig}
setOpened={setOpened}
{...service}
message={t('modal.buttons.save')}
/>
</Modal>
<Menu
withinPortal
width={150}
shadow="xl"
withArrow
radius="md"
position="right"
styles={{
dropdown: {
// Add shadow and elevation to the body
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
},
}}
>
<Menu.Target>
<ActionIcon style={{}}>
<IconMenu />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('menu.labels.settings')}</Menu.Label>
<Menu.Item color={secondaryColor} icon={<Edit />} onClick={() => setOpened(true)}>
{t('menu.actions.edit')}
</Menu.Item>
<Menu.Label>{t('menu.labels.dangerZone')}</Menu.Label>
<Menu.Item
color="red"
onClick={(e: any) => {
setConfig({
...config,
services: config.services.filter((s) => s.id !== service.id),
});
showNotification({
autoClose: 5000,
title: (
<Text>
Service <b>{service.name}</b> removed successfully!
</Text>
),
color: 'green',
icon: <Check />,
message: undefined,
});
}}
icon={<Trash />}
>
{t('menu.actions.delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}

View File

@@ -0,0 +1,76 @@
import { ActionIcon, Menu, Text } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconCheck, IconEdit, IconMenu, IconTrash } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useColorTheme } from '../../../tools/color';
import { ServiceType } from '../../../types/service';
interface TileMenuProps {
service: ServiceType;
}
export const TileMenu = ({ service }: TileMenuProps) => {
const { secondaryColor } = useColorTheme();
const { t } = useTranslation('layout/app-shelf-menu');
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
return (
<Menu withinPortal width={150} shadow="xl" withArrow radius="md" position="right">
<Menu.Target>
<ActionIcon>
<IconMenu />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('menu.labels.settings')}</Menu.Label>
<Menu.Item
color={secondaryColor}
icon={<IconEdit />}
onClick={() =>
openContextModal({
modal: 'changeTilePosition',
innerProps: {
type: 'service',
tile: service,
},
})
}
>
{t('menu.actions.edit')}
</Menu.Item>
<Menu.Label>{t('menu.labels.dangerZone')}</Menu.Label>
<Menu.Item
color="red"
onClick={(e: any) => {
if (!configName) {
return;
}
updateConfig(configName, (previousConfig) => ({
...previousConfig,
services: previousConfig.services.filter((x) => x.id !== service.id),
})).then(() => {
showNotification({
autoClose: 5000,
title: (
<Text>
Service <b>{service.name}</b> removed successfully!
</Text>
),
color: 'green',
icon: <IconCheck />,
message: undefined,
});
});
}}
icon={<IconTrash />}
>
{t('menu.actions.delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};

View File

@@ -0,0 +1,94 @@
import { Button, Flex, Grid, NumberInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { ServiceType } from '../../../../types/service';
import { TileBaseType } from '../../../../types/tile';
export const ChangePositionModal = ({
context,
id,
innerProps,
}: ContextModalProps<{ type: 'service' | 'type'; tile: TileBaseType }>) => {
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
const form = useForm({
initialValues: {
tile: innerProps.tile,
},
validateInputOnChange: true,
validateInputOnBlur: true,
});
const onSubmit = () => {
if (!configName) {
return;
}
const tileAsService = form.values.tile as ServiceType;
updateConfig(configName, (previous) => ({
...previous,
services: [...previous.services.filter((x) => x.id === tileAsService.id), tileAsService],
}));
closeModal(id);
};
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<Grid>
<Grid.Col xs={12} md={6}>
<NumberInput
max={99}
min={0}
label="X Position"
description="0 or higher"
{...form.getInputProps('tile.shape.location.x')}
/>
</Grid.Col>
<Grid.Col xs={12} md={6}>
<NumberInput
max={99}
min={0}
label="Y Position"
description="0 or higher"
{...form.getInputProps('tile.shape.location.y')}
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col xs={12} md={6}>
<NumberInput
max={24}
min={1}
label="Width"
description="Between 1 and 24"
{...form.getInputProps('tile.shape.size.width')}
/>
</Grid.Col>
<Grid.Col xs={12} md={6}>
<NumberInput
max={24}
min={1}
label="Height"
description="Between 1 and 24"
{...form.getInputProps('tile.shape.size.height')}
/>
</Grid.Col>
</Grid>
<Flex justify="end" gap="sm" mt="md">
<Button onClick={() => closeModal(id)} variant="light" color="gray">
Cancel
</Button>
<Button type="submit">Change Position</Button>
</Flex>
</form>
);
};

View File

@@ -1,24 +1,30 @@
import Image from 'next/image';
import { Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core';
import { Alert, Button, createStyles, Group, Stack, Tabs, Text, ThemeIcon } from '@mantine/core';
import { useForm } from '@mantine/form';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { ContextModalProps } from '@mantine/modals';
import { hideNotification, showNotification } from '@mantine/notifications';
import {
IconAccessPoint,
IconAdjustments,
IconAlertTriangle,
IconBrush,
IconClick,
IconDeviceFloppy,
IconDoorExit,
IconPlug,
} from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { hideNotification, showNotification } from '@mantine/notifications';
import Image from 'next/image';
import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { ServiceType } from '../../../../types/service';
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab';
import { GeneralTab } from './Tabs/GeneralTab/GeneralTab';
import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab';
import { NetworkTab } from './Tabs/NetworkTab/NetworkTab';
import { EditServiceModalTab } from './Tabs/type';
const serviceUrlRegex =
'(https?://(?:www.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|www.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|https?://(?:www.|(?!www))[a-zA-Z0-9]+.[^\\s]{2,}|www.[a-zA-Z0-9]+.[^\\s]{2,})';
export const EditServiceModal = ({
context,
@@ -27,16 +33,66 @@ export const EditServiceModal = ({
}: ContextModalProps<{ service: ServiceType }>) => {
const { t } = useTranslation();
const { classes } = useStyles();
const { name: configName, config } = useConfigContext();
const updateConfig = useConfigStore((store) => store.updateConfig);
const form = useForm<ServiceType>({
initialValues: innerProps.service,
validate: {
name: (name) => (!name ? 'Name is required' : null),
url: (url) => {
if (!url) {
return 'Url is required';
}
if (!url.match(serviceUrlRegex)) {
return 'Value is not a valid url';
}
return null;
},
appearance: {
iconUrl: (url: string) => {
if (url.length < 1) {
return 'This field is required';
}
return null;
},
},
behaviour: {
onClickUrl: (url: string) => {
if (url === undefined || url.length < 1) {
return null;
}
if (!url.match(serviceUrlRegex)) {
return 'Uri override is not a valid uri';
}
return null;
},
},
},
validateInputOnChange: true,
});
const onSubmit = (values: ServiceType) => {
console.log('form submitted');
console.log(values);
if (!configName) {
return;
}
updateConfig(configName, (previousConfig) => ({
...previousConfig,
services: [...previousConfig.services.filter((x) => x.id !== form.values.id), form.values],
}));
// also close the parent modal
context.closeAll();
};
const [activeTab, setActiveTab] = useState<EditServiceModalTab>('general');
const tryCloseModal = () => {
if (form.isDirty()) {
showNotification({
@@ -63,8 +119,27 @@ export const EditServiceModal = ({
context.closeModal(id);
};
const validationErrors = Object.keys(form.errors);
const ValidationErrorIndicator = ({ keys }: { keys: string[] }) => {
const relevantErrors = validationErrors.filter((x) => keys.includes(x));
return (
<ThemeIcon opacity={relevantErrors.length === 0 ? 0 : 1} color="red" variant="light">
<IconAlertTriangle size={15} />
</ThemeIcon>
);
};
return (
<>
{configName === undefined ||
(config === undefined && (
<Alert color="red">
There was an unexpected problem loading the configuration. Functionality might be
restricted. Please report this incident.
</Alert>
))}
<Stack spacing={0} align="center" my="lg">
{form.values.appearance.iconUrl ? (
// disabled because image target is too dynamic for next image cache
@@ -84,27 +159,52 @@ export const EditServiceModal = ({
{form.values.name ?? 'New Service'}
</Text>
</Stack>
<form onSubmit={form.onSubmit(onSubmit)}>
<Tabs defaultValue="general">
<Tabs
value={activeTab}
onTabChange={(tab) => setActiveTab(tab as EditServiceModalTab)}
defaultValue="general"
>
<Tabs.List grow>
<Tabs.Tab value="general" icon={<IconAdjustments size={14} />}>
<Tabs.Tab
rightSection={<ValidationErrorIndicator keys={['name', 'url']} />}
icon={<IconAdjustments size={14} />}
value="general"
>
General
</Tabs.Tab>
<Tabs.Tab value="behaviour" icon={<IconClick size={14} />}>
<Tabs.Tab
rightSection={<ValidationErrorIndicator keys={['behaviour.onClickUrl']} />}
icon={<IconClick size={14} />}
value="behaviour"
>
Behaviour
</Tabs.Tab>
<Tabs.Tab value="network" icon={<IconAccessPoint size={14} />}>
<Tabs.Tab
rightSection={<ValidationErrorIndicator keys={[]} />}
icon={<IconAccessPoint size={14} />}
value="network"
>
Network
</Tabs.Tab>
<Tabs.Tab value="appearance" icon={<IconBrush size={14} />}>
<Tabs.Tab
rightSection={<ValidationErrorIndicator keys={['appearance.iconUrl']} />}
icon={<IconBrush size={14} />}
value="appearance"
>
Appearance
</Tabs.Tab>
<Tabs.Tab value="integration" icon={<IconPlug size={14} />}>
<Tabs.Tab
rightSection={<ValidationErrorIndicator keys={[]} />}
icon={<IconPlug size={14} />}
value="integration"
>
Integration
</Tabs.Tab>
</Tabs.List>
<GeneralTab form={form} />
<GeneralTab form={form} openTab={(targetTab) => setActiveTab(targetTab)} />
<BehaviourTab form={form} />
<NetworkTab form={form} />
<AppearanceTab form={form} />
@@ -112,16 +212,10 @@ export const EditServiceModal = ({
</Tabs>
<Group position="right" mt={100}>
<Button
leftIcon={<IconDoorExit size={20} />}
px={50}
variant="light"
color="gray"
onClick={tryCloseModal}
>
<Button onClick={tryCloseModal} px={50} variant="light" color="gray">
Cancel
</Button>
<Button type="submit" leftIcon={<IconDeviceFloppy size={20} />} px={50}>
<Button disabled={!form.isValid()} px={50} type="submit">
Save
</Button>
</Group>

View File

@@ -1,8 +1,9 @@
import { Tabs, TextInput, createStyles } from '@mantine/core';
import Image from 'next/image';
import { createStyles, Flex, Tabs, TextInput } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconPhoto } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { ServiceType } from '../../../../../../types/service';
import { IconSelector } from './IconSelector/IconSelector';
interface AppearanceTabProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
@@ -12,32 +13,56 @@ export const AppearanceTab = ({ form }: AppearanceTabProps) => {
const { t } = useTranslation('');
const { classes } = useStyles();
const Image = () => {
if (form.values.appearance.iconUrl !== undefined) {
const PreviewImage = () => {
if (form.values.appearance.iconUrl !== undefined && form.values.appearance.iconUrl.length > 0) {
// disabled due to too many dynamic targets for next image cache
// eslint-disable-next-line @next/next/no-img-element
return <img className={classes.iconImage} src={form.values.appearance.iconUrl} alt="jife" />;
return <img className={classes.iconImage} src={form.values.appearance.iconUrl} alt="" />;
}
return <IconPhoto />;
return (
<Image
src="/imgs/logo/logo.png"
width={20}
height={20}
objectFit="contain"
alt=""
/>
);
};
return (
<Tabs.Panel value="appearance" pt="lg">
<TextInput
icon={<Image />}
label="Service Icon"
variant="default"
defaultValue={form.values.appearance.iconUrl}
{...form.getInputProps('appearance.iconUrl')}
withAsterisk
required
/>
<Flex gap={5}>
<TextInput
defaultValue={form.values.appearance.iconUrl}
className={classes.textInput}
icon={<PreviewImage />}
label="Service Icon"
description="Logo of your service displayed in your dashboard. Must return a body content containg an image"
variant="default"
withAsterisk
required
{...form.getInputProps('appearance.iconUrl')}
/>
<IconSelector
onChange={(item) =>
form.setValues({
appearance: {
iconUrl: item.url,
},
})
}
/>
</Flex>
</Tabs.Panel>
);
};
const useStyles = createStyles(() => ({
textInput: {
flexGrow: 1,
},
iconImage: {
objectFit: 'contain',
width: 20,

View File

@@ -0,0 +1,117 @@
/* eslint-disable @next/next/no-img-element */
import {
ActionIcon,
Button,
createStyles,
Divider,
Flex,
Loader,
Popover,
ScrollArea,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconSearch, IconX } from '@tabler/icons';
import { useState } from 'react';
import { ICON_PICKER_SLICE_LIMIT } from '../../../../../../../../data/constants';
import { useRepositoryIconsQuery } from '../../../../../../../tools/hooks/useRepositoryIconsQuery';
import { IconSelectorItem } from '../../../../../../../types/iconSelector/iconSelectorItem';
import { WalkxcodeRepositoryIcon } from '../../../../../../../types/iconSelector/repositories/walkxcodeIconRepository';
interface IconSelectorProps {
onChange: (icon: IconSelectorItem) => void;
}
export const IconSelector = ({ onChange }: IconSelectorProps) => {
const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({
url: 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png',
converter: (item) => ({
url: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${item.name}`,
}),
});
const [searchTerm, setSearchTerm] = useState<string>('');
const { classes } = useStyles();
if (isLoading || !data) {
return <Loader />;
}
const replaceCharacters = (value: string) =>
value.toLowerCase().replaceAll(' ', '').replaceAll('-', '');
const filteredItems = searchTerm
? data.filter((x) => replaceCharacters(x.url).includes(replaceCharacters(searchTerm)))
: data;
const slicedFilteredItems = filteredItems.slice(0, ICON_PICKER_SLICE_LIMIT);
const isTruncated =
slicedFilteredItems.length > 0 && slicedFilteredItems.length !== filteredItems.length;
return (
<Popover width={310}>
<Popover.Target>
<Button
className={classes.actionIcon}
variant="default"
leftIcon={<IconSearch size={20} />}
>
Icon Picker
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack pt={4}>
<TextInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.currentTarget.value)}
placeholder="Search for icons..."
variant="filled"
rightSection={
<ActionIcon onClick={() => setSearchTerm('')}>
<IconX opacity={0.5} size={20} strokeWidth={2} />
</ActionIcon>
}
/>
<ScrollArea style={{ height: 250 }} type="always">
<Flex gap={4} wrap="wrap" pr={15}>
{slicedFilteredItems.map((item) => (
<ActionIcon onClick={() => onChange(item)} size={40} p={3}>
<img className={classes.icon} src={item.url} alt="" />
</ActionIcon>
))}
</Flex>
{isTruncated && (
<Stack spacing="xs" pr={15}>
<Divider mt={35} mx="xl" />
<Title order={6} color="dimmed" align="center">
Search is limited to {ICON_PICKER_SLICE_LIMIT} icons
</Title>
<Text color="dimmed" align="center" size="sm">
To keep things snappy and fast, the search is limited to {ICON_PICKER_SLICE_LIMIT}{' '}
icons. Use the search box to find more icons.
</Text>
</Stack>
)}
</ScrollArea>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const useStyles = createStyles(() => ({
flameIcon: {
margin: '0 auto',
},
icon: {
width: '100%',
height: '100%',
objectFit: 'contain',
},
actionIcon: {
alignSelf: 'end',
},
}));

View File

@@ -15,32 +15,16 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => {
<TextInput
icon={<IconClick size={16} />}
label="On click url"
placeholder="Override the default service url when clicking on the service"
description="Overrides the service URL when clicking on the service"
placeholder="URL that should be opened instead when clicking on the service"
variant="default"
mb="md"
{...form.getInputProps('onClickUrl')}
{...form.getInputProps('behaviour.onClickUrl')}
/>
<Switch
value="disable_handle"
label="Disable direct moving in edit modus"
description={
<Text color="dimmed" size="sm">
Disables the direct movement of the tile
</Text>
}
mb="md"
{...form.getInputProps('isEditModeMovingDisabled')}
/>
<Switch
value="freze"
label="Freeze tile within edit modus"
description={
<Text color="dimmed" size="sm">
Disables the movement of the tile when moving others
</Text>
}
{...form.getInputProps('isEditModeTileFreezed')}
label="Open in new tab"
{...form.getInputProps('behaviour.isOpeningNewTab', { type: 'checkbox' })}
/>
</Tabs.Panel>
);

View File

@@ -1,32 +1,52 @@
import { Tabs, TextInput } from '@mantine/core';
import { Group, Tabs, Text, TextInput } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconCursorText, IconLink } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { ServiceType } from '../../../../../../types/service';
import { EditServiceModalTab } from '../type';
interface GeneralTabProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
openTab: (tab: EditServiceModalTab) => void;
}
export const GeneralTab = ({ form }: GeneralTabProps) => {
export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
const { t } = useTranslation('');
return (
<Tabs.Panel value="general" pt="lg">
<TextInput
icon={<IconCursorText size={16} />}
label="Service name"
description="Used for displaying the service on the dashboard"
placeholder="My example service"
variant="default"
mb="md"
required
withAsterisk
{...form.getInputProps('name')}
/>
<TextInput
icon={<IconLink size={16} />}
label="Service url"
description={
<Text>
URL that will be opened when clicking on the service. Can be overwritten using
<Text
onClick={() => openTab('behaviour')}
variant="link"
span
style={{
cursor: 'pointer',
}}
>
{' '}
on click URL{' '}
</Text>
when using external URLs to enhance security.
</Text>
}
placeholder="https://google.com"
variant="default"
required
withAsterisk
{...form.getInputProps('url')}
/>
</Tabs.Panel>

View File

@@ -1,21 +1,9 @@
/* eslint-disable @next/next/no-img-element */
import {
Alert,
Card,
Group,
PasswordInput,
Select,
SelectItem,
Space,
Text,
TextInput,
} from '@mantine/core';
import { Group, Select, SelectItem, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconKey, IconUser } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { forwardRef, useState } from 'react';
import { ServiceType } from '../../../../../../../../../types/service';
import { TextExplanation } from '../TextExplanation/TextExplanation';
import { ServiceType } from '../../../../../../../../types/service';
interface IntegrationSelectorProps {
form: UseFormReturnType<ServiceType, (item: ServiceType) => ServiceType>;
@@ -62,11 +50,9 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
return (
<>
<TextExplanation />
<Space h="sm" />
<Select
label="Configure this service for the following integration"
label="Integration configuration"
description="Treats this service as the selected integration and provides you with per-service configuration"
placeholder="Select your desired configuration"
itemComponent={SelectItemComponent}
data={data}

View File

@@ -1,10 +0,0 @@
import { Text } from '@mantine/core';
export const TextExplanation = () => {
return (
<Text color="dimmed">
You can optionally connect your services using integrations. Integration elements on your
dashboard will communicate to your services using the configuration below.
</Text>
);
};

View File

@@ -14,6 +14,7 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
<Tabs.Panel value="network" pt="lg">
<Switch
label="Enable status checker"
description="Sends a simple HTTP / HTTPS request to check if your service is online"
mb="md"
defaultChecked={form.values.network.enabledStatusChecker}
{...form.getInputProps('network.enabledStatusChecker')}
@@ -22,6 +23,7 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
<MultiSelect
required
label="HTTP status codes"
description="Determines what response codes are allowed for this service to be 'Online'"
data={StatusCodes}
clearable
searchable

View File

@@ -0,0 +1 @@
export type EditServiceModalTab = 'general' | 'behaviour' | 'network' | 'appereance' | 'integration';

View File

@@ -42,10 +42,14 @@ export const AvailableElementTypes = ({
okStatus: [],
},
behaviour: {
isOpeningNewTab: false,
isOpeningNewTab: true,
onClickUrl: '',
},
area: {
type: 'wrapper',
type: 'sidebar',
properties: {
location: 'right',
},
},
shape: {
location: {

View File

@@ -1,12 +1,11 @@
import { ActionIcon, Button, Group, Text } from '@mantine/core';
import { Button, Text } from '@mantine/core';
import { IconArrowNarrowLeft } from '@tabler/icons';
interface SelectorBackArrowProps {
onClickBack: () => void;
}
export const SelectorBackArrow = ({ title, onClickBack }: SelectorBackArrowProps) => {
return (
export const SelectorBackArrow = ({ onClickBack }: SelectorBackArrowProps) => (
<Button
leftIcon={<IconArrowNarrowLeft />}
onClick={onClickBack}
@@ -18,4 +17,3 @@ export const SelectorBackArrow = ({ title, onClickBack }: SelectorBackArrowProps
<Text>See all available elements</Text>
</Button>
);
};

View File

@@ -1,10 +1,10 @@
import { Space, Stack, Text } from '@mantine/core';
import { useConfigContext } from '../../config/provider';
import ConfigChanger from '../Config/ConfigChanger';
import ConfigActions from './Common/ConfigActions';
import LanguageSelect from './Common/LanguageSelect';
import { SearchEngineSelector } from './Common/SearchEngineSelector';
import { SearchNewTabSwitch } from './Common/SearchNewTabSwitch';
import { useConfigContext } from '../../../config/provider';
import ConfigChanger from '../../Config/ConfigChanger';
import ConfigActions from './Config/ConfigActions';
import LanguageSelect from './Language/LanguageSelect';
import { SearchEngineSelector } from './SearchEngine/SearchEngineSelector';
import { SearchNewTabSwitch } from './SearchNewTabSwitch';
export default function CommonSettings() {
const { config } = useConfigContext();

View File

@@ -1,13 +1,13 @@
import { Button, Center, Group } from '@mantine/core';
import { ActionIcon, Center, createStyles, Flex, Text, useMantineTheme } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconCheck, IconDownload, IconPlus, IconTrash, IconX } from '@tabler/icons';
import { IconCheck, IconCopy, IconDownload, IconTrash, IconX } from '@tabler/icons';
import { useMutation } from '@tanstack/react-query';
import fileDownload from 'js-file-download';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../config/provider';
import Tip from '../../layout/Tip';
import { CreateConfigCopyModal } from './ConfigActions/CreateCopyModal';
import { useConfigContext } from '../../../../config/provider';
import Tip from '../../../layout/Tip';
import { CreateConfigCopyModal } from './CreateCopyModal';
export default function ConfigActions() {
const { t } = useTranslation(['settings/general/config-changer', 'settings/common']);
@@ -26,6 +26,9 @@ export default function ConfigActions() {
await mutateAsync();
};
const { classes } = useStyles();
const { colors } = useMantineTheme();
return (
<>
<CreateConfigCopyModal
@@ -33,32 +36,25 @@ export default function ConfigActions() {
closeModal={createCopyModal.close}
initialConfigName={config.configProperties.name}
/>
<Group spacing="xs" position="center">
<Button
size="xs"
leftIcon={<IconDownload size={18} />}
variant="default"
onClick={handleDownload}
>
{t('buttons.download')}
</Button>
<Button
size="xs"
leftIcon={<IconTrash size={18} />}
variant="default"
<Flex gap="xs" justify="stretch">
<ActionIcon className={classes.actionIcon} onClick={handleDownload} variant="default">
<IconDownload size={20} />
<Text>{t('buttons.download')}</Text>
</ActionIcon>
<ActionIcon
className={classes.actionIcon}
onClick={handleDeletion}
color="red"
variant="light"
>
{t('buttons.delete.text')}
</Button>
<Button
size="xs"
leftIcon={<IconPlus size={18} />}
variant="default"
onClick={createCopyModal.open}
>
{t('buttons.saveCopy')}
</Button>
</Group>
<IconTrash color={colors.red[2]} size={20} />
<Text>{t('buttons.delete.text')}</Text>
</ActionIcon>
<ActionIcon className={classes.actionIcon} onClick={createCopyModal.open} variant="default">
<IconCopy size={20} />
<Text>{t('buttons.saveCopy')}</Text>
</ActionIcon>
</Flex>
<Center>
<Tip>{t('settings/common:tips.configTip')}</Tip>
@@ -67,6 +63,23 @@ export default function ConfigActions() {
);
}
const useStyles = createStyles(() => ({
actionIcon: {
width: 'auto',
height: 'auto',
maxWidth: 'auto',
maxHeight: 'auto',
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
rowGap: 10,
padding: 10,
},
}));
const useDeleteConfigMutation = (configName: string) => {
const { t } = useTranslation(['settings/general/config-changer']);

View File

@@ -2,7 +2,7 @@ import { Group, ActionIcon, Anchor, Text } from '@mantine/core';
import { IconBrandDiscord, IconBrandGithub } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { CURRENT_VERSION } from '../../../data/constants';
import { CURRENT_VERSION } from '../../../../data/constants';
export default function Credits() {
const { t } = useTranslation('settings/common');

View File

@@ -5,7 +5,7 @@ import { forwardRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { getCookie, setCookie } from 'cookies-next';
import { getLanguageByCode, Language } from '../../../tools/language';
import { getLanguageByCode, Language } from '../../../../tools/language';
export default function LanguageSelect() {
const { t, i18n } = useTranslation('settings/general/internationalization');

View File

@@ -2,13 +2,12 @@ import { Alert, Paper, SegmentedControl, Space, Stack, TextInput, Title } from '
import { IconInfoCircle } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import {
CommonSearchEngineCommonSettingsType,
SearchEngineCommonSettingsType,
} from '../../../types/settings';
import Tip from '../../layout/Tip';
} from '../../../../types/settings';
import { SearchNewTabSwitch } from './SearchNewTabSwitch';
interface Props {

View File

@@ -1,9 +1,9 @@
import { Switch } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { SearchEngineCommonSettingsType } from '../../../types/settings';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { SearchEngineCommonSettingsType } from '../../../../types/settings';
interface SearchNewTabSwitchProps {
defaultValue: boolean | undefined;

View File

@@ -1,15 +1,15 @@
import { Stack } from '@mantine/core';
import { useConfigContext } from '../../config/provider';
import { ColorSelector } from './Customization/ColorSelector';
import { BackgroundChanger } from './Customization/BackgroundChanger';
import { CustomCssChanger } from './Customization/CustomCssChanger';
import { FaviconChanger } from './Customization/FaviconChanger';
import { LogoImageChanger } from './Customization/LogoImageChanger';
import { MetaTitleChanger } from './Customization/MetaTitleChanger';
import { PageTitleChanger } from './Customization/PageTitleChanger';
import { OpacitySelector } from './Customization/OpacitySelector';
import { ShadeSelector } from './Customization/ShadeSelector';
import { LayoutSelector } from './Customization/LayoutSelector';
import { useConfigContext } from '../../../config/provider';
import { ColorSelector } from './Theme/ColorSelector';
import { BackgroundChanger } from './Meta/BackgroundChanger';
import { CustomCssChanger } from './Theme/CustomCssChanger';
import { FaviconChanger } from './Meta/FaviconChanger';
import { LogoImageChanger } from './Meta/LogoImageChanger';
import { MetaTitleChanger } from './Meta/MetaTitleChanger';
import { PageTitleChanger } from './Meta/PageTitleChanger';
import { OpacitySelector } from './Theme/OpacitySelector';
import { ShadeSelector } from './Theme/ShadeSelector';
import { LayoutSelector } from './Layout/LayoutSelector';
export default function CustomizationSettings() {
const { config } = useConfigContext();

View File

@@ -0,0 +1,167 @@
import {
ActionIcon,
Center,
Checkbox,
createStyles,
Flex,
Group,
Paper,
Stack,
Text,
Title,
useMantineTheme,
} from '@mantine/core';
import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { CustomizationSettingsType } from '../../../../types/settings';
import { Logo } from '../../../layout/Logo';
interface LayoutSelectorProps {
defaultLayout: CustomizationSettingsType['layout'] | undefined;
}
// TODO: add translations
export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
const { classes } = useStyles();
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const [leftSidebar, setLeftSidebar] = useState(defaultLayout?.enabledLeftSidebar ?? true);
const [rightSidebar, setRightSidebar] = useState(defaultLayout?.enabledRightSidebar ?? true);
const [docker, setDocker] = useState(defaultLayout?.enabledDocker ?? false);
const [ping, setPing] = useState(defaultLayout?.enabledPing ?? false);
const [searchBar, setSearchBar] = useState(defaultLayout?.enabledSearchbar ?? false);
const { colors, colorScheme } = useMantineTheme();
if (!configName) return null;
const handleChange = (
key: keyof CustomizationSettingsType['layout'],
event: ChangeEvent<HTMLInputElement>,
setState: Dispatch<SetStateAction<boolean>>
) => {
const value = event.target.checked;
setState(value);
updateConfig(configName, (prev) => {
const { layout } = prev.settings.customization;
layout[key] = value;
return {
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
layout,
},
},
};
});
};
return (
<Stack spacing="xs">
<Title order={6}>Dashboard layout</Title>
<Paper px="xs" py={4} withBorder>
<Group position="apart">
<Logo size="xs" />
<Group spacing={5}>
{searchBar ? (
<Paper
style={{
height: 10,
backgroundColor: colorScheme === 'dark' ? colors.gray[8] : colors.gray[1],
}}
p={2}
w={60}
/>
) : null}
{docker ? <ActionIcon size={10} disabled /> : null}
</Group>
</Group>
</Paper>
<Group align="stretch">
{leftSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex align="center" justify="center" direction="column">
<Text align="center">Sidebar</Text>
<Text color="dimmed" size="xs" align="center">
Only for
<br />
services &<br />
integrations
</Text>
</Flex>
</Paper>
)}
<Paper className={classes.primaryWrapper} p="xs" withBorder>
<Text align="center">Main</Text>
<Text color="dimmed" size="xs" align="center">
Cannot be turned of.
</Text>
</Paper>
{rightSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex align="center" justify="center" direction="column">
<Text align="center">Sidebar</Text>
<Text color="dimmed" size="xs" align="center">
Only for
<br />
services &<br />
integrations
</Text>
</Flex>
</Paper>
)}
</Group>
<Stack spacing="xs">
<Checkbox
label="Enable left sidebar"
description="Optional. Can be used for services and integrations only"
checked={leftSidebar}
onChange={(ev) => handleChange('enabledLeftSidebar', ev, setLeftSidebar)}
/>
<Checkbox
label="Enable right sidebar"
description="Optional. Can be used for services and integrations only"
checked={rightSidebar}
onChange={(ev) => handleChange('enabledRightSidebar', ev, setRightSidebar)}
/>
<Checkbox
label="Enable search bar"
checked={searchBar}
onChange={(ev) => handleChange('enabledSearchbar', ev, setSearchBar)}
/>
<Checkbox
label="Enable docker"
checked={docker}
onChange={(ev) => handleChange('enabledDocker', ev, setDocker)}
/>
<Checkbox
label="Enable pings"
checked={ping}
onChange={(ev) => handleChange('enabledPing', ev, setPing)}
/>
</Stack>
</Stack>
);
};
const useStyles = createStyles((theme) => ({
primaryWrapper: {
flexGrow: 2,
},
secondaryWrapper: {
flexGrow: 1,
maxWidth: 100,
},
}));

View File

@@ -1,164 +0,0 @@
import {
Box,
Center,
Checkbox,
createStyles,
Group,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconBrandDocker, IconLayout, IconSearch } from '@tabler/icons';
import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { CustomizationSettingsType } from '../../../types/settings';
import { Logo } from '../../layout/Logo';
interface LayoutSelectorProps {
defaultLayout: CustomizationSettingsType['layout'] | undefined;
}
// TODO: add translations
export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
const { classes } = useStyles();
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const [leftSidebar, setLeftSidebar] = useState(defaultLayout?.enabledLeftSidebar ?? true);
const [rightSidebar, setRightSidebar] = useState(defaultLayout?.enabledRightSidebar ?? true);
const [docker, setDocker] = useState(defaultLayout?.enabledDocker ?? false);
const [ping, setPing] = useState(defaultLayout?.enabledPing ?? false);
const [searchBar, setSearchBar] = useState(defaultLayout?.enabledSearchbar ?? false);
if (!configName) return null;
const handleChange = (
key: keyof CustomizationSettingsType['layout'],
event: ChangeEvent<HTMLInputElement>,
setState: Dispatch<SetStateAction<boolean>>
) => {
const value = event.target.checked;
setState(value);
updateConfig(configName, (prev) => {
const layout = prev.settings.customization.layout;
layout[key] = value;
return {
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
layout,
},
},
};
});
};
return (
<Box className={classes.box} p="xl" pb="sm">
<Stack spacing="xs">
<Group spacing={5}>
<IconLayout size={20} />
<Title order={6}>Dashboard layout</Title>
</Group>
<Text color="dimmed" size="sm">
You can adjust the layout of the Dashboard to your preferences. The main are cannot be
turned on or off
</Text>
<Paper px="xs" py={2} withBorder>
<Group position="apart">
<Logo size="xs" />
<Group spacing={4}>
{searchBar ? (
<Paper withBorder p={2} w={60}>
<Group spacing={2} align="center">
<IconSearch size={8} />
<Text size={8} color="dimmed">
Search
</Text>
</Group>
</Paper>
) : null}
{docker ? <IconBrandDocker size={18} color="#0db7ed" /> : null}
</Group>
</Group>
</Paper>
<Group align="stretch">
{leftSidebar && (
<Paper p="xs" withBorder>
<Center style={{ height: '100%' }}>
<Text align="center">Sidebar</Text>
</Center>
</Paper>
)}
<Paper className={classes.main} p="xs" withBorder>
<Text align="center">Main</Text>
<Text color="dimmed" size="xs" align="center">
Can be used for categories,
<br />
services and integrations
</Text>
</Paper>
{rightSidebar && (
<Paper p="xs" withBorder>
<Center style={{ height: '100%' }}>
<Text align="center">Sidebar</Text>
</Center>
</Paper>
)}
</Group>
<Stack spacing="xs">
<Checkbox
label="Enable left sidebar"
description="Optional. Can be used for services and integrations only"
checked={leftSidebar}
onChange={(ev) => handleChange('enabledLeftSidebar', ev, setLeftSidebar)}
/>
<Checkbox
label="Enable right sidebar"
description="Optional. Can be used for services and integrations only"
checked={rightSidebar}
onChange={(ev) => handleChange('enabledRightSidebar', ev, setRightSidebar)}
/>
<Checkbox
label="Enable search bar"
checked={searchBar}
onChange={(ev) => handleChange('enabledSearchbar', ev, setSearchBar)}
/>
<Checkbox
label="Enable docker"
checked={docker}
onChange={(ev) => handleChange('enabledDocker', ev, setDocker)}
/>
<Checkbox
label="Enable pings"
checked={ping}
onChange={(ev) => handleChange('enabledPing', ev, setPing)}
/>
</Stack>
</Stack>
</Box>
);
};
const useStyles = createStyles((theme) => ({
main: {
flexGrow: 1,
},
box: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
borderRadius: theme.radius.md,
},
}));

View File

@@ -1,8 +1,8 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface BackgroundChangerProps {
defaultValue: string | undefined;
@@ -17,7 +17,7 @@ export const BackgroundChanger = ({ defaultValue }: BackgroundChangerProps) => {
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const { value } = ev.currentTarget;
const backgroundImageUrl = value.trim().length === 0 ? undefined : value;
setBackgroundImageUrl(backgroundImageUrl);
updateConfig(configName, (prev) => ({

View File

@@ -1,8 +1,8 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface FaviconChangerProps {
defaultValue: string | undefined;
@@ -17,7 +17,7 @@ export const FaviconChanger = ({ defaultValue }: FaviconChangerProps) => {
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const { value } = ev.currentTarget;
const faviconUrl = value.trim().length === 0 ? undefined : value;
setFaviconUrl(faviconUrl);
updateConfig(configName, (prev) => ({

View File

@@ -1,8 +1,8 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface LogoImageChangerProps {
defaultValue: string | undefined;
@@ -17,7 +17,7 @@ export const LogoImageChanger = ({ defaultValue }: LogoImageChangerProps) => {
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const { value } = ev.currentTarget;
const logoImageSrc = value.trim().length === 0 ? undefined : value;
setLogoImageSrc(logoImageSrc);
updateConfig(configName, (prev) => ({

View File

@@ -1,8 +1,8 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface MetaTitleChangerProps {
defaultValue: string | undefined;
@@ -18,7 +18,7 @@ export const MetaTitleChanger = ({ defaultValue }: MetaTitleChangerProps) => {
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const { value } = ev.currentTarget;
const metaTitle = value.trim().length === 0 ? undefined : value;
setMetaTitle(metaTitle);
updateConfig(configName, (prev) => ({

View File

@@ -1,8 +1,8 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface PageTitleChangerProps {
defaultValue: string | undefined;
@@ -18,7 +18,7 @@ export const PageTitleChanger = ({ defaultValue }: PageTitleChangerProps) => {
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const { value } = ev.currentTarget;
const pageTitle = value.trim().length === 0 ? undefined : value;
setPageTitle(pageTitle);
updateConfig(configName, (prev) => ({

View File

@@ -1,4 +1,3 @@
import React, { useState } from 'react';
import {
ColorSwatch,
Grid,
@@ -8,11 +7,12 @@ import {
Text,
useMantineTheme,
} from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useColorTheme } from '../../../tools/color';
import { useDisclosure } from '@mantine/hooks';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../config/provider';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { useColorTheme } from '../../../../tools/color';
interface ColorControlProps {
defaultValue: MantineTheme['primaryColor'] | undefined;
@@ -40,7 +40,7 @@ export function ColorSelector({ type, defaultValue }: ColorControlProps) {
if (type === 'primary') setPrimaryColor(color);
else setSecondaryColor(color);
updateConfig(configName, (prev) => {
const colors = prev.settings.customization.colors;
const { colors } = prev.settings.customization;
colors[type] = color;
return {
...prev,

View File

@@ -1,8 +1,8 @@
import { Textarea } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface CustomCssChangerProps {
defaultValue: string | undefined;
@@ -17,7 +17,7 @@ export const CustomCssChanger = ({ defaultValue }: CustomCssChangerProps) => {
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (ev) => {
const value = ev.currentTarget.value;
const { value } = ev.currentTarget;
const customCss = value.trim().length === 0 ? undefined : value;
setCustomCss(customCss);
updateConfig(configName, (prev) => ({

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Text, Slider, Stack } from '@mantine/core';
import { Slider, Stack, Text } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface OpacitySelectorProps {
defaultValue: number | undefined;

View File

@@ -1,19 +1,19 @@
import React, { useState } from 'react';
import {
ColorSwatch,
Grid,
Group,
MantineTheme,
Popover,
Stack,
Text,
useMantineTheme,
MantineTheme,
Stack,
Grid,
} from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useColorTheme } from '../../../tools/color';
import { useDisclosure } from '@mantine/hooks';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../config/provider';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { useColorTheme } from '../../../../tools/color';
interface ShadeSelectorProps {
defaultValue: MantineTheme['primaryShade'] | undefined;

View File

@@ -4,9 +4,9 @@ import { useState } from 'react';
import { IconSettings } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import CustomizationSettings from './CustomizationSettings';
import CommonSettings from './CommonSettings';
import Credits from './Credits';
import CustomizationSettings from './Customization/CustomizationSettings';
import CommonSettings from './Common/CommonSettings';
import Credits from './Common/Credits';
function SettingsMenu() {
const { t } = useTranslation('settings/common');

View File

@@ -3,7 +3,7 @@ import { useConfigContext } from '../../config/provider';
import { Background } from './Background';
import { Footer } from './Footer';
import { Header } from './Header/Header';
import { Head } from './Head/Head';
import { Head } from './Header/Meta/Head';
const useStyles = createStyles(() => ({}));

View File

@@ -14,7 +14,7 @@ export function Logo({ size = 'md', withoutText = false }: LogoProps) {
return (
<Group spacing={size === 'md' ? 'xs' : 4} noWrap>
<Image
width={size === 'md' ? 50 : 20}
width={size === 'md' ? 50 : 12}
src={config?.settings.customization.logoImageUrl || '/imgs/logo/logo.png'}
style={{
position: 'relative',
@@ -22,7 +22,7 @@ export function Logo({ size = 'md', withoutText = false }: LogoProps) {
/>
{withoutText ? null : (
<Text
size={size === 'md' ? 22 : 'xs'}
size={size === 'md' ? 22 : 10}
weight="bold"
variant="gradient"
gradient={primaryGradient}

View File

@@ -2,9 +2,6 @@ import { ActionIcon, Tooltip } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { IconApps } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { v4 as uuidv4 } from 'uuid';
import { openContextModalGeneric } from '../../../../../tools/mantineModalManagerExtensions';
import { ServiceType } from '../../../../../types/service';
export const AddElementAction = () => {
const { t } = useTranslation('layout/add-service-app-shelf');

View File

@@ -0,0 +1,36 @@
import { Button, Popover, Tooltip, Text } from '@mantine/core';
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore';
export const ToggleEditModeAction = () => {
const { t } = useTranslation('layout/header/actions/toggle-edit-mode');
const { enabled, toggleEditMode } = useEditModeStore();
return (
<Tooltip label={t('tooltip')} withinPortal>
<Popover opened={enabled} width={250} withArrow>
<Popover.Target>
<Button
onClick={() => toggleEditMode()}
leftIcon={enabled ? <IconEditCircleOff /> : <IconEditCircle />}
variant="default"
radius="md"
color="blue"
style={{ height: 'auto', alignSelf: 'stretch' }}
>
{enabled ? t('button.enabled') : t('button.disabled')}
</Button>
</Popover.Target>
<Popover.Dropdown>
<Text align="center">
<Text weight="bold">{t('popover.title')}</Text>
{t('popover.text')}
</Text>
</Popover.Dropdown>
</Popover>
</Tooltip>
);
};

View File

@@ -1,15 +1,12 @@
import { Box, createStyles, Group, Header as MantineHeader, useMantineTheme } from '@mantine/core';
import { useViewportSize } from '@mantine/hooks';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
import DockerMenuButton from '../../../modules/docker/DockerModule';
import { Search } from './Search';
import { Box, createStyles, Group, Header as MantineHeader, Text } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { useConfigContext } from '../../../config/provider';
import { Logo } from '../Logo';
import { useCardStyles } from '../useCardStyles';
import { SettingsMenu } from './SettingsMenu';
import { ToolsMenu } from './ToolsMenu';
import { AddElementAction } from './Actions/AddElementAction/AddElementAction';
import { ViewToggleButton } from '../../Dashboard/Views/ViewToggleButton';
import { ToggleEditModeAction } from './Actions/ToggleEditMode/ToggleEditMode';
import { Search } from './Search';
import { SettingsMenu } from './SettingsMenu';
export const HeaderHeight = 64;
@@ -17,6 +14,8 @@ export function Header(props: any) {
const { classes } = useStyles();
const { classes: cardClasses } = useCardStyles();
const { config } = useConfigContext();
return (
<MantineHeader height={HeaderHeight} className={cardClasses.card}>
<Group p="xs" noWrap grow>
@@ -24,11 +23,24 @@ export function Header(props: any) {
<Logo />
</Box>
<Group position="right" noWrap>
<Text
onClick={() => {
openContextModal({
modal: 'changeTilePosition',
title: 'Change tile position',
innerProps: {
tile: config?.services[0],
},
});
}}
variant="link"
>
Test: Open Change Pos Modal
</Text>
<Search />
<AddItemShelfButton />
<AddElementAction />
<ToolsMenu />
<ViewToggleButton />
<ToggleEditModeAction />
<SettingsMenu />
</Group>
</Group>

View File

@@ -2,7 +2,7 @@
import React from 'react';
import NextHead from 'next/head';
import { SafariStatusBarStyle } from './SafariStatusBarStyle';
import { useConfigContext } from '../../../config/provider';
import { useConfigContext } from '../../../../config/provider';
export function Head() {
const { config } = useConfigContext();

View File

@@ -18,7 +18,7 @@ import { useTranslation } from 'next-i18next';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import SmallServiceItem from '../../AppShelf/SmallServiceItem';
import Tip from '../Tip';
import { searchUrls } from '../../Settings/Common/SearchEngineSelector';
import { searchUrls } from '../../Settings/Common/SearchEngine/SearchEngineSelector';
import { useConfigContext } from '../../../config/provider';
import { OverseerrMediaDisplay } from '../../../modules/common';
import { IModule } from '../../../modules/ModuleTypes';

View File

@@ -1,26 +0,0 @@
import { ActionIcon, Menu, Text } from '@mantine/core';
import { IconAxe } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import DockerMenuButton from '../../../modules/docker/DockerModule';
export const ToolsMenu = () => {
const { t } = useTranslation('layout/tools');
return (
<Menu>
<Menu.Target>
<ActionIcon variant="default" radius="md" size="xl" color="blue">
<IconAxe />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{/* TODO: Implement check to display fallback when no tools */}
<DockerMenuButton />
<Menu.Item closeMenuOnClick={false} disabled>
<Text size="sm" color="dimmed">
{t('fallback.title')}
</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};