Merge branch 'dev' into edit-mode-password
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
createStyles,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
HoverCard,
|
||||
Kbd,
|
||||
Modal,
|
||||
Table,
|
||||
Text,
|
||||
@@ -36,6 +37,7 @@ import { useConfigStore } from '../../../../config/store';
|
||||
import { useEditModeInformationStore } from '../../../../hooks/useEditModeInformation';
|
||||
import { usePackageAttributesStore } from '../../../../tools/client/zustands/usePackageAttributesStore';
|
||||
import { useColorTheme } from '../../../../tools/color';
|
||||
import Tip from '../../../layout/Tip';
|
||||
import { usePrimaryGradient } from '../../../layout/useGradient';
|
||||
import Credits from '../../../Settings/Common/Credits';
|
||||
|
||||
@@ -51,6 +53,23 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
|
||||
const informations = useInformationTableItems(newVersionAvailable);
|
||||
const { t } = useTranslation(['common', 'layout/modals/about']);
|
||||
|
||||
const keybinds = [
|
||||
{ key: 'Mod + J', shortcut: 'Toggle light/dark mode' },
|
||||
{ key: 'Mod + K', shortcut: 'Focus on search bar' },
|
||||
{ key: 'Mod + B', shortcut: 'Open docker widget' },
|
||||
{ key: 'Mod + E', shortcut: 'Toggle Edit mode' },
|
||||
];
|
||||
const rows = keybinds.map((element) => (
|
||||
<tr key={element.key}>
|
||||
<td>
|
||||
<Kbd>{element.key}</Kbd>
|
||||
</td>
|
||||
<td>
|
||||
<Text>{element.shortcut}</Text>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => closeModal()}
|
||||
@@ -77,7 +96,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
|
||||
<Trans i18nKey="layout/modals/about:description" />
|
||||
</Text>
|
||||
|
||||
<Table mb="lg" striped highlightOnHover withBorder>
|
||||
<Table mb="lg" highlightOnHover withBorder>
|
||||
<tbody>
|
||||
{informations.map((item, index) => (
|
||||
<tr key={index}>
|
||||
@@ -101,8 +120,26 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Accordion mb={5} variant="contained" radius="md">
|
||||
<Accordion.Item value="keybinds">
|
||||
<Accordion.Control icon={<IconKey size={20} />}>
|
||||
{t('layout/modals/about:keybinds')}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Table mb={5}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('layout/modals/about:key')}</th>
|
||||
<th>{t('layout/modals/about:action')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
<Tip>{t('layout/modals/about:tip')}</Tip>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Divider variant="dashed" mb="md" />
|
||||
<Title order={6} mb="xs" align="center">
|
||||
{t('layout/modals/about:contact')}
|
||||
</Title>
|
||||
|
||||
@@ -90,7 +90,8 @@ export const IconSelector = ({
|
||||
}
|
||||
variant="default"
|
||||
withAsterisk
|
||||
dropdownComponent={(props: any) => <ScrollArea {...props} mah={400} />}
|
||||
dropdownComponent={(props: any) => <ScrollArea {...props} mah={250} />}
|
||||
dropdownPosition="bottom"
|
||||
required
|
||||
onChange={(event) => {
|
||||
if (allowAppNamePropagation) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Text,
|
||||
@@ -40,7 +39,7 @@ export const GenericSecretInput = ({
|
||||
|
||||
const Icon = setIcon;
|
||||
|
||||
const [displayUpdateField, setDisplayUpdateField] = useState<boolean>(false);
|
||||
const [displayUpdateField, setDisplayUpdateField] = useState<boolean>(!secretIsPresent);
|
||||
const { t } = useTranslation(['layout/modals/add-app', 'common']);
|
||||
|
||||
return (
|
||||
@@ -51,26 +50,26 @@ export const GenericSecretInput = ({
|
||||
<ThemeIcon color={secretIsPresent ? 'green' : 'red'} variant="light" size="lg">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack spacing={0}>
|
||||
<Flex justify="start" align="start" direction="column">
|
||||
<Group spacing="xs">
|
||||
<Title className={classes.subtitle} order={6}>
|
||||
{t(label)}
|
||||
</Title>
|
||||
|
||||
<Group spacing="xs">
|
||||
{secretIsPresent ? (
|
||||
<Badge className={classes.textTransformUnset} color="green" variant="dot">
|
||||
{t('integration.type.defined')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className={classes.textTransformUnset} color="red" variant="dot">
|
||||
{t('integration.type.undefined')}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
className={classes.textTransformUnset}
|
||||
color={secretIsPresent ? 'green' : 'red'}
|
||||
variant="dot"
|
||||
>
|
||||
{secretIsPresent
|
||||
? t('integration.type.defined')
|
||||
: t('integration.type.undefined')}
|
||||
</Badge>
|
||||
{type === 'private' ? (
|
||||
<Tooltip
|
||||
label={t('integration.type.explanationPrivate')}
|
||||
width={200}
|
||||
width={400}
|
||||
multiline
|
||||
withinPortal
|
||||
withArrow
|
||||
@@ -82,7 +81,7 @@ export const GenericSecretInput = ({
|
||||
) : (
|
||||
<Tooltip
|
||||
label={t('integration.type.explanationPublic')}
|
||||
width={200}
|
||||
width={400}
|
||||
multiline
|
||||
withinPortal
|
||||
withArrow
|
||||
@@ -94,29 +93,20 @@ export const GenericSecretInput = ({
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" color="dimmed">
|
||||
<Text size="xs" color="dimmed" w={400}>
|
||||
{type === 'private'
|
||||
? 'Private: Once saved, you cannot read out this value again'
|
||||
: 'Public: Can be read out repeatedly'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} md={6}>
|
||||
<Flex gap={10} justify="end" align="end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDisplayUpdateField(false);
|
||||
onClickUpdateButton(undefined);
|
||||
}}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
px="xl"
|
||||
>
|
||||
{t('integration.secrets.clear')}
|
||||
</Button>
|
||||
{displayUpdateField === true ? (
|
||||
<PasswordInput
|
||||
required
|
||||
defaultValue={value}
|
||||
placeholder="new secret"
|
||||
styles={{ root: { width: 200 } }}
|
||||
{...props}
|
||||
|
||||
@@ -10,6 +10,9 @@ interface NetworkTabProps {
|
||||
|
||||
export const NetworkTab = ({ form }: NetworkTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const acceptableStatusCodes = (form.values.network.statusCodes ?? ['200']).map((x) =>
|
||||
x.toString()
|
||||
);
|
||||
return (
|
||||
<Tabs.Panel value="network" pt="lg">
|
||||
<Switch
|
||||
@@ -27,7 +30,7 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
|
||||
data={StatusCodes}
|
||||
clearable
|
||||
searchable
|
||||
defaultValue={form.values.network.okStatus.map((x) => `${x}`)}
|
||||
defaultValue={acceptableStatusCodes}
|
||||
variant="default"
|
||||
{...form.getInputProps('network.statusCodes')}
|
||||
/>
|
||||
|
||||
@@ -95,7 +95,7 @@ export const AvailableElementTypes = ({
|
||||
},
|
||||
network: {
|
||||
enabledStatusChecker: true,
|
||||
okStatus: [200],
|
||||
statusCodes: ['200'],
|
||||
},
|
||||
behaviour: {
|
||||
isOpeningNewTab: true,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const AppPing = ({ app }: AppPingProps) => {
|
||||
queryKey: ['ping', { id: app.id, name: app.name }],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/modules/ping?url=${encodeURI(app.url)}`);
|
||||
const isOk = app.network.okStatus.includes(response.status);
|
||||
const isOk = app.network.statusCodes.includes(response.status.toString());
|
||||
return {
|
||||
status: response.status,
|
||||
state: isOk ? 'online' : 'down',
|
||||
@@ -60,5 +60,3 @@ export const AppPing = ({ app }: AppPingProps) => {
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
type PingState = 'loading' | 'down' | 'online';
|
||||
|
||||
@@ -36,7 +36,13 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
className="dashboard-tile-app"
|
||||
>
|
||||
<Box hidden={false}>
|
||||
<Title order={5} size="md" ta="center" lineClamp={1} className={cx(classes.appName, 'dashboard-tile-app-title')}>
|
||||
<Title
|
||||
order={5}
|
||||
size="md"
|
||||
ta="center"
|
||||
lineClamp={1}
|
||||
className={cx(classes.appName, 'dashboard-tile-app-title')}
|
||||
>
|
||||
{app.name}
|
||||
</Title>
|
||||
</Box>
|
||||
|
||||
@@ -24,9 +24,9 @@ export const GenericTileMenu = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start">
|
||||
<Menu withinPortal withArrow position="right">
|
||||
<Menu.Target>
|
||||
<ActionIcon pos="absolute" top={4} right={4}>
|
||||
<ActionIcon size="md" radius="md" variant="light" pos="absolute" top={8} right={8}>
|
||||
<IconDots />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -10,9 +10,7 @@ export default function CustomizationSettings() {
|
||||
return (
|
||||
<ScrollArea style={{ height: height - 100 }} offsetScrollbars>
|
||||
<Stack mt="xs" mb="md" spacing="xs">
|
||||
<Text color="dimmed">
|
||||
{t('text')}
|
||||
</Text>
|
||||
<Text color="dimmed">{t('text')}</Text>
|
||||
<CustomizationSettingsAccordeon />
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -8,7 +8,9 @@ export const LogoImageChanger = () => {
|
||||
const { t } = useTranslation('settings/customization/page-appearance');
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [logoImageSrc, setLogoImageSrc] = useState(config?.settings.customization.logoImageUrl ?? '/imgs/logo/logo.png');
|
||||
const [logoImageSrc, setLogoImageSrc] = useState(
|
||||
config?.settings.customization.logoImageUrl ?? '/imgs/logo/logo.png'
|
||||
);
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { useHotkeys, useWindowEvent } from '@mantine/hooks';
|
||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons';
|
||||
import axios from 'axios';
|
||||
import Consola from 'consola';
|
||||
import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons';
|
||||
import { getCookie } from 'cookies-next';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||
import { useConfigContext } from '../../../../../config/provider';
|
||||
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
|
||||
|
||||
import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore';
|
||||
import { AddElementAction } from '../AddElementAction/AddElementAction';
|
||||
import { useNamedWrapperColumnCount } from '../../../../Dashboard/Wrappers/gridstack/store';
|
||||
import { useCardStyles } from '../../../useCardStyles';
|
||||
import { AddElementAction } from '../AddElementAction/AddElementAction';
|
||||
|
||||
const beforeUnloadEventText = 'Exit the edit mode to save your changes';
|
||||
|
||||
export const ToggleEditModeAction = () => {
|
||||
const { enabled, toggleEditMode } = useEditModeStore();
|
||||
@@ -27,7 +29,17 @@ export const ToggleEditModeAction = () => {
|
||||
const { config } = useConfigContext();
|
||||
const { classes } = useCardStyles(true);
|
||||
|
||||
useHotkeys([['ctrl+E', toggleEditMode]]);
|
||||
useHotkeys([['mod+E', toggleEditMode]]);
|
||||
|
||||
useWindowEvent('beforeunload', (event: BeforeUnloadEvent) => {
|
||||
if (enabled) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.returnValue = beforeUnloadEventText;
|
||||
return beforeUnloadEventText;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const toggleButtonClicked = () => {
|
||||
toggleEditMode();
|
||||
|
||||
@@ -10,5 +10,7 @@ export const useGetDashboardIcons = () =>
|
||||
return data as NormalizedIconRepositoryResult[];
|
||||
},
|
||||
refetchOnMount: false,
|
||||
// Cache for infinity, refetch every so often.
|
||||
cacheTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NormalizedDownloadQueueResponse } from '../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
|
||||
export const useGetDownloadClientsQueue = () => useQuery({
|
||||
queryKey: ['network-speed'],
|
||||
queryFn: async (): Promise<NormalizedDownloadQueueResponse> => {
|
||||
const response = await fetch('/api/modules/downloads');
|
||||
return response.json();
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
export const useGetDownloadClientsQueue = () =>
|
||||
useQuery({
|
||||
queryKey: ['network-speed'],
|
||||
queryFn: async (): Promise<NormalizedDownloadQueueResponse> => {
|
||||
const response = await fetch('/api/modules/downloads');
|
||||
return response.json();
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
export function middleware(req: NextRequest, ev: NextFetchEvent) {
|
||||
export function middleware(req: NextRequest) {
|
||||
const { cookies } = req;
|
||||
|
||||
// Don't even bother with the middleware if there is no defined password
|
||||
if (!process.env.PASSWORD) return NextResponse.next();
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
},
|
||||
network: {
|
||||
enabledStatusChecker: true,
|
||||
okStatus: [200],
|
||||
statusCodes: ['200'],
|
||||
},
|
||||
behaviour: {
|
||||
isOpeningNewTab: true,
|
||||
|
||||
@@ -38,6 +38,7 @@ function App(
|
||||
colorScheme: ColorScheme;
|
||||
packageAttributes: ServerSidePackageAttributesType;
|
||||
editModeEnabled: boolean;
|
||||
defaultColorScheme: ColorScheme;
|
||||
}
|
||||
) {
|
||||
const { Component, pageProps } = props;
|
||||
@@ -55,7 +56,7 @@ function App(
|
||||
|
||||
// hook will return either 'dark' or 'light' on client
|
||||
// and always 'light' during ssr as window.matchMedia is not available
|
||||
const preferredColorScheme = useColorScheme();
|
||||
const preferredColorScheme = useColorScheme(props.defaultColorScheme);
|
||||
const [colorScheme, setColorScheme] = useLocalStorage<ColorScheme>({
|
||||
key: 'mantine-color-scheme',
|
||||
defaultValue: preferredColorScheme,
|
||||
@@ -144,10 +145,18 @@ App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
||||
'EXPERIMENTAL: You have disabled the edit mode. Modifications are no longer possible and any requests on the API will be dropped. If you want to disable this, unset the DISABLE_EDIT_MODE environment variable. This behaviour may be removed in future versions of Homarr'
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.DEFAULT_COLOR_SCHEME !== undefined) {
|
||||
Consola.debug(`Overriding the default color scheme with ${process.env.DEFAULT_COLOR_SCHEME}`);
|
||||
}
|
||||
|
||||
const colorScheme: ColorScheme = process.env.DEFAULT_COLOR_SCHEME as ColorScheme ?? 'light';
|
||||
|
||||
return {
|
||||
colorScheme: getCookie('color-scheme', ctx) || 'light',
|
||||
packageAttributes: getServiceSidePackageAttributes(),
|
||||
editModeEnabled: !disableEditMode,
|
||||
defaultColorScheme: colorScheme,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,10 +6,26 @@ import { UnpkgIconsRepository } from '../../../tools/server/images/unpkg-icons-r
|
||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const respositories = [
|
||||
new LocalIconsRepository(),
|
||||
new JsdelivrIconsRepository(JsdelivrIconsRepository.tablerRepository, 'Walkxcode Dashboard Icons', 'Walkxcode on Github'),
|
||||
new UnpkgIconsRepository(UnpkgIconsRepository.tablerRepository, 'Tabler Icons', 'Tabler Icons - GitHub (MIT)'),
|
||||
new JsdelivrIconsRepository(JsdelivrIconsRepository.papirusRepository, 'Papirus Icons', 'Papirus Development Team on GitHub (Apache 2.0)'),
|
||||
new JsdelivrIconsRepository(JsdelivrIconsRepository.homelabSvgAssetsRepository, 'Homelab Svg Assets', 'loganmarchione on GitHub (MIT)'),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.tablerRepository,
|
||||
'Walkxcode Dashboard Icons',
|
||||
'Walkxcode on Github'
|
||||
),
|
||||
new UnpkgIconsRepository(
|
||||
UnpkgIconsRepository.tablerRepository,
|
||||
'Tabler Icons',
|
||||
'Tabler Icons - GitHub (MIT)'
|
||||
),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.papirusRepository,
|
||||
'Papirus Icons',
|
||||
'Papirus Development Team on GitHub (Apache 2.0)'
|
||||
),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.homelabSvgAssetsRepository,
|
||||
'Homelab Svg Assets',
|
||||
'loganmarchione on GitHub (MIT)'
|
||||
),
|
||||
];
|
||||
const fetches = respositories.map((rep) => rep.fetch());
|
||||
const data = await Promise.all(fetches);
|
||||
|
||||
@@ -53,7 +53,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
);
|
||||
|
||||
const IntegrationTypeEndpointMap = new Map<AppIntegrationType['type'], string>([
|
||||
['sonarr', useSonarrv4 ? '/api/v3/calendar' : '/api/calendar'],
|
||||
['sonarr', useSonarrv4 ? '/api/v3/calendar' : '/api/calendar'],
|
||||
['radarr', '/api/v3/calendar'],
|
||||
['lidarr', '/api/v1/calendar'],
|
||||
['readarr', '/api/v1/calendar'],
|
||||
|
||||
@@ -61,7 +61,9 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const responseBody = { apps: data, failedApps: failedClients } as NormalizedDownloadQueueResponse;
|
||||
|
||||
if (failedClients.length > 0) {
|
||||
Consola.warn(`${failedClients.length} download clients failed. Please check your configuration and the above log`);
|
||||
Consola.warn(
|
||||
`${failedClients.length} download clients failed. Please check your configuration and the above log`
|
||||
);
|
||||
}
|
||||
|
||||
return response.status(200).json(responseBody);
|
||||
|
||||
@@ -53,6 +53,7 @@ export const Get = async (request: NextApiRequest, response: NextApiResponse) =>
|
||||
title: item.title ? decode(item.title) : undefined,
|
||||
content: decode(item.content),
|
||||
enclosure: createEnclosure(item),
|
||||
link: createLink(item),
|
||||
}))
|
||||
.sort((a: { pubDate: number }, b: { pubDate: number }) => {
|
||||
if (!a.pubDate || !b.pubDate) {
|
||||
@@ -70,6 +71,14 @@ export const Get = async (request: NextApiRequest, response: NextApiResponse) =>
|
||||
});
|
||||
};
|
||||
|
||||
const createLink = (item: any) => {
|
||||
if (item.link) {
|
||||
return item.link;
|
||||
}
|
||||
|
||||
return item.guid;
|
||||
};
|
||||
|
||||
const createEnclosure = (item: any) => {
|
||||
if (item.enclosure) {
|
||||
return item.enclosure;
|
||||
|
||||
@@ -159,7 +159,7 @@ const migrateService = (oldService: serviceItem, areaType: AreaType): ConfigAppT
|
||||
},
|
||||
network: {
|
||||
enabledStatusChecker: oldService.ping ?? true,
|
||||
okStatus: oldService.status?.map((str) => parseInt(str, 10)) ?? [200],
|
||||
statusCodes: oldService.status ?? ['200'],
|
||||
},
|
||||
appearance: {
|
||||
iconUrl: migrateIcon(oldService.icon),
|
||||
|
||||
@@ -23,7 +23,7 @@ export class JsdelivrIconsRepository extends AbstractIconRepository {
|
||||
constructor(
|
||||
private readonly repository: JsdelivrRepositoryUrl,
|
||||
private readonly displayName: string,
|
||||
copyright: string,
|
||||
copyright: string
|
||||
) {
|
||||
super(copyright);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const dashboardNamespaces = [
|
||||
'modules/media-server',
|
||||
'modules/common-media-cards',
|
||||
'modules/video-stream',
|
||||
'widgets/error-boundary',
|
||||
];
|
||||
|
||||
export const loginNamespaces = ['authentication/login'];
|
||||
|
||||
@@ -14,33 +14,39 @@ export type GenericCurrentlyPlaying = {
|
||||
episodeCount: number | undefined;
|
||||
type: 'audio' | 'video' | 'tv' | 'movie' | undefined;
|
||||
metadata: {
|
||||
video: {
|
||||
videoCodec: string | undefined;
|
||||
videoFrameRate: string | undefined;
|
||||
height: number | undefined;
|
||||
width: number | undefined;
|
||||
bitrate: number | undefined;
|
||||
} | undefined;
|
||||
audio: {
|
||||
audioCodec: string | undefined;
|
||||
audioChannels: number | undefined;
|
||||
} | undefined;
|
||||
transcoding: {
|
||||
context: string | undefined;
|
||||
sourceVideoCodec: string | undefined;
|
||||
sourceAudioCodec: string | undefined;
|
||||
videoDecision: string | undefined;
|
||||
audioDecision: string | undefined;
|
||||
container: string | undefined;
|
||||
videoCodec: string | undefined;
|
||||
audioCodec: string | undefined;
|
||||
error: boolean | undefined;
|
||||
duration: number | undefined;
|
||||
audioChannels: number | undefined;
|
||||
width: number | undefined;
|
||||
height: number | undefined;
|
||||
transcodeHwRequested: boolean | undefined;
|
||||
timeStamp: number | undefined;
|
||||
} | undefined;
|
||||
video:
|
||||
| {
|
||||
videoCodec: string | undefined;
|
||||
videoFrameRate: string | undefined;
|
||||
height: number | undefined;
|
||||
width: number | undefined;
|
||||
bitrate: number | undefined;
|
||||
}
|
||||
| undefined;
|
||||
audio:
|
||||
| {
|
||||
audioCodec: string | undefined;
|
||||
audioChannels: number | undefined;
|
||||
}
|
||||
| undefined;
|
||||
transcoding:
|
||||
| {
|
||||
context: string | undefined;
|
||||
sourceVideoCodec: string | undefined;
|
||||
sourceAudioCodec: string | undefined;
|
||||
videoDecision: string | undefined;
|
||||
audioDecision: string | undefined;
|
||||
container: string | undefined;
|
||||
videoCodec: string | undefined;
|
||||
audioCodec: string | undefined;
|
||||
error: boolean | undefined;
|
||||
duration: number | undefined;
|
||||
audioChannels: number | undefined;
|
||||
width: number | undefined;
|
||||
height: number | undefined;
|
||||
transcodeHwRequested: boolean | undefined;
|
||||
timeStamp: number | undefined;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ interface AppBehaviourType {
|
||||
|
||||
interface AppNetworkType {
|
||||
enabledStatusChecker: boolean;
|
||||
okStatus: number[];
|
||||
statusCodes: string[];
|
||||
}
|
||||
|
||||
interface AppAppearanceType {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ComponentType, useMemo } from 'react';
|
||||
import Widgets from '.';
|
||||
import { HomarrCardWrapper } from '../components/Dashboard/Tiles/HomarrCardWrapper';
|
||||
import { WidgetsMenu } from '../components/Dashboard/Tiles/Widgets/WidgetsMenu';
|
||||
import ErrorBoundary from './boundary';
|
||||
import { IWidget } from './widgets';
|
||||
|
||||
interface WidgetWrapperProps {
|
||||
@@ -40,9 +41,11 @@ export const WidgetWrapper = ({
|
||||
const widgetWithDefaultProps = useWidget(widget);
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<WidgetsMenu integration={widgetId} widget={widgetWithDefaultProps} />
|
||||
<WidgetComponent widget={widgetWithDefaultProps} />
|
||||
</HomarrCardWrapper>
|
||||
<ErrorBoundary>
|
||||
<HomarrCardWrapper className={className}>
|
||||
<WidgetsMenu integration={widgetId} widget={widgetWithDefaultProps} />
|
||||
<WidgetComponent widget={widgetWithDefaultProps} />
|
||||
</HomarrCardWrapper>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
127
src/widgets/boundary.tsx
Normal file
127
src/widgets/boundary.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import Consola from 'consola';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { withTranslation } from 'next-i18next';
|
||||
import { Button, Card, Center, Code, Group, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconBrandGithub, IconBug, IconInfoCircle, IconRefresh } from '@tabler/icons';
|
||||
|
||||
type ErrorBoundaryState = {
|
||||
hasError: boolean;
|
||||
error: Error | undefined;
|
||||
};
|
||||
|
||||
type ErrorBoundaryProps = {
|
||||
t: (key: string) => string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom error boundary, that catches errors within widgets and renders an error component.
|
||||
* The error component can be refreshed and shows a modal with error details
|
||||
*/
|
||||
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
// Define a state variable to track whether is an error or not
|
||||
this.state = { hasError: false, error: undefined };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: any) {
|
||||
Consola.error(`Error while rendering widget, ${error}: ${errorInfo}`);
|
||||
}
|
||||
|
||||
render() {
|
||||
// Check if the error is thrown
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Card
|
||||
m={10}
|
||||
sx={(theme) => ({
|
||||
backgroundColor: theme.colors.red[5],
|
||||
})}
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
withBorder
|
||||
>
|
||||
<Center>
|
||||
<Stack align="center">
|
||||
<IconBug color="white" />
|
||||
<Stack spacing={0} align="center">
|
||||
<Title order={4} color="white" align="center">
|
||||
{this.props.t('card.title')}
|
||||
</Title>
|
||||
{this.state.error && (
|
||||
<Text color="white" align="center" size="sm">
|
||||
{this.state.error.toString()}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group>
|
||||
<Button
|
||||
onClick={() =>
|
||||
openModal({
|
||||
title: 'Your widget had an error',
|
||||
children: (
|
||||
<>
|
||||
<Text size="sm" mb="sm">
|
||||
{this.props.t('modal.text')}
|
||||
</Text>
|
||||
{this.state.error && (
|
||||
<>
|
||||
<Text weight="bold" size="sm">
|
||||
{this.props.t('modal.label')}
|
||||
</Text>
|
||||
<Code block>{this.state.error.toString()}</Code>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
sx={(theme) => ({
|
||||
backgroundColor: theme.colors.gray[8],
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.gray[9],
|
||||
},
|
||||
})}
|
||||
leftIcon={<IconBrandGithub />}
|
||||
component="a"
|
||||
href="https://github.com/ajnart/homarr/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug.yml&title=New%20bug"
|
||||
target="_blank"
|
||||
mt="md"
|
||||
fullWidth
|
||||
>
|
||||
{(this.props.t('modal.reportButton'))}
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
})
|
||||
}
|
||||
leftIcon={<IconInfoCircle size={16} />}
|
||||
variant="light"
|
||||
>
|
||||
{this.props.t('card.buttons.details')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
leftIcon={<IconRefresh size={16} />}
|
||||
variant="light"
|
||||
>
|
||||
{this.props.t('card.buttons.tryAgain')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Return children components in case of no error
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation('widgets/error-boundary')(ErrorBoundary);
|
||||
@@ -25,7 +25,7 @@ const definition = defineWidget({
|
||||
component: DateTile,
|
||||
});
|
||||
|
||||
export type IDateWidget = IWidget<typeof definition['id'], typeof definition>;
|
||||
export type IDateWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface DateTileProps {
|
||||
widget: IDateWidget;
|
||||
|
||||
@@ -43,7 +43,7 @@ function IFrameTile({ widget }: IFrameTileProps) {
|
||||
<IconUnlink size={36} strokeWidth={1.2} />
|
||||
<Stack align="center" spacing={0}>
|
||||
<Title order={6} align="center">
|
||||
{t('card.errors.noUrl.title')}
|
||||
{t('card.errors.noUrl.title')}
|
||||
</Title>
|
||||
<Text align="center" maw={200}>
|
||||
{t('card.errors.noUrl.text')}
|
||||
|
||||
@@ -107,7 +107,9 @@ export const DetailCollapseable = ({ session }: { session: GenericSessionInfo })
|
||||
</Group>
|
||||
<Text>{session.sessionName}</Text>
|
||||
</Flex>
|
||||
{details.length > 0 && <Divider label="Stats for nerds" labelPosition="center" mt="lg" mb="sm" />}
|
||||
{details.length > 0 && (
|
||||
<Divider label="Stats for nerds" labelPosition="center" mt="lg" mb="sm" />
|
||||
)}
|
||||
<Grid>
|
||||
{details.map((detail, index) => (
|
||||
<Grid.Col xs={12} sm={6} key={index}>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import {
|
||||
IconBulldozer,
|
||||
IconCalendarTime,
|
||||
@@ -65,7 +64,6 @@ function RssTile({ widget }: RssTileProps) {
|
||||
);
|
||||
const { classes } = useStyles();
|
||||
const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false);
|
||||
const { ref, height } = useElementSize();
|
||||
|
||||
if (!data || isLoading) {
|
||||
return (
|
||||
@@ -88,7 +86,7 @@ function RssTile({ widget }: RssTileProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack ref={ref} h="100%">
|
||||
<Stack h="100%">
|
||||
<LoadingOverlay visible={loadingOverlayVisible} />
|
||||
<Flex gap="md">
|
||||
{data.feed.image ? (
|
||||
@@ -121,7 +119,7 @@ function RssTile({ widget }: RssTileProps) {
|
||||
<Card
|
||||
key={index}
|
||||
withBorder
|
||||
component={Link}
|
||||
component={Link ?? 'div'}
|
||||
href={item.link}
|
||||
radius="md"
|
||||
target="_blank"
|
||||
@@ -137,16 +135,18 @@ function RssTile({ widget }: RssTileProps) {
|
||||
)}
|
||||
|
||||
<Flex gap="xs">
|
||||
<MediaQuery query="(max-width: 1200px)" styles={{ display: 'none' }}>
|
||||
<Image
|
||||
src={item.enclosure?.url ?? undefined}
|
||||
width={140}
|
||||
height={140}
|
||||
radius="md"
|
||||
withPlaceholder
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Flex gap={2} direction="column">
|
||||
{item.enclosure && (
|
||||
<MediaQuery query="(max-width: 1200px)" styles={{ display: 'none' }}>
|
||||
<Image
|
||||
src={item.enclosure?.url ?? undefined}
|
||||
width={140}
|
||||
height={140}
|
||||
radius="md"
|
||||
withPlaceholder
|
||||
/>
|
||||
</MediaQuery>
|
||||
)}
|
||||
<Flex gap={2} direction="column" w="100%">
|
||||
{item.categories && (
|
||||
<Flex gap="xs" wrap="wrap" h={20} style={{ overflow: 'hidden' }}>
|
||||
{item.categories.map((category: any, categoryIndex: number) => (
|
||||
@@ -181,12 +181,14 @@ function RssTile({ widget }: RssTileProps) {
|
||||
{data.feed.pubDate}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<IconBulldozer size={14} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{data.feed.lastBuildDate}
|
||||
</Text>
|
||||
</Group>
|
||||
{data.feed.lastBuildDate && (
|
||||
<Group>
|
||||
<IconBulldozer size={14} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{data.feed.lastBuildDate}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{data.feed.feedUrl && (
|
||||
<Group spacing="sm">
|
||||
<IconSpeakerphone size={14} />
|
||||
|
||||
@@ -46,7 +46,7 @@ const definition = defineWidget({
|
||||
},
|
||||
});
|
||||
|
||||
export type IUsenetWidget = IWidget<typeof definition['id'], typeof definition>;
|
||||
export type IUsenetWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface UseNetTileProps {
|
||||
widget: IUsenetWidget;
|
||||
|
||||
@@ -28,7 +28,7 @@ const definition = defineWidget({
|
||||
component: WeatherTile,
|
||||
});
|
||||
|
||||
export type IWeatherWidget = IWidget<typeof definition['id'], typeof definition>;
|
||||
export type IWeatherWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface WeatherTileProps {
|
||||
widget: IWeatherWidget;
|
||||
|
||||
Reference in New Issue
Block a user