Update default config

This commit is contained in:
ajnart
2022-12-24 17:18:16 +09:00
parent 3fb82a7336
commit e3d7b04059
20 changed files with 815 additions and 169 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
PASSWORD=TOTOISCOOL

View File

@@ -1,23 +1,30 @@
{ {
"schemaVersion": "1.0", "schemaVersion": 1,
"configProperties": { "configProperties": {
"name": "default" "name": "default"
}, },
"categories": [], "categories": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f",
"position": 0,
"name": "Example Category"
}
],
"wrappers": [ "wrappers": [
{ {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e", "id": "default",
"position": 1 "position": 1
} }
], ],
"apps": [ "apps": [
{ {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33a", "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
"name": "Documentation", "name": "Community",
"url": "https://homarr.dev", "url": "https://discord.com/invite/aCsmEV5RgA",
"behaviour": { "behaviour": {
"onClickUrl": "https://homarr.dev", "onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
"isOpeningInNewTab": true "isOpeningNewTab": true,
"externalUrl": "https://discord.com/invite/aCsmEV5RgA"
}, },
"network": { "network": {
"enabledStatusChecker": false, "enabledStatusChecker": false,
@@ -26,7 +33,7 @@
] ]
}, },
"appearance": { "appearance": {
"iconUrl": "/imgs/logo/logo.png" "iconUrl": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/discord.png"
}, },
"integration": { "integration": {
"type": null, "type": null,
@@ -35,7 +42,46 @@
"area": { "area": {
"type": "wrapper", "type": "wrapper",
"properties": { "properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e" "id": "default"
}
},
"shape": {
"location": {
"x": 3,
"y": 0
},
"size": {
"width": 3,
"height": 3
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
"name": "Donate",
"url": "https://ko-fi.com/ajnart",
"behaviour": {
"onClickUrl": "https://ko-fi.com/ajnart",
"externalUrl": "https://ko-fi.com/ajnart",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://uploads-ssl.webflow.com/5c14e387dab576fe667689cf/61e1116779fc0a9bd5bdbcc7_Frame%206.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "wrapper",
"properties": {
"id": "default"
} }
}, },
"shape": { "shape": {
@@ -55,7 +101,8 @@
"url": "https://github.com/ajnart/homarr", "url": "https://github.com/ajnart/homarr",
"behaviour": { "behaviour": {
"onClickUrl": "https://github.com/ajnart/homarr", "onClickUrl": "https://github.com/ajnart/homarr",
"isOpeningInNewTab": true "externalUrl": "https://github.com/ajnart/homarr",
"isOpeningNewTab": true
}, },
"network": { "network": {
"enabledStatusChecker": false, "enabledStatusChecker": false,
@@ -73,107 +120,7 @@
"area": { "area": {
"type": "wrapper", "type": "wrapper",
"properties": { "properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e" "id": "default"
}
},
"shape": {
"location": {
"x": 3,
"y": 0
},
"size": {
"width": 3,
"height": 3
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
"name": "Community",
"url": "https://discord.com/invite/aCsmEV5RgA",
"behaviour": {
"onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
"isOpeningInNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/discord.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "wrapper",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e"
}
},
"shape": {
"location": {
"x": 6,
"y": 0
},
"size": {
"width": 3,
"height": 3
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
"name": "Donate",
"url": "https://ko-fi.com/ajnart",
"behaviour": {
"onClickUrl": "https://ko-fi.com/ajnart",
"isOpeningInNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://uploads-ssl.webflow.com/5c14e387dab576fe667689cf/61e1116779fc0a9bd5bdbcc7_Frame%206.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "wrapper",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e"
}
},
"shape": {
"location": {
"x": 9,
"y": 0
},
"size": {
"width": 3,
"height": 3
}
}
}
],
"widgets": [
{
"id": "date",
"properties": {
"display24HourFormat": true
},
"area": {
"type": "wrapper",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e"
} }
}, },
"shape": { "shape": {
@@ -181,11 +128,227 @@
"x": 0, "x": 0,
"y": 3 "y": 3
}, },
"size": {
"width": 3,
"height": 3
}
}
},
{
"id": "5df743d9-5cb1-457c-85d2-64ff86855652",
"name": "Your app",
"url": "https://homarr.dev",
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"network": {
"enabledStatusChecker": false,
"okStatus": []
},
"behaviour": {
"isOpeningNewTab": true,
"externalUrl": "https://homarr.dev"
},
"area": {
"type": "wrapper",
"properties": {
"id": "default"
}
},
"shape": {
"location": {
"x": 15,
"y": 5
},
"size": {
"width": 5,
"height": 4
}
},
"integration": {
"type": null,
"properties": []
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33a",
"name": "Documentation",
"url": "https://homarr.dev",
"behaviour": {
"onClickUrl": "https://homarr.dev",
"externalUrl": "https://homarr.dev",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "wrapper",
"properties": {
"id": "default"
}
},
"shape": {
"location": {
"x": 3,
"y": 3
},
"size": {
"width": 3,
"height": 3
}
}
},
{
"id": "76217a87-7151-42d0-b0cf-1b72aef63f83",
"name": "Small app",
"url": "https://homarr.dev",
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"network": {
"enabledStatusChecker": false,
"okStatus": []
},
"behaviour": {
"isOpeningNewTab": true,
"externalUrl": "https://homarr.dev"
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"location": {
"x": 17,
"y": 0
},
"size": {
"width": 2,
"height": 2
}
},
"integration": {
"type": null,
"properties": []
}
},
{
"id": "e41a11f5-9c6e-41bc-ac0e-4c4c47582faa",
"name": "Your app",
"url": "https://homarr.dev",
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"network": {
"enabledStatusChecker": false,
"okStatus": []
},
"behaviour": {
"isOpeningNewTab": true,
"externalUrl": ""
},
"area": {
"type": "wrapper",
"properties": {
"id": "default"
}
},
"shape": {
"location": {
"x": 0,
"y": 6
},
"size": {
"width": 2,
"height": 2
}
},
"integration": {
"type": null,
"properties": []
}
}
],
"widgets": [
{
"id": "weather",
"properties": {
"displayInFahrenheit": false,
"location": "Paris"
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"location": {
"x": 6,
"y": 0
},
"size": { "size": {
"width": 4, "width": 4,
"height": 2 "height": 2
} }
} }
},
{
"id": "date",
"properties": {
"display24HourFormat": true
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 4,
"height": 2
}
}
},
{
"id": "calendar",
"properties": {
"sundayStart": false
},
"area": {
"type": "wrapper",
"properties": {
"id": "default"
}
},
"shape": {
"location": {
"x": 15,
"y": 0
},
"size": {
"width": 5,
"height": 5
}
}
} }
], ],
"settings": { "settings": {

View File

@@ -20,7 +20,6 @@ export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
description={t('general.appname.description')} description={t('general.appname.description')}
placeholder="My example app" placeholder="My example app"
variant="default" variant="default"
mb="md"
withAsterisk withAsterisk
required required
{...form.getInputProps('name')} {...form.getInputProps('name')}
@@ -45,7 +44,7 @@ export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
description={t('general.externalAddress.description')} description={t('general.externalAddress.description')}
placeholder="https://homarr.mywebsite.com/" placeholder="https://homarr.mywebsite.com/"
variant="default" variant="default"
mb="md" required
{...form.getInputProps('behaviour.externalUrl')} {...form.getInputProps('behaviour.externalUrl')}
/> />
</Tabs.Panel> </Tabs.Panel>

View File

@@ -3,6 +3,7 @@ import { ContextModalProps } from '@mantine/modals';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useState } from 'react'; import { useState } from 'react';
import Widgets from '../../../../widgets'; import Widgets from '../../../../widgets';
import type { IWidgetOptionValue } from '../../../../widgets/widgets';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
import { IWidget } from '../../../../widgets/widgets'; import { IWidget } from '../../../../widgets/widgets';
@@ -23,6 +24,8 @@ export const WidgetsEditModal = ({
const [moduleProperties, setModuleProperties] = useState(innerProps.options); const [moduleProperties, setModuleProperties] = useState(innerProps.options);
const items = Object.entries(moduleProperties ?? {}) as [string, IntegrationOptionsValueType][]; const items = Object.entries(moduleProperties ?? {}) as [string, IntegrationOptionsValueType][];
// Find the Key in the "Widgets" Object that matches the widgetId
const currentWidgetDefinition = Widgets[innerProps.widgetId as keyof typeof Widgets];
const { name: configName } = useConfigContext(); const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
@@ -63,33 +66,38 @@ export const WidgetsEditModal = ({
return ( return (
<Stack> <Stack>
{items.map(([key, value]) => ( {items.map(([key, value]) => {
<> const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue;
{typeof value === 'boolean' ? ( switch (option.type) {
case 'switch':
return (
<Switch <Switch
label={t(`descriptor.settings.${key}.label`)} label={t(`descriptor.settings.${key}.label`)}
checked={value} checked={value as boolean}
onChange={(ev) => handleChange(key, ev.currentTarget.checked)} onChange={(ev) => handleChange(key, ev.currentTarget.checked)}
/> />
) : null} );
{typeof value === 'string' ? ( case 'text':
return (
<TextInput <TextInput
label={t(`descriptor.settings.${key}.label`)} label={t(`descriptor.settings.${key}.label`)}
value={value} value={value as string}
onChange={(ev) => handleChange(key, ev.currentTarget.value)} onChange={(ev) => handleChange(key, ev.currentTarget.value)}
/> />
) : null} );
{typeof value === 'object' && Array.isArray(value) ? ( case 'multi-select':
return (
<MultiSelect <MultiSelect
data={getMutliselectData(key)} data={getMutliselectData(key)}
label={t(`descriptor.settings.${key}.label`)} label={t(`descriptor.settings.${key}.label`)}
value={value} value={value as string[]}
onChange={(v) => handleChange(key, v)} onChange={(v) => handleChange(key, v)}
/> />
) : null} );
</> default:
))} return null;
}
})}
<Group position="right"> <Group position="right">
<Button onClick={() => context.closeModal(id)} variant="light"> <Button onClick={() => context.closeModal(id)} variant="light">
{t('common:cancel')} {t('common:cancel')}
@@ -99,3 +107,41 @@ export const WidgetsEditModal = ({
</Stack> </Stack>
); );
}; };
// <Stack>
// {items.map(([key, value]) => (
// <>
// {typeof value === 'boolean' ? (
// <Switch
// label={t(`descriptor.settings.${key}.label`)}
// checked={value}
// onChange={(ev) => handleChange(key, ev.currentTarget.checked)}
// />
// ) : null}
// {typeof value === 'string' ? (
// <TextInput
// label={t(`descriptor.settings.${key}.label`)}
// value={value}
// onChange={(ev) => handleChange(key, ev.currentTarget.value)}
// />
// ) : null}
// {typeof value === 'object' && Array.isArray(value) ? (
// <MultiSelect
// data={getMutliselectData(key)}
// label={t(`descriptor.settings.${key}.label`)}
// value={value}
// onChange={(v) => handleChange(key, v)}
// />
// ) : null}
// </>
// ))}
// <Group position="right">
// <Button onClick={() => context.closeModal(id)} variant="light">
// {t('common:cancel')}
// </Button>
// <Button onClick={handleSave}>{t('common:save')}</Button>
// </Group>
// </Stack>
// );
// };

View File

@@ -6,6 +6,7 @@ import {
IconRowInsertTop, IconRowInsertTop,
IconRowInsertBottom, IconRowInsertBottom,
IconEdit, IconEdit,
IconTrash,
} from '@tabler/icons'; } from '@tabler/icons';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { CategoryType } from '../../../../types/category'; import { CategoryType } from '../../../../types/category';
@@ -17,11 +18,11 @@ interface CategoryEditMenuProps {
export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => { export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => {
const { name: configName } = useConfigContext(); const { name: configName } = useConfigContext();
const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit } = const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } =
useCategoryActions(configName, category); useCategoryActions(configName, category);
return ( return (
<Menu withinPortal> <Menu withinPortal position="left-start" withArrow>
<Menu.Target> <Menu.Target>
<ActionIcon> <ActionIcon>
<IconDots /> <IconDots />
@@ -31,6 +32,9 @@ export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => {
<Menu.Item icon={<IconEdit size={20} />} onClick={edit}> <Menu.Item icon={<IconEdit size={20} />} onClick={edit}>
Edit Edit
</Menu.Item> </Menu.Item>
<Menu.Item icon={<IconTrash size={20} />} onClick={remove}>
Remove
</Menu.Item>
<Menu.Label>Change positon</Menu.Label> <Menu.Label>Change positon</Menu.Label>
<Menu.Item icon={<IconTransitionTop size={20} />} onClick={moveCategoryUp}> <Menu.Item icon={<IconTransitionTop size={20} />} onClick={moveCategoryUp}>
Move up Move up

View File

@@ -39,7 +39,7 @@ export const CategoryEditModal = ({
<TextInput data-autoFocus {...form.getInputProps('name')} label="Name of category" /> <TextInput data-autoFocus {...form.getInputProps('name')} label="Name of category" />
<Group mt="md" grow> <Group mt="md" grow>
<Button onClick={() => context.closeModal(id)} variant="light" color="gray"> <Button onClick={() => context.closeModal(id)} variant="filled" color="gray">
{t('cancel')} {t('cancel')}
</Button> </Button>
<Button type="submit">{t('save')}</Button> <Button type="submit">{t('save')}</Button>

View File

@@ -176,6 +176,37 @@ export const useCategoryActions = (configName: string | undefined, category: Cat
); );
}; };
// Removes the current category
const remove = () => {
if (!configName) return;
updateConfig(
configName,
(previous) => {
const currentItem = previous.categories.find((x) => x.id === category.id);
if (!currentItem) return previous;
// Find the main wrapper
const mainWrapper = previous.wrappers.find((x) => x.position === 1);
// Check that the app has an area.type or "category" and that the area.id is the current category
const appsToMove = previous.apps.filter(
(x) => x.area && x.area.type === 'category' && x.area.properties.id === currentItem.id
);
appsToMove.forEach((x) => {
// eslint-disable-next-line no-param-reassign
x.area = { type: 'wrapper', properties: { id: mainWrapper?.id ?? 'default' } };
});
return {
...previous,
apps: previous.apps,
categories: previous.categories.filter((x) => x.id !== category.id),
wrappers: previous.wrappers.filter((x) => x.position !== currentItem.position),
};
},
true
);
};
const edit = async () => { const edit = async () => {
openContextModalGeneric<CategoryEditModalInnerProps>({ openContextModalGeneric<CategoryEditModalInnerProps>({
modal: 'categoryEditModal', modal: 'categoryEditModal',
@@ -201,6 +232,7 @@ export const useCategoryActions = (configName: string | undefined, category: Cat
addCategoryBelow, addCategoryBelow,
moveCategoryUp, moveCategoryUp,
moveCategoryDown, moveCategoryDown,
remove,
edit, edit,
}; };
}; };

View File

@@ -15,10 +15,8 @@ export function Logo({ size = 'md', withoutText = false }: LogoProps) {
<Group spacing={size === 'md' ? 'xs' : 4} noWrap> <Group spacing={size === 'md' ? 'xs' : 4} noWrap>
<Image <Image
width={size === 'md' ? 50 : 12} width={size === 'md' ? 50 : 12}
src={config?.settings.customization.logoImageUrl || '/imgs/logo/logo.png'} src={config?.settings.customization.logoImageUrl || '/imgs/logo/logo-color.svg'}
style={{ alt="Homarr Logo"
position: 'relative',
}}
/> />
{withoutText ? null : ( {withoutText ? null : (
<Text <Text

View File

@@ -1,7 +1,11 @@
import { ActionIcon, Button, Popover, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Button, Popover, Text, Tooltip } from '@mantine/core';
import { IconEditCircle, IconEditCircleOff, IconX } from '@tabler/icons'; import { IconEditCircle, IconEditCircleOff, IconX } from '@tabler/icons';
import axios from 'axios';
import Consola from 'consola';
import { getCookie } from 'cookies-next';
import { Trans, useTranslation } from 'next-i18next'; import { Trans, useTranslation } from 'next-i18next';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useConfigContext } from '../../../../../config/provider';
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan'; import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore'; import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore';
@@ -13,9 +17,18 @@ export const ToggleEditModeAction = () => {
const { t } = useTranslation('layout/header/actions/toggle-edit-mode'); const { t } = useTranslation('layout/header/actions/toggle-edit-mode');
const smallerThanSm = useScreenSmallerThan('sm'); const smallerThanSm = useScreenSmallerThan('sm');
const { config } = useConfigContext();
useEffect(() => {
if (enabled || config === undefined || config?.schemaVersion === undefined) return;
const configName = getCookie('config-name')?.toString() ?? 'default';
axios.put(`/api/configs/${configName}`, { ...config });
Consola.log('Saved config to server', configName);
}, [enabled]);
const toggleButtonClicked = () => { const toggleButtonClicked = () => {
toggleEditMode(); toggleEditMode();
setPopoverManuallyHidden(false); setPopoverManuallyHidden(false);
}; };

View File

@@ -1,10 +1,7 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { Results } from 'sabnzbd-api'; import { Results } from 'sabnzbd-api';
import { import { UsenetQueueRequestParams, UsenetQueueResponse } from '../pages/api/modules/usenet/queue';
UsenetQueueRequestParams,
UsenetQueueResponse,
} from '../pages/api/modules/usenet/queue';
import { import {
UsenetHistoryRequestParams, UsenetHistoryRequestParams,
UsenetHistoryResponse, UsenetHistoryResponse,

View File

@@ -11,6 +11,7 @@ export function middleware(req: NextRequest, ev: NextFetchEvent) {
(url.pathname.includes('/_next/') && !url.pathname.includes('/pages/')) || (url.pathname.includes('/_next/') && !url.pathname.includes('/pages/')) ||
url.pathname === '/favicon.ico' || url.pathname === '/favicon.ico' ||
url.pathname === '/404' || url.pathname === '/404' ||
url.pathname === '/migrate' ||
url.pathname.includes('pages/_app')); url.pathname.includes('pages/_app'));
if (!skipURL && !isCorrectPassword && process.env.PASSWORD) { if (!skipURL && !isCorrectPassword && process.env.PASSWORD) {
url.pathname = '/login'; url.pathname = '/login';

View File

@@ -33,7 +33,7 @@ export async function getServerSideProps({
return { return {
props: { props: {
config: { config: {
schemaVersion: '1.0', schemaVersion: 1,
configProperties: { configProperties: {
name: 'Default Configuration', name: 'Default Configuration',
}, },

View File

@@ -3,6 +3,7 @@ import { GetServerSidePropsContext } from 'next';
import { SSRConfig } from 'next-i18next'; import { SSRConfig } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import fs from 'fs';
import LoadConfigComponent from '../components/Config/LoadConfig'; import LoadConfigComponent from '../components/Config/LoadConfig';
import { Dashboard } from '../components/Dashboard/Dashboard'; import { Dashboard } from '../components/Dashboard/Dashboard';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
@@ -24,6 +25,24 @@ export async function getServerSideProps({
res, res,
locale, locale,
}: GetServerSidePropsContext): Promise<{ props: ServerSideProps }> { }: GetServerSidePropsContext): Promise<{ props: ServerSideProps }> {
// Check that all the json files in the /data/configs folder are migrated
// If not, redirect to the migrate page
const configs = await fs.readdirSync('./data/configs');
if (
!configs.every(
(config) => JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8')).schemaVersion
)
) {
// Replace the current page with the migrate page but don't redirect
// This is to prevent the user from seeing the redirect
res.writeHead(302, {
Location: '/migrate',
});
res.end();
return { props: {} as ServerSideProps };
}
let configName = getCookie('config-name', { req, res }); let configName = getCookie('config-name', { req, res });
const configLocale = getCookie('config-locale', { req, res }); const configLocale = getCookie('config-locale', { req, res });
if (!configName) { if (!configName) {

366
src/pages/migrate.tsx Normal file
View File

@@ -0,0 +1,366 @@
import React, { useEffect, useState } from 'react';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import fs from 'fs';
import { GetServerSidePropsContext } from 'next';
import {
createStyles,
Title,
Text,
Container,
Group,
Stepper,
useMantineTheme,
Header,
AppShell,
useMantineColorScheme,
Switch,
Box,
Button,
Alert,
Badge,
List,
Loader,
Paper,
Progress,
Space,
Stack,
ThemeIcon,
Anchor,
} from '@mantine/core';
import {
IconSun,
IconMoonStars,
IconCheck,
IconAlertCircle,
IconCircleCheck,
IconBrandDiscord,
} from '@tabler/icons';
import { motion } from 'framer-motion';
import { Logo } from '../components/layout/Logo';
import { usePrimaryGradient } from '../components/layout/useGradient';
import { migrateConfig } from '../tools/config/migrateConfig';
const useStyles = createStyles((theme) => ({
root: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
backgroundColor: theme.fn.variant({ variant: 'light', color: theme.primaryColor }).background,
},
label: {
textAlign: 'center',
color: theme.colors[theme.primaryColor][8],
fontWeight: 900,
fontSize: 110,
lineHeight: 1,
marginBottom: theme.spacing.xl * 1.5,
[theme.fn.smallerThan('sm')]: {
fontSize: 60,
},
},
title: {
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
textAlign: 'center',
fontWeight: 900,
fontSize: 38,
[theme.fn.smallerThan('sm')]: {
fontSize: 32,
},
},
card: {
position: 'relative',
overflow: 'visible',
padding: theme.spacing.xl,
},
icon: {
position: 'absolute',
top: -ICON_SIZE / 3,
left: `calc(50% - ${ICON_SIZE / 2}px)`,
},
description: {
maxWidth: 700,
margin: 'auto',
marginTop: theme.spacing.xl,
marginBottom: theme.spacing.xl * 1.5,
},
}));
export default function ServerError({ configs }: { configs: any }) {
const { classes } = useStyles();
const [active, setActive] = React.useState(0);
const gradient = usePrimaryGradient();
const [progress, setProgress] = React.useState(0);
const [isUpgrading, setIsUpgrading] = React.useState(false);
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current));
return (
<AppShell
padding={0}
header={
<Header
height={60}
px="xs"
styles={(theme) => ({
main: {
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0],
},
})}
>
<Group position="apart" align="start">
<Box mt="xs">
<Logo />
</Box>
<SwitchToggle />
</Group>
{/* Header content */}
</Header>
}
styles={(theme) => ({
main: {
backgroundColor: theme.fn.variant({ variant: 'light', color: theme.primaryColor })
.background,
},
})}
>
<div className={classes.root}>
<Container>
<Group noWrap>
<motion.div
animate={{ scale: [1, 1.2, 1] }}
// Spring animation
transition={{ duration: 1.5, ease: 'easeInOut' }}
style={{ cursor: 'pointer' }}
>
<Title variant="gradient" gradient={gradient} className={classes.label}>
Homarr v0.11
</Title>
</motion.div>
</Group>
<Title mb="md" className={classes.title}>
{active === 0 && "Good to see you back! Let's get started"}
{active === 1 && progress !== 100 && 'Migrating your configs'}
{active === 1 && progress === 100 && 'Migration complete!'}
</Title>
<Stepper active={active}>
<Stepper.Step label="Step 1" description="Welcome back">
<Text size="lg" align="center" className={classes.description}>
<b>A few things have changed since the last time you used Homarr.</b> We&apos;ll
help you migrate your old configuration to the new format. This process is automatic
and should take less than a minute. Then, you&apos;ll be able to use the new
features of Homarr!
</Text>
<Alert
style={{ maxWidth: 700, margin: 'auto' }}
icon={<IconAlertCircle size={16} />}
title="Please make a backup of your configs!"
color="red"
radius="md"
variant="outline"
>
Please make sure to have a backup of your configs in case something goes wrong.{' '}
<b>Not all settings can be migrated</b>, so you&apos;ll have to re-do some
configuration yourself.
</Alert>
</Stepper.Step>
<Stepper.Step
loading={progress < 100 && active === 1}
icon={progress === 100 && <IconCheck />}
label="Step 2"
description="Migrating your configs"
>
<StatsCard configs={configs} progress={progress} setProgress={setProgress} />
</Stepper.Step>
<Stepper.Step label="Step 3" description="New features">
<Text size="lg" align="center" className={classes.description}>
<b>Homarr v0.11</b> brings a lot of new features, if you are interested in learning
about them, please check out the{' '}
<Anchor target="_blank" href="https://homarr.dev/">
documentation page
</Anchor>
</Text>
</Stepper.Step>
<Stepper.Completed>
<Text size="lg" align="center" className={classes.description}>
That&apos;s it ! We hope you enjoy the new flexibility v0.11 brings. If you spot any
bugs make sure to report them as a{' '}
<Anchor
target="_blank"
href="
https://github.com/ajnart/homarr/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug.yml&title=New%20bug"
>
<b>github issue</b>
</Anchor>{' '}
or directly on the
<Anchor target="_blank" href="https://discord.gg/aCsmEV5RgA">
<b>
<IconBrandDiscord size={20} />
discord !
</b>
</Anchor>
</Text>
</Stepper.Completed>
</Stepper>
<Group position="center" mt="xl">
<Button
leftIcon={active === 3 && <IconCheck size={20} stroke={1.5} />}
onClick={active === 3 ? () => window.location.reload() : nextStep}
variant="filled"
disabled={active === 1 && progress < 100}
>
{active === 3 ? 'Finish' : 'Next'}
</Button>
</Group>
</Container>
</div>
</AppShell>
);
}
function SwitchToggle() {
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const theme = useMantineTheme();
return (
<Switch
checked={colorScheme === 'dark'}
onChange={() => toggleColorScheme()}
size="lg"
onLabel={<IconSun color={theme.white} size={20} stroke={1.5} />}
offLabel={<IconMoonStars color={theme.colors.gray[6]} size={20} stroke={1.5} />}
/>
);
}
export async function getServerSideProps({ req, res, locale }: GetServerSidePropsContext) {
// Get all the configs in the /data/configs folder
const configs = await fs.readdirSync('./data/configs');
// If there is no config, redirect to the index
if (configs.length === 0) {
res.writeHead(302, {
Location: '/',
});
res.end();
return { props: {} };
}
// If all the configs are migrated (contains a schemaVersion), redirect to the index
if (
configs.every(
(config) => JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8')).schemaVersion
)
) {
res.writeHead(302, {
Location: '/',
});
res.end();
return {
props: {
...(await serverSideTranslations(locale ?? 'en', [])),
},
};
}
configs.every((config) => {
const configData = JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8'));
if (!configData.schemaVersion) {
// Migrate the config
migrateConfig(configData, config.replace('.json', ''));
}
return config;
});
return {
props: {
configs: configs.map(
// Get all the file names in ./data/configs
(config) => config.replace('.json', '')
),
...(await serverSideTranslations(locale!, [])),
// Will be passed to the page component as props
},
};
}
const ICON_SIZE = 60;
export function StatsCard({
configs,
progress,
setProgress,
}: {
configs: string[];
progress: number;
setProgress: (progress: number) => void;
}) {
const { classes } = useStyles();
const numberOfConfigs = configs.length;
// Update the progress every 100ms
const [treatedConfigs, setTreatedConfigs] = useState<string[]>([]);
// Stop the progress at 100%
useEffect(() => {
const interval = setInterval(() => {
if (configs.length === 0) {
clearInterval(interval);
setProgress(100);
return;
}
// Add last element of configs to the treatedConfigs array
setTreatedConfigs((treatedConfigs) => [...treatedConfigs, configs[configs.length - 1]]);
// Remove last element of configs
configs.pop();
}, 500);
return () => clearInterval(interval);
}, []);
// TODO: Actually use the configs
return (
<Paper radius="md" className={classes.card} mt={ICON_SIZE / 3}>
<Group position="apart">
<Text size="sm" color="dimmed">
Progress
</Text>
<Text size="sm" color="dimmed">
{(100 / (numberOfConfigs + 1)).toFixed(1)}%
</Text>
</Group>
<Stack>
<Progress animate={progress < 100} value={100 / (numberOfConfigs + 1)} mt={5} />
<List
spacing="xs"
size="sm"
center
icon={
<ThemeIcon color="teal" size={24} radius="xl">
<IconCircleCheck size={16} />
</ThemeIcon>
}
>
{configs.map((config, index) => (
<List.Item key={index + 10} icon={<Loader size={24} color="orange" />}>
{config ?? 'Unknown'}
</List.Item>
))}
{treatedConfigs.map((config, index) => (
<List.Item key={index}>{config ?? 'Unknown'}</List.Item>
))}
</List>
</Stack>
<Group position="apart" mt="md">
<Space />
<Badge size="sm">{configs.length} configs left</Badge>
</Group>
</Paper>
);
}

View File

@@ -9,10 +9,10 @@ export const getConfig = (name: string): BackendConfigType => {
// Else if config exists but contains no "schema_version" property // Else if config exists but contains no "schema_version" property
// then it is an old config file and we should try to migrate it // then it is an old config file and we should try to migrate it
// to the new format. // to the new format.
let config = readConfig(name); const config = readConfig(name);
if (!config.schemaVersion) { if (config.schemaVersion === undefined) {
// TODO: Migrate config to new format console.log('Migrating config file...', config);
config = migrateConfig(config); return migrateConfig(config, name);
} }
return config; return config;
}; };

View File

@@ -1,7 +1,7 @@
import { BackendConfigType } from '../../types/config'; import { BackendConfigType } from '../../types/config';
export const getFallbackConfig = (name?: string): BackendConfigType => ({ export const getFallbackConfig = (name?: string): BackendConfigType => ({
schemaVersion: '1.0.0', schemaVersion: 1,
configProperties: { configProperties: {
name: name ?? 'default', name: name ?? 'default',
}, },

View File

@@ -9,9 +9,10 @@ export const getFrontendConfig = (name: string): ConfigType => {
apps: config.apps.map((app) => ({ apps: config.apps.map((app) => ({
...app, ...app,
integration: { integration: {
...app.integration ?? null, ...(app.integration ?? null),
type: app.integration?.type ?? null, type: app.integration?.type ?? null,
properties: app.integration?.properties.map((property) => ({ properties:
app.integration?.properties.map((property) => ({
...property, ...property,
value: property.type === 'private' ? undefined : property.value, value: property.type === 'private' ? undefined : property.value,
isDefined: property.value != null, isDefined: property.value != null,

View File

@@ -1,9 +1,10 @@
import fs from 'fs';
import { ConfigType } from '../../types/config'; import { ConfigType } from '../../types/config';
import { Config } from '../types'; import { Config } from '../types';
export function migrateConfig(config: Config): ConfigType { export function migrateConfig(config: Config, name: string): ConfigType {
const newConfig: ConfigType = { const newConfig: ConfigType = {
schemaVersion: '1.0.0', schemaVersion: 1,
configProperties: { configProperties: {
name: config.name ?? 'default', name: config.name ?? 'default',
}, },
@@ -76,6 +77,9 @@ export function migrateConfig(config: Config): ConfigType {
}, },
}, },
})); }));
// Overrite the file ./data/configs/${name}.json
// with the new config format
fs.writeFileSync(`./data/configs/${name}.json`, JSON.stringify(newConfig, null, 2));
return newConfig; return newConfig;
} }

View File

@@ -5,7 +5,7 @@ import { SettingsType } from './settings';
import { IWidget } from '../widgets/widgets'; import { IWidget } from '../widgets/widgets';
export interface ConfigType { export interface ConfigType {
schemaVersion: string; schemaVersion: number;
configProperties: ConfigPropertiesType; configProperties: ConfigPropertiesType;
categories: CategoryType[]; categories: CategoryType[];
wrappers: WrapperType[]; wrappers: WrapperType[];

View File

@@ -1,4 +1,4 @@
import { createStyles, MantineThemeColors, useMantineTheme } from '@mantine/core'; import { Center, createStyles, MantineThemeColors, useMantineTheme } from '@mantine/core';
import { Calendar } from '@mantine/dates'; import { Calendar } from '@mantine/dates';
import { IconCalendarTime } from '@tabler/icons'; import { IconCalendarTime } from '@tabler/icons';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -57,6 +57,8 @@ function CalendarTile({ widget }: CalendarTileProps) {
return ( return (
<Calendar <Calendar
month={month} month={month}
// Should be offset 5px to the left
style={{ position: 'relative', left: -10, top: -10 }}
onMonthChange={setMonth} onMonthChange={setMonth}
size="xs" size="xs"
fullWidth fullWidth