🔀 Merge branch 'dev' into tests/add-tests

This commit is contained in:
Manuel
2023-03-29 12:17:20 +02:00
50 changed files with 1035 additions and 910 deletions

10
.deepsource.toml Normal file
View File

@@ -0,0 +1,10 @@
version = 1
[[analyzers]]
name = "javascript"
[analyzers.meta]
plugins = ["react"]
[[transformers]]
name = "prettier"

5
.gitignore vendored
View File

@@ -47,4 +47,7 @@ data/configs
!.yarn/plugins !.yarn/plugins
!.yarn/releases !.yarn/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
#envfiles
.env

View File

@@ -28,17 +28,17 @@
"@ctrl/qbittorrent": "^4.1.0", "@ctrl/qbittorrent": "^4.1.0",
"@ctrl/shared-torrent": "^4.1.1", "@ctrl/shared-torrent": "^4.1.1",
"@ctrl/transmission": "^4.1.1", "@ctrl/transmission": "^4.1.1",
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",
"@jellyfin/sdk": "^0.7.0", "@jellyfin/sdk": "^0.7.0",
"@mantine/core": "^5.9.3", "@mantine/core": "^6.0.0",
"@mantine/dates": "^5.9.3", "@mantine/dates": "^6.0.0",
"@mantine/dropzone": "^5.9.3", "@mantine/dropzone": "^6.0.0",
"@mantine/form": "^5.9.3", "@mantine/form": "^6.0.0",
"@mantine/hooks": "^5.9.3", "@mantine/hooks": "^6.0.0",
"@mantine/modals": "^5.9.3", "@mantine/modals": "^6.0.0",
"@mantine/next": "^5.9.3", "@mantine/next": "^6.0.0",
"@mantine/notifications": "^5.9.3", "@mantine/notifications": "^6.0.0",
"@nivo/core": "^0.80.0", "@nivo/core": "^0.80.0",
"@nivo/line": "^0.80.0", "@nivo/line": "^0.80.0",
"@tabler/icons": "^1.106.0", "@tabler/icons": "^1.106.0",

View File

