mirror of
https://github.com/ajnart/homarr.git
synced 2026-05-07 13:27:15 +02:00
✨ Improve layout system, Add parts of create button for apps, integrations, layouts and boards
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
# should be updated accordingly.
|
||||
|
||||
# Database
|
||||
DATABASE_URL="file:../database/db.sqlite"
|
||||
DATABASE_URL="file:./database/db.sqlite"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
|
||||
106
src/components/Board/BoardCreateModal.tsx
Normal file
106
src/components/Board/BoardCreateModal.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ActionIcon, Button, Group, Loader, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { IconAlertTriangle, IconCheck, IconPencil, IconPencilOff } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { api } from '~/utils/api';
|
||||
import { createBoardSchema } from '~/validations/boards';
|
||||
|
||||
export const CreateBoardModal = ({ id, context }: ContextModalProps<Record<string, never>>) => {
|
||||
const [autoBoardName, setAutoBoardName] = useState(true);
|
||||
const router = useRouter();
|
||||
const form = useForm<FormType>({
|
||||
validate: zodResolver(createBoardSchema),
|
||||
validateInputOnBlur: true,
|
||||
initialValues: {
|
||||
pageTitle: '',
|
||||
boardName: '',
|
||||
allowGuests: false,
|
||||
},
|
||||
});
|
||||
const [debouncedRouteName] = useDebouncedValue(form.values.boardName, 500);
|
||||
const { data: boardNameAvailable, isFetching } = api.boards.checkNameAvailable.useQuery(
|
||||
{ boardName: debouncedRouteName },
|
||||
{ enabled: !!debouncedRouteName }
|
||||
);
|
||||
const { mutate: createBoard, isLoading, isSuccess } = api.boards.create.useMutation();
|
||||
const utils = api.useContext();
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
if (!boardNameAvailable) return;
|
||||
createBoard(values, {
|
||||
onSuccess: async () => {
|
||||
await router.push(`/board/${values.boardName}`);
|
||||
context.closeModal(id);
|
||||
utils.boards.checkNameAvailable.invalidate({ boardName: values.boardName });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Page Title"
|
||||
required
|
||||
{...form.getInputProps('pageTitle')}
|
||||
onChange={(e) => {
|
||||
form.getInputProps('pageTitle').onChange(e);
|
||||
if (!autoBoardName) return;
|
||||
form.setFieldValue(
|
||||
'boardName',
|
||||
e.currentTarget.value
|
||||
.replace(/([A-z0-9])[^A-z0-9]+([A-z0-9])/g, '$1-$2')
|
||||
.replace(/([A-z0-9])[^A-z0-9\-]+([A-z0-9])/g, '$1-$2')
|
||||
.replace(/[^A-z\-0-9]+/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label="Routename"
|
||||
required
|
||||
{...form.getInputProps('boardName')}
|
||||
readOnly={autoBoardName}
|
||||
rightSection={
|
||||
<ActionIcon variant="transparent" onClick={() => setAutoBoardName((c) => !c)}>
|
||||
{autoBoardName ? (
|
||||
<IconPencil size="1.25rem" stroke={1.5} />
|
||||
) : (
|
||||
<IconPencilOff size="1.25rem" stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
}
|
||||
icon={
|
||||
debouncedRouteName !== form.values.boardName || isFetching ? (
|
||||
<Loader size="xs" />
|
||||
) : boardNameAvailable === true ? (
|
||||
<IconCheck size="1.25rem" stroke={1.5} color="green" />
|
||||
) : boardNameAvailable === false ? (
|
||||
<IconAlertTriangle size="1.25rem" stroke={1.5} color="red" />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
label="Allow Guests"
|
||||
description="Allow users that are not logged in to view your board"
|
||||
{...form.getInputProps('allowGuests')}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="button" color="gray" variant="subtle">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={isLoading || isSuccess}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof createBoardSchema>;
|
||||
@@ -39,7 +39,7 @@ export const AppearanceTab = ({
|
||||
}, [debouncedValue]);
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="appearance" pt="lg">
|
||||
<Tabs.Panel value="appearance" pt="sm">
|
||||
<Stack spacing="xs">
|
||||
<Flex gap={5} mb="xs">
|
||||
<IconSelector
|
||||
|
||||
@@ -12,7 +12,7 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="behaviour" pt="xs">
|
||||
<Tabs.Panel value="behaviour" pt="lg">
|
||||
<Stack spacing="xs">
|
||||
<Switch
|
||||
label={t('behaviour.isOpeningNewTab.label')}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
import { Icon } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AppIntegrationPropertyAccessabilityType } from '~/types/app';
|
||||
|
||||
interface GenericSecretInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
setIcon: Icon;
|
||||
secretIsPresent: boolean;
|
||||
type: AppIntegrationPropertyAccessabilityType;
|
||||
onClickUpdateButton: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export const GenericSecretInput = ({
|
||||
label,
|
||||
value,
|
||||
setIcon,
|
||||
secretIsPresent,
|
||||
type,
|
||||
onClickUpdateButton,
|
||||
...props
|
||||
}: GenericSecretInputProps) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const Icon = setIcon;
|
||||
|
||||
const [displayUpdateField, setDisplayUpdateField] = useState<boolean>(!secretIsPresent);
|
||||
const { t } = useTranslation(['layout/modals/add-app', 'common']);
|
||||
|
||||
return (
|
||||
<Card p="xs" withBorder>
|
||||
<Grid>
|
||||
<Grid.Col className={classes.alignSelfCenter} xs={12} md={6}>
|
||||
<Group spacing="sm" noWrap>
|
||||
<ThemeIcon color={secretIsPresent ? 'green' : 'red'} variant="light" size="lg">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Flex justify="start" align="start" direction="column">
|
||||
<Group spacing="xs">
|
||||
<Title className={classes.subtitle} order={6}>
|
||||
{t(label)}
|
||||
</Title>
|
||||
|
||||
<Group spacing="xs">
|
||||
<Badge
|
||||
className={classes.textTransformUnset}
|
||||
color={secretIsPresent ? 'green' : 'red'}
|
||||
variant="dot"
|
||||
>
|
||||
{secretIsPresent
|
||||
? t('integration.type.defined')
|
||||
: t('integration.type.undefined')}
|
||||
</Badge>
|
||||
{type === 'private' ? (
|
||||
<Tooltip
|
||||
label={t('integration.type.explanationPrivate')}
|
||||
width={400}
|
||||
multiline
|
||||
withinPortal
|
||||
withArrow
|
||||
>
|
||||
<Badge className={classes.textTransformUnset} color="orange" variant="dot">
|
||||
{t('integration.type.private')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={t('integration.type.explanationPublic')}
|
||||
width={400}
|
||||
multiline
|
||||
withinPortal
|
||||
withArrow
|
||||
>
|
||||
<Badge className={classes.textTransformUnset} color="red" variant="dot">
|
||||
{t('integration.type.public')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" color="dimmed" w={400}>
|
||||
{type === 'private'
|
||||
? 'Private: Once saved, you cannot read out this value again'
|
||||
: 'Public: Can be read out repeatedly'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} md={6}>
|
||||
<Flex gap={10} justify="end" align="end">
|
||||
{displayUpdateField === true ? (
|
||||
<PasswordInput
|
||||
required
|
||||
defaultValue={value}
|
||||
placeholder="new secret"
|
||||
styles={{ root: { width: 200 } }}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<Button onClick={() => setDisplayUpdateField(true)} variant="light">
|
||||
{t('integration.secrets.update')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
subtitle: {
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
alignSelfCenter: {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
textTransformUnset: {
|
||||
textTransform: 'inherit',
|
||||
},
|
||||
}));
|
||||
@@ -1,187 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Group, Image, Select, SelectItem, Text } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import {
|
||||
AppIntegrationPropertyType,
|
||||
AppIntegrationType,
|
||||
AppType,
|
||||
IntegrationField,
|
||||
integrationFieldDefinitions,
|
||||
integrationFieldProperties,
|
||||
} from '~/types/app';
|
||||
|
||||
interface IntegrationSelectorProps {
|
||||
form: UseFormReturnType<AppType, (item: AppType) => AppType>;
|
||||
}
|
||||
|
||||
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
|
||||
const data = availableIntegrations.filter((x) =>
|
||||
Object.keys(integrationFieldProperties).includes(x.value)
|
||||
);
|
||||
|
||||
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
|
||||
if (!value) return [];
|
||||
const integrationType = value as Exclude<AppIntegrationType['type'], null>;
|
||||
if (integrationType === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const requiredProperties = Object.entries(integrationFieldDefinitions).filter(([k, v]) => {
|
||||
const val = integrationFieldProperties[integrationType];
|
||||
return val.includes(k as IntegrationField);
|
||||
})!;
|
||||
return requiredProperties.map(([k, value]) => ({
|
||||
type: value.type,
|
||||
field: k as IntegrationField,
|
||||
value: undefined,
|
||||
isDefined: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const inputProps = form.getInputProps('integration.type');
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={t('integration.type.label')}
|
||||
description={t('integration.type.description')}
|
||||
placeholder={t('integration.type.placeholder') ?? undefined}
|
||||
itemComponent={SelectItemComponent}
|
||||
data={data}
|
||||
maxDropdownHeight={250}
|
||||
dropdownPosition="bottom"
|
||||
clearable
|
||||
variant="default"
|
||||
searchable
|
||||
zIndex={203}
|
||||
withinPortal
|
||||
filter={(value, item) =>
|
||||
item.label?.toLowerCase().includes(value.toLowerCase().trim()) ||
|
||||
item.description?.toLowerCase().includes(value.toLowerCase().trim())
|
||||
}
|
||||
icon={
|
||||
form.values.integration?.type && (
|
||||
<Image
|
||||
src={data.find((x) => x.value === form.values.integration?.type)?.image}
|
||||
alt="integration"
|
||||
width={20}
|
||||
height={20}
|
||||
fit="contain"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...inputProps}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('integration.properties', getNewProperties(value));
|
||||
inputProps.onChange(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
image: string;
|
||||
description: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>(
|
||||
({ image, label, description, ...others }: ItemProps, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<Image src={image} alt="integration icon" width={20} height={20} fit="contain" />
|
||||
|
||||
<div>
|
||||
<Text size="sm">{label}</Text>
|
||||
{description && (
|
||||
<Text size="xs" color="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export const availableIntegrations = [
|
||||
{
|
||||
value: 'sabnzbd',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
|
||||
label: 'SABnzbd',
|
||||
},
|
||||
{
|
||||
value: 'nzbGet',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
|
||||
label: 'NZBGet',
|
||||
},
|
||||
{
|
||||
value: 'deluge',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png',
|
||||
label: 'Deluge',
|
||||
},
|
||||
{
|
||||
value: 'transmission',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png',
|
||||
label: 'Transmission',
|
||||
},
|
||||
{
|
||||
value: 'qBittorrent',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png',
|
||||
label: 'qBittorrent',
|
||||
},
|
||||
{
|
||||
value: 'jellyseerr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png',
|
||||
label: 'Jellyseerr',
|
||||
},
|
||||
{
|
||||
value: 'overseerr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png',
|
||||
label: 'Overseerr',
|
||||
},
|
||||
{
|
||||
value: 'sonarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png',
|
||||
label: 'Sonarr',
|
||||
},
|
||||
{
|
||||
value: 'radarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png',
|
||||
label: 'Radarr',
|
||||
},
|
||||
{
|
||||
value: 'lidarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png',
|
||||
label: 'Lidarr',
|
||||
},
|
||||
{
|
||||
value: 'readarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
|
||||
label: 'Readarr',
|
||||
},
|
||||
{
|
||||
value: 'jellyfin',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
|
||||
label: 'Jellyfin',
|
||||
},
|
||||
{
|
||||
value: 'plex',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
|
||||
label: 'Plex',
|
||||
},
|
||||
{
|
||||
value: 'pihole',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png',
|
||||
label: 'PiHole',
|
||||
},
|
||||
{
|
||||
value: 'adGuardHome',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
|
||||
label: 'AdGuard Home',
|
||||
},
|
||||
] as const satisfies Readonly<SelectItem[]>;
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Stack } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconKey } from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
AppIntegrationPropertyType,
|
||||
AppType,
|
||||
IntegrationField,
|
||||
integrationFieldDefinitions,
|
||||
integrationFieldProperties,
|
||||
} from '~/types/app';
|
||||
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
|
||||
|
||||
interface IntegrationOptionsRendererProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
}
|
||||
|
||||
export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererProps) => {
|
||||
const selectedIntegration = form.values.integration?.type;
|
||||
|
||||
if (!selectedIntegration) return null;
|
||||
|
||||
const displayedProperties = integrationFieldProperties[selectedIntegration];
|
||||
|
||||
return (
|
||||
<Stack spacing="xs" mb="md">
|
||||
{displayedProperties.map((property, index) => {
|
||||
const [_, definition] = Object.entries(integrationFieldDefinitions).find(
|
||||
([key]) => property === key
|
||||
)!;
|
||||
|
||||
let indexInFormValue =
|
||||
form.values.integration?.properties.findIndex((p) => p.field === property) ?? -1;
|
||||
if (indexInFormValue === -1) {
|
||||
const { type } = Object.entries(integrationFieldDefinitions).find(
|
||||
([k, v]) => k === property
|
||||
)![1];
|
||||
const newProperty: AppIntegrationPropertyType = {
|
||||
type,
|
||||
field: property as IntegrationField,
|
||||
isDefined: false,
|
||||
};
|
||||
form.insertListItem('integration.properties', newProperty);
|
||||
indexInFormValue = form.values.integration!.properties.length;
|
||||
}
|
||||
const formValue = form.values.integration?.properties[indexInFormValue];
|
||||
|
||||
const isPresent = formValue?.isDefined;
|
||||
const accessabilityType = formValue?.type;
|
||||
|
||||
if (!definition) {
|
||||
return (
|
||||
<GenericSecretInput
|
||||
onClickUpdateButton={(value) => {
|
||||
form.setFieldValue(`integration.properties.${index}.value`, value);
|
||||
form.setFieldValue(
|
||||
`integration.properties.${index}.isDefined`,
|
||||
value !== undefined
|
||||
);
|
||||
}}
|
||||
key={`input-${property}`}
|
||||
label={`${property} (potentionally unmapped)`}
|
||||
secretIsPresent={isPresent}
|
||||
setIcon={IconKey}
|
||||
type={accessabilityType}
|
||||
{...form.getInputProps(`integration.properties.${index}.value`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericSecretInput
|
||||
onClickUpdateButton={(value) => {
|
||||
form.setFieldValue(`integration.properties.${index}.value`, value);
|
||||
form.setFieldValue(`integration.properties.${index}.isDefined`, value !== undefined);
|
||||
}}
|
||||
key={`input-${definition.label}`}
|
||||
label={definition.label}
|
||||
secretIsPresent={isPresent}
|
||||
setIcon={definition.icon}
|
||||
type={accessabilityType}
|
||||
{...form.getInputProps(`integration.properties.${index}.value`)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Alert, Divider, Tabs, Text } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
import { AppForm } from '../../EditAppModal';
|
||||
import { IntegrationSelector } from './InputElements/IntegrationSelector';
|
||||
import { IntegrationOptionsRenderer } from './IntegrationOptionsRenderer/IntegrationOptionsRenderer';
|
||||
|
||||
interface IntegrationTabProps {
|
||||
form: AppForm;
|
||||
}
|
||||
|
||||
export const IntegrationTab = ({ form }: IntegrationTabProps) => {
|
||||
return <></>; /*
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const hasIntegrationSelected = form.values.integrationId?.type;
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="integration" pt="lg">
|
||||
<IntegrationSelector form={form} />
|
||||
|
||||
{hasIntegrationSelected && (
|
||||
<>
|
||||
<Divider label={t('integration.type.label')} labelPosition="center" mt="xl" mb="md" />
|
||||
<Text size="sm" color="dimmed" mb="lg">
|
||||
{t('integration.secrets.description')}
|
||||
</Text>
|
||||
<IntegrationOptionsRenderer form={form} />
|
||||
<Alert icon={<IconAlertTriangle />} color="yellow">
|
||||
<Text>
|
||||
<Trans i18nKey="layout/modals/add-app:integration.secrets.warning" />
|
||||
</Text>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);*/
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconBrush,
|
||||
IconClick,
|
||||
IconPlug,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
@@ -23,7 +22,6 @@ import { AppItem } from '../../context';
|
||||
import { AppearanceTab } from './Edit/AppereanceTab';
|
||||
import { BehaviourTab } from './Edit/BehaviourTab';
|
||||
import { GeneralTab } from './Edit/GeneralTab';
|
||||
import { IntegrationTab } from './Edit/IntegrationTab/IntegrationTab';
|
||||
import { NetworkTab } from './Edit/NetworkTab';
|
||||
import { useAppActions } from './app-actions';
|
||||
|
||||
@@ -146,13 +144,6 @@ export const EditAppModal = ({
|
||||
>
|
||||
{t('tabs.appearance')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={[]} />}
|
||||
icon={<IconPlug size={14} />}
|
||||
value="integration"
|
||||
>
|
||||
{t('tabs.integration')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<GeneralTab form={form} />
|
||||
@@ -163,7 +154,6 @@ export const EditAppModal = ({
|
||||
disallowAppNamePropagation={() => setAllowAppNamePropagation(false)}
|
||||
allowAppNamePropagation={allowAppNamePropagation}
|
||||
/>
|
||||
<IntegrationTab form={form} />
|
||||
</Tabs>
|
||||
|
||||
<Group noWrap position="right" mt="md">
|
||||
@@ -196,7 +186,7 @@ const SaveButton = ({ formIsValid }: { formIsValid: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export type EditAppModalTab = 'general' | 'behaviour' | 'network' | 'appereance' | 'integration';
|
||||
export type EditAppModalTab = 'general' | 'behaviour' | 'network' | 'appereance';
|
||||
|
||||
export const appFormSchema = z.object({
|
||||
id: z.string().nonempty(),
|
||||
@@ -213,7 +203,6 @@ export const appFormSchema = z.object({
|
||||
statusCodes: z.array(z.number().min(100).max(999)),
|
||||
openInNewTab: z.boolean(),
|
||||
description: z.string().nonempty().max(512).nullable(),
|
||||
integrationId: z.string().nonempty().nullable(),
|
||||
});
|
||||
|
||||
type FormType = z.infer<typeof appFormSchema>;
|
||||
|
||||
@@ -24,6 +24,7 @@ export const useAppActions = ({ boardName }: { boardName: string }) => {
|
||||
sectionId = prev.sections
|
||||
.filter((section): section is EmptySection => section.type === 'empty')
|
||||
.sort((a, b) => a.position - b.position)[0].id;
|
||||
console.log(sectionId);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -31,15 +32,10 @@ export const useAppActions = ({ boardName }: { boardName: string }) => {
|
||||
sections: prev.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== sectionId) return section;
|
||||
console.log(section);
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
if (item.id !== app.id) return item;
|
||||
return {
|
||||
...(app as AppItem),
|
||||
};
|
||||
}),
|
||||
items: section.items.filter((item) => item.id !== app.id).concat(app as AppItem),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
119
src/components/Board/Layout/Form/LayoutForm.tsx
Normal file
119
src/components/Board/Layout/Form/LayoutForm.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Button, Checkbox, Grid, Group, Input, Slider, Stack, TextInput } from '@mantine/core';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { api } from '~/utils/api';
|
||||
import { layoutCreateFormSchema } from '~/validations/layouts';
|
||||
|
||||
import { LayoutPreviewProps } from './Preview/DesktopPreview';
|
||||
|
||||
type LayoutFormProps = {
|
||||
kind: 'mobile' | 'desktop';
|
||||
columns: {
|
||||
min: number;
|
||||
default: number;
|
||||
max: number;
|
||||
};
|
||||
boardId: string;
|
||||
preview: (props: LayoutPreviewProps) => JSX.Element;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const LayoutForm = ({
|
||||
kind,
|
||||
columns,
|
||||
boardId,
|
||||
preview: Preview,
|
||||
onClose,
|
||||
}: LayoutFormProps) => {
|
||||
const sliderMarks = useMemo(calculateSliderMarks(columns), [columns.min, columns.max]);
|
||||
const router = useRouter();
|
||||
const { mutate: createLayout } = api.layouts.create.useMutation();
|
||||
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
showRightSidebar: false,
|
||||
showLeftSidebar: false,
|
||||
columns: columns.default,
|
||||
name: '',
|
||||
},
|
||||
validate: zodResolver(layoutCreateFormSchema),
|
||||
});
|
||||
|
||||
console.log(router);
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
createLayout(
|
||||
{
|
||||
...values,
|
||||
kind,
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ id }) => {
|
||||
onClose();
|
||||
router.replace({
|
||||
query: {
|
||||
...router.query,
|
||||
layout: id,
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={12} md={6}>
|
||||
<Preview {...form.values} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12} md={6}>
|
||||
<Stack>
|
||||
<Checkbox
|
||||
label="Show left sidebar"
|
||||
{...form.getInputProps('showLeftSidebar', { type: 'checkbox' })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Show right sidebar"
|
||||
{...form.getInputProps('showRightSidebar', { type: 'checkbox' })}
|
||||
/>
|
||||
<Input.Wrapper label="Columns">
|
||||
<Slider
|
||||
marks={sliderMarks}
|
||||
{...form.getInputProps('columns')}
|
||||
min={columns.min}
|
||||
max={columns.max}
|
||||
step={1}
|
||||
thumbLabel=""
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<TextInput label="Layout name" {...form.getInputProps('name')} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group position="right">
|
||||
<Button onClick={onClose} type="button" variant="subtle" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
Create layout
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof layoutCreateFormSchema>;
|
||||
|
||||
const calculateSliderMarks = (columns: Omit<LayoutFormProps['columns'], 'default'>) => () => {
|
||||
return Array.from({ length: 5 })
|
||||
.map((_, index) => index * ((columns.max - columns.min) / 4) + columns.min)
|
||||
.map((value) => ({ value, label: value.toString() }));
|
||||
};
|
||||
147
src/components/Board/Layout/Form/Preview/DesktopPreview.tsx
Normal file
147
src/components/Board/Layout/Form/Preview/DesktopPreview.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Flex, Group, Paper, Stack, createStyles } from '@mantine/core';
|
||||
import { Logo } from '~/components/layout/Common/Logo';
|
||||
import { createDummyArray } from '~/tools/client/arrays';
|
||||
|
||||
export type LayoutPreviewProps = {
|
||||
showLeftSidebar?: boolean;
|
||||
showRightSidebar?: boolean;
|
||||
columns?: number;
|
||||
};
|
||||
export const DesktopLayoutPreview = ({
|
||||
showLeftSidebar,
|
||||
showRightSidebar,
|
||||
columns = 11,
|
||||
}: LayoutPreviewProps) => {
|
||||
const { classes } = usePreviewStyles();
|
||||
|
||||
const sidebarCount =
|
||||
showLeftSidebar && showRightSidebar ? 2 : showLeftSidebar || showRightSidebar ? 1 : 0;
|
||||
const elementWidth = (234 - sidebarCount * 23 - (columns - 1) * 5) / columns;
|
||||
const elementCount = Math.floor(4 * Math.pow(columns, 1.4)) - 1;
|
||||
const elementCountAfterSidebar = elementCount - sidebarCount * 2;
|
||||
|
||||
return (
|
||||
<Stack spacing="xs" w={256}>
|
||||
<Paper px="xs" py={4} withBorder>
|
||||
<Stack spacing={2}>
|
||||
<Group position="apart">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Logo size="xs" withoutText />
|
||||
</div>
|
||||
<BaseElement width={64} height={10} />
|
||||
<Group noWrap spacing={2} style={{ flex: 1 }} position="right">
|
||||
<BaseElement width={10} height={10} />
|
||||
<BaseElement width={10} height={10} />
|
||||
<BaseElement width={10} height={10} />
|
||||
<BaseElement width={10} height={10} />
|
||||
</Group>
|
||||
</Group>
|
||||
<Group></Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Flex gap={6} pos="relative">
|
||||
{showLeftSidebar && (
|
||||
<Paper h={175} className={classes.secondaryWrapper} p="xs" withBorder>
|
||||
<Flex
|
||||
gap={5}
|
||||
align="start"
|
||||
wrap="wrap"
|
||||
w={elementWidth * 2 + 5}
|
||||
style={{
|
||||
maxHeight: 'calc(100% + 10px)',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
>
|
||||
{createDummyArray(Math.floor((elementCount / columns) * 2)).map((_item, index) => (
|
||||
<PlaceholderElement
|
||||
height={elementWidth}
|
||||
width={index % 4 === 0 ? elementWidth * 2 + 5 : elementWidth}
|
||||
key={`example-item-left-sidebard-${index}`}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
className={classes.primaryWrapper}
|
||||
h={175}
|
||||
style={{ overflow: 'hidden' }}
|
||||
p="xs"
|
||||
withBorder
|
||||
>
|
||||
<Flex gap={5} wrap="wrap">
|
||||
{createDummyArray(elementCountAfterSidebar).map((_item, index) => (
|
||||
<PlaceholderElement
|
||||
height={elementWidth}
|
||||
width={
|
||||
(index % 5 === 0 || index % 7 === 0) && columns >= 2
|
||||
? elementWidth * 2 + 5
|
||||
: elementWidth
|
||||
}
|
||||
key={`example-item-main-${index}`}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Paper>
|
||||
|
||||
{showRightSidebar && (
|
||||
<Paper h={175} className={classes.secondaryWrapper} p="xs" withBorder>
|
||||
<Flex
|
||||
gap={5}
|
||||
align="start"
|
||||
wrap="wrap"
|
||||
w={elementWidth * 2 + 5}
|
||||
style={{
|
||||
maxHeight: 'calc(100% + 10px)',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
>
|
||||
{createDummyArray(Math.floor((elementCount / columns) * 2)).map((_item, index) => (
|
||||
<PlaceholderElement
|
||||
height={elementWidth}
|
||||
width={index % 4 === 0 ? elementWidth * 2 + 5 : elementWidth}
|
||||
key={`example-item-right-sidebard-${index}`}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Paper>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const usePreviewStyles = createStyles((theme) => ({
|
||||
primaryWrapper: {
|
||||
flexGrow: 2,
|
||||
},
|
||||
secondaryWrapper: {
|
||||
flexGrow: 1,
|
||||
maxWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const BaseElement = ({ height, width }: { height: number; width: number | string }) => (
|
||||
<Paper
|
||||
sx={(theme) => ({
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.gray[8] : theme.colors.gray[1],
|
||||
})}
|
||||
h={height}
|
||||
p={2}
|
||||
w={width}
|
||||
/>
|
||||
);
|
||||
|
||||
type PlaceholderElementProps = {
|
||||
height: number;
|
||||
width: number;
|
||||
index: number;
|
||||
};
|
||||
const PlaceholderElement = ({ height, width, index }: PlaceholderElementProps) => {
|
||||
return <BaseElement width={width} height={height} />;
|
||||
};
|
||||
111
src/components/Board/Layout/Form/Preview/MobilePreview.tsx
Normal file
111
src/components/Board/Layout/Form/Preview/MobilePreview.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Flex, Group, Paper, Stack, createStyles } from '@mantine/core';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
import { Logo } from '~/components/layout/Common/Logo';
|
||||
import { createDummyArray } from '~/tools/client/arrays';
|
||||
|
||||
import { LayoutPreviewProps } from './DesktopPreview';
|
||||
|
||||
export const MobileLayoutPreview = ({
|
||||
showLeftSidebar,
|
||||
showRightSidebar,
|
||||
columns = 3,
|
||||
}: LayoutPreviewProps) => {
|
||||
const { classes } = usePreviewStyles();
|
||||
|
||||
const elementWidth = (100 - (columns - 1) * 5) / columns;
|
||||
const elementCount = Math.floor(4 * Math.pow(columns, 1.5)) - 1;
|
||||
|
||||
return (
|
||||
<Stack spacing="xs" w={122}>
|
||||
<Paper px="xs" py={4} withBorder>
|
||||
<Stack spacing={2}>
|
||||
<Group position="apart">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Logo size="xs" withoutText />
|
||||
</div>
|
||||
<Group noWrap spacing={2}>
|
||||
<BaseElement width={10} height={10} />
|
||||
<BaseElement width={10} height={10} />
|
||||
<BaseElement width={10} height={10} />
|
||||
<BaseElement width={10} height={10} />
|
||||
</Group>
|
||||
</Group>
|
||||
<Group>
|
||||
<BaseElement width="100%" height={10} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Flex gap={6} pos="relative">
|
||||
{showLeftSidebar && (
|
||||
<Flex pos="absolute" left={0} h="100%" align="center">
|
||||
<Paper className={classes.secondaryWrapper} p={2} withBorder h={32}>
|
||||
<IconChevronRight size={8} />
|
||||
</Paper>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
className={classes.primaryWrapper}
|
||||
h={175}
|
||||
style={{ overflow: 'hidden' }}
|
||||
p="xs"
|
||||
withBorder
|
||||
>
|
||||
<Flex gap={5} wrap="wrap">
|
||||
{createDummyArray(elementCount).map((_item, index) => (
|
||||
<PlaceholderElement
|
||||
height={elementWidth}
|
||||
width={
|
||||
(index % 5 === 0 || index % 7 === 0) && columns >= 2
|
||||
? elementWidth * 2 + 5
|
||||
: elementWidth
|
||||
}
|
||||
key={`example-item-main-${index}`}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Paper>
|
||||
|
||||
{showRightSidebar && (
|
||||
<Flex pos="absolute" right={0} h="100%" align="center">
|
||||
<Paper className={classes.secondaryWrapper} p={2} withBorder h={32}>
|
||||
<IconChevronLeft size={8} />
|
||||
</Paper>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const usePreviewStyles = createStyles((theme) => ({
|
||||
primaryWrapper: {
|
||||
flexGrow: 2,
|
||||
},
|
||||
secondaryWrapper: {
|
||||
flexGrow: 1,
|
||||
maxWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const BaseElement = ({ height, width }: { height: number; width: number | string }) => (
|
||||
<Paper
|
||||
sx={(theme) => ({
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.gray[8] : theme.colors.gray[1],
|
||||
})}
|
||||
h={height}
|
||||
p={2}
|
||||
w={width}
|
||||
/>
|
||||
);
|
||||
|
||||
type PlaceholderElementProps = {
|
||||
height: number;
|
||||
width: number;
|
||||
index: number;
|
||||
};
|
||||
const PlaceholderElement = ({ height, width, index }: PlaceholderElementProps) => {
|
||||
return <BaseElement width={width} height={height} />;
|
||||
};
|
||||
118
src/components/Board/Layout/LayoutCreateModal.tsx
Normal file
118
src/components/Board/Layout/LayoutCreateModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Grid, Stack, Text, UnstyledButton, createStyles } from '@mantine/core';
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { IconDeviceDesktop, IconDeviceMobile, TablerIconsProps } from '@tabler/icons-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { LayoutKind } from '~/server/db/items';
|
||||
import { isMobileUserAgent } from '~/validations/mobile';
|
||||
|
||||
import { useStyles } from '../SelectElement/Shared/styles';
|
||||
import { LayoutForm } from './Form/LayoutForm';
|
||||
import { DesktopLayoutPreview } from './Form/Preview/DesktopPreview';
|
||||
import { MobileLayoutPreview } from './Form/Preview/MobilePreview';
|
||||
|
||||
export const CreateLayoutModal = ({
|
||||
id,
|
||||
context,
|
||||
innerProps,
|
||||
}: ContextModalProps<{ boardId: string }>) => {
|
||||
const currentKind = isMobileUserAgent(navigator.userAgent) ? 'mobile' : 'desktop';
|
||||
const [kind, setKind] = useState<LayoutKind>();
|
||||
|
||||
if (kind === 'mobile') {
|
||||
return (
|
||||
<LayoutForm
|
||||
kind="mobile"
|
||||
columns={{ min: 1, default: 3, max: 9 }}
|
||||
preview={MobileLayoutPreview}
|
||||
onClose={() => context.closeModal(id)}
|
||||
{...innerProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'desktop') {
|
||||
return (
|
||||
<LayoutForm
|
||||
kind="desktop"
|
||||
columns={{ min: 7, default: 11, max: 23 }}
|
||||
preview={DesktopLayoutPreview}
|
||||
onClose={() => context.closeModal(id)}
|
||||
{...innerProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col span={12} md={6}>
|
||||
<KindItem
|
||||
isCurrent={currentKind === 'desktop'}
|
||||
kind="desktop"
|
||||
onSelect={setKind}
|
||||
icon={IconDeviceDesktop}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12} md={6}>
|
||||
<KindItem
|
||||
isCurrent={currentKind === 'mobile'}
|
||||
kind="mobile"
|
||||
onSelect={setKind}
|
||||
icon={IconDeviceMobile}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
interface KindItemProps {
|
||||
isCurrent?: boolean;
|
||||
kind: LayoutKind;
|
||||
onSelect: (kind: LayoutKind) => void;
|
||||
icon: (props: TablerIconsProps) => JSX.Element;
|
||||
}
|
||||
|
||||
const KindItem = ({ isCurrent, kind, onSelect, icon: Icon }: KindItemProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const { classes: additionalClasses } = useAditionalStyles();
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={cx(
|
||||
classes.elementButton,
|
||||
classes.styledButton,
|
||||
isCurrent && additionalClasses.currentKind
|
||||
)}
|
||||
onClick={() => onSelect(kind)}
|
||||
py="md"
|
||||
pos="relative"
|
||||
>
|
||||
<Stack className={classes.elementStack} align="center" spacing={5}>
|
||||
<motion.div
|
||||
// On hover zoom in
|
||||
whileHover={{ scale: 1.2 }}
|
||||
>
|
||||
<Icon size={40} strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
<Text className={classes.elementName} weight={500} size="sm">
|
||||
{kind} Layout
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
const useAditionalStyles = createStyles((theme) => ({
|
||||
currentKind: {
|
||||
':after': {
|
||||
content: '"Your device"',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
padding: '0.25rem',
|
||||
backgroundColor: 'red',
|
||||
borderTopRightRadius: theme.radius.sm,
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
border: '1px solid red',
|
||||
},
|
||||
}));
|
||||
@@ -15,7 +15,7 @@ export const BoardProvider = ({ children, ...props }: BoardProviderProps) => {
|
||||
const { data: board } = api.boards.byName.useQuery(
|
||||
{
|
||||
boardName: props.initialBoard.name,
|
||||
layout: props.layout,
|
||||
layoutId: props.layout,
|
||||
},
|
||||
{
|
||||
initialData: props.initialBoard,
|
||||
@@ -63,3 +63,5 @@ type ItemOfType<TItem extends Item, TItemType extends Item['type']> = TItem exte
|
||||
: never;
|
||||
export type AppItem = ItemOfType<Item, 'app'>;
|
||||
export type WidgetItem = ItemOfType<Item, 'widget'>;
|
||||
|
||||
export type IntegrationSecret = Exclude<AppItem['integration'], null>['secrets'][number];
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Button, Global, Text, Title, Tooltip, clsx } from '@mantine/core';
|
||||
import { Button, Global, Menu, Text, Title, Tooltip, clsx } from '@mantine/core';
|
||||
import { useHotkeys, useWindowEvent } from '@mantine/hooks';
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconApps,
|
||||
IconBox,
|
||||
IconBrandDocker,
|
||||
IconEditCircle,
|
||||
IconEditCircleOff,
|
||||
IconLayoutBoardSplit,
|
||||
IconLayoutDashboard,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
} from '@tabler/icons-react';
|
||||
import Consola from 'consola';
|
||||
@@ -19,7 +24,7 @@ import { useNamedWrapperColumnCount } from '~/components/Board/gridstack/store';
|
||||
import { useEditModeStore } from '~/components/Board/useEditModeStore';
|
||||
import { BoardHeadOverride } from '~/components/layout/Meta/BoardHeadOverride';
|
||||
import { HeaderActionButton } from '~/components/layout/header/ActionButton';
|
||||
import { api } from '~/utils/api';
|
||||
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||
|
||||
import { MainLayout } from './MainLayout';
|
||||
|
||||
@@ -56,6 +61,7 @@ export const HeaderActions = ({ dockerEnabled }: HeaderActionProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateMenu />
|
||||
{dockerEnabled && <DockerButton />}
|
||||
<ToggleEditModeButton />
|
||||
<CustomizeBoardButton />
|
||||
@@ -75,6 +81,50 @@ const DockerButton = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CreateMenu = () => {
|
||||
const board = useRequiredBoard();
|
||||
const createBoard = () => {
|
||||
openContextModalGeneric({
|
||||
modal: 'createBoardModal',
|
||||
title: 'Create a new board',
|
||||
innerProps: {},
|
||||
});
|
||||
};
|
||||
|
||||
const createLayout = () => {
|
||||
openContextModalGeneric({
|
||||
modal: 'createLayoutModal',
|
||||
title: 'Create a new layout',
|
||||
innerProps: {
|
||||
boardId: board.id,
|
||||
},
|
||||
size: 'lg',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<HeaderActionButton onClick={() => {}}>
|
||||
<IconPlus size={20} stroke={1.5} />
|
||||
</HeaderActionButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item icon={<IconBox stroke={1.5} size="1rem" />}>New app</Menu.Item>
|
||||
<Menu.Item icon={<IconPlug stroke={1.5} size="1rem" />}>New integration</Menu.Item>
|
||||
<Menu.Divider></Menu.Divider>
|
||||
<Menu.Item onClick={createLayout} icon={<IconLayoutBoardSplit stroke={1.5} size="1rem" />}>
|
||||
New layout
|
||||
</Menu.Item>
|
||||
<Menu.Divider></Menu.Divider>
|
||||
<Menu.Item onClick={createBoard} icon={<IconLayoutDashboard stroke={1.5} size="1rem" />}>
|
||||
New board
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomizeBoardButton = () => {
|
||||
const { name } = useRequiredBoard();
|
||||
const { t } = useTranslation('boards/common');
|
||||
|
||||
@@ -20,9 +20,9 @@ import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useMemo } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { availableIntegrations } from '~/components/Board/Items/App/Edit/IntegrationTab/InputElements/IntegrationSelector';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { RequestModal } from '~/modules/overseerr/RequestModal';
|
||||
import { integrationTypes } from '~/server/db/items';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
type MovieModalProps = {
|
||||
@@ -45,7 +45,7 @@ export const MovieModal = ({ opened, closeModal }: MovieModalProps) => {
|
||||
}
|
||||
|
||||
const integration = useMemo(() => {
|
||||
return availableIntegrations.find((x) => x.value === queryParams.data.type)!;
|
||||
return integrationTypes[queryParams.data.type];
|
||||
}, [queryParams.data.type]);
|
||||
|
||||
return (
|
||||
@@ -56,7 +56,12 @@ export const MovieModal = ({ opened, closeModal }: MovieModalProps) => {
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
title={
|
||||
<Group>
|
||||
<Image src={integration.image} width={30} height={30} alt={`${integration.label} icon`} />
|
||||
<Image
|
||||
src={integration.iconUrl}
|
||||
width={30}
|
||||
height={30}
|
||||
alt={`${integration.label} icon`}
|
||||
/>
|
||||
<Title order={4}>{integration.label} search</Title>
|
||||
</Group>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { env } from 'process';
|
||||
|
||||
import { getUrl } from './tools/server/url';
|
||||
import { client } from './utils/api';
|
||||
import { isMobileUserAgent } from './validations/mobile';
|
||||
|
||||
const skippedUrls = [
|
||||
'/onboard',
|
||||
|
||||
@@ -5,7 +5,9 @@ import { WidgetsEditModal } from '~/components/Board/Items/Widget/WidgetsEditMod
|
||||
import { CategoryEditModal } from '~/components/Board/Sections/Category/CategoryEditModal';
|
||||
import { SelectElementModal } from '~/components/Board/SelectElement/SelectElementModal';
|
||||
|
||||
import { CreateBoardModal } from './components/Manage/Board/create-board.modal';
|
||||
import { CreateBoardModal } from './components/Board/BoardCreateModal';
|
||||
import { CreateLayoutModal } from './components/Board/Layout/LayoutCreateModal';
|
||||
//import { CreateBoardModal } from './components/Manage/Board/create-board.modal';
|
||||
import { DeleteBoardModal } from './components/Manage/Board/delete-board.modal';
|
||||
import { DockerSelectBoardModal } from './components/Manage/Tools/Docker/docker-select-board.modal';
|
||||
import { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal';
|
||||
@@ -24,7 +26,9 @@ export const modals = {
|
||||
deleteUserModal: DeleteUserModal,
|
||||
createInviteModal: CreateInviteModal,
|
||||
deleteInviteModal: DeleteInviteModal,
|
||||
//createBoardModal: CreateBoardModal,
|
||||
createBoardModal: CreateBoardModal,
|
||||
createLayoutModal: CreateLayoutModal,
|
||||
copyInviteModal: CopyInviteModal,
|
||||
deleteBoardModal: DeleteBoardModal,
|
||||
changeUserRoleModal: ChangeUserRoleModal,
|
||||
|
||||
@@ -36,9 +36,14 @@ const routeParamsSchema = z.object({
|
||||
slug: z.string(),
|
||||
});
|
||||
|
||||
const querySchema = z.object({
|
||||
layout: z.string().optional(),
|
||||
});
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async (ctx) => {
|
||||
const routeParams = routeParamsSchema.safeParse(ctx.params);
|
||||
if (!routeParams.success) {
|
||||
const query = querySchema.safeParse(ctx.query);
|
||||
if (!routeParams.success || !query.success) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
@@ -53,7 +58,7 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
|
||||
const helpers = await createTrpcServersideHelpers(ctx);
|
||||
const board = await helpers.boards.byName
|
||||
.fetch({ boardName: routeParams.data.slug })
|
||||
.fetch({ boardName: routeParams.data.slug, layoutId: query.data.layout })
|
||||
.catch((err) => {
|
||||
if (err instanceof TRPCError && err.code === 'NOT_FOUND') {
|
||||
return null;
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { createTRPCRouter } from '~/server/api/trpc';
|
||||
|
||||
import { appRouter } from './routers/app';
|
||||
import { boardRouter } from './routers/board';
|
||||
import { calendarRouter } from './routers/calendar';
|
||||
import { configRouter } from './routers/config';
|
||||
import { dashDotRouter } from './routers/dash-dot';
|
||||
import { dnsHoleRouter } from './routers/dns-hole/router';
|
||||
import { dockerRouter } from './routers/docker/router';
|
||||
import { downloadRouter } from './routers/download';
|
||||
import { iconRouter } from './routers/icon';
|
||||
import { inviteRouter } from './routers/invite';
|
||||
import { layoutsRouter } from './routers/layout/layout.router';
|
||||
import { mediaRequestsRouter } from './routers/media-request';
|
||||
import { mediaServerRouter } from './routers/media-server';
|
||||
import { notebookRouter } from './routers/notebook';
|
||||
import { overseerrRouter } from './routers/overseerr';
|
||||
import { passwordRouter } from './routers/password';
|
||||
import { rssRouter } from './routers/rss';
|
||||
import { timezoneRouter } from './routers/timezone';
|
||||
import { usenetRouter } from './routers/usenet/router';
|
||||
import { userRouter } from './routers/user';
|
||||
import { weatherRouter } from './routers/weather';
|
||||
import { dockerRouter } from './routers/docker/router';
|
||||
import { usenetRouter } from './routers/usenet/router';
|
||||
import { createTRPCRouter } from '~/server/api/trpc';
|
||||
import { timezoneRouter } from './routers/timezone';
|
||||
import { notebookRouter } from './routers/notebook';
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -45,7 +47,8 @@ export const rootRouter = createTRPCRouter({
|
||||
invites: inviteRouter,
|
||||
boards: boardRouter,
|
||||
password: passwordRouter,
|
||||
notebook: notebookRouter
|
||||
notebook: notebookRouter,
|
||||
layouts: layoutsRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -4,7 +4,7 @@ import { eq, inArray } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { WidgetType } from '~/server/db/items';
|
||||
import { LayoutKind, WidgetType } from '~/server/db/items';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import {
|
||||
appItems,
|
||||
@@ -20,7 +20,8 @@ import { configExists } from '~/tools/config/configExists';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
import { generateDefaultApp } from '~/tools/shared/app';
|
||||
import { boardCustomizationSchema } from '~/validations/boards';
|
||||
import { boardCustomizationSchema, createBoardSchema } from '~/validations/boards';
|
||||
import { isMobileUserAgent } from '~/validations/mobile';
|
||||
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
import { configNameSchema } from './config';
|
||||
@@ -118,12 +119,9 @@ export const boardRouter = createTRPCRouter({
|
||||
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||
}),
|
||||
byName: publicProcedure
|
||||
.input(z.object({ boardName: configNameSchema, layout: z.string().optional() }))
|
||||
.input(z.object({ boardName: configNameSchema, layoutId: z.string().optional() }))
|
||||
.query(async ({ input }) => {
|
||||
const board = await getFullBoardWithLayoutSectionsAsync(
|
||||
input.boardName,
|
||||
input.layout ?? 'default'
|
||||
);
|
||||
const board = await getFullBoardWithLayoutSectionsAsync(input.boardName, input.layoutId);
|
||||
|
||||
if (!board) {
|
||||
throw new TRPCError({
|
||||
@@ -158,6 +156,7 @@ export const boardRouter = createTRPCRouter({
|
||||
const { layouts, ...withoutLayouts } = board;
|
||||
return {
|
||||
...withoutLayouts,
|
||||
layoutName: layout.name,
|
||||
sections: preparedSections,
|
||||
};
|
||||
}),
|
||||
@@ -194,8 +193,6 @@ export const boardRouter = createTRPCRouter({
|
||||
.update(boards)
|
||||
.set({
|
||||
allowGuests: input.customization.access.allowGuests,
|
||||
isLeftSidebarVisible: input.customization.layout.leftSidebarEnabled,
|
||||
isRightSidebarVisible: input.customization.layout.rightSidebarEnabled,
|
||||
isPingEnabled: input.customization.layout.pingsEnabled,
|
||||
appOpacity: input.customization.appearance.opacity,
|
||||
backgroundImageUrl: input.customization.appearance.backgroundSrc,
|
||||
@@ -228,6 +225,7 @@ export const boardRouter = createTRPCRouter({
|
||||
id: layoutId,
|
||||
name: 'default',
|
||||
boardId,
|
||||
kind: 'desktop',
|
||||
});
|
||||
|
||||
await db.insert(sections).values({
|
||||
@@ -325,8 +323,76 @@ export const boardRouter = createTRPCRouter({
|
||||
y: 1,
|
||||
});
|
||||
}),
|
||||
create: adminProcedure.input(createBoardSchema).mutation(async ({ ctx, input }) => {
|
||||
const existingBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.name, input.boardName),
|
||||
});
|
||||
|
||||
if (existingBoard) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Board already exists',
|
||||
});
|
||||
}
|
||||
|
||||
const isMobile = isMobileUserAgent(ctx.headers['user-agent'] ?? '');
|
||||
const boardId = randomUUID();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: input.boardName,
|
||||
pageTitle: input.pageTitle,
|
||||
allowGuests: input.allowGuests,
|
||||
ownerId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
const mobileLayout = await addLayoutAsync({
|
||||
boardId,
|
||||
name: 'Mobile layout',
|
||||
kind: 'mobile',
|
||||
});
|
||||
|
||||
const desktopLayout = await addLayoutAsync({
|
||||
boardId,
|
||||
name: 'Desktop layout',
|
||||
kind: 'desktop',
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
return mobileLayout;
|
||||
}
|
||||
return desktopLayout;
|
||||
}),
|
||||
checkNameAvailable: adminProcedure
|
||||
.input(z.object({ boardName: configNameSchema }))
|
||||
.query(async ({ input }) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.name, input.boardName),
|
||||
});
|
||||
|
||||
return !board;
|
||||
}),
|
||||
});
|
||||
|
||||
type AddLayoutProps = {
|
||||
boardId: string;
|
||||
name: string;
|
||||
kind: LayoutKind;
|
||||
};
|
||||
const addLayoutAsync = async (props: AddLayoutProps) => {
|
||||
const layout = {
|
||||
id: randomUUID(),
|
||||
...props,
|
||||
};
|
||||
await db.insert(layouts).values(layout);
|
||||
await db.insert(sections).values({
|
||||
id: randomUUID(),
|
||||
layoutId: layout.id,
|
||||
type: 'empty',
|
||||
position: 0,
|
||||
});
|
||||
return layout;
|
||||
};
|
||||
|
||||
type AddWidgetProps = {
|
||||
boardId: string;
|
||||
sectionId: string;
|
||||
@@ -440,7 +506,10 @@ const getAppsForSectionsAsync = async (sectionIds: string[]) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getFullBoardWithLayoutSectionsAsync = async (boardName: string, layoutName: string) => {
|
||||
const getFullBoardWithLayoutSectionsAsync = async (
|
||||
boardName: string,
|
||||
layoutId: string | undefined
|
||||
) => {
|
||||
return await db.query.boards.findFirst({
|
||||
columns: {
|
||||
ownerId: false,
|
||||
@@ -461,7 +530,7 @@ const getFullBoardWithLayoutSectionsAsync = async (boardName: string, layoutName
|
||||
orderBy: sections.position,
|
||||
},
|
||||
},
|
||||
where: eq(layouts.name, layoutName),
|
||||
where: layoutId ? eq(layouts.id, layoutId) : undefined,
|
||||
},
|
||||
},
|
||||
where: eq(boards.name, boardName),
|
||||
@@ -556,7 +625,7 @@ const mapApp = (appItem: MapApp) => {
|
||||
const { sectionId, itemId, id, ...commonLayoutItem } = appItem.item.layouts.at(0)!;
|
||||
const common = { ...commonLayoutItem, id: itemId };
|
||||
const { app: innerApp, appId, itemId: _itemId, item, ...otherAppItem } = appItem;
|
||||
const { id: _id, integration, statusCodes, ...app } = appItem.app!;
|
||||
const { id: _id, integration, statusCodes, integrationId, ...app } = appItem.app!;
|
||||
return {
|
||||
...common,
|
||||
...otherAppItem,
|
||||
|
||||
26
src/server/api/routers/layout/layout.router.ts
Normal file
26
src/server/api/routers/layout/layout.router.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '~/server/db';
|
||||
import { layouts } from '~/server/db/schema';
|
||||
import { layoutCreateSchema } from '~/validations/layouts';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../../trpc';
|
||||
|
||||
export const layoutsRouter = createTRPCRouter({
|
||||
create: publicProcedure.input(layoutCreateSchema).mutation(async ({ ctx, input }) => {
|
||||
const id = randomUUID();
|
||||
await db
|
||||
.insert(layouts)
|
||||
.values({
|
||||
id,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
boardId: input.boardId,
|
||||
showLeftSidebar: input.showLeftSidebar,
|
||||
showRightSidebar: input.showRightSidebar,
|
||||
columnCount: input.columns,
|
||||
})
|
||||
.execute();
|
||||
|
||||
return { id };
|
||||
}),
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { type Session } from 'next-auth';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
@@ -25,6 +26,7 @@ import { getServerAuthSession } from '../auth';
|
||||
interface CreateContextOptions {
|
||||
session: Session | null;
|
||||
cookies: Partial<Record<string, string>>;
|
||||
headers: IncomingHttpHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,7 @@ interface CreateContextOptions {
|
||||
const createInnerTRPCContext = (opts: CreateContextOptions) => ({
|
||||
session: opts.session,
|
||||
cookies: opts.cookies,
|
||||
headers: opts.headers,
|
||||
});
|
||||
|
||||
export type TRPCContext = ReturnType<typeof createInnerTRPCContext>;
|
||||
@@ -59,6 +62,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
return createInnerTRPCContext({
|
||||
session,
|
||||
cookies: req.cookies,
|
||||
headers: req.headers,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export const statusCodeTypes = [
|
||||
'serverError',
|
||||
] as const;
|
||||
export const sectionTypes = ['sidebar', 'empty', 'category', 'hidden'] as const;
|
||||
export const layoutKinds = ['mobile', 'desktop'] as const;
|
||||
export const integrationTypes = {
|
||||
readarr: {
|
||||
secrets: ['apiKey'],
|
||||
@@ -162,6 +163,7 @@ export type AppNamePosition = (typeof appNamePositions)[number];
|
||||
export type AppNameStyle = (typeof appNameStyles)[number];
|
||||
export type StatusCodeType = (typeof statusCodeTypes)[number];
|
||||
export type SectionType = (typeof sectionTypes)[number];
|
||||
export type LayoutKind = (typeof layoutKinds)[number];
|
||||
|
||||
type InferIntegrationTypeFromGroup<TGroup extends IntegrationGroup> = {
|
||||
[key in keyof typeof integrationTypes]: (typeof integrationTypes)[key] extends {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IntegrationSecretKey,
|
||||
IntegrationSecretVisibility,
|
||||
IntegrationType,
|
||||
LayoutKind,
|
||||
SectionType,
|
||||
StatusCodeType,
|
||||
WidgetOptionType,
|
||||
@@ -113,8 +114,6 @@ export const boards = sqliteTable('board', {
|
||||
name: text('name').notNull(),
|
||||
|
||||
// Layout settings
|
||||
isLeftSidebarVisible: int('is_left_sidebar_visible', { mode: 'boolean' }).default(false),
|
||||
isRightSidebarVisible: int('is_right_sidebar_visible', { mode: 'boolean' }).default(false),
|
||||
isPingEnabled: int('is_ping_enabled', { mode: 'boolean' }).default(false),
|
||||
|
||||
// Access control
|
||||
@@ -142,9 +141,7 @@ export const boards = sqliteTable('board', {
|
||||
|
||||
export const integrations = sqliteTable('integration', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').$type<IntegrationType>().notNull(),
|
||||
url: text('url').notNull(),
|
||||
});
|
||||
|
||||
export const integrationSecrets = sqliteTable(
|
||||
@@ -255,6 +252,10 @@ export const layoutItems = sqliteTable('layout_item', {
|
||||
export const layouts = sqliteTable('layout', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind').$type<LayoutKind>().notNull(),
|
||||
showRightSidebar: int('show_right_sidebar', { mode: 'boolean' }).notNull().default(false),
|
||||
showLeftSidebar: int('show_left_sidebar', { mode: 'boolean' }).notNull().default(false),
|
||||
columnCount: int('column_count').notNull().default(10),
|
||||
boardId: text('board_id')
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const objectKeys = <T extends object>(
|
||||
obj: T
|
||||
): (keyof T extends infer U
|
||||
@@ -39,3 +41,10 @@ export type Entry<T extends {}> = T extends readonly [unknown, ...unknown[]]
|
||||
: T extends ReadonlyArray<infer U>
|
||||
? [`${number}`, U]
|
||||
: ObjectEntry<T>;
|
||||
|
||||
export const zodEnumFromObjKeys = <K extends string>(
|
||||
obj: Record<K, any>
|
||||
): z.ZodEnum<[K, ...K[]]> => {
|
||||
const [firstKey, ...otherKeys] = Object.keys(obj) as K[];
|
||||
return z.enum([firstKey, ...otherKeys]);
|
||||
};
|
||||
|
||||
@@ -2,24 +2,29 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { AppItem } from '~/components/Board/context';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
export const generateDefaultApp2 = (): Omit<AppItem, 'height' | 'width' | 'x' | 'y'> => ({
|
||||
id: uuidv4(),
|
||||
type: 'app',
|
||||
name: 'Your app',
|
||||
internalUrl: 'https://homarr.dev',
|
||||
externalUrl: 'https://homarr.dev',
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
nameStyle: 'normal',
|
||||
namePosition: 'top',
|
||||
nameLineClamp: 1,
|
||||
fontSize: 16,
|
||||
isPingEnabled: true,
|
||||
statusCodes: [200, 301, 302, 304, 307, 308],
|
||||
openInNewTab: true,
|
||||
description: null,
|
||||
integration: null,
|
||||
integrationId: null,
|
||||
});
|
||||
export const generateDefaultApp2 = (): AppItem => {
|
||||
const appId = uuidv4();
|
||||
return {
|
||||
id: appId,
|
||||
type: 'app',
|
||||
name: 'Your app',
|
||||
internalUrl: 'https://homarr.dev',
|
||||
externalUrl: 'https://homarr.dev',
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
nameStyle: 'normal',
|
||||
namePosition: 'top',
|
||||
nameLineClamp: 1,
|
||||
fontSize: 16,
|
||||
isPingEnabled: true,
|
||||
statusCodes: [200, 301, 302, 304, 307, 308],
|
||||
openInNewTab: true,
|
||||
description: null,
|
||||
height: 1,
|
||||
width: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateDefaultApp = (wrapperId: string): AppType =>
|
||||
({
|
||||
|
||||
@@ -7,7 +7,7 @@ export const createBoardSchemaValidation = z.object({
|
||||
|
||||
export const boardCustomizationSchema = z.object({
|
||||
access: z.object({
|
||||
allowGuests: z.boolean()
|
||||
allowGuests: z.boolean(),
|
||||
}),
|
||||
layout: z.object({
|
||||
leftSidebarEnabled: z.boolean(),
|
||||
@@ -41,3 +41,11 @@ export const boardCustomizationSchema = z.object({
|
||||
customCss: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const boardNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
|
||||
|
||||
export const createBoardSchema = z.object({
|
||||
pageTitle: z.string(),
|
||||
boardName: boardNameSchema,
|
||||
allowGuests: z.boolean(),
|
||||
});
|
||||
|
||||
16
src/validations/layouts.ts
Normal file
16
src/validations/layouts.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
import { layoutKinds } from '~/server/db/items';
|
||||
|
||||
export const layoutCreateFormSchema = z.object({
|
||||
showRightSidebar: z.boolean(),
|
||||
showLeftSidebar: z.boolean(),
|
||||
columns: z.number(),
|
||||
name: z.string().nonempty().max(64),
|
||||
});
|
||||
|
||||
export const layoutCreateSchema = z
|
||||
.object({
|
||||
boardId: z.string(),
|
||||
kind: z.enum(layoutKinds),
|
||||
})
|
||||
.merge(layoutCreateFormSchema);
|
||||
16
src/validations/mobile.ts
Normal file
16
src/validations/mobile.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const regex1 =
|
||||
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i;
|
||||
const regex2 =
|
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i;
|
||||
|
||||
const schemaWithoutSubstring = z.string().regex(regex1);
|
||||
const schemaWithSubstring = z.string().regex(regex2);
|
||||
|
||||
export const isMobileUserAgent = (userAgent: string) => {
|
||||
return (
|
||||
schemaWithoutSubstring.safeParse(userAgent).success ||
|
||||
schemaWithSubstring.safeParse(userAgent.substring(0, 5)).success
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user