Merge branch 'manuel-rw-gridstack' into gridstack-wip-meierschlumpf

This commit is contained in:
Meierschlumpf
2023-01-07 17:59:43 +01:00
56 changed files with 962 additions and 785 deletions

View File

@@ -4,205 +4,37 @@
"name": "default" "name": "default"
}, },
"categories": [ "categories": [
{
"id": "c1c4bec3-1044-4a80-957f-afe7ff49f421",
"name": "Test",
"position": 2
},
{ {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f", "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f",
"position": 0, "position": 0,
"name": "Example Category" "name": "Example Category"
},
{
"id": "c8407d2c-2353-4775-87c3-602f6f2684d5",
"name": "Test",
"position": 4
},
{
"id": "c1c4bec3-1044-4a80-957f-afe7ff49f421",
"name": "Test",
"position": 2
} }
], ],
"wrappers": [ "wrappers": [
{ {
"id": "943f0681-a15b-4576-9a61-a74bd6fdd3ab", "id": "5823c4d6-6baf-4436-b990-93fe77e1dc62",
"position": 1 "position": 1
}, },
{ {
"id": "default", "id": "943f0681-a15b-4576-9a61-a74bd6fdd3ab",
"position": 3 "position": 3
},
{
"id": "default",
"position": 5
} }
], ],
"apps": [ "apps": [
{
"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": "sidebar",
"properties": {
"location": "left"
}
},
"shape": {
"md": {
"location": {
"x": 1,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"sm": {
"location": {
"x": 1,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 1,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"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": "sidebar",
"properties": {
"location": "left"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
}
},
"integration": {
"type": null,
"properties": []
}
},
{
"id": "e41a11f5-9c6e-41bc-ac0e-4c4c47582faa",
"name": "Haha",
"url": "https://homarr.dev",
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"network": {
"enabledStatusChecker": false,
"okStatus": []
},
"behaviour": {
"isOpeningNewTab": true,
"externalUrl": ""
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 5,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 2
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 4,
"y": 2
},
"size": {
"width": 1,
"height": 1
}
}
},
"integration": {
"type": null,
"properties": []
}
},
{ {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337", "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
"name": "Discord", "name": "Discord",
@@ -234,8 +66,8 @@
"shape": { "shape": {
"md": { "md": {
"location": { "location": {
"x": 3, "x": 2,
"y": 0 "y": 1
}, },
"size": { "size": {
"width": 1, "width": 1,
@@ -254,7 +86,7 @@
}, },
"lg": { "lg": {
"location": { "location": {
"x": 0, "x": 2,
"y": 0 "y": 0
}, },
"size": { "size": {
@@ -268,16 +100,23 @@
"id": "5df743d9-5cb1-457c-85d2-64ff86855652", "id": "5df743d9-5cb1-457c-85d2-64ff86855652",
"name": "Your app", "name": "Your app",
"url": "https://homarr.dev", "url": "https://homarr.dev",
"appearance": { "behaviour": {
"iconUrl": "/imgs/logo/logo.png" "onClickUrl": "https://homarr.dev",
"externalUrl": "https://homarr.dev",
"isOpeningNewTab": true
}, },
"network": { "network": {
"enabledStatusChecker": false, "enabledStatusChecker": false,
"okStatus": [] "okStatus": [
200
]
}, },
"behaviour": { "appearance": {
"isOpeningNewTab": true, "iconUrl": "/imgs/logo/logo.png"
"externalUrl": "https://homarr.dev" },
"integration": {
"type": null,
"properties": []
}, },
"area": { "area": {
"type": "category", "type": "category",
@@ -288,8 +127,69 @@
"shape": { "shape": {
"md": { "md": {
"location": { "location": {
"x": 2, "x": 0,
"y": 0 "y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
}
}
},
{
"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": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 3,
"y": 1
}, },
"size": { "size": {
"width": 1, "width": 1,
@@ -298,7 +198,7 @@
}, },
"sm": { "sm": {
"location": { "location": {
"x": 2, "x": 3,
"y": 1 "y": 1
}, },
"size": { "size": {
@@ -308,8 +208,62 @@
}, },
"lg": { "lg": {
"location": { "location": {
"x": 2, "x": 3,
"y": 2 "y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"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": "sidebar",
"properties": {
"location": "left"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 0
}, },
"size": { "size": {
"width": 1, "width": 1,
@@ -333,12 +287,10 @@
}, },
"network": { "network": {
"enabledStatusChecker": false, "enabledStatusChecker": false,
"okStatus": [ "okStatus": []
200
]
}, },
"appearance": { "appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/github.png" "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png"
}, },
"integration": { "integration": {
"type": null, "type": null,
@@ -354,7 +306,7 @@
"md": { "md": {
"location": { "location": {
"x": 0, "x": 0,
"y": 1 "y": 2
}, },
"size": { "size": {
"width": 1, "width": 1,
@@ -363,8 +315,8 @@
}, },
"sm": { "sm": {
"location": { "location": {
"x": 1, "x": 0,
"y": 1 "y": 2
}, },
"size": { "size": {
"width": 1, "width": 1,
@@ -373,7 +325,7 @@
}, },
"lg": { "lg": {
"location": { "location": {
"x": 3, "x": 0,
"y": 2 "y": 2
}, },
"size": { "size": {
@@ -383,6 +335,64 @@
} }
} }
}, },
{
"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": "c8407d2c-2353-4775-87c3-602f6f2684d5"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 6,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 8,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 8,
"height": 1
}
}
},
"integration": {
"type": null,
"properties": []
}
},
{ {
"id": "615e43bd-f0aa-4117-ba49-b6495c039f3e", "id": "615e43bd-f0aa-4117-ba49-b6495c039f3e",
"name": "Your app", "name": "Your app",
@@ -418,7 +428,7 @@
"md": { "md": {
"location": { "location": {
"x": 0, "x": 0,
"y": 9 "y": 5
}, },
"size": { "size": {
"width": 1, "width": 1,
@@ -440,110 +450,49 @@
"type": null, "type": null,
"properties": [] "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": "category",
"properties": {
"id": "c1c4bec3-1044-4a80-957f-afe7ff49f421"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 1,
"y": 2
},
"size": {
"width": 1,
"height": 1
}
}
}
} }
], ],
"widgets": [ "widgets": [
{ {
"id": "date", "id": "calendar",
"properties": { "properties": {
"display24HourFormat": true "sundayStart": false
}, },
"area": { "area": {
"type": "category", "type": "wrapper",
"properties": { "properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f" "id": "default"
} }
}, },
"shape": { "shape": {
"sm": {
"location": {
"x": 0,
"y": 2
},
"size": {
"width": 2,
"height": 1
}
},
"md": { "md": {
"location": { "location": {
"x": 4, "x": 0,
"y": 0 "y": 0
}, },
"size": { "size": {
"width": 2, "width": 12,
"height": 1 "height": 5
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 12,
"height": 5
} }
}, },
"lg": { "lg": {
"location": { "location": {
"x": 7, "x": 0,
"y": 0 "y": 0
}, },
"size": { "size": {
"width": 5, "width": 12,
"height": 2 "height": 5
} }
} }
} }
@@ -577,7 +526,7 @@
"y": 0 "y": 0
}, },
"size": { "size": {
"width": 3, "width": 2,
"height": 1 "height": 1
} }
}, },
@@ -587,52 +536,52 @@
"y": 0 "y": 0
}, },
"size": { "size": {
"width": 7, "width": 2,
"height": 2 "height": 1
} }
} }
} }
}, },
{ {
"id": "calendar", "id": "date",
"properties": { "properties": {
"sundayStart": false "display24HourFormat": true
}, },
"area": { "area": {
"type": "wrapper", "type": "category",
"properties": { "properties": {
"id": "default" "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
} }
}, },
"shape": { "shape": {
"md": {
"location": {
"x": 0,
"y": 4
},
"size": {
"width": 3,
"height": 5
}
},
"sm": { "sm": {
"location": { "location": {
"x": 0, "x": 2,
"y": 0 "y": 0
}, },
"size": { "size": {
"width": 3, "width": 2,
"height": 5 "height": 1
}
},
"md": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
} }
}, },
"lg": { "lg": {
"location": { "location": {
"x": 0, "x": 2,
"y": 0 "y": 0
}, },
"size": { "size": {
"width": 3, "width": 2,
"height": 5 "height": 1
} }
} }
} }
@@ -647,8 +596,8 @@
}, },
"customization": { "customization": {
"layout": { "layout": {
"enabledLeftSidebar": false, "enabledLeftSidebar": true,
"enabledRightSidebar": false, "enabledRightSidebar": true,
"enabledDocker": false, "enabledDocker": false,
"enabledPing": false, "enabledPing": false,
"enabledSearchbar": true "enabledSearchbar": true
@@ -659,9 +608,9 @@
"backgroundImageUrl": "", "backgroundImageUrl": "",
"customCss": "", "customCss": "",
"colors": { "colors": {
"primary": "red", "primary": "pink",
"secondary": "orange", "secondary": "yellow",
"shade": 5 "shade": 4
}, },
"appOpacity": 100 "appOpacity": 100
} }

View File

@@ -8,7 +8,7 @@ module.exports = withBundleAnalyzer({
images: { images: {
domains: ['cdn.jsdelivr.net'], domains: ['cdn.jsdelivr.net'],
}, },
reactStrictMode: false, reactStrictMode: true,
output: 'standalone', output: 'standalone',
i18n, i18n,
}); });

View File

@@ -0,0 +1,3 @@
{
"title": "{{position}} sidebar"
}

View File

@@ -46,11 +46,17 @@
"type": { "type": {
"label": "Integration configuration", "label": "Integration configuration",
"description": "Treats this app as the selected integration and provides you with per-app configuration", "description": "Treats this app as the selected integration and provides you with per-app configuration",
"placeholder": "Select an integration" "placeholder": "Select an integration",
"defined": "Defined",
"undefined": "Undefined",
"public": "Public",
"private": "Private",
"explanationPublic": "A private secret will be sent to the server. Once your browser has refreshed the page, it will never be sent to the client.",
"explanationPrivate": "A public secret will always be sent to the client and is accessible over the API. It should not contain any confidential values such as usernames, passwords, tokens, certificates and similar"
}, },
"secrets": { "secrets": {
"description": "To update a secret, enter a value and click the save button. To remove a secret, use the clear button.", "description": "To update a secret, enter a value and click the save button. To remove a secret, use the clear button.",
"warning": "Please note that Homarr removes secrets from the configuration for security reasons. Thus, you can only either define or unset any credentials. Your credentials act as the main access for your integrations and you should <strong>never</strong> share them with anybody else. Make sure to <strong>store and manage your secrets safely</strong>.", "warning": "Your credentials act as the access for your integrations and you should <strong>never</strong> share them with anybody else. The official Homarr team will never ask for credentials. Make sure to <strong>store and manage your secrets safely</strong>.",
"clear": "Clear secret", "clear": "Clear secret",
"save": "Save secret", "save": "Save secret",
"update": "Update secret" "update": "Update secret"

View File

@@ -26,5 +26,6 @@
} }
}, },
"tip": "You can select the search bar with the shortcut ", "tip": "You can select the search bar with the shortcut ",
"switchedSearchEngine": "Switched to searching with {{searchEngine}}" "switchedSearchEngine": "Switched to searching with {{searchEngine}}",
"configurationName": "Search engine configuration"
} }

View File

@@ -4,6 +4,9 @@
"description": "Displays a list of the torrent which are currently downloading", "description": "Displays a list of the torrent which are currently downloading",
"settings": { "settings": {
"title": "Settings for BitTorrent integration", "title": "Settings for BitTorrent integration",
"refreshInterval": {
"label": "Refresh interval (in seconds)"
},
"displayCompletedTorrents": { "displayCompletedTorrents": {
"label": "Display completed torrents" "label": "Display completed torrents"
}, },

View File

@@ -1,6 +1,8 @@
{ {
"configSelect": { "configSelect": {
"label": "Config loader" "label": "Config loader",
"loadingNew": "Loading your config...",
"pleaseWait": "Please wait until your new config is loaded"
}, },
"modal": { "modal": {
"title": "Choose the name of your new config", "title": "Choose the name of your new config",

View File

@@ -31,6 +31,7 @@ import { CURRENT_VERSION } from '../../../data/constants';
import { useConfigContext } from '../../config/provider'; import { useConfigContext } from '../../config/provider';
import { useConfigStore } from '../../config/store'; import { useConfigStore } from '../../config/store';
import { usePrimaryGradient } from '../layout/useGradient'; import { usePrimaryGradient } from '../layout/useGradient';
import Credits from '../Settings/Common/Credits';
interface AboutModalProps { interface AboutModalProps {
opened: boolean; opened: boolean;
@@ -113,6 +114,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
Discord Discord
</Button> </Button>
</Group> </Group>
<Credits />
</Modal> </Modal>
); );
}; };

View File

@@ -1,5 +1,7 @@
import { Center, Loader, Select, Tooltip } from '@mantine/core'; import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core';
import { useToggle } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { setCookie } from 'cookies-next';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useState } from 'react'; import { useState } from 'react';
import { useConfigContext } from '../../config/provider'; import { useConfigContext } from '../../config/provider';
@@ -11,19 +13,22 @@ export default function ConfigChanger() {
const { data: configs, isLoading, isError } = useConfigsQuery(); const { data: configs, isLoading, isError } = useConfigsQuery();
const [activeConfig, setActiveConfig] = useState(configName); const [activeConfig, setActiveConfig] = useState(configName);
const [isRefreshing, toggle] = useToggle();
const onConfigChange = (value: string) => { const onConfigChange = (value: string) => {
// TODO: check what should happen here with @manuel-rw // TODO: check what should happen here with @manuel-rw
// Wheter it should check for the current url and then load the new config only on index // Wheter it should check for the current url and then load the new config only on index
// Or it should always load the selected config and open index or ? --> change url to page // Or it should always load the selected config and open index or ? --> change url to page
setActiveConfig(value); setCookie('config-name', value ?? 'default', {
/*
loadConfig(e ?? 'default');
setCookie('config-name', e ?? 'default', {
maxAge: 60 * 60 * 24 * 30, maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict', sameSite: 'strict',
}); });
*/ setActiveConfig(value);
toggle();
// Use timeout to wait for the cookie to be set
setTimeout(() => {
window.location.reload();
}, 1000);
}; };
// If configlist is empty, return a loading indicator // If configlist is empty, return a loading indicator
@@ -38,12 +43,26 @@ export default function ConfigChanger() {
} }
return ( return (
<>
<Select <Select
label={t('configSelect.label')} label={t('configSelect.label')}
value={activeConfig} value={activeConfig}
onChange={onConfigChange} onChange={onConfigChange}
data={configs} data={configs}
/> />
<Dialog
position={{ top: 0, left: 0 }}
unstyled
opened={isRefreshing}
onClose={() => toggle()}
size="lg"
radius="md"
>
<Notification loading title={t('configSelect.loadingNew')} radius="md" disallowClose>
{t('configSelect.pleaseWait')}
</Notification>
</Dialog>
</>
); );
} }

View File

@@ -2,6 +2,7 @@ import { Group, Stack, Text, Title, useMantineTheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons'; import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons';
import Consola from 'consola';
import { setCookie } from 'cookies-next'; import { setCookie } from 'cookies-next';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useConfigStore } from '../../config/store'; import { useConfigStore } from '../../config/store';
@@ -36,9 +37,7 @@ export const LoadConfigComponent = () => {
let newConfig: ConfigType = JSON.parse(fileText); let newConfig: ConfigType = JSON.parse(fileText);
if (!newConfig.schemaVersion) { if (!newConfig.schemaVersion) {
// client side logging Consola.warn(
// eslint-disable-next-line no-console
console.warn(
'a legacy configuration schema was deteced and migrated to the current schema' 'a legacy configuration schema was deteced and migrated to the current schema'
); );
const oldConfig = JSON.parse(fileText) as Config; const oldConfig = JSON.parse(fileText) as Config;

View File

@@ -1,4 +1,4 @@
import { ActionIcon, createStyles } from '@mantine/core'; import { ActionIcon, createStyles, Space } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { IconChevronLeft, IconChevronRight } from '@tabler/icons'; import { IconChevronLeft, IconChevronRight } from '@tabler/icons';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
@@ -35,7 +35,9 @@ export const MobileRibbons = () => {
location="left" location="left"
/> />
</> </>
) : null} ) : (
<Space />
)}
{layoutSettings.enabledRightSidebar ? ( {layoutSettings.enabledRightSidebar ? (
<> <>

View File

@@ -1,4 +1,5 @@
import { Drawer, Title } from '@mantine/core'; import { Drawer, Title } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { DashboardSidebar } from '../../Wrappers/Sidebar/Sidebar'; import { DashboardSidebar } from '../../Wrappers/Sidebar/Sidebar';
interface MobileRibbonSidebarDrawerProps { interface MobileRibbonSidebarDrawerProps {
@@ -10,16 +11,25 @@ interface MobileRibbonSidebarDrawerProps {
export const MobileRibbonSidebarDrawer = ({ export const MobileRibbonSidebarDrawer = ({
location, location,
...props ...props
}: MobileRibbonSidebarDrawerProps) => ( }: MobileRibbonSidebarDrawerProps) => {
const { t } = useTranslation('layout/mobile/drawer');
return (
<Drawer <Drawer
padding={10}
position={location} position={location}
title={<Title order={4}>{location} sidebar</Title>} title={<Title order={4}>{t('title', { position: location })}</Title>}
style={{ style={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
}} }}
styles={{
title: {
width: '100%',
},
}}
{...props} {...props}
> >
<DashboardSidebar location={location} isGridstackReady /> <DashboardSidebar location={location} isGridstackReady />
</Drawer> </Drawer>
); );
};

View File

@@ -36,7 +36,7 @@ export const IconSelector = ({ onChange, allowAppNamePropagation, form }: IconSe
const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({ const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({
url: 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png', url: 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png',
converter: (item) => ({ converter: (item) => ({
url: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${item.name}`, url: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/${item.name}`,
fileName: item.name, fileName: item.name,
}), }),
}); });

View File

@@ -9,15 +9,21 @@ import {
Stack, Stack,
ThemeIcon, ThemeIcon,
Title, Title,
Text,
Badge,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { TablerIcon } from '@tabler/icons'; import { TablerIcon } from '@tabler/icons';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useState } from 'react'; import { useState } from 'react';
import { AppIntegrationPropertyAccessabilityType } from '../../../../../../../../types/app';
interface GenericSecretInputProps { interface GenericSecretInputProps {
label: string; label: string;
value: string; value: string;
setIcon: TablerIcon; setIcon: TablerIcon;
secretIsPresent: boolean;
type: AppIntegrationPropertyAccessabilityType;
onClickUpdateButton: (value: string | undefined) => void; onClickUpdateButton: (value: string | undefined) => void;
} }
@@ -25,6 +31,8 @@ export const GenericSecretInput = ({
label, label,
value, value,
setIcon, setIcon,
secretIsPresent,
type,
onClickUpdateButton, onClickUpdateButton,
...props ...props
}: GenericSecretInputProps) => { }: GenericSecretInputProps) => {
@@ -36,17 +44,61 @@ export const GenericSecretInput = ({
const { t } = useTranslation(['layout/modals/add-app', 'common']); const { t } = useTranslation(['layout/modals/add-app', 'common']);
return ( return (
<Card withBorder> <Card p="xs" withBorder>
<Grid> <Grid>
<Grid.Col className={classes.alignSelfCenter} xs={12} md={6}> <Grid.Col className={classes.alignSelfCenter} xs={12} md={6}>
<Group spacing="sm"> <Group spacing="sm">
<ThemeIcon color="green" variant="light"> <ThemeIcon color={secretIsPresent ? 'green' : 'red'} variant="light" size="lg">
<Icon size={18} /> <Icon size={18} />
</ThemeIcon> </ThemeIcon>
<Stack spacing={0}> <Stack spacing={0}>
<Group spacing="xs">
<Title className={classes.subtitle} order={6}> <Title className={classes.subtitle} order={6}>
{t(label)} {t(label)}
</Title> </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>
)}
{type === 'private' ? (
<Tooltip
label={t('integration.type.explanationPrivate')}
width={200}
multiline
withinPortal
withArrow
>
<Badge className={classes.textTransformUnset} color="orange" variant="dot">
{t('integration.type.private')}
</Badge>
</Tooltip>
) : (
<Tooltip
label={t('integration.type.explanationPublic')}
width={200}
multiline
withinPortal
withArrow
>
<Badge className={classes.textTransformUnset} color="red" variant="dot">
{t('integration.type.public')}
</Badge>
</Tooltip>
)}
</Group>
</Group>
<Text size="xs" color="dimmed">
{type === 'private'
? 'Private: Once saved, you cannot read out this value again'
: 'Public: Can be read out repeatedly'}
</Text>
</Stack> </Stack>
</Group> </Group>
</Grid.Col> </Grid.Col>
@@ -80,4 +132,7 @@ const useStyles = createStyles(() => ({
alignSelfCenter: { alignSelfCenter: {
alignSelf: 'center', alignSelf: 'center',
}, },
textTransformUnset: {
textTransform: 'inherit',
},
})); }));

View File

@@ -36,7 +36,7 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
label: 'Transmission', label: 'Transmission',
}, },
{ {
value: 'qbittorrent', value: 'qBittorrent',
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/qbittorrent.png', image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/qbittorrent.png',
label: 'qBittorrent', label: 'qBittorrent',
}, },
@@ -100,16 +100,20 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
placeholder={t('integration.type.placeholder')} placeholder={t('integration.type.placeholder')}
itemComponent={SelectItemComponent} itemComponent={SelectItemComponent}
data={data} data={data}
maxDropdownHeight={150} maxDropdownHeight={250}
dropdownPosition="bottom" dropdownPosition="bottom"
clearable clearable
variant="default" variant="default"
searchable searchable
filter={(value, item) =>
item.label?.toLowerCase().includes(value.toLowerCase().trim()) ||
item.description?.toLowerCase().includes(value.toLowerCase().trim())
}
icon={ icon={
form.values.integration?.type && ( form.values.integration?.type && (
<img <img
src={data.find((x) => x.value === form.values.integration?.type)?.image} src={data.find((x) => x.value === form.values.integration?.type)?.image}
alt="test" alt="integration"
width={20} width={20}
height={20} height={20}
/> />
@@ -119,6 +123,7 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
form.setFieldValue('integration.properties', getNewProperties(value)); form.setFieldValue('integration.properties', getNewProperties(value));
inputProps.onChange(value); inputProps.onChange(value);
}} }}
withinPortal
{...inputProps} {...inputProps}
/> />
); );
@@ -126,17 +131,23 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
image: string; image: string;
description: string;
label: string; label: string;
} }
const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>( const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>(
({ image, label, ...others }: ItemProps, ref) => ( ({ image, label, description, ...others }: ItemProps, ref) => (
<div ref={ref} {...others}> <div ref={ref} {...others}>
<Group noWrap> <Group noWrap>
<img src={image} alt="integration icon" width={20} height={20} /> <img src={image} alt="integration icon" width={20} height={20} />
<div> <div>
<Text size="sm">{label}</Text> <Text size="sm">{label}</Text>
{description && (
<Text size="xs" color="dimmed">
{description}
</Text>
)}
</div> </div>
</Group> </Group>
</div> </div>

View File

@@ -45,6 +45,7 @@ export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererP
const formValue = form.values.integration?.properties[indexInFormValue]; const formValue = form.values.integration?.properties[indexInFormValue];
const isPresent = formValue?.isDefined; const isPresent = formValue?.isDefined;
const accessabilityType = formValue?.type;
if (!definition) { if (!definition) {
return ( return (
@@ -57,6 +58,7 @@ export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererP
secretIsPresent={isPresent} secretIsPresent={isPresent}
setIcon={IconKey} setIcon={IconKey}
value={formValue.value} value={formValue.value}
type={accessabilityType}
{...form.getInputProps(`integration.properties.${index}.value`)} {...form.getInputProps(`integration.properties.${index}.value`)}
/> />
); );
@@ -72,6 +74,7 @@ export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererP
value="" value=""
secretIsPresent={isPresent} secretIsPresent={isPresent}
setIcon={definition.icon} setIcon={definition.icon}
type={accessabilityType}
{...form.getInputProps(`integration.properties.${index}.value`)} {...form.getInputProps(`integration.properties.${index}.value`)}
/> />
); );

View File

@@ -97,8 +97,8 @@ export const AvailableElementTypes = ({
iconUrl: '/imgs/logo/logo.png', iconUrl: '/imgs/logo/logo.png',
}, },
network: { network: {
enabledStatusChecker: false, enabledStatusChecker: true,
okStatus: [], okStatus: [200],
}, },
behaviour: { behaviour: {
isOpeningNewTab: true, isOpeningNewTab: true,

View File

@@ -1,5 +1,6 @@
import { useModals } from '@mantine/modals'; import { useModals } from '@mantine/modals';
import { TablerIcon } from '@tabler/icons'; import { showNotification } from '@mantine/notifications';
import { IconChecks, TablerIcon } from '@tabler/icons';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../../../config/provider'; import { useConfigContext } from '../../../../../../config/provider';
import { useConfigStore } from '../../../../../../config/store'; import { useConfigStore } from '../../../../../../config/store';
@@ -83,8 +84,13 @@ export const WidgetElementType = ({ id, image, disabled, widget }: WidgetElement
true, true,
!isEditMode !isEditMode
); );
closeModal('selectElement'); closeModal('selectElement');
showNotification({
title: t('descriptor.name'),
message: t('descriptor.description'),
icon: <IconChecks stroke={1.5} />,
color: 'teal',
});
}; };
return ( return (

View File

@@ -1,4 +1,15 @@
import { Alert, Button, Group, MultiSelect, Stack, Switch, TextInput, Text } from '@mantine/core'; import {
Alert,
Button,
Group,
MultiSelect,
Stack,
Switch,
TextInput,
Text,
NumberInput,
Slider,
} from '@mantine/core';
import { ContextModalProps } from '@mantine/modals'; import { ContextModalProps } from '@mantine/modals';
import { IconAlertTriangle } from '@tabler/icons'; import { IconAlertTriangle } from '@tabler/icons';
import { Trans, useTranslation } from 'next-i18next'; import { Trans, useTranslation } from 'next-i18next';
@@ -8,10 +19,12 @@ 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';
import { useColorTheme } from '../../../../tools/color';
export type WidgetEditModalInnerProps = { export type WidgetEditModalInnerProps = {
widgetId: string; widgetId: string;
options: IWidget<string, any>['properties']; options: IWidget<string, any>['properties'];
widgetOptions: IWidget<string, any>['properties'];
}; };
type IntegrationOptionsValueType = IWidget<string, any>['properties'][string]; type IntegrationOptionsValueType = IWidget<string, any>['properties'][string];
@@ -23,7 +36,11 @@ export const WidgetsEditModal = ({
}: ContextModalProps<WidgetEditModalInnerProps>) => { }: ContextModalProps<WidgetEditModalInnerProps>) => {
const { t } = useTranslation([`modules/${innerProps.widgetId}`, 'common']); const { t } = useTranslation([`modules/${innerProps.widgetId}`, 'common']);
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][];
const items = Object.entries(innerProps.widgetOptions ?? {}) as [
string,
IntegrationOptionsValueType
][];
// Find the Key in the "Widgets" Object that matches the widgetId // Find the Key in the "Widgets" Object that matches the widgetId
const currentWidgetDefinition = Widgets[innerProps.widgetId as keyof typeof Widgets]; const currentWidgetDefinition = Widgets[innerProps.widgetId as keyof typeof Widgets];
@@ -67,8 +84,9 @@ export const WidgetsEditModal = ({
return ( return (
<Stack> <Stack>
{items.map(([key, value], index) => { {items.map(([key, defaultValue], index) => {
const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue; const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue;
const value = moduleProperties[key] ?? defaultValue;
if (!option) { if (!option) {
return ( return (
@@ -83,7 +101,39 @@ export const WidgetsEditModal = ({
</Alert> </Alert>
); );
} }
return WidgetOptionTypeSwitch(
option,
index,
t,
key,
value,
handleChange,
getMutliselectData
);
})}
<Group position="right">
<Button onClick={() => context.closeModal(id)} variant="light">
{t('common:cancel')}
</Button>
<Button onClick={handleSave}>{t('common:save')}</Button>
</Group>
</Stack>
);
};
// Widget switch
// Widget options are computed based on their type.
// here you can define new types for options (along with editing the widgets.d.ts file)
function WidgetOptionTypeSwitch(
option: IWidgetOptionValue,
index: number,
t: any,
key: string,
value: string | number | boolean | string[],
handleChange: (key: string, value: IntegrationOptionsValueType) => void,
getMutliselectData: (option: string) => any
) {
const { primaryColor, secondaryColor } = useColorTheme();
switch (option.type) { switch (option.type) {
case 'switch': case 'switch':
return ( return (
@@ -97,6 +147,7 @@ export const WidgetsEditModal = ({
case 'text': case 'text':
return ( return (
<TextInput <TextInput
color={primaryColor}
key={`${option.type}-${index}`} key={`${option.type}-${index}`}
label={t(`descriptor.settings.${key}.label`)} label={t(`descriptor.settings.${key}.label`)}
value={value as string} value={value as string}
@@ -106,6 +157,7 @@ export const WidgetsEditModal = ({
case 'multi-select': case 'multi-select':
return ( return (
<MultiSelect <MultiSelect
color={primaryColor}
key={`${option.type}-${index}`} key={`${option.type}-${index}`}
data={getMutliselectData(key)} data={getMutliselectData(key)}
label={t(`descriptor.settings.${key}.label`)} label={t(`descriptor.settings.${key}.label`)}
@@ -113,16 +165,32 @@ export const WidgetsEditModal = ({
onChange={(v) => handleChange(key, v)} onChange={(v) => handleChange(key, v)}
/> />
); );
case 'number':
return (
<NumberInput
color={primaryColor}
key={`${option.type}-${index}`}
label={t(`descriptor.settings.${key}.label`)}
value={value as number}
onChange={(v) => handleChange(key, v!)}
/>
);
case 'slider':
return (
<Stack spacing="xs">
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
<Slider
color={primaryColor}
key={`${option.type}-${index}`}
value={value as number}
min={option.min}
max={option.max}
step={option.step}
onChange={(v) => handleChange(key, v)}
/>
</Stack>
);
default: default:
return null; return 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 { useWrapperColumnCount } from '../../Wrappers/gridstack/store';
import { GenericTileMenu } from '../GenericTileMenu'; import { GenericTileMenu } from '../GenericTileMenu';
import { WidgetEditModalInnerProps } from './WidgetsEditModal'; import { WidgetEditModalInnerProps } from './WidgetsEditModal';
import { WidgetsRemoveModalInnerProps } from './WidgetsRemoveModal'; import { WidgetsRemoveModalInnerProps } from './WidgetsRemoveModal';
import WidgetsDefinitions from '../../../../widgets';
export type WidgetChangePositionModalInnerProps = { export type WidgetChangePositionModalInnerProps = {
widgetId: string; widgetId: string;
@@ -23,6 +24,14 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
const wrapperColumnCount = useWrapperColumnCount(); const wrapperColumnCount = useWrapperColumnCount();
if (!widget || !wrapperColumnCount) return null; if (!widget || !wrapperColumnCount) return null;
// Match widget.id with WidgetsDefinitions
// First get the keys
const keys = Object.keys(WidgetsDefinitions);
// Then find the key that matches the widget.id
const widgetDefinition = keys.find((key) => key === widget.id);
// Then get the widget definition
const widgetDefinitionObject =
WidgetsDefinitions[widgetDefinition as keyof typeof WidgetsDefinitions];
const handleDeleteClick = () => { const handleDeleteClick = () => {
openContextModalGeneric<WidgetsRemoveModalInnerProps>({ openContextModalGeneric<WidgetsRemoveModalInnerProps>({
@@ -54,7 +63,10 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
innerProps: { innerProps: {
widgetId: integration, widgetId: integration,
options: widget.properties, options: widget.properties,
// Cast as the right type for the correct widget
widgetOptions: widgetDefinitionObject.options as any,
}, },
zIndex: 5,
}); });
}; };

View File

@@ -1,5 +1,6 @@
import { Card } from '@mantine/core'; import { Card } from '@mantine/core';
import { RefObject } from 'react'; import { RefObject } from 'react';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useGridstack } from '../gridstack/use-gridstack'; import { useGridstack } from '../gridstack/use-gridstack';
import { WrapperContent } from '../WrapperContent'; import { WrapperContent } from '../WrapperContent';
@@ -30,18 +31,23 @@ const SidebarInner = ({ location }: DashboardSidebarInnerProps) => {
const { refs, apps, widgets } = useGridstack('sidebar', location); const { refs, apps, widgets } = useGridstack('sidebar', location);
const minRow = useMinRowForFullHeight(refs.wrapper); const minRow = useMinRowForFullHeight(refs.wrapper);
const {
cx,
classes: { card: cardClass },
} = useCardStyles(false);
return ( return (
<Card withBorder mih="100%" p={0} radius="lg" className={cardClass} ref={refs.wrapper}>
<div <div
className="grid-stack grid-stack-sidebar" className="grid-stack grid-stack-sidebar"
style={{ transitionDuration: '0s', height: '100%' }} style={{ transitionDuration: '0s', height: '100%' }}
data-sidebar={location} data-sidebar={location}
// eslint-disable-next-line react/no-unknown-property // eslint-disable-next-line react/no-unknown-property
gs-min-row={minRow} gs-min-row={minRow}
ref={refs.wrapper}
> >
<WrapperContent apps={apps} refs={refs} widgets={widgets} /> <WrapperContent apps={apps} refs={refs} widgets={widgets} />
</div> </div>
</Card>
); );
}; };

View File

@@ -1,4 +1,5 @@
import { Space, Stack, Text } from '@mantine/core'; import { ScrollArea, Space, Stack, Text } from '@mantine/core';
import { useViewportSize } from '@mantine/hooks';
import { useConfigContext } from '../../../config/provider'; import { useConfigContext } from '../../../config/provider';
import ConfigChanger from '../../Config/ConfigChanger'; import ConfigChanger from '../../Config/ConfigChanger';
import ConfigActions from './Config/ConfigActions'; import ConfigActions from './Config/ConfigActions';
@@ -7,6 +8,7 @@ import { SearchEngineSelector } from './SearchEngine/SearchEngineSelector';
export default function CommonSettings() { export default function CommonSettings() {
const { config } = useConfigContext(); const { config } = useConfigContext();
const { height, width } = useViewportSize();
if (!config) { if (!config) {
return ( return (
@@ -15,14 +17,15 @@ export default function CommonSettings() {
</Text> </Text>
); );
} }
return ( return (
<Stack mb="md" mr="sm"> <ScrollArea style={{ height: height - 100 }} offsetScrollbars>
<Stack>
<SearchEngineSelector searchEngine={config.settings.common.searchEngine} /> <SearchEngineSelector searchEngine={config.settings.common.searchEngine} />
<Space /> <Space />
<LanguageSelect /> <LanguageSelect />
<ConfigChanger /> <ConfigChanger />
<ConfigActions /> <ConfigActions />
</Stack> </Stack>
</ScrollArea>
); );
} }

View File

@@ -35,7 +35,7 @@ export default function ConfigActions() {
closeModal={createCopyModal.close} closeModal={createCopyModal.close}
initialConfigName={config.configProperties.name} initialConfigName={config.configProperties.name}
/> />
<Flex gap="xs" justify="stretch"> <Flex gap="xs" mt="xs" justify="stretch">
<ActionIcon className={classes.actionIcon} onClick={handleDownload} variant="default"> <ActionIcon className={classes.actionIcon} onClick={handleDownload} variant="default">
<IconDownload size={20} /> <IconDownload size={20} />
<Text size="sm">{t('buttons.download')}</Text> <Text size="sm">{t('buttons.download')}</Text>

View File

@@ -1,29 +1,11 @@
import { Group, ActionIcon, Anchor, Text } from '@mantine/core'; import { Group, Anchor, Text } from '@mantine/core';
import { IconBrandDiscord, IconBrandGithub } from '@tabler/icons';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { CURRENT_VERSION } from '../../../../data/constants';
export default function Credits() { export default function Credits() {
const { t } = useTranslation('settings/common'); const { t } = useTranslation('settings/common');
return ( return (
<Group position="center" mt="xs"> <Group position="center" mt="xs">
<Group spacing={0}>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<IconBrandGithub size={18} />
</ActionIcon>
<Text
style={{
position: 'relative',
fontSize: '0.90rem',
color: 'gray',
}}
>
{CURRENT_VERSION}
</Text>
</Group>
<Group spacing={1}>
<Text <Text
style={{ style={{
fontSize: '0.90rem', fontSize: '0.90rem',
@@ -37,12 +19,9 @@ export default function Credits() {
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }} style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
> >
ajnart ajnart
</Anchor> </Anchor>{' '}
and you !
</Text> </Text>
<ActionIcon<'a'> component="a" href="https://discord.gg/aCsmEV5RgA" size="lg">
<IconBrandDiscord size={18} />
</ActionIcon>
</Group>
</Group> </Group>
); );
} }

View File

@@ -50,7 +50,7 @@ export const SearchEngineSelector = ({ searchEngine }: Props) => {
/> />
<Paper p="md" py="sm" mb="md" withBorder> <Paper p="md" py="sm" mb="md" withBorder>
<Title order={6} mb={0}> <Title order={6} mb={0}>
Search engine configuration {t('configurationName')}
</Title> </Title>
<SearchNewTabSwitch defaultValue={searchEngine.properties.openInNewTab} /> <SearchNewTabSwitch defaultValue={searchEngine.properties.openInNewTab} />

View File

@@ -1,4 +1,6 @@
import { Button, ScrollArea, Stack } from '@mantine/core'; import { Button, ScrollArea, Stack } from '@mantine/core';
import { useViewportSize } from '@mantine/hooks';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../config/provider'; import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store'; import { useConfigStore } from '../../../config/store';
import { LayoutSelector } from './Layout/LayoutSelector'; import { LayoutSelector } from './Layout/LayoutSelector';
@@ -14,20 +16,14 @@ import { ShadeSelector } from './Theme/ShadeSelector';
export default function CustomizationSettings() { export default function CustomizationSettings() {
const { config, name: configName } = useConfigContext(); const { config, name: configName } = useConfigContext();
const { t } = useTranslation('common');
const { height, width } = useViewportSize();
const { updateConfig } = useConfigStore(); const { updateConfig } = useConfigStore();
const saveConfiguration = () => {
if (!configName || !config) {
return;
}
updateConfig(configName, (_) => config, false, true);
};
return ( return (
<Stack mb="md" mr="sm" mt="xs"> <ScrollArea style={{ height: height - 100 }} offsetScrollbars>
<ScrollArea style={{ height: '76vh' }} offsetScrollbars> <Stack mt="xs" mb="md" spacing="xs">
<LayoutSelector defaultLayout={config?.settings.customization.layout} /> <LayoutSelector defaultLayout={config?.settings.customization.layout} />
<PageTitleChanger defaultValue={config?.settings.customization.pageTitle} /> <PageTitleChanger defaultValue={config?.settings.customization.pageTitle} />
<MetaTitleChanger defaultValue={config?.settings.customization.metaTitle} /> <MetaTitleChanger defaultValue={config?.settings.customization.metaTitle} />
@@ -45,11 +41,7 @@ export default function CustomizationSettings() {
/> />
<ShadeSelector defaultValue={config?.settings.customization.colors.shade} /> <ShadeSelector defaultValue={config?.settings.customization.colors.shade} />
<OpacitySelector defaultValue={config?.settings.customization.appOpacity} /> <OpacitySelector defaultValue={config?.settings.customization.appOpacity} />
</ScrollArea>
<Button onClick={saveConfiguration} variant="light">
Save Customizations
</Button>
</Stack> </Stack>
</ScrollArea>
); );
} }

View File

@@ -32,7 +32,7 @@ export function OpacitySelector({ defaultValue }: OpacitySelectorProps) {
}; };
return ( return (
<Stack spacing="xs"> <Stack spacing="xs" mb="md">
<Text>{t('label')}</Text> <Text>{t('label')}</Text>
<Slider <Slider
defaultValue={opacity} defaultValue={opacity}

View File

@@ -1,8 +1,9 @@
import { Drawer, ScrollArea, Tabs, Title } from '@mantine/core'; import { Drawer, Tabs, Title } from '@mantine/core';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../config/provider';
import { useConfigStore } from '../../config/store';
import CommonSettings from './Common/CommonSettings'; import CommonSettings from './Common/CommonSettings';
import Credits from './Common/Credits';
import CustomizationSettings from './Customization/CustomizationSettings'; import CustomizationSettings from './Customization/CustomizationSettings';
function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) { function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) {
@@ -15,9 +16,7 @@ function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string })
<Tabs.Tab value="customization">{t('tabs.customizations')}</Tabs.Tab> <Tabs.Tab value="customization">{t('tabs.customizations')}</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel data-autofocus value="common"> <Tabs.Panel data-autofocus value="common">
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
<CommonSettings /> <CommonSettings />
</ScrollArea>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="customization"> <Tabs.Panel value="customization">
<CustomizationSettings /> <CustomizationSettings />
@@ -37,6 +36,8 @@ export function SettingsDrawer({
newVersionAvailable, newVersionAvailable,
}: SettingsDrawerProps & { newVersionAvailable: string }) { }: SettingsDrawerProps & { newVersionAvailable: string }) {
const { t } = useTranslation('settings/common'); const { t } = useTranslation('settings/common');
const { config, name: configName } = useConfigContext();
const { updateConfig } = useConfigStore();
return ( return (
<Drawer <Drawer
@@ -45,10 +46,16 @@ export function SettingsDrawer({
position="right" position="right"
title={<Title order={5}>{t('title')}</Title>} title={<Title order={5}>{t('title')}</Title>}
opened={opened} opened={opened}
onClose={closeDrawer} onClose={() => {
closeDrawer();
if (!configName || !config) {
return;
}
updateConfig(configName, (_) => config, false, true);
}}
> >
<SettingsMenu newVersionAvailable={newVersionAvailable} /> <SettingsMenu newVersionAvailable={newVersionAvailable} />
<Credits />
</Drawer> </Drawer>
); );
} }

View File

@@ -15,9 +15,8 @@ export const AddElementAction = ({ type }: AddElementActionProps) => {
return ( return (
<Tooltip label={t('actionIcon.tooltip')} withinPortal withArrow> <Tooltip label={t('actionIcon.tooltip')} withinPortal withArrow>
<Button <Button
variant="default"
radius="md" radius="md"
color="blue" variant="default"
style={{ height: 43 }} style={{ height: 43 }}
onClick={() => onClick={() =>
openContextModal({ openContextModal({

View File

@@ -1,10 +1,11 @@
import axios from 'axios'; import axios from 'axios';
import Consola from 'consola'; import Consola from 'consola';
import { ActionIcon, Button, Group, Popover, Text } from '@mantine/core'; import { ActionIcon, Button, Group, Title, Tooltip } from '@mantine/core';
import { IconEditCircle, IconEditCircleOff, IconX } from '@tabler/icons'; import { IconEditCircle, IconEditCircleOff } from '@tabler/icons';
import { getCookie } from 'cookies-next'; import { getCookie } from 'cookies-next';
import { Trans, useTranslation } from 'next-i18next'; import { Trans, useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { hideNotification, showNotification } from '@mantine/notifications';
import { useConfigContext } from '../../../../../config/provider'; import { useConfigContext } from '../../../../../config/provider';
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan'; import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
@@ -13,7 +14,6 @@ import { AddElementAction } from '../AddElementAction/AddElementAction';
export const ToggleEditModeAction = () => { export const ToggleEditModeAction = () => {
const { enabled, toggleEditMode } = useEditModeStore(); const { enabled, toggleEditMode } = useEditModeStore();
const [popoverManuallyHidden, setPopoverManuallyHidden] = useState<boolean>();
const { t } = useTranslation('layout/header/actions/toggle-edit-mode'); const { t } = useTranslation('layout/header/actions/toggle-edit-mode');
@@ -29,21 +29,44 @@ export const ToggleEditModeAction = () => {
const toggleButtonClicked = () => { const toggleButtonClicked = () => {
toggleEditMode(); toggleEditMode();
if (!enabled) {
showNotification({
styles: (theme) => ({
root: {
backgroundColor: theme.colors.orange[7],
borderColor: theme.colors.orange[7],
setPopoverManuallyHidden(false); '&::before': { backgroundColor: theme.white },
},
title: { color: theme.white },
description: { color: theme.white },
closeButton: {
color: theme.white,
'&:hover': { backgroundColor: theme.colors.orange[7] },
},
}),
radius: 'md',
id: 'toggle-edit-mode',
autoClose: false,
title: <Title order={4}>{t('popover.title')}</Title>,
message: <Trans i18nKey="layout/header/actions/toggle-edit-mode:popover.text" />,
});
} else {
hideNotification('toggle-edit-mode');
}
}; };
const ToggleButtonDesktop = () => ( const ToggleButtonDesktop = () => (
<Tooltip label={enabled ? t('button.enabled') : t('button.disabled')}>
<Button <Button
onClick={() => toggleButtonClicked()} onClick={() => toggleButtonClicked()}
leftIcon={enabled ? <IconEditCircleOff /> : <IconEditCircle />}
variant="default"
radius="md" radius="md"
color="blue" variant="default"
style={{ height: 43 }} style={{ height: 43 }}
> >
<Text>{enabled ? t('button.enabled') : t('button.disabled')}</Text> {enabled ? <IconEditCircleOff /> : <IconEditCircle />}
</Button> </Button>
</Tooltip>
); );
const ToggleActionIconMobile = () => ( const ToggleActionIconMobile = () => (
@@ -59,13 +82,7 @@ export const ToggleEditModeAction = () => {
); );
return ( return (
<Popover <>
opened={enabled && !smallerThanSm && !popoverManuallyHidden}
width="target"
transition="scale"
zIndex={199}
>
<Popover.Target>
{smallerThanSm ? ( {smallerThanSm ? (
enabled ? ( enabled ? (
<Group style={{ flexWrap: 'nowrap' }}> <Group style={{ flexWrap: 'nowrap' }}>
@@ -83,21 +100,6 @@ export const ToggleEditModeAction = () => {
) : ( ) : (
<ToggleButtonDesktop /> <ToggleButtonDesktop />
)} )}
</Popover.Target> </>
<Popover.Dropdown p={4} px={6} mt={-5}>
<div style={{ position: 'absolute', top: 2, right: 2 }}>
<ActionIcon onClick={() => setPopoverManuallyHidden(true)}>
<IconX size={18} />
</ActionIcon>
</div>
<Text align="center" size="sm">
<Text weight="bold">{t('popover.title')}</Text>
<Text>
<Trans i18nKey="layout/header/actions/toggle-edit-mode:popover.text" />
</Text>
</Text>
</Popover.Dropdown>
</Popover>
); );
}; };

View File

@@ -4,7 +4,7 @@ import { CURRENT_VERSION, REPO_URL } from '../../../../data/constants';
import { useConfigContext } from '../../../config/provider'; import { useConfigContext } from '../../../config/provider';
import { Logo } from '../Logo'; import { Logo } from '../Logo';
import { useCardStyles } from '../useCardStyles'; import { useCardStyles } from '../useCardStyles';
import DockerMenuButton from './Actions/Docker/DockerModule'; import DockerMenuButton from '../../../modules/Docker/DockerModule';
import { ToggleEditModeAction } from './Actions/ToggleEditMode/ToggleEditMode'; import { ToggleEditModeAction } from './Actions/ToggleEditMode/ToggleEditMode';
import { Search } from './Search'; import { Search } from './Search';
import { SettingsMenu } from './SettingsMenu'; import { SettingsMenu } from './SettingsMenu';

View File

@@ -55,9 +55,6 @@ export function Search() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [debounced, cancel] = useDebouncedValue(searchQuery, 250); const [debounced, cancel] = useDebouncedValue(searchQuery, 250);
// TODO: ask manuel-rw about overseerr
// Answer: We can simply check if there is a app of the type overseer and display results if there is one.
// Overseerr is not use anywhere else, so it makes no sense to add a standalone toggle for displaying results
const isOverseerrEnabled = config?.apps.some( const isOverseerrEnabled = config?.apps.some(
(x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr' (x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr'
); );

View File

@@ -1,4 +1,4 @@
import { ActionIcon, Badge, Menu } from '@mantine/core'; import { Badge, Button, Menu } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { IconInfoCircle, IconMenu2, IconSettings } from '@tabler/icons'; import { IconInfoCircle, IconMenu2, IconSettings } from '@tabler/icons';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
@@ -15,9 +15,9 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
<> <>
<Menu width={250}> <Menu width={250}>
<Menu.Target> <Menu.Target>
<ActionIcon variant="default" radius="md" size="xl" color="blue"> <Button variant="default" radius="md" style={{ height: 43 }}>
<IconMenu2 /> <IconMenu2 />
</ActionIcon> </Button>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<ColorSchemeSwitch /> <ColorSchemeSwitch />

View File

@@ -8,7 +8,6 @@ interface smallAppItem {
export default function SmallAppItem(props: any) { export default function SmallAppItem(props: any) {
const { app }: { app: smallAppItem } = props; const { app }: { app: smallAppItem } = props;
// TODO : Use Next/link
return ( return (
<Group> <Group>
{app.icon && <Avatar src={app.icon} />} {app.icon && <Avatar src={app.icon} />}

View File

@@ -6,6 +6,7 @@ const POLLING_INTERVAL = 2000;
interface TorrentsDataRequestParams { interface TorrentsDataRequestParams {
appId: string; appId: string;
refreshInterval: number;
} }
export const useGetTorrentData = (params: TorrentsDataRequestParams) => export const useGetTorrentData = (params: TorrentsDataRequestParams) =>
@@ -15,7 +16,7 @@ export const useGetTorrentData = (params: TorrentsDataRequestParams) =>
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchInterval(_: any, query: Query) { refetchInterval(_: any, query: Query) {
if (query.state.fetchFailureCount < 3) { if (query.state.fetchFailureCount < 3) {
return 5000; return params.refreshInterval;
} }
return false; return false;
}, },

View File

@@ -16,8 +16,11 @@ import { useTranslation } from 'next-i18next';
import { useState } from 'react'; import { useState } from 'react';
import { TFunction } from 'react-i18next'; import { TFunction } from 'react-i18next';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { openContextModalGeneric } from '../../../../../tools/mantineModalManagerExtensions'; import { useConfigContext } from '../../config/provider';
import { AppType } from '../../../../../types/app'; import { tryMatchService } from '../../tools/addToHomarr';
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
import { AppType } from '../../types/app';
import { appTileDefinition } from '../../components/Dashboard/Tiles/Apps/AppTile';
let t: TFunction<'modules/docker', undefined>; let t: TFunction<'modules/docker', undefined>;
@@ -68,6 +71,8 @@ export interface ContainerActionBarProps {
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) { export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
t = useTranslation('modules/docker').t; t = useTranslation('modules/docker').t;
const [isLoading, setisLoading] = useState(false); const [isLoading, setisLoading] = useState(false);
const { name: configName, config } = useConfigContext();
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
return ( return (
<Group spacing="xs"> <Group spacing="xs">
@@ -158,61 +163,40 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
radius="md" radius="md"
disabled={selected.length === 0 || selected.length > 1} disabled={selected.length === 0 || selected.length > 1}
onClick={() => { onClick={() => {
const app = tryMatchService(selected.at(0)!);
const containerUrl = `http://localhost:${selected[0].Ports[0].PublicPort}`; const containerUrl = `http://localhost:${selected[0].Ports[0].PublicPort}`;
openContextModalGeneric<{ service: AppType }>({ openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
modal: 'editService', modal: 'editApp',
innerProps: { innerProps: {
service: { app: {
id: uuidv4(), id: uuidv4(),
name: selected[0].Names[0], name: app.name ? app.name : selected[0].Names[0].substring(1),
url: containerUrl, url: containerUrl,
appearance: { appearance: {
iconUrl: '/imgs/logo/logo.png', // TODO: find icon automatically iconUrl: app.icon ? app.icon : '/imgs/logo/logo.png',
}, },
network: { network: {
enabledStatusChecker: false, enabledStatusChecker: true,
okStatus: [], okStatus: [200],
}, },
behaviour: { behaviour: {
isOpeningNewTab: true, isOpeningNewTab: true,
externalUrl: '', externalUrl: '',
}, },
area: { area: {
type: 'sidebar', // TODO: Set the wrapper automatically type: 'wrapper',
properties: { properties: {
location: 'right', id: getLowestWrapper()?.id ?? 'default',
}, },
}, },
shape: { shape: {
lg: {
location: { location: {
x: 0, x: 0,
y: 0, y: 0,
}, },
size: { size: {
height: 1, width: appTileDefinition.minWidth,
width: 1, height: appTileDefinition.minHeight,
},
},
md: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
},
sm: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
}, },
}, },
integration: { integration: {
@@ -220,7 +204,9 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
properties: [], properties: [],
}, },
}, },
allowAppNamePropagation: true,
}, },
size: 'xl',
}); });
}} }}
> >

View File

@@ -5,7 +5,7 @@ import axios from 'axios';
import Docker from 'dockerode'; import Docker from 'dockerode';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useConfigContext } from '../../../../../config/provider'; import { useConfigContext } from '../../config/provider';
import ContainerActionBar from './ContainerActionBar'; import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable'; import DockerTable from './DockerTable';
@@ -60,6 +60,7 @@ export default function DockerMenuButton(props: any) {
opened={opened} opened={opened}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
padding="xl" padding="xl"
position="right"
size="full" size="full"
title={<ContainerActionBar selected={selection} reload={reload} />} title={<ContainerActionBar selected={selection} reload={reload} />}
> >

View File

@@ -118,7 +118,6 @@ export default function DockerTable({
icon={<IconSearch size={14} />} icon={<IconSearch size={14} />}
value={search} value={search}
onChange={handleSearchChange} onChange={handleSearchChange}
disabled={usedContainers.length === 0}
/> />
<Table ref={ref} captionSide="bottom" highlightOnHover verticalSpacing="sm"> <Table ref={ref} captionSide="bottom" highlightOnHover verticalSpacing="sm">
<thead> <thead>

View File

@@ -45,13 +45,21 @@ function Put(req: NextApiRequest, res: NextApiResponse) {
(previousProperty) => previousProperty.field === property.field (previousProperty) => previousProperty.field === property.field
); );
if (property.value !== undefined && property.value !== null) {
Consola.info(
'Detected credential change of private secret. Value will be overwritten in configuration'
);
return { return {
field: property.field, field: property.field,
type: property.type, type: property.type,
value: value: property.value,
property.value !== undefined || property.value === null };
? property.value }
: previousProperty?.value,
return {
field: property.field,
type: property.type,
value: previousProperty?.value,
}; };
}), }),
}, },

View File

@@ -3,7 +3,7 @@ import { getCookie } from 'cookies-next';
import axios from 'axios'; import axios from 'axios';
import Consola from 'consola'; import Consola from 'consola';
import { getConfig } from '../../../../tools/config/getConfig'; import { getConfig } from '../../../../tools/config/getConfig';
import { MediaType } from '../../../../modules/overseerr/SearchResult'; import type { MediaType } from '../../../../modules/overseerr/SearchResult';
async function Get(req: NextApiRequest, res: NextApiResponse) { async function Get(req: NextApiRequest, res: NextApiResponse) {
// Get the slug of the request // Get the slug of the request

View File

@@ -17,7 +17,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
} else if (error.code === 'ECONNABORTED') { } else if (error.code === 'ECONNABORTED') {
res.status(408).json('Request Timeout'); res.status(408).json('Request Timeout');
} else { } else {
res.status(500).json('Server Error'); res.status(error.response ? error.response.status : 500).json('Server Error');
} }
}); });
// // Make a request to the URL // // Make a request to the URL

View File

@@ -81,7 +81,7 @@
} }
.grid-stack > .grid-stack-item > .grid-stack-item-content { .grid-stack > .grid-stack-item > .grid-stack-item-content {
overflow-y: hidden; overflow-y: auto;
} }
.grid-stack.grid-stack-animate { .grid-stack.grid-stack-animate {

View File

@@ -1,21 +1,21 @@
export const StatusCodes = [ export const StatusCodes = [
{ value: '200', label: '200 - OK', group: 'Sucessful responses' }, { value: 200, label: '200 - OK', group: 'Sucessful responses' },
{ value: '204', label: '204 - No Content', group: 'Sucessful responses' }, { value: 204, label: '204 - No Content', group: 'Sucessful responses' },
{ value: '301', label: '301 - Moved Permanently', group: 'Redirection responses' }, { value: 301, label: '301 - Moved Permanently', group: 'Redirection responses' },
{ value: '302', label: '302 - Found / Moved Temporarily', group: 'Redirection responses' }, { value: 302, label: '302 - Found / Moved Temporarily', group: 'Redirection responses' },
{ value: '304', label: '304 - Not Modified', group: 'Redirection responses' }, { value: 304, label: '304 - Not Modified', group: 'Redirection responses' },
{ value: '307', label: '307 - Temporary Redirect', group: 'Redirection responses' }, { value: 307, label: '307 - Temporary Redirect', group: 'Redirection responses' },
{ value: '308', label: '308 - Permanent Redirect', group: 'Redirection responses' }, { value: 308, label: '308 - Permanent Redirect', group: 'Redirection responses' },
{ value: '400', label: '400 - Bad Request', group: 'Client error responses' }, { value: 400, label: '400 - Bad Request', group: 'Client error responses' },
{ value: '401', label: '401 - Unauthorized', group: 'Client error responses' }, { value: 401, label: '401 - Unauthorized', group: 'Client error responses' },
{ value: '403', label: '403 - Forbidden', group: 'Client error responses' }, { value: 403, label: '403 - Forbidden', group: 'Client error responses' },
{ value: '404', label: '404 - Not Found', group: 'Client error responses' }, { value: 404, label: '404 - Not Found', group: 'Client error responses' },
{ value: '405', label: '405 - Method Not Allowed', group: 'Client error responses' }, { value: 405, label: '405 - Method Not Allowed', group: 'Client error responses' },
{ value: '408', label: '408 - Request Timeout', group: 'Client error responses' }, { value: 408, label: '408 - Request Timeout', group: 'Client error responses' },
{ value: '410', label: '410 - Gone', group: 'Client error responses' }, { value: 410, label: '410 - Gone', group: 'Client error responses' },
{ value: '429', label: '429 - Too Many Requests', group: 'Client error responses' }, { value: 429, label: '429 - Too Many Requests', group: 'Client error responses' },
{ value: '500', label: '500 - Internal Server Error', group: 'Server error responses' }, { value: 500, label: '500 - Internal Server Error', group: 'Server error responses' },
{ value: '502', label: '502 - Bad Gateway', group: 'Server error responses' }, { value: 502, label: '502 - Bad Gateway', group: 'Server error responses' },
{ value: '503', label: '503 - Service Unavailable', group: 'Server error responses' }, { value: 503, label: '503 - Service Unavailable', group: 'Server error responses' },
{ value: '054', label: '504 - Gateway Timeout Error', group: 'Server error responses' }, { value: 504, label: '504 - Gateway Timeout Error', group: 'Server error responses' },
]; ];

View File

@@ -6,6 +6,7 @@ export const dashboardNamespaces = [
'layout/modals/change-position', 'layout/modals/change-position',
'layout/modals/about', 'layout/modals/about',
'layout/header/actions/toggle-edit-mode', 'layout/header/actions/toggle-edit-mode',
'layout/mobile/drawer',
'settings/common', 'settings/common',
'settings/general/theme-selector', 'settings/general/theme-selector',
'settings/general/config-changer', 'settings/general/config-changer',

View File

@@ -102,6 +102,7 @@ export const portmap = [
{ name: 'nzbget', value: '6789' }, { name: 'nzbget', value: '6789' },
]; ];
//TODO: Fix this to be used in the docker add to homarr button
export const MatchingImages: { export const MatchingImages: {
image: string; image: string;
type: ServiceType; type: ServiceType;

View File

@@ -52,12 +52,14 @@ export type ConfigAppIntegrationType = Omit<AppIntegrationType, 'properties'> &
}; };
export type AppIntegrationPropertyType = { export type AppIntegrationPropertyType = {
type: 'private' | 'public'; type: AppIntegrationPropertyAccessabilityType;
field: IntegrationField; field: IntegrationField;
value?: string | null; value?: string | null;
isDefined: boolean; isDefined: boolean;
}; };
export type AppIntegrationPropertyAccessabilityType = 'private' | 'public';
type ConfigAppIntegrationPropertyType = Omit<AppIntegrationPropertyType, 'isDefined'>; type ConfigAppIntegrationPropertyType = Omit<AppIntegrationPropertyType, 'isDefined'>;
export type IntegrationField = 'apiKey' | 'password' | 'username'; export type IntegrationField = 'apiKey' | 'password' | 'username';

View File

@@ -18,7 +18,7 @@ export const BitTorrrentQueueItem = ({ torrent }: BitTorrentQueueItemProps) => {
return ( return (
<tr key={torrent.id}> <tr key={torrent.id}>
<td> <td>
<Tooltip position="top" label={torrent.name}> <Tooltip position="top" withinPortal label={torrent.name}>
<Text <Text
style={{ style={{
maxWidth: '30vw', maxWidth: '30vw',

View File

@@ -1,6 +1,7 @@
import { NormalizedTorrent, TorrentState } from '@ctrl/shared-torrent'; import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { import {
Center, Center,
Flex,
Group, Group,
Loader, Loader,
ScrollArea, ScrollArea,
@@ -12,6 +13,9 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useElementSize } from '@mantine/hooks'; import { useElementSize } from '@mantine/hooks';
import { IconFileDownload } from '@tabler/icons'; import { IconFileDownload } from '@tabler/icons';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useConfigContext } from '../../config/provider'; import { useConfigContext } from '../../config/provider';
@@ -21,6 +25,9 @@ import { defineWidget } from '../helper';
import { IWidget } from '../widgets'; import { IWidget } from '../widgets';
import { BitTorrrentQueueItem } from './BitTorrentQueueItem'; import { BitTorrrentQueueItem } from './BitTorrentQueueItem';
dayjs.extend(duration);
dayjs.extend(relativeTime);
const downloadAppTypes: AppIntegrationType['type'][] = ['deluge', 'qBittorrent', 'transmission']; const downloadAppTypes: AppIntegrationType['type'][] = ['deluge', 'qBittorrent', 'transmission'];
const definition = defineWidget({ const definition = defineWidget({
@@ -35,6 +42,13 @@ const definition = defineWidget({
type: 'switch', type: 'switch',
defaultValue: true, defaultValue: true,
}, },
refreshInterval: {
type: 'slider',
defaultValue: 1,
min: 1,
max: 60,
step: 1,
},
}, },
gridstack: { gridstack: {
minWidth: 4, minWidth: 4,
@@ -62,7 +76,10 @@ function BitTorrentTile({ widget }: BitTorrentTileProps) {
[]; [];
const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id); const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id);
const { data, isFetching, isError } = useGetTorrentData({ appId: selectedAppId! }); const { data, isError, isInitialLoading, dataUpdatedAt } = useGetTorrentData({
appId: selectedAppId!,
refreshInterval: widget.properties.refreshInterval * 1000,
});
useEffect(() => { useEffect(() => {
if (!selectedAppId && downloadApps.length) { if (!selectedAppId && downloadApps.length) {
@@ -92,9 +109,15 @@ function BitTorrentTile({ widget }: BitTorrentTileProps) {
); );
} }
if (isFetching) { if (isInitialLoading) {
return ( return (
<Stack align="center"> <Stack
align="center"
justify="center"
style={{
height: '100%',
}}
>
<Loader /> <Loader />
<Stack align="center" spacing={0}> <Stack align="center" spacing={0}>
<Text>{t('card.loading.title')}</Text> <Text>{t('card.loading.title')}</Text>
@@ -124,8 +147,13 @@ function BitTorrentTile({ widget }: BitTorrentTileProps) {
return true; return true;
}; };
const difference = new Date().getTime() - dataUpdatedAt;
const duration = dayjs.duration(difference, 'ms');
const humanizedDuration = duration.humanize();
return ( return (
<ScrollArea sx={{ height: 300, width: '100%' }}> <Flex direction="column" sx={{ height: '100%' }}>
<ScrollArea sx={{ height: '100%', width: '100%' }}>
<Table highlightOnHover p="sm"> <Table highlightOnHover p="sm">
<thead> <thead>
<tr> <tr>
@@ -144,6 +172,10 @@ function BitTorrentTile({ widget }: BitTorrentTileProps) {
</tbody> </tbody>
</Table> </Table>
</ScrollArea> </ScrollArea>
<Text color="dimmed" size="xs">
Last updated {humanizedDuration} ago
</Text>
</Flex>
); );
} }

View File

@@ -54,9 +54,11 @@ function DashDotTile({ widget }: DashDotTileProps) {
const { classes } = useDashDotTileStyles(); const { classes } = useDashDotTileStyles();
const { t } = useTranslation('modules/dashdot'); const { t } = useTranslation('modules/dashdot');
const dashDotUrl = widget?.properties.url; const dashDotUrl = widget.properties.url;
const { data: info } = useDashDotInfo({ dashDotUrl }); const { data: info } = useDashDotInfo({
dashDotUrl,
});
const graphs = widget?.properties.graphs.map((g) => ({ const graphs = widget?.properties.graphs.map((g) => ({
id: g, id: g,
@@ -112,6 +114,7 @@ function DashDotTile({ widget }: DashDotTileProps) {
const useDashDotInfo = ({ dashDotUrl }: { dashDotUrl: string }) => { const useDashDotInfo = ({ dashDotUrl }: { dashDotUrl: string }) => {
const { name: configName } = useConfigContext(); const { name: configName } = useConfigContext();
return useQuery({ return useQuery({
refetchInterval: 50000,
queryKey: [ queryKey: [
'dashdot/info', 'dashdot/info',
{ {

View File

@@ -1,4 +1,4 @@
import { Center, Stack, Text, Title } from '@mantine/core'; import { Stack, Text, Title } from '@mantine/core';
import { IconClock } from '@tabler/icons'; import { IconClock } from '@tabler/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';

View File

@@ -5,6 +5,4 @@ import { IWidgetDefinition } from './widgets';
// The options of IWidgetDefinition are so heavily typed that it even used 'true' as type // The options of IWidgetDefinition are so heavily typed that it even used 'true' as type
export const defineWidget = <TKey extends string, TOptions extends IWidgetDefinition<TKey>>( export const defineWidget = <TKey extends string, TOptions extends IWidgetDefinition<TKey>>(
options: TOptions options: TOptions
) => { ) => options;
return options;
};

View File

@@ -46,16 +46,20 @@ const definition = defineWidget({
}, },
}); });
export type IWeatherWidget = IWidget<typeof definition['id'], typeof definition>; export type IUsenetWidget = IWidget<typeof definition['id'], typeof definition>;
interface UseNetTileProps {} interface UseNetTileProps {
widget: IUsenetWidget;
}
function UseNetTile({}: UseNetTileProps) { function UseNetTile({ widget }: UseNetTileProps) {
const { t } = useTranslation('modules/usenet'); const { t } = useTranslation('modules/usenet');
const { config } = useConfigContext(); const { config } = useConfigContext();
const downloadApps = const downloadApps =
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 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! });
@@ -84,9 +88,6 @@ function UseNetTile({}: UseNetTileProps) {
return null; return null;
} }
const { ref, width, height } = useElementSize();
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
return ( return (
<Tabs keepMounted={false} defaultValue="queue"> <Tabs keepMounted={false} defaultValue="queue">
<Tabs.List ref={ref} mb="md" style={{ flex: 1 }} grow> <Tabs.List ref={ref} mb="md" style={{ flex: 1 }} grow>

View File

@@ -4,8 +4,8 @@ import {
Code, Code,
Group, Group,
Pagination, Pagination,
ScrollArea,
Skeleton, Skeleton,
Stack,
Table, Table,
Text, Text,
Title, Title,
@@ -28,7 +28,7 @@ interface UsenetHistoryListProps {
appId: string; appId: string;
} }
const PAGE_SIZE = 10; const PAGE_SIZE = 13;
export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ appId }) => { export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ appId }) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -39,7 +39,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
const { data, isLoading, isError, error } = useGetUsenetHistory({ const { data, isLoading, isError, error } = useGetUsenetHistory({
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE, offset: (page - 1) * PAGE_SIZE,
appId: appId, appId,
}); });
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE); const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);
@@ -81,8 +81,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
} }
return ( return (
<> <Stack justify="space-around" spacing="xs">
<ScrollArea style={{ flex: 1 }}>
<Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}> <Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}>
<thead> <thead>
<tr> <tr>
@@ -122,9 +121,9 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
))} ))}
</tbody> </tbody>
</Table> </Table>
</ScrollArea>
{totalPages > 1 && ( {totalPages > 1 && (
<Pagination <Pagination
noWrap
size="sm" size="sm"
position="center" position="center"
mt="md" mt="md"
@@ -133,6 +132,6 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
onChange={setPage} onChange={setPage}
/> />
)} )}
</> </Stack>
); );
}; };

View File

@@ -1,6 +1,7 @@
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
Button,
Center, Center,
Code, Code,
Group, Group,
@@ -8,6 +9,7 @@ import {
Progress, Progress,
ScrollArea, ScrollArea,
Skeleton, Skeleton,
Stack,
Table, Table,
Text, Text,
Title, Title,
@@ -30,7 +32,7 @@ interface UsenetQueueListProps {
appId: string; appId: string;
} }
const PAGE_SIZE = 10; const PAGE_SIZE = 13;
export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId }) => { export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId }) => {
const theme = useMantineTheme(); const theme = useMantineTheme();
@@ -38,13 +40,13 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
const progressbarBreakpoint = theme.breakpoints.xs; const progressbarBreakpoint = theme.breakpoints.xs;
const progressBreakpoint = 400; const progressBreakpoint = 400;
const sizeBreakpoint = 300; const sizeBreakpoint = 300;
const { ref, width, height } = useElementSize(); const { ref, width } = useElementSize();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const { data, isLoading, isError, error } = useGetUsenetDownloads({ const { data, isLoading, isError, error } = useGetUsenetDownloads({
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE, offset: (page - 1) * PAGE_SIZE,
appId: appId, appId,
}); });
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE); const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);
@@ -85,9 +87,9 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
); );
} }
// TODO: Set ScollArea dynamic height based on the widget size
return ( return (
<> <Stack justify="space-around" spacing="xs">
<ScrollArea style={{ flex: 1 }}>
<Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}> <Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}>
<thead> <thead>
<tr> <tr>
@@ -171,17 +173,16 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
))} ))}
</tbody> </tbody>
</Table> </Table>
</ScrollArea>
{totalPages > 1 && ( {totalPages > 1 && (
<Pagination <Pagination
noWrap
size="sm" size="sm"
position="center" position="center"
mt="md"
total={totalPages} total={totalPages}
page={page} page={page}
onChange={setPage} onChange={setPage}
/> />
)} )}
</> </Stack>
); );
}; };

View File

@@ -1,6 +1,5 @@
import { IconSun, TablerIcon } from '@tabler/icons'; import { TablerIcon } from '@tabler/icons';
import React from 'react'; import React from 'react';
import { BaseTileProps } from '../components/Dashboard/Tiles/type';
// Type of widgets which are safed to config // Type of widgets which are safed to config
export type IWidget<TKey extends string, TDefinition extends IWidgetDefinition> = { export type IWidget<TKey extends string, TDefinition extends IWidgetDefinition> = {
@@ -32,6 +31,7 @@ export type IWidgetOptionValue =
| IMultiSelectOptionValue | IMultiSelectOptionValue
| ISwitchOptionValue | ISwitchOptionValue
| ITextInputOptionValue | ITextInputOptionValue
| ISliderInputOptionValue
| INumberInputOptionValue; | INumberInputOptionValue;
// will show a multi-select with specified data // will show a multi-select with specified data
@@ -56,7 +56,16 @@ export type ITextInputOptionValue = {
// will show a number-input // will show a number-input
export type INumberInputOptionValue = { export type INumberInputOptionValue = {
type: 'number'; type: 'number';
defaultValue: string; defaultValue: number;
};
// will show a slider-input
export type ISliderInputOptionValue = {
type: 'slider';
defaultValue: number;
min: number;
max: number;
step: number;
}; };
// is used to type the widget definitions which will be used to display all widgets // is used to type the widget definitions which will be used to display all widgets