@@ -2,6 +2,10 @@
"description": "Homarr is a <strong>sleek</strong>, <strong>modern</strong> dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one convenient location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.", "description": "Homarr is a <strong>sleek</strong>, <strong>modern</strong> dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one convenient location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.",
"contact": "Having trouble or questions? Connect with us!", "contact": "Having trouble or questions? Connect with us!",
"addToDashboard": "Add to Dashboard", "addToDashboard": "Add to Dashboard",
"tip": "Mod refers to your modifier key, it is Ctrl and Command/Super/Windows key",
"key": "Shortcut key",
"action": "Action",
"keybinds": "Keybinds",
"metrics": { "metrics": {
"configurationSchemaVersion": "Configuration schema version", "configurationSchemaVersion": "Configuration schema version",
"configurationsCount": "Available configurations", "configurationsCount": "Available configurations",

View File

@@ -57,7 +57,12 @@ export default function ConfigChanger() {
size="lg" size="lg"
radius="md" radius="md"
> >
<Notification loading title={t('configSelect.loadingNew')} radius="md" disallowClose> <Notification
loading
title={t('configSelect.loadingNew')}
radius="md"
withCloseButton={false}
>
{t('configSelect.pleaseWait')} {t('configSelect.pleaseWait')}
</Notification> </Notification>
</Dialog> </Dialog>

View File

@@ -1,18 +1,19 @@
import { import {
Accordion,
ActionIcon, ActionIcon,
Anchor, Anchor,
Badge, Badge,
Button, Button,
createStyles, createStyles,
Divider,
Grid, Grid,
Group, Group,
HoverCard, HoverCard,
Kbd,
Modal, Modal,
Stack,
Table, Table,
Text, Text,
Title, Title,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconAnchor, IconAnchor,
@@ -35,6 +36,8 @@ import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
import { useEditModeInformationStore } from '../../../../hooks/useEditModeInformation'; import { useEditModeInformationStore } from '../../../../hooks/useEditModeInformation';
import { usePackageAttributesStore } from '../../../../tools/client/zustands/usePackageAttributesStore'; import { usePackageAttributesStore } from '../../../../tools/client/zustands/usePackageAttributesStore';
import { useColorTheme } from '../../../../tools/color';
import Tip from '../../../layout/Tip';
import { usePrimaryGradient } from '../../../layout/useGradient'; import { usePrimaryGradient } from '../../../layout/useGradient';
import Credits from '../../../Settings/Common/Credits'; import Credits from '../../../Settings/Common/Credits';
@@ -50,6 +53,23 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
const informations = useInformationTableItems(newVersionAvailable); const informations = useInformationTableItems(newVersionAvailable);
const { t } = useTranslation(['common', 'layout/modals/about']); 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 ( return (
<Modal <Modal
onClose={() => closeModal()} onClose={() => closeModal()}
@@ -76,7 +96,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
<Trans i18nKey="layout/modals/about:description" /> <Trans i18nKey="layout/modals/about:description" />
</Text> </Text>
<Table mb="lg" striped highlightOnHover withBorder> <Table mb="lg" highlightOnHover withBorder>
<tbody> <tbody>
{informations.map((item, index) => ( {informations.map((item, index) => (
<tr key={index}> <tr key={index}>
@@ -100,8 +120,26 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
))} ))}
</tbody> </tbody>
</Table> </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"> <Title order={6} mb="xs" align="center">
{t('layout/modals/about:contact')} {t('layout/modals/about:contact')}
</Title> </Title>
@@ -161,9 +199,9 @@ interface ExtendedInitOptions extends InitOptions {
} }
const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => { const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => {
const colorGradiant = usePrimaryGradient();
const { attributes } = usePackageAttributesStore(); const { attributes } = usePackageAttributesStore();
const { editModeEnabled } = useEditModeInformationStore(); const { editModeEnabled } = useEditModeInformationStore();
const { primaryColor } = useColorTheme();
const { configVersion } = useConfigContext(); const { configVersion } = useConfigContext();
const { configs } = useConfigStore(); const { configs } = useConfigStore();
@@ -177,15 +215,19 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
icon: <IconKey size={20} />, icon: <IconKey size={20} />,
label: 'experimental_disableEditMode', label: 'experimental_disableEditMode',
content: ( content: (
<Stack> <Tooltip
color="red"
withinPortal
width={300}
multiline
withArrow
label="This is an experimental feature, where the edit mode is disabled entirely - no config
modifications are possbile anymore. All update requests for the config will be dropped
on the API. This will be removed in future versions, as Homarr will receive a proper
authentication system, which will make this obsolete."
>
<Badge color="red">WARNING</Badge> <Badge color="red">WARNING</Badge>
<Text color="red" size="xs"> </Tooltip>
This is an experimental feature, where the edit mode is disabled entirely - no config
modifications are possbile anymore. All update requests for the config will be dropped
on the API. This will be removed in future versions, as Homarr will receive a proper
authentication system, which will make this obsolete.
</Text>
</Stack>
), ),
}, },
]; ];
@@ -201,7 +243,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
icon: <IconLanguage size={20} />, icon: <IconLanguage size={20} />,
label: 'i18n', label: 'i18n',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="light" color={primaryColor}>
{usedI18nNamespaces.length} {usedI18nNamespaces.length}
</Badge> </Badge>
), ),
@@ -210,7 +252,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
icon: <IconVocabulary size={20} />, icon: <IconVocabulary size={20} />,
label: 'locales', label: 'locales',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="light" color={primaryColor}>
{initOptions.locales.length} {initOptions.locales.length}
</Badge> </Badge>
), ),
@@ -223,7 +265,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
icon: <IconSchema size={20} />, icon: <IconSchema size={20} />,
label: 'configurationSchemaVersion', label: 'configurationSchemaVersion',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="light" color={primaryColor}>
{configVersion} {configVersion}
</Badge> </Badge>
), ),
@@ -232,7 +274,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
icon: <IconFile size={20} />, icon: <IconFile size={20} />,
label: 'configurationsCount', label: 'configurationsCount',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="light" color={primaryColor}>
{configs.length} {configs.length}
</Badge> </Badge>
), ),
@@ -242,7 +284,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
label: 'version', label: 'version',
content: ( content: (
<Group position="right"> <Group position="right">
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="light" color={primaryColor}>
{attributes.packageVersion ?? 'Unknown'} {attributes.packageVersion ?? 'Unknown'}
</Badge> </Badge>
{newVersionAvailable && ( {newVersionAvailable && (
@@ -282,7 +324,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
icon: <IconAnchor size={20} />, icon: <IconAnchor size={20} />,
label: 'nodeEnvironment', label: 'nodeEnvironment',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="light" color={primaryColor}>
{attributes.environment} {attributes.environment}
</Badge> </Badge>
), ),

View File

@@ -6,7 +6,6 @@ import {
Grid, Grid,
Group, Group,
PasswordInput, PasswordInput,
Stack,
ThemeIcon, ThemeIcon,
Title, Title,
Text, Text,
@@ -40,7 +39,7 @@ export const GenericSecretInput = ({
const Icon = setIcon; const Icon = setIcon;
const [displayUpdateField, setDisplayUpdateField] = useState<boolean>(false); const [displayUpdateField, setDisplayUpdateField] = useState<boolean>(!secretIsPresent);
const { t } = useTranslation(['layout/modals/add-app', 'common']); const { t } = useTranslation(['layout/modals/add-app', 'common']);
return ( return (
@@ -51,26 +50,26 @@ export const GenericSecretInput = ({
<ThemeIcon color={secretIsPresent ? 'green' : 'red'} variant="light" size="lg"> <ThemeIcon color={secretIsPresent ? 'green' : 'red'} variant="light" size="lg">
<Icon size={18} /> <Icon size={18} />
</ThemeIcon> </ThemeIcon>
<Stack spacing={0}> <Flex justify="start" align="start" direction="column">
<Group spacing="xs"> <Group spacing="xs">
<Title className={classes.subtitle} order={6}> <Title className={classes.subtitle} order={6}>
{t(label)} {t(label)}
</Title> </Title>
<Group spacing="xs"> <Group spacing="xs">
{secretIsPresent ? ( <Badge
<Badge className={classes.textTransformUnset} color="green" variant="dot"> className={classes.textTransformUnset}
{t('integration.type.defined')} color={secretIsPresent ? 'green' : 'red'}
</Badge> variant="dot"
) : ( >
<Badge className={classes.textTransformUnset} color="red" variant="dot"> {secretIsPresent
{t('integration.type.undefined')} ? t('integration.type.defined')
</Badge> : t('integration.type.undefined')}
)} </Badge>
{type === 'private' ? ( {type === 'private' ? (
<Tooltip <Tooltip
label={t('integration.type.explanationPrivate')} label={t('integration.type.explanationPrivate')}
width={200} width={400}
multiline multiline
withinPortal withinPortal
withArrow withArrow
@@ -82,7 +81,7 @@ export const GenericSecretInput = ({
) : ( ) : (
<Tooltip <Tooltip
label={t('integration.type.explanationPublic')} label={t('integration.type.explanationPublic')}
width={200} width={400}
multiline multiline
withinPortal withinPortal
withArrow withArrow
@@ -94,29 +93,20 @@ export const GenericSecretInput = ({
)} )}
</Group> </Group>
</Group> </Group>
<Text size="xs" color="dimmed"> <Text size="xs" color="dimmed" w={400}>
{type === 'private' {type === 'private'
? 'Private: Once saved, you cannot read out this value again' ? 'Private: Once saved, you cannot read out this value again'
: 'Public: Can be read out repeatedly'} : 'Public: Can be read out repeatedly'}
</Text> </Text>
</Stack> </Flex>
</Group> </Group>
</Grid.Col> </Grid.Col>
<Grid.Col xs={12} md={6}> <Grid.Col xs={12} md={6}>
<Flex gap={10} justify="end" align="end"> <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 ? ( {displayUpdateField === true ? (
<PasswordInput <PasswordInput
required
defaultValue={value}
placeholder="new secret" placeholder="new secret"
styles={{ root: { width: 200 } }} styles={{ root: { width: 200 } }}
{...props} {...props}

View File

@@ -29,7 +29,7 @@ export const GenericAvailableElementType = ({
: image; : image;
return ( return (
<Grid.Col span={3}> <Grid.Col span="auto">
<Card style={{ height: '100%' }}> <Card style={{ height: '100%' }}>
<Stack justify="space-between" style={{ height: '100%' }}> <Stack justify="space-between" style={{ height: '100%' }}>
<Stack spacing="xs"> <Stack spacing="xs">

View File

@@ -19,7 +19,7 @@ export const AvailableStaticTypes = ({ onClickBack }: AvailableStaticTypesProps)
they don&apos;t integrate with any apps and their content never changes. they don&apos;t integrate with any apps and their content never changes.
</Text> </Text>
<Grid> <Grid grow>
<GenericAvailableElementType <GenericAvailableElementType
name="Static Text" name="Static Text"
description="Display a fixed string on your dashboard" description="Display a fixed string on your dashboard"

View File

@@ -93,12 +93,10 @@ export const AppTile = ({ className, app }: AppTileProps) => {
const useStyles = createStyles((theme, _params, getRef) => ({ const useStyles = createStyles((theme, _params, getRef) => ({
image: { image: {
ref: getRef('image'),
maxHeight: '90%', maxHeight: '90%',
maxWidth: '90%', maxWidth: '90%',
}, },
appName: { appName: {
ref: getRef('appName'),
wordBreak: 'break-word', wordBreak: 'break-word',
}, },
button: { button: {

View File

@@ -18,12 +18,11 @@ interface GridstackStoreType {
export const useNamedWrapperColumnCount = (): 'small' | 'medium' | 'large' | null => { export const useNamedWrapperColumnCount = (): 'small' | 'medium' | 'large' | null => {
const mainAreaWidth = useGridstackStore((x) => x.mainAreaWidth); const mainAreaWidth = useGridstackStore((x) => x.mainAreaWidth);
const { sm, xl } = useMantineTheme().breakpoints;
if (!mainAreaWidth) return null; if (!mainAreaWidth) return null;
if (mainAreaWidth >= xl) return 'large'; if (mainAreaWidth >= 1400) return 'large';
if (mainAreaWidth >= sm) return 'medium'; if (mainAreaWidth >= 800) return 'medium';
return 'small'; return 'small';
}; };

View File

@@ -18,7 +18,7 @@ export default function CommonSettings() {
); );
} }
return ( return (
<ScrollArea style={{ height: height - 100 }} offsetScrollbars> <ScrollArea style={{ height: height - 100 }} scrollbarSize={5}>
<Stack> <Stack>
<SearchEngineSelector searchEngine={config.settings.common.searchEngine} /> <SearchEngineSelector searchEngine={config.settings.common.searchEngine} />
<Space /> <Space />

View File

@@ -60,6 +60,7 @@ export const SearchEngineSelector = ({ searchEngine }: Props) => {
<Space mb="md" /> <Space mb="md" />
<TextInput <TextInput
label={t('customEngine.label')} label={t('customEngine.label')}
name={t('configurationName')}
description={t('tips.placeholderTip')} description={t('tips.placeholderTip')}
placeholder={t('customEngine.placeholder')} placeholder={t('customEngine.placeholder')}
value={searchUrl} value={searchUrl}

View File

@@ -8,7 +8,7 @@ export default function CustomizationSettings() {
const { t } = useTranslation('settings/customization/general'); const { t } = useTranslation('settings/customization/general');
return ( return (
<ScrollArea style={{ height: height - 100 }} offsetScrollbars> <ScrollArea style={{ height: height - 100 }} scrollbarSize={5}>
<Stack mt="xs" mb="md" spacing="xs"> <Stack mt="xs" mb="md" spacing="xs">
<Text color="dimmed">{t('text')}</Text> <Text color="dimmed">{t('text')}</Text>
<CustomizationSettingsAccordeon /> <CustomizationSettingsAccordeon />

View File

@@ -41,7 +41,7 @@ export function SettingsDrawer({
return ( return (
<Drawer <Drawer
size="xl" size="lg"
padding="lg" padding="lg"
position="right" position="right"
title={<Title order={5}>{t('title')}</Title>} title={<Title order={5}>{t('title')}</Title>}

View File

@@ -29,7 +29,7 @@ export const ToggleEditModeAction = () => {
const { config } = useConfigContext(); const { config } = useConfigContext();
const { classes } = useCardStyles(true); const { classes } = useCardStyles(true);
useHotkeys([['ctrl+E', toggleEditMode]]); useHotkeys([['mod+E', toggleEditMode]]);
useWindowEvent('beforeunload', (event: BeforeUnloadEvent) => { useWindowEvent('beforeunload', (event: BeforeUnloadEvent) => {
if (enabled) { if (enabled) {

View File

@@ -42,7 +42,7 @@ export function Header(props: any) {
> >
<Search /> <Search />
{!editModeEnabled && <ToggleEditModeAction />} {!editModeEnabled && <ToggleEditModeAction />}
<DockerMenuButton /> {!editModeEnabled && <DockerMenuButton />}
<Indicator <Indicator
size={15} size={15}
color="blue" color="blue"

View File

@@ -181,7 +181,6 @@ export function Search() {
shadow="md" shadow="md"
radius="md" radius="md"
zIndex={100} zIndex={100}
transition="pop-top-right"
> >
<Popover.Target> <Popover.Target>
<Autocomplete <Autocomplete
@@ -297,7 +296,7 @@ export function Search() {
setSearchEngine(item); setSearchEngine(item);
showNotification({ showNotification({
radius: 'lg', radius: 'lg',
disallowClose: true, withCloseButton: false,
id: 'spotlight', id: 'spotlight',
autoClose: 1000, autoClose: 1000,
icon: <ActionIcon size="sm">{item.icon}</ActionIcon>, icon: <ActionIcon size="sm">{item.icon}</ActionIcon>,

View File

@@ -7,6 +7,7 @@ import { AboutModal } from '../../Dashboard/Modals/AboutModal/AboutModal';
import { SettingsDrawer } from '../../Settings/SettingsDrawer'; import { SettingsDrawer } from '../../Settings/SettingsDrawer';
import { useCardStyles } from '../useCardStyles'; import { useCardStyles } from '../useCardStyles';
import { ColorSchemeSwitch } from './SettingsMenu/ColorSchemeSwitch'; import { ColorSchemeSwitch } from './SettingsMenu/ColorSchemeSwitch';
import { EditModeToggle } from './SettingsMenu/EditModeToggle';
export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) { export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) {
const [drawerOpened, drawer] = useDisclosure(false); const [drawerOpened, drawer] = useDisclosure(false);
@@ -25,6 +26,7 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<ColorSchemeSwitch /> <ColorSchemeSwitch />
<EditModeToggle />
<Menu.Divider /> <Menu.Divider />
{!editModeEnabled && ( {!editModeEnabled && (
<Menu.Item icon={<IconSettings strokeWidth={1.2} size={18} />} onClick={drawer.open}> <Menu.Item icon={<IconSettings strokeWidth={1.2} size={18} />} onClick={drawer.open}>

View File

@@ -0,0 +1,78 @@
import { Button, Code, Menu, PasswordInput, Stack, Text } from '@mantine/core';
import { useForm } from '@mantine/form';
import { openModal } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconEdit, IconEditOff } from '@tabler/icons';
import axios from 'axios';
import { useEditModeInformationStore } from '../../../../hooks/useEditModeInformation';
function ModalContent() {
const form = useForm({
initialValues: {
triedPassword: '',
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
axios
.post('/api/configs/tryPassword', { tried: values.triedPassword, type: 'edit' })
.then((res) => {
showNotification({
title: 'Success',
message: 'Successfully toggled edit mode, reloading the page...',
color: 'green',
});
setTimeout(() => {
window.location.reload();
}, 500);
})
.catch((_) => {
showNotification({
title: 'Error',
message: 'Failed to toggle edit mode, please try again.',
color: 'red',
});
});
})}
>
<Stack>
<Text size="sm">
In order to toggle edit mode, you need to enter the password you entered in the
environment variable named <Code>EDIT_MODE_PASSWORD</Code> . If it is not set, you are not
able to toggle edit mode on and off.
</Text>
<PasswordInput
id="triedPassword"
label="Edit password"
autoFocus
required
{...form.getInputProps('triedPassword')}
/>
<Button type="submit">Submit</Button>
</Stack>
</form>
);
}
export function EditModeToggle() {
const { editModeEnabled } = useEditModeInformationStore();
const Icon = editModeEnabled ? IconEdit : IconEditOff;
return (
<Menu.Item
closeMenuOnClick={false}
icon={<Icon strokeWidth={1.2} size={18} />}
onClick={() =>
openModal({
title: 'Toggle edit mode',
centered: true,
size: 'lg',
children: <ModalContent />,
})
}
>
{editModeEnabled ? 'Enable edit mode' : 'Disable edit mode'}
</Menu.Item>
);
}

View File

@@ -16,8 +16,8 @@ const ConfigContext = createContext<ConfigContextType>({
name: 'unknown', name: 'unknown',
config: undefined, config: undefined,
configVersion: undefined, configVersion: undefined,
increaseVersion: () => console.error('Provider not set'), increaseVersion: () => {},
setConfigName: () => console.error('Provider not set'), setConfigName: () => {},
}); });
export const ConfigProvider = ({ children }: { children: ReactNode }) => { export const ConfigProvider = ({ children }: { children: ReactNode }) => {

View File

@@ -0,0 +1 @@
export const MIN_WIDTH_MOBILE = 500;

View File

@@ -1,8 +1,9 @@
import { MantineSize, useMantineTheme } from '@mantine/core'; import { MantineSize, useMantineTheme } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks'; import { useMediaQuery } from '@mantine/hooks';
import { MIN_WIDTH_MOBILE } from '../constants/constants';
export const useScreenLargerThan = (size: MantineSize | number) => { export const useScreenLargerThan = (size: MantineSize | number) => {
const { breakpoints } = useMantineTheme(); const { breakpoints } = useMantineTheme();
const pixelCount = typeof size === 'string' ? breakpoints[size] : size; const pixelCount = typeof size === 'string' ? breakpoints[size] : size;
return useMediaQuery(`(min-width: ${pixelCount}px)`); return useMediaQuery(`(min-width: ${MIN_WIDTH_MOBILE})`);
}; };

View File

@@ -35,7 +35,7 @@ function sendDockerCommand(
title: `${t(`actions.${action}.start`)} ${containerName}`, title: `${t(`actions.${action}.start`)} ${containerName}`,
message: undefined, message: undefined,
autoClose: false, autoClose: false,
disallowClose: true, withCloseButton: false,
}); });
axios axios
.get(`/api/docker/container/${containerId}?action=${action}`) .get(`/api/docker/container/${containerId}?action=${action}`)

View File

@@ -1,4 +1,4 @@
import { Badge, BadgeVariant, MantineSize } from '@mantine/core'; import { Badge, BadgeProps, MantineSize } from '@mantine/core';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Dockerode from 'dockerode'; import Dockerode from 'dockerode';
@@ -14,7 +14,7 @@ export default function ContainerState(props: ContainerStateProps) {
const options: { const options: {
size: MantineSize; size: MantineSize;
radius: MantineSize; radius: MantineSize;
variant: BadgeVariant; variant: BadgeProps['variant'];
} = { } = {
size: 'md', size: 'md',
radius: 'md', radius: 'md',

View File

@@ -54,7 +54,7 @@ export default function DockerMenuButton(props: any) {
}, 300); }, 300);
} }
if (!dockerEnabled) { if (!dockerEnabled || process.env.DISABLE_EDIT_MODE === 'true') {
return null; return null;
} }
@@ -66,10 +66,13 @@ export default function DockerMenuButton(props: any) {
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
padding="xl" padding="xl"
position="right" position="right"
size="full" size="100%"
title={<ContainerActionBar selected={selection} reload={reload} />} title={<ContainerActionBar selected={selection} reload={reload} />}
transitionProps={{
transition: 'pop',
}}
styles={{ styles={{
drawer: { content: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}, },

View File

@@ -14,6 +14,7 @@ import { IconSearch } from '@tabler/icons';
import Dockerode from 'dockerode'; import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
import ContainerState from './ContainerState'; import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
@@ -34,7 +35,6 @@ export default function DockerTable({
containers: Dockerode.ContainerInfo[]; containers: Dockerode.ContainerInfo[];
selection: Dockerode.ContainerInfo[]; selection: Dockerode.ContainerInfo[];
}) { }) {
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
const [usedContainers, setContainers] = useState<Dockerode.ContainerInfo[]>(containers); const [usedContainers, setContainers] = useState<Dockerode.ContainerInfo[]>(containers);
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');

View File

@@ -221,7 +221,7 @@ function askForMedia(type: MediaType, id: number, name: string, seasons?: number
color: 'orange', color: 'orange',
loading: true, loading: true,
autoClose: false, autoClose: false,
disallowClose: true, withCloseButton: false,
icon: <IconAlertCircle />, icon: <IconAlertCircle />,
}); });
axios axios

View File

@@ -57,7 +57,7 @@ const useStyles = createStyles((theme) => ({
maxWidth: 540, maxWidth: 540,
margin: 'auto', margin: 'auto',
marginTop: theme.spacing.xl, marginTop: theme.spacing.xl,
marginBottom: theme.spacing.xl * 1.5, marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
}, },
})); }));

View File

@@ -2,7 +2,6 @@ import { ColorScheme, ColorSchemeProvider, MantineProvider, MantineTheme } from
import { useColorScheme, useHotkeys, useLocalStorage } from '@mantine/hooks'; import { useColorScheme, useHotkeys, useLocalStorage } from '@mantine/hooks';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import Consola from 'consola'; import Consola from 'consola';
import { NotificationsProvider } from '@mantine/notifications';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { getCookie } from 'cookies-next'; import { getCookie } from 'cookies-next';
@@ -11,6 +10,7 @@ import { appWithTranslation } from 'next-i18next';
import { AppProps } from 'next/app'; import { AppProps } from 'next/app';
import Head from 'next/head'; import Head from 'next/head';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Notifications } from '@mantine/notifications';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import { ChangeAppPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal'; import { ChangeAppPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal';
import { ChangeWidgetPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeWidgetPositionModal'; import { ChangeWidgetPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeWidgetPositionModal';
@@ -112,21 +112,20 @@ function App(
withNormalizeCSS withNormalizeCSS
> >
<ConfigProvider> <ConfigProvider>
<NotificationsProvider limit={4} position="bottom-left"> <Notifications limit={4} position="bottom-left" />
<ModalsProvider <ModalsProvider
modals={{ modals={{
editApp: EditAppModal, editApp: EditAppModal,
selectElement: SelectElementModal, selectElement: SelectElementModal,
integrationOptions: WidgetsEditModal, integrationOptions: WidgetsEditModal,
integrationRemove: WidgetsRemoveModal, integrationRemove: WidgetsRemoveModal,
categoryEditModal: CategoryEditModal, categoryEditModal: CategoryEditModal,
changeAppPositionModal: ChangeAppPositionModal, changeAppPositionModal: ChangeAppPositionModal,
changeIntegrationPositionModal: ChangeWidgetPositionModal, changeIntegrationPositionModal: ChangeWidgetPositionModal,
}} }}
> >
<Component {...pageProps} /> <Component {...pageProps} />
</ModalsProvider> </ModalsProvider>
</NotificationsProvider>
</ConfigProvider> </ConfigProvider>
</MantineProvider> </MantineProvider>
</ColorTheme.Provider> </ColorTheme.Provider>
@@ -150,7 +149,7 @@ App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => {
Consola.debug(`Overriding the default color scheme with ${process.env.DEFAULT_COLOR_SCHEME}`); Consola.debug(`Overriding the default color scheme with ${process.env.DEFAULT_COLOR_SCHEME}`);
} }
const colorScheme: ColorScheme = process.env.DEFAULT_COLOR_SCHEME as ColorScheme ?? 'light'; const colorScheme: ColorScheme = (process.env.DEFAULT_COLOR_SCHEME as ColorScheme) ?? 'light';
return { return {
colorScheme: getCookie('color-scheme', ctx) || 'light', colorScheme: getCookie('color-scheme', ctx) || 'light',

View File

@@ -2,26 +2,31 @@ import Consola from 'consola';
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
function Post(req: NextApiRequest, res: NextApiResponse) { function Post(req: NextApiRequest, res: NextApiResponse) {
const { tried } = req.body; const { tried, type = 'password' } = req.body;
// Try to match the password with the PASSWORD env variable // If the type of password is "edit", we run this branch to check the edit password
if (tried === process.env.PASSWORD) { if (type === 'edit') {
if (tried === process.env.EDIT_MODE_PASSWORD) {
process.env.DISABLE_EDIT_MODE = process.env.DISABLE_EDIT_MODE === 'true' ? 'false' : 'true';
return res.status(200).json({
success: true,
});
}
} else if (tried === process.env.PASSWORD) {
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
}); });
} }
// Warn that there was a wrong password attempt (date : wrong password, person's IP)
Consola.warn( Consola.warn(
`${new Date().toLocaleString()} : Wrong password attempt, from ${ `${new Date().toLocaleString()} : Wrong password attempt, from ${
req.headers['x-forwarded-for'] req.headers['x-forwarded-for']
}` }`
); );
return res.status(200).json({ return res.status(401).json({
success: false, success: false,
}); });
} }
export default async (req: NextApiRequest, res: NextApiResponse) => { export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the request is a POST or a GET
if (req.method === 'POST') { if (req.method === 'POST') {
return Post(req, res); return Post(req, res);
} }

View File

@@ -110,8 +110,9 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
supportsMediaControl: session.SupportsMediaControl ?? false, supportsMediaControl: session.SupportsMediaControl ?? false,
currentlyPlaying: session.NowPlayingItem currentlyPlaying: session.NowPlayingItem
? { ? {
name: session.NowPlayingItem.Name as string, name: `${session.NowPlayingItem.SeriesName ?? session.NowPlayingItem.Name}`,
seasonName: session.NowPlayingItem.SeasonName as string, seasonName: session.NowPlayingItem.SeasonName as string,
episodeName: session.NowPlayingItem.Name as string,
albumName: session.NowPlayingItem.Album as string, albumName: session.NowPlayingItem.Album as string,
episodeCount: session.NowPlayingItem.EpisodeCount ?? undefined, episodeCount: session.NowPlayingItem.EpisodeCount ?? undefined,
metadata: { metadata: {

View File

@@ -53,6 +53,7 @@ export const Get = async (request: NextApiRequest, response: NextApiResponse) =>
title: item.title ? decode(item.title) : undefined, title: item.title ? decode(item.title) : undefined,
content: decode(item.content), content: decode(item.content),
enclosure: createEnclosure(item), enclosure: createEnclosure(item),
link: createLink(item),
})) }))
.sort((a: { pubDate: number }, b: { pubDate: number }) => { .sort((a: { pubDate: number }, b: { pubDate: number }) => {
if (!a.pubDate || !b.pubDate) { 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) => { const createEnclosure = (item: any) => {
if (item.enclosure) { if (item.enclosure) {
return item.enclosure; return item.enclosure;

View File

@@ -62,7 +62,7 @@ export default function AuthenticationTitle() {
title: t('notifications.checking.title'), title: t('notifications.checking.title'),
message: t('notifications.checking.message'), message: t('notifications.checking.message'),
autoClose: false, autoClose: false,
disallowClose: true, withCloseButton: false,
}); });
axios axios
.post('/api/configs/tryPassword', { .post('/api/configs/tryPassword', {

View File

@@ -56,7 +56,7 @@ const useStyles = createStyles((theme) => ({
fontWeight: 900, fontWeight: 900,
fontSize: 110, fontSize: 110,
lineHeight: 1, lineHeight: 1,
marginBottom: theme.spacing.xl * 1.5, marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
[theme.fn.smallerThan('sm')]: { [theme.fn.smallerThan('sm')]: {
fontSize: 60, fontSize: 60,
@@ -90,7 +90,7 @@ const useStyles = createStyles((theme) => ({
maxWidth: 700, maxWidth: 700,
margin: 'auto', margin: 'auto',
marginTop: theme.spacing.xl, marginTop: theme.spacing.xl,
marginBottom: theme.spacing.xl * 1.5, marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
}, },
})); }));

View File

@@ -10,6 +10,7 @@ export type GenericSessionInfo = {
export type GenericCurrentlyPlaying = { export type GenericCurrentlyPlaying = {
name: string; name: string;
seasonName: string | undefined; seasonName: string | undefined;
episodeName: string | undefined;
albumName: string | undefined; albumName: string | undefined;
episodeCount: number | undefined; episodeCount: number | undefined;
type: 'audio' | 'video' | 'tv' | 'movie' | undefined; type: 'audio' | 'video' | 'tv' | 'movie' | undefined;

View File

@@ -1,5 +1,6 @@
import { Box, Indicator, IndicatorProps, Popover } from '@mantine/core'; import { Box, Indicator, IndicatorProps, Popover, useMantineTheme } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { isToday } from '../../tools/isToday';
import { MediaList } from './MediaList'; import { MediaList } from './MediaList';
import { MediasType } from './type'; import { MediasType } from './type';
@@ -22,12 +23,24 @@ export const CalendarDay = ({ date, medias }: CalendarDayProps) => {
withinPortal withinPortal
radius="lg" radius="lg"
shadow="sm" shadow="sm"
transition="pop" transitionProps={{
transition: 'pop',
}}
onClose={close} onClose={close}
opened={opened} opened={opened}
> >
<Popover.Target> <Popover.Target>
<Box onClick={open}> <Box
onClick={open}
sx={(theme) => ({
margin: 5,
backgroundColor: isToday(date)
? theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0]
: undefined,
})}
>
<DayIndicator color="red" position="bottom-start" medias={medias.books}> <DayIndicator color="red" position="bottom-start" medias={medias.books}>
<DayIndicator color="yellow" position="top-start" medias={medias.movies}> <DayIndicator color="yellow" position="top-start" medias={medias.movies}>
<DayIndicator color="blue" position="top-end" medias={medias.tvShows}> <DayIndicator color="blue" position="top-end" medias={medias.tvShows}>

View File

@@ -72,39 +72,15 @@ function CalendarTile({ widget }: CalendarTileProps) {
return ( return (
<Group grow style={{ height: '100%' }}> <Group grow style={{ height: '100%' }}>
<Calendar <Calendar
m={0} defaultDate={new Date()}
p={0} onPreviousMonth={setMonth}
month={month} onNextMonth={setMonth}
// Should be offset 5px to the left
style={{ position: 'relative', top: -15 }}
onMonthChange={setMonth}
size="xs" size="xs"
locale={i18n?.resolvedLanguage ?? 'en'} locale={i18n?.resolvedLanguage ?? 'en'}
fullWidth firstDayOfWeek={widget.properties.sundayStart ? 0 : 1}
onChange={() => {}}
firstDayOfWeek={widget.properties.sundayStart ? 'sunday' : 'monday'}
dayStyle={(date) => ({
margin: -1,
backgroundColor: isToday(date)
? colorScheme === 'dark'
? colors.dark[5]
: colors.gray[0]
: undefined,
})}
hideWeekdays hideWeekdays
styles={{ date={month}
weekdayCell: { hasNextLevel={false}
margin: 0,
padding: 0,
},
calendarHeader: {
position: 'relative',
margin: 0,
padding: 0,
},
}}
allowLevelChange={false}
dayClassName={(_, modifiers) => cx({ [classes.weekend]: modifiers.weekend })}
renderDay={(date) => ( renderDay={(date) => (
<CalendarDay date={date} medias={getReleasedMediasForDate(medias, date, widget)} /> <CalendarDay date={date} medias={getReleasedMediasForDate(medias, date, widget)} />
)} )}

View File

@@ -1,4 +1,4 @@
import { createStyles, Title, useMantineTheme } from '@mantine/core'; import { createStyles, Title, useMantineTheme, getStylesRef } from '@mantine/core';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { DashDotCompactNetwork, DashDotInfo } from './DashDotCompactNetwork'; import { DashDotCompactNetwork, DashDotInfo } from './DashDotCompactNetwork';
import { DashDotCompactStorage } from './DashDotCompactStorage'; import { DashDotCompactStorage } from './DashDotCompactStorage';
@@ -77,7 +77,7 @@ const useIframeSrc = (
); );
}; };
export const useStyles = createStyles((theme, _params, getRef) => ({ export const useStyles = createStyles((theme, _params) => ({
iframe: { iframe: {
flex: '1 0 auto', flex: '1 0 auto',
maxWidth: '100%', maxWidth: '100%',
@@ -87,7 +87,7 @@ export const useStyles = createStyles((theme, _params, getRef) => ({
colorScheme: 'light', // fixes white borders around iframe colorScheme: 'light', // fixes white borders around iframe
}, },
graphTitle: { graphTitle: {
ref: getRef('graphTitle'), ref: getStylesRef('graphTitle'),
position: 'absolute', position: 'absolute',
right: 0, right: 0,
bottom: 0, bottom: 0,
@@ -99,7 +99,7 @@ export const useStyles = createStyles((theme, _params, getRef) => ({
}, },
graphContainer: { graphContainer: {
position: 'relative', position: 'relative',
[`&:hover .${getRef('graphTitle')}`]: { [`&:hover .${getStylesRef('graphTitle')}`]: {
opacity: 0.5, opacity: 0.5,
}, },
}, },

View File

@@ -232,7 +232,7 @@ const fetchDashDotInfo = async (configName: string | undefined) => {
export const useDashDotTileStyles = createStyles((theme) => ({ export const useDashDotTileStyles = createStyles((theme) => ({
graphsContainer: { graphsContainer: {
marginRight: theme.spacing.sm * -1, // fix because margin collapses weirdly marginRight: `calc(${theme.spacing.sm} * -1)`,
}, },
})); }));

View File

@@ -12,6 +12,7 @@ import {
import { IconAlertTriangle, IconMovie } from '@tabler/icons'; import { IconAlertTriangle, IconMovie } from '@tabler/icons';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { AppAvatar } from '../../components/AppAvatar'; import { AppAvatar } from '../../components/AppAvatar';
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
import { useConfigContext } from '../../config/provider'; import { useConfigContext } from '../../config/provider';
import { useGetMediaServers } from '../../hooks/widgets/media-servers/useGetMediaServers'; import { useGetMediaServers } from '../../hooks/widgets/media-servers/useGetMediaServers';
import { defineWidget } from '../helper'; import { defineWidget } from '../helper';
@@ -40,8 +41,9 @@ interface MediaServerWidgetProps {
function MediaServerTile({ widget }: MediaServerWidgetProps) { function MediaServerTile({ widget }: MediaServerWidgetProps) {
const { t } = useTranslation('modules/media-server'); const { t } = useTranslation('modules/media-server');
const { config } = useConfigContext(); const { config } = useConfigContext();
const isEditMode = useEditModeStore((x) => x.enabled);
const { data, isError } = useGetMediaServers({ const { data, isError, isFetching, isInitialLoading } = useGetMediaServers({
enabled: config !== undefined, enabled: config !== undefined,
}); });
@@ -57,16 +59,28 @@ function MediaServerTile({ widget }: MediaServerWidgetProps) {
); );
} }
if (!data) { if (isInitialLoading) {
<Center h="100%"> return (
<Loader /> <Stack
</Center>; align="center"
justify="center"
style={{
height: '100%',
}}
>
<Loader />
<Stack align="center" spacing={0}>
<Text>{t('descriptor.name')}</Text>
<Text color="dimmed">Homarr is loading streams...</Text>
</Stack>
</Stack>
);
} }
return ( return (
<Stack h="100%"> <Stack h="100%">
<ScrollArea offsetScrollbars> <ScrollArea offsetScrollbars>
<Table highlightOnHover striped> <Table highlightOnHover>
<thead> <thead>
<tr> <tr>
<th>{t('card.table.header.session')}</th> <th>{t('card.table.header.session')}</th>
@@ -97,7 +111,8 @@ function MediaServerTile({ widget }: MediaServerWidgetProps) {
return ( return (
<AppAvatar <AppAvatar
iconUrl={app.appearance.iconUrl} iconUrl={app.appearance.iconUrl}
color={server.success === true ? undefined : 'red'} // If success, the color is undefined, otherwise it's red but if isFetching is true, it's yellow
color={server.success ? (isFetching ? 'yellow' : undefined) : 'red'}
/> />
); );
})} })}

View File

@@ -30,11 +30,10 @@ export const NowPlayingDisplay = ({ session }: { session: GenericSessionInfo })
}; };
const Test = Icon(); const Test = Icon();
return ( return (
<Flex wrap="nowrap" gap="sm" align="center"> <Flex wrap="nowrap" gap="sm" align="center">
<Test size={16} /> <Test size={16} />
<Stack spacing={0}> <Stack spacing={0} w={200}>
<Text lineClamp={1}>{session.currentlyPlaying.name}</Text> <Text lineClamp={1}>{session.currentlyPlaying.name}</Text>
{session.currentlyPlaying.albumName ? ( {session.currentlyPlaying.albumName ? (
@@ -43,8 +42,8 @@ export const NowPlayingDisplay = ({ session }: { session: GenericSessionInfo })
</Text> </Text>
) : ( ) : (
session.currentlyPlaying.seasonName && ( session.currentlyPlaying.seasonName && (
<Text lineClamp={1} color="dimmed" size="xs"> <Text lineClamp={2} color="dimmed" size="xs">
{session.currentlyPlaying.seasonName} {session.currentlyPlaying.seasonName} - {session.currentlyPlaying.episodeName}
</Text> </Text>
) )
)} )}

View File

@@ -16,7 +16,6 @@ import {
Title, Title,
UnstyledButton, UnstyledButton,
} from '@mantine/core'; } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { import {
IconBulldozer, IconBulldozer,
IconCalendarTime, IconCalendarTime,
@@ -65,7 +64,6 @@ function RssTile({ widget }: RssTileProps) {
); );
const { classes } = useStyles(); const { classes } = useStyles();
const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false); const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false);
const { ref, height } = useElementSize();
if (!data || isLoading) { if (!data || isLoading) {
return ( return (
@@ -88,7 +86,7 @@ function RssTile({ widget }: RssTileProps) {
} }
return ( return (
<Stack ref={ref} h="100%"> <Stack h="100%">
<LoadingOverlay visible={loadingOverlayVisible} /> <LoadingOverlay visible={loadingOverlayVisible} />
<Flex gap="md"> <Flex gap="md">
{data.feed.image ? ( {data.feed.image ? (
@@ -121,7 +119,7 @@ function RssTile({ widget }: RssTileProps) {
<Card <Card
key={index} key={index}
withBorder withBorder
component={Link} component={Link ?? 'div'}
href={item.link} href={item.link}
radius="md" radius="md"
target="_blank" target="_blank"
@@ -183,12 +181,14 @@ function RssTile({ widget }: RssTileProps) {
{data.feed.pubDate} {data.feed.pubDate}
</Text> </Text>
</Group> </Group>
<Group> {data.feed.lastBuildDate && (
<IconBulldozer size={14} /> <Group>
<Text color="dimmed" size="sm"> <IconBulldozer size={14} />
{data.feed.lastBuildDate} <Text color="dimmed" size="sm">
</Text> {data.feed.lastBuildDate}
</Group> </Text>
</Group>
)}
{data.feed.feedUrl && ( {data.feed.feedUrl && (
<Group spacing="sm"> <Group spacing="sm">
<IconSpeakerphone size={14} /> <IconSpeakerphone size={14} />

View File

@@ -35,7 +35,7 @@ interface TorrentQueueItemProps {
export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) => { export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) => {
const [popoverOpened, { open: openPopover, close: closePopover }] = useDisclosure(false); const [popoverOpened, { open: openPopover, close: closePopover }] = useDisclosure(false);
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs; const theme = useMantineTheme();
const { width } = useElementSize(); const { width } = useElementSize();
const { t } = useTranslation('modules/torrents-status'); const { t } = useTranslation('modules/torrents-status');
@@ -75,17 +75,17 @@ export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) =>
<td> <td>
<Text size="xs">{humanFileSize(size, false)}</Text> <Text size="xs">{humanFileSize(size, false)}</Text>
</td> </td>
{width > MIN_WIDTH_MOBILE && ( {theme.fn.largerThan('xs') && (
<td> <td>
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text> <Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td> </td>
)} )}
{width > MIN_WIDTH_MOBILE && ( {theme.fn.largerThan('xs') && (
<td> <td>
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text> <Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td> </td>
)} )}
{width > MIN_WIDTH_MOBILE && ( {theme.fn.largerThan('xs') && (
<td> <td>
<Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text> <Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text>
</td> </td>

View File

@@ -17,6 +17,7 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed'; import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse'; import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
import { AppIntegrationType } from '../../types/app'; import { AppIntegrationType } from '../../types/app';
@@ -59,7 +60,6 @@ interface TorrentTileProps {
function TorrentTile({ widget }: TorrentTileProps) { function TorrentTile({ widget }: TorrentTileProps) {
const { t } = useTranslation('modules/torrents-status'); const { t } = useTranslation('modules/torrents-status');
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
const { width } = useElementSize(); const { width } = useElementSize();
const { const {

View File

@@ -28,6 +28,7 @@ import { defineWidget } from '../helper';
import { IWidget } from '../widgets'; import { IWidget } from '../widgets';
import { UsenetHistoryList } from './UsenetHistoryList'; import { UsenetHistoryList } from './UsenetHistoryList';
import { UsenetQueueList } from './UsenetQueueList'; import { UsenetQueueList } from './UsenetQueueList';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
dayjs.extend(duration); dayjs.extend(duration);
@@ -59,7 +60,6 @@ function UseNetTile({ widget }: UseNetTileProps) {
config?.apps.filter((x) => x.integration && downloadAppTypes.includes(x.integration.type)) ?? config?.apps.filter((x) => x.integration && downloadAppTypes.includes(x.integration.type)) ??
[]; [];
const { ref, width, height } = useElementSize(); const { ref, width, height } = useElementSize();
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id); const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id);
const { data } = useGetUsenetInfo({ appId: selectedAppId! }); const { data } = useGetUsenetInfo({ appId: selectedAppId! });

View File

@@ -128,7 +128,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
position="center" position="center"
mt="md" mt="md"
total={totalPages} total={totalPages}
page={page} value={page}
onChange={setPage} onChange={setPage}
/> />
)} )}

View File

@@ -35,7 +35,7 @@ const PAGE_SIZE = 13;
export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId }) => { export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId }) => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const { t } = useTranslation('modules/usenet'); const { t } = useTranslation('modules/usenet');
const progressbarBreakpoint = theme.breakpoints.xs; const progressbarBreakpoint = parseInt(theme.breakpoints.xs, 10);
const progressBreakpoint = 400; const progressBreakpoint = 400;
const sizeBreakpoint = 300; const sizeBreakpoint = 300;
const { ref, width } = useElementSize(); const { ref, width } = useElementSize();
@@ -177,7 +177,7 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
size="sm" size="sm"
position="center" position="center"
total={totalPages} total={totalPages}
page={page} value={page}
onChange={setPage} onChange={setPage}
/> />
)} )}

6
vercel.json Normal file
View File

@@ -0,0 +1,6 @@
{
"env": {
"EDIT_MODE_PASSWORD": "edit",
"DISABLE_EDIT_MODE": "TRUE"
}
}

1429
yarn.lock

File diff suppressed because it is too large Load Diff