Improve layout system, Add parts of create button for apps, integrations, layouts and boards

This commit is contained in:
Meier Lukas
2023-10-20 23:04:28 +02:00
parent 98007f3776
commit 5723c4a291
31 changed files with 884 additions and 524 deletions

View File

@@ -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:

View 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>;

View File

@@ -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

View File

@@ -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')}

View File

@@ -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',
},
}));

View File

@@ -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[]>;

View File

@@ -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>
);
};

View File

@@ -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>
);*/
};

View File

@@ -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>;

View File

@@ -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),
};
}),
};

View 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() }));
};

View 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} />;
};

View 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} />;
};

View 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',
},
}));

View File

@@ -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];

View File

@@ -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');

View File

@@ -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>
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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,

View 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 };
}),
});

View File

@@ -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,
});
};

View File

@@ -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 {

View File

@@ -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' }),

View File

@@ -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]);
};

View File

@@ -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 =>
({

View File

@@ -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(),
});

View 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
View 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
);
};