Merge branch 'dev' into edit-mode-password

This commit is contained in:
Thomas Camlong
2023-03-22 22:22:16 +08:00
committed by GitHub
41 changed files with 501 additions and 228 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,7 +95,7 @@ export const AvailableElementTypes = ({
},
network: {
enabledStatusChecker: true,
okStatus: [200],
statusCodes: ['200'],
},
behaviour: {
isOpeningNewTab: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,5 +10,7 @@ export const useGetDashboardIcons = () =>
return data as NormalizedIconRepositoryResult[];
},
refetchOnMount: false,
// Cache for infinity, refetch every so often.
cacheTime: Infinity,
refetchOnWindowFocus: false,
});

View File

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

View File

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

View File

@@ -177,7 +177,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
},
network: {
enabledStatusChecker: true,
okStatus: [200],
statusCodes: ['200'],
},
behaviour: {
isOpeningNewTab: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ export class JsdelivrIconsRepository extends AbstractIconRepository {
constructor(
private readonly repository: JsdelivrRepositoryUrl,
private readonly displayName: string,
copyright: string,
copyright: string
) {
super(copyright);
}

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ interface AppBehaviourType {
interface AppNetworkType {
enabledStatusChecker: boolean;
okStatus: number[];
statusCodes: string[];
}
interface AppAppearanceType {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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