Merge branch 'dev' of https://github.com/ajnart/homarr into common-troubleshoot-and-auto-handling
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { Stack, Switch } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useBoardCustomizationFormContext } from '~/components/Board/Customize/form';
|
||||
|
||||
export const AccessCustomization = () => {
|
||||
const { t } = useTranslation('settings/customization/access');
|
||||
const form = useBoardCustomizationFormContext();
|
||||
return (
|
||||
<Stack>
|
||||
<Switch
|
||||
label={t('allowGuests.label')}
|
||||
description={t('allowGuests.description')}
|
||||
{...form.getInputProps('access.allowGuests', { type: 'checkbox' })}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { useColorScheme } from '~/hooks/use-colorscheme';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
|
||||
import { useBoardCustomizationFormContext } from '../form';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
|
||||
import { createFormContext } from '@mantine/form';
|
||||
import { z } from 'zod';
|
||||
import { boardCustomizationSchema } from '~/validations/boards';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ActionIcon, Space, createStyles } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
|
||||
import { MobileRibbonSidebarDrawer } from './MobileRibbonSidebarDrawer';
|
||||
|
||||
export const MobileRibbons = () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SelectItem } from '@mantine/core';
|
||||
import { ContextModalProps, closeModal } from '@mantine/modals';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||
import { ChangePositionModal } from './ChangePositionModal';
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Flex, Grid, NumberInput, Select, SelectItem } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
|
||||
interface ChangePositionModalProps {
|
||||
@@ -95,6 +94,7 @@ export const ChangePositionModal = ({
|
||||
min: widthData.at(0)?.label,
|
||||
max: widthData.at(-1)?.label,
|
||||
})}
|
||||
withinPortal
|
||||
{...form.getInputProps('width')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -109,6 +109,7 @@ export const ChangePositionModal = ({
|
||||
min: heightData.at(0)?.label,
|
||||
max: heightData.at(-1)?.label,
|
||||
})}
|
||||
withinPortal
|
||||
{...form.getInputProps('height')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SelectItem } from '@mantine/core';
|
||||
import { ContextModalProps, closeModal } from '@mantine/modals';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
|
||||
import widgets from '../../../../widgets';
|
||||
import { WidgetChangePositionModalInnerProps } from '../../Tiles/Widgets/WidgetsMenu';
|
||||
import { useGridstackStore, useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||
|
||||
@@ -3,9 +3,8 @@ import { UseFormReturnType } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { AppType } from '~/types/app';
|
||||
import { IconSelector } from '~/components/IconSelector/IconSelector';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
interface AppearanceTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
@@ -83,7 +82,8 @@ export const AppearanceTab = ({
|
||||
data={[
|
||||
{
|
||||
value: 'column',
|
||||
label: t('appearance.positionAppName.dropdown.top') as string },
|
||||
label: t('appearance.positionAppName.dropdown.top') as string,
|
||||
},
|
||||
{
|
||||
value: 'row-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.right') as string,
|
||||
@@ -94,7 +94,8 @@ export const AppearanceTab = ({
|
||||
},
|
||||
{
|
||||
value: 'row',
|
||||
label: t('appearance.positionAppName.dropdown.left') as string },
|
||||
label: t('appearance.positionAppName.dropdown.left') as string,
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps('appearance.positionAppName')}
|
||||
onChange={(value) => {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Text, TextInput, Tooltip, Stack, Switch, Tabs, Group, useMantineTheme, HoverCard } from '@mantine/core';
|
||||
import {
|
||||
Group,
|
||||
HoverCard,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { InfoCard } from '~/components/InfoCard/InfoCard';
|
||||
import { AppType } from '~/types/app';
|
||||
import { InfoCard } from '~/components/InfoCard/InfoCard'
|
||||
|
||||
interface BehaviourTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
@@ -19,7 +28,7 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => {
|
||||
<Switch
|
||||
label={t('behaviour.isOpeningNewTab.label')}
|
||||
description={t('behaviour.isOpeningNewTab.description')}
|
||||
styles={{ label: { fontWeight: 500, }, description: { marginTop: 0, }, }}
|
||||
styles={{ label: { fontWeight: 500 }, description: { marginTop: 0 } }}
|
||||
{...form.getInputProps('behaviour.isOpeningNewTab', { type: 'checkbox' })}
|
||||
/>
|
||||
<Stack spacing="0.25rem">
|
||||
@@ -27,13 +36,11 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => {
|
||||
<Text size="0.875rem" weight={500}>
|
||||
{t('behaviour.tooltipDescription.label')}
|
||||
</Text>
|
||||
<InfoCard message={t('behaviour.tooltipDescription.description')}/>
|
||||
<InfoCard message={t('behaviour.tooltipDescription.description')} />
|
||||
</Group>
|
||||
<TextInput
|
||||
{...form.getInputProps('behaviour.tooltipDescription')}
|
||||
/>
|
||||
<TextInput {...form.getInputProps('behaviour.tooltipDescription')} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { Icon } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AppIntegrationPropertyAccessabilityType } from '~/types/app';
|
||||
|
||||
interface GenericSecretInputProps {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Stack } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconKey } from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
AppIntegrationPropertyType,
|
||||
AppType,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
integrationFieldDefinitions,
|
||||
integrationFieldProperties,
|
||||
} from '~/types/app';
|
||||
|
||||
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
|
||||
|
||||
interface IntegrationOptionsRendererProps {
|
||||
|
||||
@@ -2,8 +2,8 @@ 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 { IntegrationSelector } from './Components/InputElements/IntegrationSelector';
|
||||
import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MultiSelect, Stack, Switch, Tabs } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { StatusCodes } from '~/tools/acceptableStatusCodes';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
@@ -20,7 +19,7 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
|
||||
<Switch
|
||||
label={t('network.statusChecker.label')}
|
||||
description={t('network.statusChecker.description')}
|
||||
styles={{ label: { fontWeight: 500, }, description: { marginTop: 0, }, }}
|
||||
styles={{ label: { fontWeight: 500 }, description: { marginTop: 0 } }}
|
||||
defaultChecked={form.values.network.enabledStatusChecker}
|
||||
{...form.getInputProps('network.enabledStatusChecker')}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useStyles } from './styles';
|
||||
|
||||
interface GenericAvailableElementTypeProps {
|
||||
name: string;
|
||||
id: string;
|
||||
handleAddition: () => Promise<void>;
|
||||
description?: string;
|
||||
image: string | Icon;
|
||||
@@ -16,6 +17,7 @@ interface GenericAvailableElementTypeProps {
|
||||
|
||||
export const GenericAvailableElementType = ({
|
||||
name,
|
||||
id,
|
||||
description,
|
||||
image,
|
||||
disabled,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Grid, Text } from '@mantine/core';
|
||||
import { Container, Grid, Text } from '@mantine/core';
|
||||
import { IconCursorText } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
@@ -21,12 +21,13 @@ export const AvailableStaticTypes = ({ onClickBack }: AvailableStaticTypesProps)
|
||||
</Text>
|
||||
|
||||
<Grid grow>
|
||||
{/*
|
||||
<GenericAvailableElementType
|
||||
name="Static Text"
|
||||
description="Display a fixed string on your dashboard"
|
||||
image={IconCursorText}
|
||||
handleAddition={/* TODO: add something? */ async () => {}}
|
||||
/>
|
||||
handleAddition={async () => {}}
|
||||
/> */}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,10 @@ import { showNotification } from '@mantine/notifications';
|
||||
import { Icon, IconChecks } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
|
||||
|
||||
import { useEditModeStore } from '../../../../Views/useEditModeStore';
|
||||
import { GenericAvailableElementType } from '../Shared/GenericElementType';
|
||||
|
||||
@@ -97,6 +97,7 @@ export const WidgetElementType = ({ id, image, disabled, widget }: WidgetElement
|
||||
icon: <IconChecks stroke={1.5} />,
|
||||
color: 'teal',
|
||||
});
|
||||
umami.track('Add widget', { id: widget.id });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -104,6 +105,7 @@ export const WidgetElementType = ({ id, image, disabled, widget }: WidgetElement
|
||||
name={t('descriptor.name')}
|
||||
description={t('descriptor.description') ?? undefined}
|
||||
image={image}
|
||||
id={widget.id}
|
||||
disabled={disabled}
|
||||
handleAddition={handleAddition}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
import { GenericTileMenu } from '../GenericTileMenu';
|
||||
|
||||
interface TileMenuProps {
|
||||
|
||||
@@ -4,10 +4,9 @@ import Consola from 'consola';
|
||||
import { TargetAndTransition, Transition, motion } from 'framer-motion';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { AppType } from '~/types/app';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
interface AppPingProps {
|
||||
app: AppType;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Affix, Box, Text, Tooltip, UnstyledButton } from '@mantine/core';
|
||||
import { createStyles, useMantineTheme } from '@mantine/styles';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||
import { BaseTileProps } from '../type';
|
||||
@@ -87,7 +87,7 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
) : (
|
||||
<UnstyledButton
|
||||
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
|
||||
component={Link}
|
||||
component="a"
|
||||
href={app.behaviour.externalUrl.length > 0 ? app.behaviour.externalUrl : app.url}
|
||||
target={app.behaviour.isOpeningNewTab ? '_blank' : '_self'}
|
||||
className={`${classes.button} ${classes.base}`}
|
||||
@@ -111,7 +111,7 @@ const useStyles = createStyles((theme, _params, getRef) => ({
|
||||
overflow: 'visible',
|
||||
flexGrow: 5,
|
||||
},
|
||||
appImage:{
|
||||
appImage: {
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconLayoutKanban, IconPencil, IconSettings, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
|
||||
import { useEditModeStore } from '../Views/useEditModeStore';
|
||||
|
||||
interface GenericTileMenuProps {
|
||||
@@ -12,14 +12,12 @@ interface GenericTileMenuProps {
|
||||
displayEdit: boolean;
|
||||
}
|
||||
|
||||
export const GenericTileMenu = (
|
||||
{
|
||||
handleClickEdit,
|
||||
handleClickChangePosition,
|
||||
handleClickDelete,
|
||||
displayEdit,
|
||||
}: GenericTileMenuProps
|
||||
) => {
|
||||
export const GenericTileMenu = ({
|
||||
handleClickEdit,
|
||||
handleClickChangePosition,
|
||||
handleClickDelete,
|
||||
displayEdit,
|
||||
}: GenericTileMenuProps) => {
|
||||
const { t } = useTranslation('common');
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconChevronDown, IconGripVertical } from '@tabler/icons-react';
|
||||
import { Reorder, useDragControls } from 'framer-motion';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
|
||||
import { IDraggableEditableListInputValue } from '~/widgets/widgets';
|
||||
|
||||
interface DraggableListProps {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconChevronDown, IconGripVertical } from '@tabler/icons-react';
|
||||
import { Reorder, useDragControls } from 'framer-motion';
|
||||
import { FC, ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import { IDraggableListInputValue } from '~/widgets/widgets';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
|
||||
@@ -18,13 +18,13 @@ import { ContextModalProps } from '@mantine/modals';
|
||||
import { IconAlertTriangle, IconPlaylistX, IconPlus } from '@tabler/icons-react';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { mapObject } from '~/tools/client/objects';
|
||||
import Widgets from '../../../../widgets';
|
||||
import type { IDraggableListInputValue, IWidgetOptionValue } from '~/widgets/widgets';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
|
||||
import Widgets from '../../../../widgets';
|
||||
import { InfoCard } from '../../../InfoCard/InfoCard';
|
||||
import { DraggableList } from './Inputs/DraggableList';
|
||||
import { LocationSelection } from './Inputs/LocationSelection';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Title } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||
import WidgetsDefinitions from '../../../../widgets';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
|
||||
import WidgetsDefinitions from '../../../../widgets';
|
||||
import { useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||
import { GenericTileMenu } from '../GenericTileMenu';
|
||||
import { WidgetEditModalInnerProps } from './WidgetsEditModal';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Group, Stack, Text } from '@mantine/core';
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useResize } from '~/hooks/use-resize';
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
import { CategoryType } from '~/types/category';
|
||||
import { WrapperType } from '~/types/wrapper';
|
||||
|
||||
import { DashboardCategory } from '../Wrappers/Category/Category';
|
||||
import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar';
|
||||
import { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ActionIcon, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconEdit, IconEditOff } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
|
||||
import { useEditModeStore } from './useEditModeStore';
|
||||
|
||||
export const ViewToggleButton = () => {
|
||||
|
||||
@@ -13,9 +13,9 @@ import { useLocalStorage } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { IconDotsVertical, IconShare3 } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { CategoryType } from '~/types/category';
|
||||
|
||||
import { useCardStyles } from '../../../layout/Common/useCardStyles';
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { WrapperContent } from '../WrapperContent';
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
IconTransitionTop,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { CategoryType } from '~/types/category';
|
||||
|
||||
import { useCategoryActions } from './useCategoryActions';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
interface CategoryEditMenuProps {
|
||||
category: CategoryType;
|
||||
@@ -22,7 +22,7 @@ export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } =
|
||||
useCategoryActions(configName, category);
|
||||
const { t } = useTranslation(['layout/common','common']);
|
||||
const { t } = useTranslation(['layout/common', 'common']);
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow>
|
||||
@@ -33,28 +33,24 @@ export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item icon={<IconEdit size={20} />} onClick={edit}>
|
||||
{t('common:edit')}
|
||||
{t('common:edit')}
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconTrash size={20} />} onClick={remove}>
|
||||
{t('common:remove')}
|
||||
</Menu.Item>
|
||||
<Menu.Label>
|
||||
{t('common:changePosition')}
|
||||
</Menu.Label>
|
||||
<Menu.Label>{t('common:changePosition')}</Menu.Label>
|
||||
<Menu.Item icon={<IconTransitionTop size={20} />} onClick={moveCategoryUp}>
|
||||
{t('menu.moveUp')}
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconTransitionBottom size={20} />} onClick={moveCategoryDown}>
|
||||
{t('menu.moveDown')}
|
||||
</Menu.Item>
|
||||
<Menu.Label>
|
||||
{t('menu.addCategory',{location: ''})}
|
||||
</Menu.Label>
|
||||
<Menu.Label>{t('menu.addCategory', { location: '' })}</Menu.Label>
|
||||
<Menu.Item icon={<IconRowInsertTop size={20} />} onClick={addCategoryAbove}>
|
||||
{t('menu.addCategory',{location: t('menu.addAbove')})}
|
||||
{t('menu.addCategory', { location: t('menu.addAbove') })}
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconRowInsertBottom size={20} />} onClick={addCategoryBelow}>
|
||||
{t('menu.addCategory',{location: t('menu.addBelow')})}
|
||||
{t('menu.addCategory', { location: t('menu.addBelow') })}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Button, Group, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { CategoryType } from '~/types/category';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||
import { AppType } from '~/types/app';
|
||||
import { CategoryType } from '~/types/category';
|
||||
import { WrapperType } from '~/types/wrapper';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
|
||||
import { CategoryEditModalInnerProps } from './CategoryEditModal';
|
||||
|
||||
export const useCategoryActions = (configName: string | undefined, category: CategoryType) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WrapperType } from '~/types/wrapper';
|
||||
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { WrapperContent } from '../WrapperContent';
|
||||
import { useGridstack } from '../gridstack/use-gridstack';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { GridStack } from 'fily-publish-gridstack';
|
||||
import { MutableRefObject, RefObject } from 'react';
|
||||
|
||||
import { AppType } from '~/types/app';
|
||||
import Widgets from '../../../widgets';
|
||||
import { WidgetWrapper } from '~/widgets/WidgetWrapper';
|
||||
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
|
||||
|
||||
import Widgets from '../../../widgets';
|
||||
import { appTileDefinition } from '../Tiles/Apps/AppTile';
|
||||
import { GridstackTileWrapper } from '../Tiles/TileWrapper';
|
||||
import { useGridstackStore } from './gridstack/store';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { GridItemHTMLElement, GridStack, GridStackNode } from 'fily-publish-gridstack';
|
||||
import { MutableRefObject, RefObject } from 'react';
|
||||
|
||||
import { AppType } from '~/types/app';
|
||||
import { ShapeType } from '~/types/shape';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { GridstackBreakpoints } from '~/constants/gridstack-breakpoints';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
|
||||
import { MutableRefObject, RefObject, createRef, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { AppType } from '~/types/app';
|
||||
import { AreaType } from '~/types/area';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { TileWithUnknownLocation, initializeGridstack } from './init-gridstack';
|
||||
import { useGridstackStore, useWrapperColumnCount } from './store';
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { humanFileSize } from '~/tools/humanFileSize';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { humanFileSize } from '~/tools/humanFileSize';
|
||||
import { DebouncedImage } from './DebouncedImage';
|
||||
|
||||
export const IconSelector = forwardRef(
|
||||
@@ -83,9 +83,7 @@ export const IconSelector = forwardRef(
|
||||
}
|
||||
icon={<DebouncedImage src={value ?? currentValue} width={20} height={20} />}
|
||||
rightSection={
|
||||
(value ?? currentValue).length > 0 ? (
|
||||
<CloseButton onClick={() => onChange("")} />
|
||||
) : null
|
||||
(value ?? currentValue).length > 0 ? <CloseButton onClick={() => onChange('')} /> : null
|
||||
}
|
||||
itemComponent={AutoCompleteItem}
|
||||
className={classes.textInput}
|
||||
|
||||
@@ -23,7 +23,9 @@ interface InfoCardProps {
|
||||
export const InfoCard = ({ bg, cardProp, message, link, hoverProp, position }: InfoCardProps) => {
|
||||
const { colorScheme } = useMantineTheme();
|
||||
const { t } = useTranslation('common');
|
||||
const content = link? message + ` <a href=\"${link}\" target=\"_blank\">${t('seeMore')}</a>` : message;
|
||||
const content = link
|
||||
? message + ` <a href=\"${link}\" target=\"_blank\">${t('seeMore')}</a>`
|
||||
: message;
|
||||
const editor = useEditor({
|
||||
content,
|
||||
editable: false,
|
||||
|
||||
@@ -58,7 +58,9 @@ export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={async () => {}}
|
||||
onClick={async () => {
|
||||
umami.track('Create new board')
|
||||
}}
|
||||
disabled={isLoading}
|
||||
variant="light"
|
||||
color="green"
|
||||
|
||||
@@ -26,7 +26,7 @@ export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps<Inn
|
||||
await mutateAsync(
|
||||
{
|
||||
apps: innerProps.containers.map((container) => ({
|
||||
name: container.Names.at(0) ?? 'App',
|
||||
name: (container.Names.at(0) ?? 'App').replace('/', ''),
|
||||
port: container.Ports.at(0)?.PublicPort,
|
||||
})),
|
||||
boardName: values.board,
|
||||
@@ -117,4 +117,5 @@ export const openDockerSelectBoardModal = (innerProps: InnerProps) => {
|
||||
),
|
||||
innerProps,
|
||||
});
|
||||
umami.track('Add to homarr modal')
|
||||
};
|
||||
|
||||
@@ -23,7 +23,12 @@ export const ReviewInputStep = ({ values, prevStep, nextStep }: ReviewInputStepP
|
||||
const { t } = useTranslation('manage/users/create');
|
||||
|
||||
const utils = api.useContext();
|
||||
const { mutateAsync: createAsync, isLoading, isError, error } = api.user.create.useMutation({
|
||||
const {
|
||||
mutateAsync: createAsync,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = api.user.create.useMutation({
|
||||
onSettled: () => {
|
||||
void utils.user.all.invalidate();
|
||||
},
|
||||
@@ -106,6 +111,7 @@ export const ReviewInputStep = ({ values, prevStep, nextStep }: ReviewInputStepP
|
||||
password: values.security.password,
|
||||
email: values.account.eMail === '' ? undefined : values.account.eMail,
|
||||
});
|
||||
umami.track('Create user', { username: values.account.username});
|
||||
}}
|
||||
loading={isLoading}
|
||||
rightIcon={<IconCheck size="1rem" />}
|
||||
|
||||
@@ -66,6 +66,7 @@ export const CreateAccountSecurityStep = ({
|
||||
onClick={async () => {
|
||||
const randomPassword = await mutateAsync();
|
||||
form.setFieldValue('password', randomPassword);
|
||||
umami.track('Generate random password');
|
||||
}}
|
||||
loading={isLoading}
|
||||
variant="default"
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DeleteInviteModal = ({ id, innerProps }: ContextModalProps<{ tokenI
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await deleteAsync({
|
||||
tokenId: innerProps.tokenId,
|
||||
id: innerProps.tokenId,
|
||||
});
|
||||
}}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -59,6 +59,7 @@ export const StepCreateAccount = ({
|
||||
<Text>
|
||||
Your administrator account <b>must be secure</b>, that's why we have so many rules surrounding it.
|
||||
<br/>Try not to make it adminadmin this time...
|
||||
<br/>Note: these password requirements <b>are not forced</b>, they are just recommendations.
|
||||
</Text>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const StepOnboardingFinished = () => {
|
||||
<Divider />
|
||||
<NavLink
|
||||
component={Link}
|
||||
href="/b/default"
|
||||
href="/b"
|
||||
rightSection={<IconChevronRight size="0.8rem" stroke={1.5} />}
|
||||
className={classes.link}
|
||||
icon={<IconDashboard />}
|
||||
|
||||
@@ -39,7 +39,7 @@ services:
|
||||
const added = { color: 'green', label: '+' };
|
||||
|
||||
export const StepUpdatePathMappings = ({ next }: { next: () => void }) => {
|
||||
const [selectedTab, setSelectedTab] = useState<TabsValue>("standard_docker");
|
||||
const [selectedTab, setSelectedTab] = useState<TabsValue>('standard_docker');
|
||||
return (
|
||||
<OnboardingStepWrapper>
|
||||
<Title order={2} align="center" mb="md">
|
||||
@@ -140,7 +140,9 @@ export const StepUpdatePathMappings = ({ next }: { next: () => void }) => {
|
||||
{dockerComposeCommand}
|
||||
</Prism>
|
||||
</List.Item>
|
||||
<List.Item>Run <Code>docker compose up</Code>.</List.Item>
|
||||
<List.Item>
|
||||
Run <Code>docker compose up</Code>.
|
||||
</List.Item>
|
||||
<List.Item>Refresh this page and click on "continue"</List.Item>
|
||||
</List>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { minPasswordLength } from "~/validations/user";
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { minPasswordLength } from '~/validations/user';
|
||||
|
||||
export const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => {
|
||||
const { t } = useTranslation('password-requirements');
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={meets ? 'teal' : 'red'}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
mt={7}
|
||||
size="sm"
|
||||
>
|
||||
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}{' '}
|
||||
<Box ml={10}>
|
||||
{t(`${label}`, {
|
||||
count: minPasswordLength,
|
||||
})}
|
||||
</Box>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
const { t } = useTranslation('password-requirements');
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={meets ? 'teal' : 'red'}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
mt={7}
|
||||
size="sm"
|
||||
>
|
||||
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}
|
||||
<Box ml={10}>
|
||||
{t(`${label}`, {
|
||||
count: minPasswordLength,
|
||||
})}
|
||||
</Box>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,6 @@ function getStrength(password: string) {
|
||||
score += 1;
|
||||
}
|
||||
return (score / goal) * 100;
|
||||
|
||||
}
|
||||
|
||||
export const PasswordRequirements = ({ value }: { value: string }) => {
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
ActionIconProps,
|
||||
} from '@mantine/core';
|
||||
import { useColorScheme } from '~/hooks/use-colorscheme';
|
||||
import { ActionIcon, ActionIconProps } from '@mantine/core';
|
||||
import { IconMoonStars, IconSun } from '@tabler/icons-react';
|
||||
import { useColorScheme } from '~/hooks/use-colorscheme';
|
||||
|
||||
export const ThemeSchemeToggle = (props : Partial<ActionIconProps>) => {
|
||||
export const ThemeSchemeToggle = (props: Partial<ActionIconProps>) => {
|
||||
const { colorScheme, toggleColorScheme } = useColorScheme();
|
||||
const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars;
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
size={50}
|
||||
variant="outline"
|
||||
radius="md"
|
||||
onClick={toggleColorScheme}
|
||||
{...props}
|
||||
>
|
||||
<Icon size="66%"/>
|
||||
<ActionIcon size={50} variant="outline" radius="md" onClick={toggleColorScheme} {...props}>
|
||||
<Icon size="66%" />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Group, Select, Stack, Text } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { getCookie, setCookie } from 'cookies-next';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { forwardRef, useState } from 'react';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { COOKIE_LOCALE_KEY } from '../../../../../data/constants';
|
||||
import { Language, getLanguageByCode } from '~/tools/language';
|
||||
|
||||
export default function LanguageSelect() {
|
||||
const { data: sessionData } = useSession();
|
||||
const { t, i18n } = useTranslation('settings/general/internationalization');
|
||||
const { changeLanguage } = i18n;
|
||||
const configLocale = getCookie(COOKIE_LOCALE_KEY);
|
||||
const { locale, locales, pathname, query, asPath, push } = useRouter();
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string>(
|
||||
sessionData?.user.language ?? (configLocale as string) ?? locale ?? 'en'
|
||||
);
|
||||
const { mutateAsync } = api.user.changeLanguage.useMutation();
|
||||
|
||||
const data = locales
|
||||
? locales.map((localeItem) => ({
|
||||
value: localeItem,
|
||||
label: getLanguageByCode(localeItem).originalName,
|
||||
icon: getLanguageByCode(localeItem).emoji,
|
||||
language: getLanguageByCode(localeItem),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const onChangeSelect = (value: string) => {
|
||||
setSelectedLanguage(value);
|
||||
|
||||
const newLanguage = getLanguageByCode(value);
|
||||
changeLanguage(value)
|
||||
.then(async () => {
|
||||
setCookie(COOKIE_LOCALE_KEY, value, {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
if (sessionData?.user && new Date(sessionData.expires) > new Date()) {
|
||||
await mutateAsync({
|
||||
language: value,
|
||||
});
|
||||
}
|
||||
|
||||
push(
|
||||
{
|
||||
pathname,
|
||||
query,
|
||||
},
|
||||
asPath,
|
||||
{ locale: value }
|
||||
);
|
||||
|
||||
showNotification({
|
||||
title: 'Language changed',
|
||||
message: `You changed the language to '${newLanguage.originalName}'`,
|
||||
color: 'green',
|
||||
autoClose: 5000,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
showNotification({
|
||||
title: 'Failed to change language',
|
||||
message: `Failed to change to '${newLanguage.originalName}', Error:'${err}`,
|
||||
color: 'red',
|
||||
autoClose: 5000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Select
|
||||
icon={<Text>{getLanguageByCode(selectedLanguage).emoji}</Text>}
|
||||
label={t('label')}
|
||||
data={data}
|
||||
itemComponent={SelectItem}
|
||||
nothingFound="Nothing found"
|
||||
onChange={onChangeSelect}
|
||||
value={selectedLanguage}
|
||||
defaultValue={locale}
|
||||
searchable
|
||||
filter={(value, item) => {
|
||||
const selectItems = item as unknown as { value: string; language: Language };
|
||||
return (
|
||||
selectItems.language.originalName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.includes(value.toLowerCase().trim()) ||
|
||||
selectItems.language.translatedName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.includes(value.toLowerCase().trim())
|
||||
);
|
||||
}}
|
||||
styles={{
|
||||
icon: {
|
||||
width: 42,
|
||||
},
|
||||
input: {
|
||||
paddingLeft: '45px !important',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
image: string;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
const SelectItem = forwardRef<HTMLDivElement, ItemProps>(
|
||||
({ language, image, ...others }: ItemProps, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<Text>{language.emoji}</Text>
|
||||
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{language.originalName} ({language.translatedName})
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
@@ -31,6 +31,11 @@ export const SearchEngineSettings = () => {
|
||||
label={t('searchEngine.newTab.label')}
|
||||
{...form.getInputProps('openSearchInNewTab', { type: 'checkbox' })}
|
||||
/>
|
||||
<Switch
|
||||
label={t('searchEngine.autoFocus.label')}
|
||||
description={t('searchEngine.autoFocus.description')}
|
||||
{...form.getInputProps('autoFocusSearch', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('searchEngine.template.label')}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const PolkaElement = ({
|
||||
<Box
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
pointerEvents: 'none'
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
className="polka"
|
||||
pos="absolute"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Group, Image, Text } from '@mantine/core';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { usePrimaryGradient } from './useGradient';
|
||||
|
||||
interface LogoProps {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createStyles } from '@mantine/core';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
|
||||
export const useCardStyles = (isCategory: boolean) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MantineGradient } from '@mantine/core';
|
||||
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
|
||||
export const usePrimaryGradient = () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import { firstUpperCase } from '~/tools/shared/strings';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { firstUpperCase } from '~/tools/shared/strings';
|
||||
|
||||
export const BoardHeadOverride = () => {
|
||||
const { config, name } = useConfigContext();
|
||||
|
||||
@@ -19,9 +19,11 @@ import { useNamedWrapperColumnCount } from '~/components/Dashboard/Wrappers/grid
|
||||
import { BoardHeadOverride } from '~/components/layout/Meta/BoardHeadOverride';
|
||||
import { HeaderActionButton } from '~/components/layout/header/ActionButton';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { MainLayout } from './MainLayout';
|
||||
import { env } from 'process';
|
||||
|
||||
type BoardLayoutProps = {
|
||||
dockerEnabled: boolean;
|
||||
@@ -30,9 +32,13 @@ type BoardLayoutProps = {
|
||||
|
||||
export const BoardLayout = ({ children, dockerEnabled }: BoardLayoutProps) => {
|
||||
const { config } = useConfigContext();
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<MainLayout headerActions={<HeaderActions dockerEnabled={dockerEnabled} />}>
|
||||
<MainLayout
|
||||
autoFocusSearch={session?.user.autoFocusSearch}
|
||||
headerActions={<HeaderActions dockerEnabled={dockerEnabled} />}
|
||||
>
|
||||
<BoardHeadOverride />
|
||||
<BackgroundImage />
|
||||
{children}
|
||||
@@ -102,7 +108,7 @@ const ToggleEditModeButton = () => {
|
||||
useHotkeys([['mod+E', toggleEditMode]]);
|
||||
|
||||
useWindowEvent('beforeunload', (event: BeforeUnloadEvent) => {
|
||||
if (enabled) {
|
||||
if (enabled && env.NODE_ENV === 'production') {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.returnValue = beforeUnloadEventText;
|
||||
return beforeUnloadEventText;
|
||||
@@ -213,7 +219,7 @@ const BackgroundImage = () => {
|
||||
return (
|
||||
<Global
|
||||
styles={{
|
||||
body: {
|
||||
'.mantine-AppShell-root': {
|
||||
minHeight: '100vh',
|
||||
backgroundImage: `url('${config?.settings.customization.backgroundImageUrl}')`,
|
||||
backgroundPosition: 'center center',
|
||||
|
||||
@@ -6,9 +6,16 @@ type MainLayoutProps = {
|
||||
headerActions?: React.ReactNode;
|
||||
contentComponents?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
autoFocusSearch?: boolean;
|
||||
};
|
||||
|
||||
export const MainLayout = ({ showExperimental, headerActions, contentComponents, children }: MainLayoutProps) => {
|
||||
export const MainLayout = ({
|
||||
showExperimental,
|
||||
headerActions,
|
||||
contentComponents,
|
||||
children,
|
||||
autoFocusSearch,
|
||||
}: MainLayoutProps) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
@@ -18,7 +25,14 @@ export const MainLayout = ({ showExperimental, headerActions, contentComponents,
|
||||
background: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
|
||||
},
|
||||
}}
|
||||
header={<MainHeader headerActions={headerActions} contentComponents={contentComponents} showExperimental={showExperimental} />}
|
||||
header={
|
||||
<MainHeader
|
||||
autoFocusSearch={autoFocusSearch}
|
||||
headerActions={headerActions}
|
||||
contentComponents={contentComponents}
|
||||
showExperimental={showExperimental}
|
||||
/>
|
||||
}
|
||||
className="dashboard-app-shell"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -89,7 +89,7 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
}
|
||||
header={<MainHeader showExperimental logoHref="/manage" leftIcon={burgerMenu} />}
|
||||
header={<MainHeader showExperimental logoHref="/b/" leftIcon={burgerMenu} />}
|
||||
footer={
|
||||
<Footer height={25}>
|
||||
<Group position="apart" px="md">
|
||||
@@ -112,7 +112,13 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
|
||||
{children}
|
||||
</Paper>
|
||||
</AppShell>
|
||||
<Drawer opened={burgerMenuOpen} onClose={closeBurgerMenu}>
|
||||
<Drawer
|
||||
opened={burgerMenuOpen}
|
||||
onClose={closeBurgerMenu}
|
||||
transitionProps={{
|
||||
transition: 'slide-right',
|
||||
}}
|
||||
>
|
||||
{navigationLinkComponents}
|
||||
</Drawer>
|
||||
</>
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
HoverCard,
|
||||
Kbd,
|
||||
Image,
|
||||
Kbd,
|
||||
Modal,
|
||||
Table,
|
||||
Text,
|
||||
@@ -31,11 +31,11 @@ import { motion } from 'framer-motion';
|
||||
import { InitOptions } from 'i18next';
|
||||
import { Trans, i18n, useTranslation } from 'next-i18next';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
|
||||
import { usePrimaryGradient } from '../../Common/useGradient';
|
||||
import Credits from './Credits';
|
||||
import Tip from './Tip';
|
||||
@@ -75,13 +75,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
|
||||
opened={opened}
|
||||
title={
|
||||
<Group spacing="sm">
|
||||
<Image
|
||||
alt="Homarr logo"
|
||||
src="/imgs/logo/logo.png"
|
||||
width={30}
|
||||
height={30}
|
||||
fit="contain"
|
||||
/>
|
||||
<Image alt="Homarr logo" src="/imgs/logo/logo.png" width={30} height={30} fit="contain" />
|
||||
<Title order={3} variant="gradient" gradient={colorGradiant}>
|
||||
{t('about')} Homarr
|
||||
</Title>
|
||||
@@ -271,13 +265,17 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
|
||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||
>
|
||||
<Badge color="green" variant="filled">
|
||||
{t('version.new',{ newVersion: newVersionAvailable})}
|
||||
{t('version.new', { newVersion: newVersionAvailable })}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Text>
|
||||
{t('version.dropdown', {currentVersion: attributes.packageVersion}).split('{{newVersion}}')[0]}
|
||||
{
|
||||
t('version.dropdown', { currentVersion: attributes.packageVersion }).split(
|
||||
'{{newVersion}}'
|
||||
)[0]
|
||||
}
|
||||
<b>
|
||||
<Anchor
|
||||
target="_blank"
|
||||
@@ -286,7 +284,11 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
|
||||
{newVersionAvailable}
|
||||
</Anchor>
|
||||
</b>
|
||||
{t('version.dropdown', {currentVersion: attributes.packageVersion}).split('{{newVersion}}')[1]}
|
||||
{
|
||||
t('version.dropdown', { currentVersion: attributes.packageVersion }).split(
|
||||
'{{newVersion}}'
|
||||
)[1]
|
||||
}
|
||||
</Text>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Anchor, Box, Collapse, Flex, Table, Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
|
||||
|
||||
export default function Credits() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Avatar, Badge, Menu, UnstyledButton, useMantineTheme } from '@mantine/core';
|
||||
import { Avatar, Badge, Indicator, Menu, UnstyledButton, useMantineTheme } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconDashboard,
|
||||
@@ -38,10 +38,17 @@ export const AvatarMenu = () => {
|
||||
<UnstyledButton>
|
||||
<Menu width={256}>
|
||||
<Menu.Target>
|
||||
<CurrentUserAvatar user={sessionData?.user ?? null} />
|
||||
<CurrentUserAvatar
|
||||
newVersionAvailable={newVersionAvailable ? true : false}
|
||||
user={sessionData?.user ?? null}
|
||||
/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item closeMenuOnClick={false} icon={<Icon size="1rem" />} onClick={toggleColorScheme}>
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
icon={<Icon size="1rem" />}
|
||||
onClick={toggleColorScheme}
|
||||
>
|
||||
{t('actions.avatar.switchTheme')}
|
||||
</Menu.Item>
|
||||
{sessionData?.user && (
|
||||
@@ -113,13 +120,24 @@ export const AvatarMenu = () => {
|
||||
};
|
||||
|
||||
type CurrentUserAvatarProps = {
|
||||
newVersionAvailable: boolean;
|
||||
user: User | null;
|
||||
};
|
||||
|
||||
const CurrentUserAvatar = forwardRef<HTMLDivElement, CurrentUserAvatarProps>(
|
||||
({ user, ...others }, ref) => {
|
||||
({ user, newVersionAvailable, ...others }, ref) => {
|
||||
const { primaryColor } = useMantineTheme();
|
||||
if (!user) return <Avatar ref={ref} {...others} />;
|
||||
|
||||
if (newVersionAvailable)
|
||||
return (
|
||||
<Indicator withBorder offset={2} color="blue" processing size={15}>
|
||||
<Avatar ref={ref} color={primaryColor} {...others}>
|
||||
{user.name?.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
</Indicator>
|
||||
);
|
||||
|
||||
return (
|
||||
<Avatar ref={ref} color={primaryColor} {...others}>
|
||||
{user.name?.slice(0, 2).toUpperCase()}
|
||||
|
||||
@@ -23,6 +23,7 @@ type MainHeaderProps = {
|
||||
headerActions?: React.ReactNode;
|
||||
contentComponents?: React.ReactNode;
|
||||
leftIcon?: React.ReactNode;
|
||||
autoFocusSearch?: boolean;
|
||||
};
|
||||
|
||||
export const MainHeader = ({
|
||||
@@ -31,6 +32,7 @@ export const MainHeader = ({
|
||||
headerActions,
|
||||
leftIcon,
|
||||
contentComponents,
|
||||
autoFocusSearch,
|
||||
}: MainHeaderProps) => {
|
||||
const { breakpoints } = useMantineTheme();
|
||||
const isSmallerThanMd = useMediaQuery(`(max-width: ${breakpoints.sm})`);
|
||||
@@ -51,7 +53,7 @@ export const MainHeader = ({
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
|
||||
{!isSmallerThanMd && <Search />}
|
||||
{!isSmallerThanMd && <Search autoFocus={autoFocusSearch} />}
|
||||
|
||||
<Group noWrap style={{ flex: 1 }} position="right">
|
||||
<Group noWrap spacing={8}>
|
||||
|
||||
@@ -19,9 +19,10 @@ import { MovieModal } from './Search/MovieModal';
|
||||
|
||||
type SearchProps = {
|
||||
isMobile?: boolean;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export const Search = ({ isMobile }: SearchProps) => {
|
||||
export const Search = ({ isMobile, autoFocus }: SearchProps) => {
|
||||
const { t } = useTranslation('layout/header');
|
||||
const [search, setSearch] = useState('');
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
@@ -62,6 +63,7 @@ export const Search = ({ isMobile }: SearchProps) => {
|
||||
variant="filled"
|
||||
placeholder={`${t('search.label')}...`}
|
||||
hoverOnSearchChange
|
||||
autoFocus={autoFocus}
|
||||
rightSection={
|
||||
<IconSearch
|
||||
onClick={() => ref.current?.focus()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { ConfigType } from '~/types/config';
|
||||
|
||||
import { useConfigContext } from './provider';
|
||||
import { useConfigStore } from './store';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ReactNode, createContext, useContext, useEffect, useState } from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
import { ConfigType } from '~/types/config';
|
||||
|
||||
import { useConfigStore } from './store';
|
||||
|
||||
export type ConfigContextType = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
import { trcpProxyClient } from '~/utils/api';
|
||||
|
||||
import { ConfigType } from '~/types/config';
|
||||
import { trcpProxyClient } from '~/utils/api';
|
||||
|
||||
export const useConfigStore = createWithEqualityFn<UseConfigStoreType>(
|
||||
(set, get) => ({
|
||||
|
||||
16
src/env.js
16
src/env.js
@@ -1,7 +1,11 @@
|
||||
const { z } = require('zod');
|
||||
const { createEnv } = require('@t3-oss/env-nextjs');
|
||||
|
||||
const portSchema = z.string().regex(/\d*/).transform((value) => value === undefined ? undefined : Number(value)).optional();
|
||||
const portSchema = z
|
||||
.string()
|
||||
.regex(/\d*/)
|
||||
.transform((value) => (value === undefined ? undefined : Number(value)))
|
||||
.optional();
|
||||
const envSchema = z.enum(['development', 'test', 'production']);
|
||||
|
||||
const env = createEnv({
|
||||
@@ -22,7 +26,8 @@ const env = createEnv({
|
||||
),
|
||||
DOCKER_HOST: z.string().optional(),
|
||||
DOCKER_PORT: portSchema,
|
||||
HOSTNAME: z.string().optional()
|
||||
DEMO_MODE: z.string().optional(),
|
||||
HOSTNAME: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -32,6 +37,7 @@ const env = createEnv({
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||
NEXT_PUBLIC_DISABLE_ANALYTICS: z.string().optional(),
|
||||
NEXT_PUBLIC_PORT: portSchema,
|
||||
NEXT_PUBLIC_NODE_ENV: envSchema,
|
||||
NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: z
|
||||
@@ -42,7 +48,6 @@ const env = createEnv({
|
||||
.default('light'),
|
||||
NEXT_PUBLIC_DOCKER_HOST: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
@@ -51,14 +56,17 @@ const env = createEnv({
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
NEXT_PUBLIC_DISABLE_ANALYTICS: process.env.DISABLE_ANALYTICS,
|
||||
DOCKER_HOST: process.env.DOCKER_HOST,
|
||||
DOCKER_PORT: process.env.DOCKER_PORT,
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: process.env.DEFAULT_COLOR_SCHEME,
|
||||
NEXT_PUBLIC_PORT: process.env.PORT,
|
||||
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
|
||||
HOSTNAME: process.env.HOSTNAME
|
||||
HOSTNAME: process.env.HOSTNAME,
|
||||
DEMO_MODE: process.env.DEMO_MODE,
|
||||
},
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
|
||||
1
src/images/undraw_secure_login_pdn4.svg
Normal file
1
src/images/undraw_secure_login_pdn4.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.4 KiB |
@@ -1,3 +1,5 @@
|
||||
import Consola from 'consola';
|
||||
import fs from 'fs/promises';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { env } from 'process';
|
||||
|
||||
@@ -34,13 +36,32 @@ export async function middleware(req: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// is only called from when there were no users in the database in this session (Since the app started)
|
||||
cachedUserCount = await client.user.count.query();
|
||||
|
||||
// Do not redirect if there are users in the database
|
||||
if (cachedUserCount > 0) {
|
||||
if (!(await shouldRedirectToOnboard())) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return NextResponse.redirect(getUrl(req) + '/onboard');
|
||||
}
|
||||
|
||||
const shouldRedirectToOnboard = async (): Promise<boolean> => {
|
||||
const cacheAndGetUserCount = async () => {
|
||||
cachedUserCount = await client.user.count.query();
|
||||
return cachedUserCount === 0;
|
||||
};
|
||||
|
||||
if (!env.DATABASE_URL?.startsWith('file:')) {
|
||||
return await cacheAndGetUserCount();
|
||||
}
|
||||
|
||||
const fileUri = env.DATABASE_URL.substring(4);
|
||||
try {
|
||||
await fs.access(fileUri, fs.constants.W_OK);
|
||||
return await cacheAndGetUserCount();
|
||||
} catch {
|
||||
Consola.warn(
|
||||
`detected that the path ${fileUri} was not readable. Showing onboarding page for setup...`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
18
src/migrate.ts
Normal file
18
src/migrate.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file is used to migrate the database to the current version
|
||||
// It is run when the docker container starts
|
||||
import Database from 'better-sqlite3';
|
||||
import dotenv from 'dotenv';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
|
||||
dotenv.config({ path: __dirname + '/../.env' });
|
||||
|
||||
const sqlite = new Database(process.env.DATABASE_URL!.replace('file:', ''));
|
||||
|
||||
const db = drizzle(sqlite);
|
||||
|
||||
const migrateDatabase = async () => {
|
||||
await migrate(db, { migrationsFolder: './drizzle' });
|
||||
};
|
||||
|
||||
migrateDatabase();
|
||||
@@ -2,9 +2,9 @@ import { Badge, Button, Group, Image, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
|
||||
import { RequestModal } from '../overseerr/RequestModal';
|
||||
import { Result } from '../overseerr/SearchResult';
|
||||
|
||||
@@ -134,7 +134,12 @@ export function LidarrMediaDisplay(props: any) {
|
||||
|
||||
export function RadarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
const { config } = useConfigContext();
|
||||
const calendar = config?.widgets.find((w) => w.type === 'calendar');
|
||||
const useRadarrv5 = calendar?.properties.useRadarrv5 ?? false;
|
||||
|
||||
// Find a poster CoverType
|
||||
const poster = media.images.find((image: any) => image.coverType === 'poster');
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
@@ -142,7 +147,7 @@ export function RadarrMediaDisplay(props: any) {
|
||||
title: media.title ?? media.originalTitle,
|
||||
overview: media.overview ?? '',
|
||||
genres: media.genres ?? [],
|
||||
poster: media.images.find((image: any) => image.coverType === 'poster')?.url,
|
||||
poster: useRadarrv5 ? poster.remoteUrl : poster.url,
|
||||
voteAverage: media.ratings.tmdb.value.toString(),
|
||||
imdbId: media.imdbId,
|
||||
type: 'movie',
|
||||
|
||||
@@ -5,9 +5,9 @@ import Consola from 'consola';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
import { MovieResult } from './Movie.d';
|
||||
import { Result } from './SearchResult';
|
||||
import { TvShowResult, TvShowResultSeason } from './TvShow.d';
|
||||
|
||||
55
src/pages/401.tsx
Normal file
55
src/pages/401.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Button, Center, Stack, Title, Text, createStyles } from "@mantine/core";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Image from "next/image";
|
||||
import Head from "next/head";
|
||||
import { MainLayout } from "~/components/layout/Templates/MainLayout";
|
||||
import Link from "next/link";
|
||||
|
||||
import imageAccessDenied from '~/images/undraw_secure_login_pdn4.svg';
|
||||
import { pageAccessDeniedNamespaces } from "~/tools/server/translation-namespaces";
|
||||
import { getServerSideTranslations } from "~/tools/server/getServerSideTranslations";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
|
||||
export default function Custom401() {
|
||||
const { classes } = useStyles();
|
||||
const { t } = useTranslation('layout/errors/access-denied');
|
||||
return (
|
||||
<MainLayout>
|
||||
<Center h="100dvh" w="100dvw">
|
||||
<Head>
|
||||
<title>Access denied • Homarr</title>
|
||||
</Head>
|
||||
<Stack maw={500} p="xl">
|
||||
<Image className={classes.image} src={imageAccessDenied} width={200} height={200} alt="" />
|
||||
<Title>{t('title')}</Title>
|
||||
<Text>{t('text')}</Text>
|
||||
|
||||
<Button component={Link} variant="light" href="/auth/login">
|
||||
{t('switchAccount')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getStaticProps({ req, res, locale }: GetServerSidePropsContext) {
|
||||
const translations = await getServerSideTranslations(
|
||||
[...pageAccessDeniedNamespaces, 'common'],
|
||||
locale,
|
||||
req,
|
||||
res
|
||||
);
|
||||
return {
|
||||
props: {
|
||||
...translations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
image: {
|
||||
margin: '0 auto',
|
||||
display: 'block',
|
||||
},
|
||||
}));
|
||||
@@ -5,9 +5,8 @@ import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import pageNotFoundImage from '~/images/undraw_page_not_found_re_e9o6.svg';
|
||||
import { pageNotFoundNamespaces } from '~/tools/server/translation-namespaces';
|
||||
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { pageNotFoundNamespaces } from '~/tools/server/translation-namespaces';
|
||||
|
||||
export default function Custom404() {
|
||||
const { classes } = useStyles();
|
||||
@@ -27,6 +26,9 @@ export default function Custom404() {
|
||||
<Button component={Link} variant="light" href="/b">
|
||||
{t('button')}
|
||||
</Button>
|
||||
<Button component={Link} variant="light" href="/auth/login">
|
||||
Login
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
@@ -13,27 +13,29 @@ import { Session } from 'next-auth';
|
||||
import { SessionProvider, getSession } from 'next-auth/react';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import { AppProps } from 'next/app';
|
||||
import Script from 'next/script';
|
||||
import { useEffect, useState } from 'react';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import { CommonHead } from '~/components/layout/Meta/CommonHead';
|
||||
import { ConfigProvider } from '~/config/provider';
|
||||
import { env } from '~/env.js';
|
||||
import { ColorSchemeProvider } from '~/hooks/use-colorscheme';
|
||||
import { modals } from '~/modals';
|
||||
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
|
||||
import { ColorTheme } from '~/tools/color';
|
||||
import { getLanguageByCode } from '~/tools/language';
|
||||
import {
|
||||
ServerSidePackageAttributesType,
|
||||
getServiceSidePackageAttributes,
|
||||
} from '~/tools/server/getPackageVersion';
|
||||
import { theme } from '~/tools/server/theme/theme';
|
||||
import { ConfigType } from '~/types/config';
|
||||
import { api } from '~/utils/api';
|
||||
import { colorSchemeParser } from '~/validations/user';
|
||||
|
||||
import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../data/constants';
|
||||
import nextI18nextConfig from '../../next-i18next.config.js';
|
||||
import { ConfigProvider } from '~/config/provider';
|
||||
import '../styles/global.scss';
|
||||
import { ColorTheme } from '~/tools/color';
|
||||
import {
|
||||
ServerSidePackageAttributesType,
|
||||
getServiceSidePackageAttributes,
|
||||
} from '~/tools/server/getPackageVersion';
|
||||
import { theme } from '~/tools/server/theme/theme';
|
||||
|
||||
dayjs.extend(locale);
|
||||
dayjs.extend(utc);
|
||||
@@ -45,6 +47,7 @@ function App(
|
||||
environmentColorScheme: MantineColorScheme;
|
||||
packageAttributes: ServerSidePackageAttributesType;
|
||||
editModeEnabled: boolean;
|
||||
analyticsEnabled: boolean;
|
||||
config?: ConfigType;
|
||||
primaryColor?: MantineTheme['primaryColor'];
|
||||
secondaryColor?: MantineTheme['primaryColor'];
|
||||
@@ -55,6 +58,7 @@ function App(
|
||||
}>
|
||||
) {
|
||||
const { Component, pageProps } = props;
|
||||
const analyticsEnabled = pageProps.analyticsEnabled ?? true;
|
||||
// TODO: make mapping from our locales to moment locales
|
||||
const language = getLanguageByCode(pageProps.session?.user?.language ?? 'en');
|
||||
require(`dayjs/locale/${language.locale}.js`);
|
||||
@@ -89,9 +93,21 @@ function App(
|
||||
};
|
||||
}, [props.pageProps]);
|
||||
|
||||
const { setInitialPackageAttributes } = usePackageAttributesStore();
|
||||
useEffect(() => {
|
||||
setInitialPackageAttributes(props.pageProps.packageAttributes);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommonHead />
|
||||
{analyticsEnabled === true && (
|
||||
<Script
|
||||
src="https://umami.homarr.dev/script.js"
|
||||
data-website-id="f133f10c-30a7-4506-889c-3a803f328fa4"
|
||||
strategy="lazyOnload"
|
||||
/>
|
||||
)}
|
||||
<SessionProvider session={pageProps.session}>
|
||||
<ColorSchemeProvider {...pageProps}>
|
||||
{(colorScheme) => (
|
||||
@@ -144,6 +160,8 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const analyticsEnabled = env.NEXT_PUBLIC_DISABLE_ANALYTICS !== 'true';
|
||||
|
||||
const session = await getSession(ctx);
|
||||
|
||||
// Set the cookie language to the user language if it is not set correctly
|
||||
@@ -156,6 +174,7 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
||||
pageProps: {
|
||||
...getActiveColorScheme(session, ctx),
|
||||
packageAttributes: getServiceSidePackageAttributes(),
|
||||
analyticsEnabled,
|
||||
session,
|
||||
locale: ctx.locale ?? 'en',
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
@@ -20,10 +21,11 @@ import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { PasswordRequirements } from '~/components/Password/password-requirements';
|
||||
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
|
||||
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
|
||||
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { prisma } from '~/server/db';
|
||||
import { db } from '~/server/db';
|
||||
import { invites } from '~/server/db/schema';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { api } from '~/utils/api';
|
||||
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||
@@ -134,10 +136,6 @@ export default function AuthInvitePage() {
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Card
|
||||
withBorder
|
||||
style={{
|
||||
display: form.isValid('password') ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<PasswordRequirements value={form.values.password} />
|
||||
</Card>
|
||||
@@ -194,14 +192,14 @@ export const getServerSideProps: GetServerSideProps = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const token = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: routeParams.data.inviteId,
|
||||
token: queryParams.data.token,
|
||||
},
|
||||
const dbInvite = await db.query.invites.findFirst({
|
||||
where: and(
|
||||
eq(invites.id, routeParams.data.inviteId),
|
||||
eq(invites.token, queryParams.data.token)
|
||||
),
|
||||
});
|
||||
|
||||
if (!token || token.expires < new Date()) {
|
||||
if (!dbInvite || dbInvite.expires < new Date()) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Head from 'next/head';
|
||||
@@ -20,14 +20,18 @@ import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
|
||||
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
|
||||
import { env } from '~/env';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||
import { signInSchema } from '~/validations/user';
|
||||
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
|
||||
|
||||
export default function LoginPage() {
|
||||
export default function LoginPage({
|
||||
redirectAfterLogin,
|
||||
isDemo,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const { t } = useTranslation('authentication/login');
|
||||
const { i18nZodResolver } = useI18nZodResolver();
|
||||
const router = useRouter();
|
||||
@@ -54,7 +58,7 @@ export default function LoginPage() {
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
router.push('/manage');
|
||||
router.push(redirectAfterLogin ?? '/manage');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -68,7 +72,7 @@ export default function LoginPage() {
|
||||
|
||||
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center">
|
||||
<FloatingBackground />
|
||||
<ThemeSchemeToggle pos="absolute" top={20} right={20}/>
|
||||
<ThemeSchemeToggle pos="absolute" top={20} right={20} />
|
||||
<Stack spacing={40} align="center" w="100%">
|
||||
<Stack spacing={0} align="center">
|
||||
<Image src="/imgs/logo/logo.svg" width={80} height={80} alt="" />
|
||||
@@ -84,6 +88,12 @@ export default function LoginPage() {
|
||||
Homarr
|
||||
</Text>
|
||||
</Stack>
|
||||
{isDemo && (
|
||||
<Alert title="Demo credentials">
|
||||
For demo purposes, you can login with the login <b>demo</b> and password :{' '}
|
||||
<b>demodemo</b>
|
||||
</Alert>
|
||||
)}
|
||||
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
|
||||
<Title style={{ whiteSpace: 'nowrap' }} align="center" weight={900}>
|
||||
{t('title')}
|
||||
@@ -120,6 +130,12 @@ export default function LoginPage() {
|
||||
<Button mt="xs" variant="light" fullWidth type="submit" loading={isLoading}>
|
||||
{t('form.buttons.submit')}
|
||||
</Button>
|
||||
|
||||
{redirectAfterLogin && (
|
||||
<Text color="dimmed" align="center" size="xs">
|
||||
{t('form.afterLoginRedirection', { url: redirectAfterLogin })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
@@ -129,9 +145,16 @@ export default function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale, req, res }) => {
|
||||
const regexExp = /^\/{1}[A-Za-z\/]*$/;
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale, req, res, query }) => {
|
||||
const session = await getServerAuthSession({ req, res });
|
||||
|
||||
const zodResult = await z
|
||||
.object({ redirectAfterLogin: z.string().regex(regexExp) })
|
||||
.safeParseAsync(query);
|
||||
const redirectAfterLogin = zodResult.success ? zodResult.data.redirectAfterLogin : null;
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
redirect: {
|
||||
@@ -141,9 +164,13 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res
|
||||
};
|
||||
}
|
||||
|
||||
const isDemo = env.DEMO_MODE === 'true';
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await getServerSideTranslations(['authentication/login'], locale, req, res)),
|
||||
redirectAfterLogin,
|
||||
isDemo,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,9 +5,11 @@ import { Dashboard } from '~/components/Dashboard/Dashboard';
|
||||
import { BoardLayout } from '~/components/layout/Templates/BoardLayout';
|
||||
import { useInitConfig } from '~/config/init';
|
||||
import { env } from '~/env';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { configExists } from '~/tools/config/configExists';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { boardNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { ConfigType } from '~/types/config';
|
||||
|
||||
@@ -34,13 +36,8 @@ const routeParamsSchema = z.object({
|
||||
slug: z.string(),
|
||||
});
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async ({
|
||||
params,
|
||||
locale,
|
||||
req,
|
||||
res,
|
||||
}) => {
|
||||
const routeParams = routeParamsSchema.safeParse(params);
|
||||
export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async (ctx) => {
|
||||
const routeParams = routeParamsSchema.safeParse(ctx.params);
|
||||
if (!routeParams.success) {
|
||||
return {
|
||||
notFound: true,
|
||||
@@ -55,7 +52,23 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
}
|
||||
|
||||
const config = await getFrontendConfig(routeParams.data.slug);
|
||||
const translations = await getServerSideTranslations(boardNamespaces, locale, req, res);
|
||||
const translations = await getServerSideTranslations(
|
||||
boardNamespaces,
|
||||
ctx.locale,
|
||||
ctx.req,
|
||||
ctx.res
|
||||
);
|
||||
|
||||
const session = await getServerAuthSession({ req: ctx.req, res: ctx.res });
|
||||
|
||||
const result = checkForSessionOrAskForLogin(
|
||||
ctx,
|
||||
session,
|
||||
() => config.settings.access.allowGuests || !!session?.user
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
@@ -19,16 +19,18 @@ import {
|
||||
IconCheck,
|
||||
IconDragDrop,
|
||||
IconLayout,
|
||||
IconLock,
|
||||
IconX,
|
||||
TablerIconsProps,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactNode } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { AccessCustomization } from '~/components/Board/Customize/Access/AccessCustomization';
|
||||
import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization';
|
||||
import { GridstackCustomization } from '~/components/Board/Customize/Gridstack/GridstackCustomization';
|
||||
import { LayoutCustomization } from '~/components/Board/Customize/Layout/LayoutCustomization';
|
||||
@@ -42,6 +44,7 @@ import { MainLayout } from '~/components/layout/Templates/MainLayout';
|
||||
import { createTrpcServersideHelpers } from '~/server/api/helper';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { firstUpperCase } from '~/tools/shared/strings';
|
||||
import { api } from '~/utils/api';
|
||||
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||
@@ -49,15 +52,28 @@ import { boardCustomizationSchema } from '~/validations/boards';
|
||||
|
||||
const notificationId = 'board-customization-notification';
|
||||
|
||||
export default function CustomizationPage() {
|
||||
const query = useRouter().query as { slug: string };
|
||||
export default function CustomizationPage({
|
||||
initialConfig,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const query = useRouter().query as {
|
||||
slug: string;
|
||||
};
|
||||
const utils = api.useContext();
|
||||
const { data: config } = api.config.byName.useQuery({ name: query.slug });
|
||||
const { data: config } = api.config.byName.useQuery(
|
||||
{ name: query.slug },
|
||||
{
|
||||
initialData: initialConfig,
|
||||
refetchOnMount: false,
|
||||
}
|
||||
);
|
||||
const { mutateAsync: saveCusomization, isLoading } = api.config.saveCusomization.useMutation();
|
||||
const { i18nZodResolver } = useI18nZodResolver();
|
||||
const { t } = useTranslation('boards/customize');
|
||||
const form = useBoardCustomizationForm({
|
||||
initialValues: {
|
||||
access: {
|
||||
allowGuests: config?.settings.access.allowGuests ?? false,
|
||||
},
|
||||
layout: {
|
||||
leftSidebarEnabled: config?.settings.customization.layout.enabledLeftSidebar ?? false,
|
||||
rightSidebarEnabled: config?.settings.customization.layout.enabledRightSidebar ?? false,
|
||||
@@ -115,6 +131,7 @@ export default function CustomizationPage() {
|
||||
color: 'green',
|
||||
icon: <IconCheck />,
|
||||
});
|
||||
form.resetDirty();
|
||||
},
|
||||
onError() {
|
||||
updateNotification({
|
||||
@@ -139,6 +156,7 @@ export default function CustomizationPage() {
|
||||
<Button
|
||||
component={Link}
|
||||
passHref
|
||||
color={config?.settings.customization.colors.primary ?? 'red'}
|
||||
href={backToBoardHref}
|
||||
variant="light"
|
||||
leftIcon={<IconArrowLeft size={16} />}
|
||||
@@ -177,12 +195,12 @@ export default function CustomizationPage() {
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!form.isValid()) {
|
||||
form.validate();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSubmit(form.values);
|
||||
}}
|
||||
disabled={!form.isValid()}
|
||||
loading={isLoading}
|
||||
color="green"
|
||||
>
|
||||
@@ -210,6 +228,10 @@ export default function CustomizationPage() {
|
||||
<SectionTitle type="layout" icon={IconLayout} />
|
||||
<LayoutCustomization />
|
||||
</Stack>
|
||||
<Stack spacing="xs">
|
||||
<SectionTitle type="access" icon={IconLock} />
|
||||
<AccessCustomization />
|
||||
</Stack>
|
||||
<Stack spacing="xs">
|
||||
<SectionTitle type="gridstack" icon={IconDragDrop} />
|
||||
<GridstackCustomization />
|
||||
@@ -232,7 +254,7 @@ export default function CustomizationPage() {
|
||||
}
|
||||
|
||||
type SectionTitleProps = {
|
||||
type: 'layout' | 'gridstack' | 'pageMetadata' | 'appereance';
|
||||
type: 'layout' | 'gridstack' | 'pageMetadata' | 'appereance' | 'access';
|
||||
icon: (props: TablerIconsProps) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -254,22 +276,22 @@ const routeParamsSchema = z.object({
|
||||
slug: z.string(),
|
||||
});
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ req, res, locale, params }) => {
|
||||
const routeParams = routeParamsSchema.safeParse(params);
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const routeParams = routeParamsSchema.safeParse(context.params);
|
||||
if (!routeParams.success) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const session = await getServerAuthSession({ req, res });
|
||||
if (!session?.user.isAdmin) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
const session = await getServerAuthSession({ req: context.req, res: context.res });
|
||||
|
||||
const result = checkForSessionOrAskForLogin(context, session, () => session?.user.isAdmin == true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const helpers = await createTrpcServersideHelpers({ req, res });
|
||||
const helpers = await createTrpcServersideHelpers({ req: context.req, res: context.res });
|
||||
|
||||
const config = await helpers.config.byName.fetch({ name: routeParams.data.slug });
|
||||
|
||||
@@ -282,14 +304,16 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res, locale,
|
||||
'settings/customization/shade-selector',
|
||||
'settings/customization/opacity-selector',
|
||||
'settings/customization/gridstack',
|
||||
'settings/customization/access',
|
||||
],
|
||||
locale,
|
||||
req,
|
||||
res
|
||||
context.locale,
|
||||
context.req,
|
||||
context.res
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialConfig: config,
|
||||
primaryColor: config.settings.customization.colors.primary,
|
||||
secondaryColor: config.settings.customization.colors.secondary,
|
||||
primaryShade: config.settings.customization.colors.shade,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import { SSRConfig } from 'next-i18next';
|
||||
import { Dashboard } from '~/components/Dashboard/Dashboard';
|
||||
@@ -5,9 +6,12 @@ import { BoardLayout } from '~/components/layout/Templates/BoardLayout';
|
||||
import { useInitConfig } from '~/config/init';
|
||||
import { env } from '~/env';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { prisma } from '~/server/db';
|
||||
import { db } from '~/server/db';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import { userSettings } from '~/server/db/schema';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { boardNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { ConfigType } from '~/types/config';
|
||||
|
||||
@@ -32,11 +36,7 @@ type BoardGetServerSideProps = {
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async (ctx) => {
|
||||
const session = await getServerAuthSession(ctx);
|
||||
const currentUserSettings = await prisma.userSettings.findFirst({
|
||||
where: {
|
||||
userId: session?.user?.id,
|
||||
},
|
||||
});
|
||||
const boardName = await getDefaultBoardAsync(session?.user?.id, 'default');
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
boardNamespaces,
|
||||
@@ -44,9 +44,13 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
ctx.req,
|
||||
ctx.res
|
||||
);
|
||||
const boardName = currentUserSettings?.defaultBoard ?? 'default';
|
||||
const config = await getFrontendConfig(boardName);
|
||||
|
||||
const result = checkForSessionOrAskForLogin(ctx, session, () => true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
config,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconBox,
|
||||
IconCategory,
|
||||
@@ -35,6 +34,7 @@ import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { sleep } from '~/tools/client/time';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { manageNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
@@ -204,10 +204,9 @@ const BoardsPage = () => {
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const session = await getServerAuthSession(ctx);
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
const result = checkForSessionOrAskForLogin(ctx, session, () => true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
|
||||
@@ -2,11 +2,11 @@ import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Image,
|
||||
UnstyledButton,
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
@@ -23,6 +23,7 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati
|
||||
import { OnlyKeysWithStructure } from '~/types/helpers';
|
||||
|
||||
import { type quickActions } from '../../../public/locales/en/manage/index.json';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
|
||||
const ManagementPage = () => {
|
||||
const { t } = useTranslation('manage/index');
|
||||
@@ -118,10 +119,9 @@ const QuickActionCard = ({ type, href }: QuickActionCardProps) => {
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const session = await getServerAuthSession(ctx);
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
const result = checkForSessionOrAskForLogin(ctx, session, () => true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
|
||||
@@ -10,8 +10,8 @@ import ContainerTable from '~/components/Manage/Tools/Docker/ContainerTable';
|
||||
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { dockerRouter } from '~/server/api/routers/docker/router';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { prisma } from '~/server/db';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { boardNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
@@ -55,25 +55,23 @@ export default function DockerPage({
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale, req, res }) => {
|
||||
const session = await getServerAuthSession({ req, res });
|
||||
if (!session?.user.isAdmin) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const session = await getServerAuthSession({ req: context.req, res: context.res });
|
||||
const result = checkForSessionOrAskForLogin(context, session, () => session?.user.isAdmin == true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const caller = dockerRouter.createCaller({
|
||||
session: session,
|
||||
cookies: req.cookies,
|
||||
prisma: prisma,
|
||||
cookies: context.req.cookies,
|
||||
});
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
[...boardNamespaces, 'layout/manage', 'tools/docker'],
|
||||
locale,
|
||||
req,
|
||||
res
|
||||
context.locale,
|
||||
context.req,
|
||||
context.res
|
||||
);
|
||||
|
||||
let containers = [];
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { manageNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||
|
||||
@@ -132,10 +133,9 @@ export type CreateAccountSchema = z.infer<typeof createAccountSchema>;
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const session = await getServerAuthSession(ctx);
|
||||
|
||||
if (!session?.user.isAdmin) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
const result = checkForSessionOrAskForLogin(ctx, session, () => session?.user.isAdmin == true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
|
||||
@@ -26,6 +26,7 @@ import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal'
|
||||
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { manageNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
@@ -148,7 +149,7 @@ const ManageUsersPage = () => {
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{debouncedSearch && debouncedSearch.length > 0 && (
|
||||
{debouncedSearch && debouncedSearch.length > 0 && data.countPages === 0 && (
|
||||
<tr>
|
||||
<td colSpan={1}>
|
||||
<Box p={15}>
|
||||
@@ -180,11 +181,9 @@ const ManageUsersPage = () => {
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const session = await getServerAuthSession(ctx);
|
||||
|
||||
if (!session?.user.isAdmin) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
const result = checkForSessionOrAskForLogin(ctx, session, () => session?.user.isAdmin == true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
|
||||
@@ -20,6 +20,7 @@ import { openCreateInviteModal } from '~/components/Manage/User/Invite/create-in
|
||||
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { manageNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
@@ -152,11 +153,9 @@ const useStyles = createStyles(() => ({
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const session = await getServerAuthSession(ctx);
|
||||
|
||||
if (!session?.user.isAdmin) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
const result = checkForSessionOrAskForLogin(ctx, session, () => session?.user.isAdmin == true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
|
||||
@@ -7,7 +7,8 @@ import Head from 'next/head';
|
||||
import { OnboardingSteps } from '~/components/Onboarding/onboarding-steps';
|
||||
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
|
||||
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
|
||||
import { prisma } from '~/server/db';
|
||||
import { db } from '~/server/db';
|
||||
import { getTotalUserCountAsync } from '~/server/db/queries/user';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
|
||||
@@ -32,11 +33,7 @@ export default function OnboardPage({
|
||||
<ThemeSchemeToggle pos="absolute" top={20} right={20} variant="default" />
|
||||
|
||||
<Stack h="100dvh" bg={background} spacing={0}>
|
||||
<Center
|
||||
bg={fn.linearGradient(145, colors.red[7], colors.red[5])}
|
||||
mih={150}
|
||||
h={150}
|
||||
>
|
||||
<Center bg={fn.linearGradient(145, colors.red[7], colors.red[5])} mih={150} h={150}>
|
||||
<Center bg={background} w={100} h={100} style={{ borderRadius: 64 }}>
|
||||
<Image width={70} src="/imgs/logo/logo-color.svg" alt="Homarr Logo" />
|
||||
</Center>
|
||||
@@ -72,7 +69,7 @@ export default function OnboardPage({
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const userCount = await prisma.user.count();
|
||||
const userCount = await getTotalUserCountAsync();
|
||||
if (userCount >= 1) {
|
||||
return {
|
||||
notFound: true,
|
||||
@@ -83,7 +80,12 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const configs = files.map((file) => getConfig(file));
|
||||
const configSchemaVersions = configs.map((config) => config.schemaVersion);
|
||||
|
||||
const translations = await getServerSideTranslations(['password-requirements'], ctx.locale, ctx.req, ctx.res);
|
||||
const translations = await getServerSideTranslations(
|
||||
['password-requirements'],
|
||||
ctx.locale,
|
||||
ctx.req,
|
||||
ctx.res
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { createFormContext } from '@mantine/form';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import { changeLanguage } from 'i18next';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { forwardRef } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { AccessibilitySettings } from '~/components/User/Preferences/AccessibilitySettings';
|
||||
@@ -76,9 +78,10 @@ const SettingsComponent = ({
|
||||
country: language.country,
|
||||
}));
|
||||
|
||||
const { t } = useTranslation(['user/preferences', 'common']);
|
||||
const { t, i18n } = useTranslation(['user/preferences', 'common']);
|
||||
|
||||
const { i18nZodResolver } = useI18nZodResolver();
|
||||
const { pathname, query, asPath, push } = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -89,6 +92,7 @@ const SettingsComponent = ({
|
||||
replaceDotsWithIcons: settings.replacePingWithIcons,
|
||||
searchTemplate: settings.searchTemplate,
|
||||
openSearchInNewTab: settings.openSearchInNewTab,
|
||||
autoFocusSearch: settings.autoFocusSearch,
|
||||
},
|
||||
validate: i18nZodResolver(updateSettingsValidationSchema),
|
||||
validateInputOnBlur: true,
|
||||
@@ -104,7 +108,22 @@ const SettingsComponent = ({
|
||||
});
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof updateSettingsValidationSchema>) => {
|
||||
mutate(values);
|
||||
mutate(values, {
|
||||
onSuccess: () => {
|
||||
if (values.language !== settings.language) {
|
||||
i18n.changeLanguage(values.language).then(() => {
|
||||
push(
|
||||
{
|
||||
pathname,
|
||||
query,
|
||||
},
|
||||
asPath,
|
||||
{ locale: values.language }
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
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 { inviteRouter } from './routers/invite/invite-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 +46,7 @@ export const rootRouter = createTRPCRouter({
|
||||
invites: inviteRouter,
|
||||
boards: boardRouter,
|
||||
password: passwordRouter,
|
||||
notebook: notebookRouter
|
||||
notebook: notebookRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -10,54 +10,60 @@ import { AppType } from '~/types/app';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
ping: publicProcedure.input(z.object({
|
||||
id: z.string(),
|
||||
configName: z.string()
|
||||
})).query(async ({ input }) => {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
const config = getConfig(input.configName);
|
||||
const app = config.apps.find((app) => app.id === input.id);
|
||||
ping: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
configName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
const config = getConfig(input.configName);
|
||||
const app = config.apps.find((app) => app.id === input.id);
|
||||
|
||||
if (!app?.url) {
|
||||
Consola.error(`App ${input} not found`);
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
cause: input,
|
||||
message: `App ${input.id} was not found`,
|
||||
});
|
||||
}
|
||||
const res = await axios
|
||||
.get(app.url, { httpsAgent: agent, timeout: 10000 })
|
||||
.then((response) => ({
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
state: isStatusOk(app as AppType, response.status) ? 'online' : 'offline'
|
||||
}))
|
||||
.catch((error: AxiosError) => {
|
||||
if (error.response) {
|
||||
return {
|
||||
state: isStatusOk(app as AppType, error.response.status) ? 'online' : 'offline',
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
Consola.error(`Ping timed out for app with id '${input.id}' in config '${input.configName}' -> url: ${app.url})`);
|
||||
throw new TRPCError({
|
||||
code: 'TIMEOUT',
|
||||
cause: input,
|
||||
message: `Ping timed out`,
|
||||
});
|
||||
}
|
||||
|
||||
Consola.error(`Unexpected response: ${error.message}`);
|
||||
if (!app?.url) {
|
||||
Consola.error(`App ${input} not found`);
|
||||
throw new TRPCError({
|
||||
code: 'UNPROCESSABLE_CONTENT',
|
||||
code: 'NOT_FOUND',
|
||||
cause: input,
|
||||
message: `Unexpected response: ${error.message}`,
|
||||
message: `App ${input.id} was not found`,
|
||||
});
|
||||
});
|
||||
return res;
|
||||
}),
|
||||
}
|
||||
const res = await axios
|
||||
.get(app.url, { httpsAgent: agent, timeout: 10000 })
|
||||
.then((response) => ({
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
state: isStatusOk(app as AppType, response.status) ? 'online' : 'offline',
|
||||
}))
|
||||
.catch((error: AxiosError) => {
|
||||
if (error.response) {
|
||||
return {
|
||||
state: isStatusOk(app as AppType, error.response.status) ? 'online' : 'offline',
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
Consola.error(
|
||||
`Ping timed out for app with id '${input.id}' in config '${input.configName}' -> url: ${app.url})`
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: 'TIMEOUT',
|
||||
cause: input,
|
||||
message: `Ping timed out`,
|
||||
});
|
||||
}
|
||||
|
||||
Consola.error(`Unexpected response: ${error.message}`);
|
||||
throw new TRPCError({
|
||||
code: 'UNPROCESSABLE_CONTENT',
|
||||
cause: input,
|
||||
message: `Unexpected response: ${error.message}`,
|
||||
});
|
||||
});
|
||||
return res;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import fs from 'fs';
|
||||
import { z } from 'zod';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import { configExists } from '~/tools/config/configExists';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
@@ -13,11 +14,7 @@ export const boardRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
|
||||
|
||||
const userSettings = await ctx.prisma.userSettings.findUniqueOrThrow({
|
||||
where: {
|
||||
userId: ctx.session?.user.id,
|
||||
},
|
||||
});
|
||||
const defaultBoard = await getDefaultBoardAsync(ctx.session.user.id, 'default');
|
||||
|
||||
return await Promise.all(
|
||||
files.map(async (file) => {
|
||||
@@ -31,7 +28,7 @@ export const boardRouter = createTRPCRouter({
|
||||
countApps: countApps,
|
||||
countWidgets: config.widgets.length,
|
||||
countCategories: config.categories.length,
|
||||
isDefaultForUser: name === userSettings.defaultBoard,
|
||||
isDefaultForUser: name === defaultBoard,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -5,12 +5,12 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { configExists } from '~/tools/config/configExists';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
import { BackendConfigType, ConfigType } from '~/types/config';
|
||||
import { boardCustomizationSchema } from '~/validations/boards';
|
||||
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
|
||||
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
|
||||
@@ -67,12 +67,6 @@ export const configRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
if (process.env.DISABLE_EDIT_MODE?.toLowerCase() === 'true') {
|
||||
throw new TRPCError({
|
||||
code: 'METHOD_NOT_SUPPORTED',
|
||||
message: 'Edit is not allowed, because edit mode is disabled'
|
||||
});
|
||||
}
|
||||
Consola.info(`Saving updated configuration of '${input.name}' config.`);
|
||||
|
||||
const previousConfig = getConfig(input.name);
|
||||
@@ -191,6 +185,10 @@ export const configRouter = createTRPCRouter({
|
||||
...previousConfig,
|
||||
settings: {
|
||||
...previousConfig.settings,
|
||||
access: {
|
||||
...previousConfig.settings.access,
|
||||
allowGuests: input.access.allowGuests,
|
||||
},
|
||||
customization: {
|
||||
...previousConfig.settings.customization,
|
||||
appOpacity: input.appearance.opacity,
|
||||
|
||||
@@ -62,22 +62,24 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
)
|
||||
);
|
||||
|
||||
const data = result.reduce(
|
||||
(prev: AdStatistics, curr) => ({
|
||||
domainsBeingBlocked: prev.domainsBeingBlocked + curr.domainsBeingBlocked,
|
||||
adsBlockedToday: prev.adsBlockedToday + curr.adsBlockedToday,
|
||||
dnsQueriesToday: prev.dnsQueriesToday + curr.dnsQueriesToday,
|
||||
status: [...prev.status, curr.status],
|
||||
adsBlockedTodayPercentage: 0,
|
||||
}),
|
||||
{
|
||||
domainsBeingBlocked: 0,
|
||||
adsBlockedToday: 0,
|
||||
adsBlockedTodayPercentage: 0,
|
||||
dnsQueriesToday: 0,
|
||||
status: [],
|
||||
}
|
||||
);
|
||||
const data = result
|
||||
.filter((x) => x !== null)
|
||||
.reduce(
|
||||
(prev: AdStatistics, curr) => ({
|
||||
domainsBeingBlocked: prev.domainsBeingBlocked + curr!.domainsBeingBlocked,
|
||||
adsBlockedToday: prev.adsBlockedToday + curr!.adsBlockedToday,
|
||||
dnsQueriesToday: prev.dnsQueriesToday + curr!.dnsQueriesToday,
|
||||
status: [...prev.status, curr!.status],
|
||||
adsBlockedTodayPercentage: 0,
|
||||
}),
|
||||
{
|
||||
domainsBeingBlocked: 0,
|
||||
adsBlockedToday: 0,
|
||||
adsBlockedTodayPercentage: 0,
|
||||
dnsQueriesToday: 0,
|
||||
status: [],
|
||||
}
|
||||
);
|
||||
|
||||
data.adsBlockedTodayPercentage = data.adsBlockedToday / data.dnsQueriesToday;
|
||||
if (Number.isNaN(data.adsBlockedTodayPercentage)) {
|
||||
@@ -131,7 +133,13 @@ const processPiHole = async (app: ConfigAppType, enable: boolean) => {
|
||||
|
||||
const collectPiHoleSummary = async (app: ConfigAppType) => {
|
||||
const piHole = new PiHoleClient(app.url, findAppProperty(app, 'apiKey'));
|
||||
const summary = await piHole.getSummary();
|
||||
const summary = await piHole.getSummary().catch(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
domainsBeingBlocked: summary.domains_being_blocked,
|
||||
@@ -152,7 +160,14 @@ const collectAdGuardSummary = async (app: ConfigAppType) => {
|
||||
findAppProperty(app, 'password')
|
||||
);
|
||||
|
||||
const stats = await adGuard.getStats();
|
||||
const stats = await adGuard.getStats().catch(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = await adGuard.getStatus();
|
||||
const countFilteredDomains = await adGuard.getCountFilteringDomains();
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ const GetDataFromClient = async (
|
||||
appId: app.id,
|
||||
nzbs: nzbgetItems,
|
||||
success: true,
|
||||
totalDownload: 0,
|
||||
totalDownload: nzbgetStatus.DownloadRate,
|
||||
};
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import dayjs from 'dayjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const inviteRouter = createTRPCRouter({
|
||||
all: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(100).nullish().default(10),
|
||||
page: z.number().min(0),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const limit = input.limit ?? 50;
|
||||
const invites = await ctx.prisma.invite.findMany({
|
||||
take: limit,
|
||||
skip: limit * input.page,
|
||||
include: {
|
||||
createdBy: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const inviteCount = await ctx.prisma.invite.count();
|
||||
|
||||
return {
|
||||
invites: invites.map((token) => ({
|
||||
id: token.id,
|
||||
expires: token.expires,
|
||||
creator: token.createdBy.name,
|
||||
})),
|
||||
countPages: Math.ceil(inviteCount / limit),
|
||||
};
|
||||
}),
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
expiration: z
|
||||
.date()
|
||||
.min(dayjs().add(5, 'minutes').toDate())
|
||||
.max(dayjs().add(6, 'months').toDate()),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const token = await ctx.prisma.invite.create({
|
||||
data: {
|
||||
expires: input.expiration,
|
||||
createdById: ctx.session.user.id,
|
||||
token: randomBytes(20).toString('hex'),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: token.id,
|
||||
token: token.token,
|
||||
expires: token.expires,
|
||||
};
|
||||
}),
|
||||
delete: adminProcedure
|
||||
.input(z.object({ tokenId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.invite.delete({
|
||||
where: {
|
||||
id: input.tokenId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
234
src/server/api/routers/invite/invite-router.spec.ts
Normal file
234
src/server/api/routers/invite/invite-router.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { Session, User } from 'next-auth';
|
||||
import { v4 } from 'uuid';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { db, sqlite } from '~/server/db';
|
||||
import { invites, users } from '~/server/db/schema';
|
||||
|
||||
import { rootRouter } from '../../root';
|
||||
import { inviteRouter } from './invite-router';
|
||||
|
||||
const sessionMock = (user?: Partial<User>): Session => ({
|
||||
user: {
|
||||
id: user?.id ?? '123',
|
||||
name: user?.name ?? 'John Doe',
|
||||
language: user?.language ?? 'en',
|
||||
colorScheme: user?.colorScheme ?? 'dark',
|
||||
autoFocusSearch: user?.autoFocusSearch ?? false,
|
||||
isAdmin: user?.isAdmin ?? false,
|
||||
},
|
||||
expires: '1',
|
||||
});
|
||||
|
||||
describe('invite router', () => {
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv('DATABASE_URL', ':memory:');
|
||||
migrate(db, { migrationsFolder: './drizzle' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Delete all data from database
|
||||
const tables = sqlite.prepare(`select name from sqlite_master where type = 'table';`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
for (const table of tables) {
|
||||
if (table.name.startsWith('__')) continue;
|
||||
sqlite.prepare(`delete from ${table.name}`).run();
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin procedure check', async () => {
|
||||
const caller = inviteRouter.createCaller({
|
||||
session: sessionMock(),
|
||||
cookies: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
(async () => {
|
||||
await caller.all({ page: 0 });
|
||||
})()
|
||||
).rejects.toThrowError('FORBIDDEN');
|
||||
});
|
||||
|
||||
test('All invites should return invites from database', async () => {
|
||||
const expireDate = new Date(2021, 1, 1);
|
||||
await db.insert(users).values({
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
await db.insert(invites).values({
|
||||
id: '123',
|
||||
createdById: '123',
|
||||
expires: expireDate,
|
||||
token: 'token',
|
||||
});
|
||||
await db.insert(invites).values({
|
||||
id: v4(),
|
||||
createdById: '123',
|
||||
expires: expireDate,
|
||||
token: v4(),
|
||||
});
|
||||
|
||||
const caller = inviteRouter.createCaller({
|
||||
session: sessionMock({ isAdmin: true }),
|
||||
cookies: {},
|
||||
});
|
||||
|
||||
const result = await caller.all({ page: 0, limit: 1 });
|
||||
|
||||
expect(result.countPages).toEqual(2);
|
||||
expect(result.invites[0]).toStrictEqual({
|
||||
id: '123',
|
||||
creator: 'John Doe',
|
||||
expires: expireDate,
|
||||
});
|
||||
});
|
||||
|
||||
test('Create should create new invite in database with expiration in 6 minutes', async () => {
|
||||
const expireDate = dayjs().add(6, 'minutes').set('milliseconds', 0).toDate();
|
||||
await db.insert(users).values({
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
|
||||
const caller = inviteRouter.createCaller({
|
||||
session: sessionMock({ isAdmin: true }),
|
||||
cookies: {},
|
||||
});
|
||||
|
||||
const result = await caller.create({ expiration: expireDate });
|
||||
|
||||
const dbInvite = await db.query.invites.findFirst({
|
||||
where: eq(invites.id, result.id),
|
||||
});
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.expires).toEqual(expireDate);
|
||||
expect(result.token).toHaveLength(40);
|
||||
expect(dbInvite).toStrictEqual({
|
||||
id: result.id,
|
||||
createdById: '123',
|
||||
expires: expireDate,
|
||||
token: result.token,
|
||||
});
|
||||
});
|
||||
|
||||
test('Create should create new invite in database with expiration in 30 days', async () => {
|
||||
const expireDate = dayjs().add(6, 'months').add(-1, 'minute').set('milliseconds', 0).toDate();
|
||||
await db.insert(users).values({
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
|
||||
const caller = inviteRouter.createCaller({
|
||||
session: sessionMock({ isAdmin: true }),
|
||||
cookies: {},
|
||||
});
|
||||
|
||||
const result = await caller.create({ expiration: expireDate });
|
||||
|
||||
const dbInvite = await db.query.invites.findFirst({
|
||||
where: eq(invites.id, result.id),
|
||||
});
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.expires).toEqual(expireDate);
|
||||
expect(result.token).toHaveLength(40);
|
||||
expect(dbInvite).toStrictEqual({
|
||||
id: result.id,
|
||||
createdById: '123',
|
||||
expires: expireDate,
|
||||
token: result.token,
|
||||
});
|
||||
});
|
||||
|
||||
test('Create should throw too_small with expiration in 4 minutes', async () => {
|
||||
const expireDate = dayjs().add(4, 'minutes').set('milliseconds', 0).toDate();
|
||||
await db.insert(users).values({
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
|
||||
const caller = inviteRouter.createCaller({
|
||||
session: sessionMock({ isAdmin: true }),
|
||||
cookies: {},
|
||||
});
|
||||
|
||||
const act = async () => await caller.create({ expiration: expireDate });
|
||||
|
||||
expect(act).rejects.toThrowError(/"code": "too_small"/);
|
||||
});
|
||||
|
||||
test('Create should throw too_big with expiration in 7 months', async () => {
|
||||
const expireDate = dayjs().add(7, 'months').set('milliseconds', 0).toDate();
|
||||
await db.insert(users).values({
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
|
||||
const caller = inviteRouter.createCaller({
|
||||
session: sessionMock({ isAdmin: true }),
|
||||
cookies: {},
|
||||
});
|
||||
|
||||
const act = async () => await caller.create({ expiration: expireDate });
|
||||
|
||||
expect(act).rejects.toThrowError(/"code": "too_big"/);
|
||||
});
|
||||
|
||||
test('Delete should delete invite from database', async () => {
|
||||
const inviteId = '123';
|
||||
await db.insert(users).values({
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
await db.insert(invites).values({
|
||||
id: inviteId,
|
||||
createdById: '123',
|
||||
expires: new Date(2023, 1, 1),
|
||||
token: 'token',
|
||||
});
|
||||
|
||||
const caller = inviteRouter.createCaller({
|
||||
session: sessionMock({ isAdmin: true }),
|
||||
cookies: {},
|
||||
});
|
||||
|
||||
await caller.delete({ id: inviteId });
|
||||
|
||||
const dbInvite = await db.query.invites.findFirst({
|
||||
where: eq(invites.id, inviteId),
|
||||
});
|
||||
|
||||
expect(dbInvite).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Delete should delete invite from database', async () => {
|
||||
const inviteId = '123';
|
||||
await db.insert(users).values({
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
await db.insert(invites).values({
|
||||
id: inviteId,
|
||||
createdById: '123',
|
||||
expires: new Date(2023, 1, 1),
|
||||
token: 'token',
|
||||
});
|
||||
|
||||
const caller = inviteRouter.createCaller({
|
||||
session: sessionMock({ isAdmin: true }),
|
||||
cookies: {},
|
||||
});
|
||||
|
||||
await caller.delete({ id: inviteId });
|
||||
|
||||
const dbInvite = await db.query.invites.findFirst({
|
||||
where: eq(invites.id, inviteId),
|
||||
});
|
||||
|
||||
expect(dbInvite).toBeUndefined();
|
||||
});
|
||||
});
|
||||
73
src/server/api/routers/invite/invite-router.ts
Normal file
73
src/server/api/routers/invite/invite-router.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import dayjs from 'dayjs';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { invites } from '~/server/db/schema';
|
||||
|
||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../../trpc';
|
||||
|
||||
export const inviteRouter = createTRPCRouter({
|
||||
all: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(100).default(10),
|
||||
page: z.number().min(0),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const limit = input.limit;
|
||||
const dbInvites = await db.query.invites.findMany({
|
||||
limit: limit,
|
||||
offset: limit * input.page,
|
||||
with: {
|
||||
createdBy: {
|
||||
columns: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const inviteCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invites)
|
||||
.then((rows) => rows[0].count);
|
||||
|
||||
return {
|
||||
invites: dbInvites.map((token) => ({
|
||||
id: token.id,
|
||||
expires: token.expires,
|
||||
creator: token.createdBy.name,
|
||||
})),
|
||||
countPages: Math.ceil(inviteCount / limit),
|
||||
};
|
||||
}),
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
expiration: z
|
||||
.date()
|
||||
.min(dayjs().add(5, 'minutes').toDate())
|
||||
.max(dayjs().add(6, 'months').toDate()),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const inviteToInsert = {
|
||||
id: randomUUID(),
|
||||
expires: input.expiration,
|
||||
createdById: ctx.session.user.id,
|
||||
token: randomBytes(20).toString('hex'),
|
||||
};
|
||||
await db.insert(invites).values(inviteToInsert);
|
||||
|
||||
return {
|
||||
id: inviteToInsert.id,
|
||||
token: inviteToInsert.token,
|
||||
expires: inviteToInsert.expires,
|
||||
};
|
||||
}),
|
||||
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
|
||||
await db.delete(invites).where(eq(invites.id, input.id));
|
||||
}),
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import Consola from 'consola';
|
||||
import { removeTrailingSlash } from 'next/dist/shared/lib/router/utils/remove-trailing-slash';
|
||||
import { z } from 'zod';
|
||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { MediaRequestListWidget } from '~/widgets/media-requests/MediaRequestListTile';
|
||||
import { MediaRequestStatsWidget } from '~/widgets/media-requests/MediaRequestStatsTile';
|
||||
import { MediaRequest, Users } from '~/widgets/media-requests/media-request-types';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { MediaRequestStatsWidget } from '~/widgets/media-requests/MediaRequestStatsTile';
|
||||
import { removeTrailingSlash } from 'next/dist/shared/lib/router/utils/remove-trailing-slash';
|
||||
|
||||
export const mediaRequestsRouter = createTRPCRouter({
|
||||
allMedia: publicProcedure
|
||||
@@ -33,9 +33,11 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
})
|
||||
.then(async (response) => {
|
||||
const body = (await response.json()) as OverseerrResponse;
|
||||
let appUrl = input.widget.properties.replaceLinksWithExternalHost && app.behaviour.externalUrl?.length > 0
|
||||
? app.behaviour.externalUrl
|
||||
: app.url;
|
||||
let appUrl =
|
||||
input.widget.properties.replaceLinksWithExternalHost &&
|
||||
app.behaviour.externalUrl?.length > 0
|
||||
? app.behaviour.externalUrl
|
||||
: app.url;
|
||||
|
||||
appUrl = removeTrailingSlash(appUrl);
|
||||
|
||||
@@ -163,7 +165,7 @@ const retrieveDetailsForItem = async (
|
||||
backdropPath: series.backdropPath,
|
||||
posterPath: series.backdropPath,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, {
|
||||
headers,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user