diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index a84bac79d22..ad88029a180 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -14,10 +14,3 @@
### Screenshot _(if applicable)_
> If you've introduced any significant UI changes, please include a screenshot.
-
-### Code Quality Checklist _(Please complete)_
-- [ ] All changes are backwards compatible
-- [ ] There are no (new) build warnings or errors
-- [ ] _(If a new config option is added)_ Attribute is outlined in the schema and documented
-- [ ] _(If a new dependency is added)_ Package is essential, and has been checked out for security or performance
-- [ ] Bumps version, if new feature added
diff --git a/.github/workflows/docker_dev.yml b/.github/workflows/docker_dev.yml
index 46c00e799b7..3a43a5ab6cd 100644
--- a/.github/workflows/docker_dev.yml
+++ b/.github/workflows/docker_dev.yml
@@ -16,7 +16,7 @@ on:
workflow_dispatch:
inputs:
tags:
- requierd: true
+ required: true
description: 'Tags to deploy to'
env:
diff --git a/Dockerfile b/Dockerfile
index 98fe3bdbea6..39faff15669 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,5 +9,6 @@ COPY /.next/standalone ./
COPY /.next/static ./.next/static
EXPOSE 7575
ENV PORT 7575
+RUN apk add tzdata
VOLUME /app/data/configs
CMD ["node", "server.js"]
diff --git a/README.md b/README.md
index 7b5045425bb..72aa27cd17d 100644
--- a/README.md
+++ b/README.md
@@ -198,7 +198,4 @@ SOFTWARE.
Thank you for visiting! For more information read the wiki!
-
-
-
diff --git a/data/constants.ts b/data/constants.ts
index 9151e0e49da..17a217ada4a 100644
--- a/data/constants.ts
+++ b/data/constants.ts
@@ -1,2 +1,2 @@
export const REPO_URL = 'ajnart/homarr';
-export const CURRENT_VERSION = 'v0.6.0';
+export const CURRENT_VERSION = 'v0.7.0';
diff --git a/package.json b/package.json
index 94be675e0a8..622116f36bb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "homarr",
- "version": "0.6.0",
+ "version": "0.7.0",
"description": "Homarr - A homepage for your server.",
"repository": {
"type": "git",
@@ -24,26 +24,27 @@
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
},
"dependencies": {
- "@ctrl/deluge": "^4.0.0",
+ "@ctrl/deluge": "^4.1.0",
"@ctrl/qbittorrent": "^4.0.0",
"@ctrl/shared-torrent": "^4.1.0",
+ "@ctrl/transmission": "^4.1.1",
"@dnd-kit/core": "^6.0.1",
"@dnd-kit/sortable": "^7.0.0",
"@dnd-kit/utilities": "^3.2.0",
- "@mantine/core": "^4.2.6",
- "@mantine/dates": "^4.2.6",
- "@mantine/dropzone": "^4.2.6",
- "@mantine/form": "^4.2.6",
- "@mantine/hooks": "^4.2.6",
- "@mantine/next": "^4.2.6",
- "@mantine/notifications": "^4.2.6",
- "@mantine/prism": "^4.2.6",
+ "@mantine/core": "^4.2.8",
+ "@mantine/dates": "^4.2.8",
+ "@mantine/dropzone": "^4.2.8",
+ "@mantine/form": "^4.2.8",
+ "@mantine/hooks": "^4.2.8",
+ "@mantine/next": "^4.2.8",
+ "@mantine/notifications": "^4.2.8",
+ "@mantine/prism": "^4.2.8",
"@nivo/core": "^0.79.0",
"@nivo/line": "^0.79.1",
"@tabler/icons": "^1.68.0",
"axios": "^0.27.2",
"cookies-next": "^2.0.4",
- "dayjs": "^1.11.2",
+ "dayjs": "^1.11.3",
"framer-motion": "^6.3.1",
"js-file-download": "^0.4.12",
"next": "12.1.6",
diff --git a/src/components/AppShelf/AddAppShelfItem.tsx b/src/components/AppShelf/AddAppShelfItem.tsx
index c05d30e6894..be6034f6bf7 100644
--- a/src/components/AppShelf/AddAppShelfItem.tsx
+++ b/src/components/AppShelf/AddAppShelfItem.tsx
@@ -14,9 +14,10 @@ import {
Text,
} from '@mantine/core';
import { useForm } from '@mantine/form';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { IconApps as Apps } from '@tabler/icons';
import { v4 as uuidv4 } from 'uuid';
+import { useDebouncedValue } from '@mantine/hooks';
import { useConfig } from '../../tools/state';
import { ServiceTypeList } from '../../tools/types';
@@ -64,7 +65,7 @@ function MatchIcon(name: string, form: any) {
}
function MatchService(name: string, form: any) {
- const service = ServiceTypeList.find((s) => s === name);
+ const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase());
if (service) {
form.setFieldValue('type', service);
}
@@ -72,16 +73,16 @@ function MatchService(name: string, form: any) {
function MatchPort(name: string, form: any) {
const portmap = [
- { name: 'qBittorrent', value: '8080' },
- { name: 'Sonarr', value: '8989' },
- { name: 'Radarr', value: '7878' },
- { name: 'Lidarr', value: '8686' },
- { name: 'Readarr', value: '8686' },
- { name: 'Deluge', value: '8112' },
- { name: 'Transmission', value: '9091' },
+ { name: 'qbittorrent', value: '8080' },
+ { name: 'sonarr', value: '8989' },
+ { name: 'radarr', value: '7878' },
+ { name: 'lidarr', value: '8686' },
+ { name: 'readarr', value: '8686' },
+ { name: 'deluge', value: '8112' },
+ { name: 'transmission', value: '9091' },
];
// Match name with portmap key
- const port = portmap.find((p) => p.name === name);
+ const port = portmap.find((p) => p.name === name.toLowerCase());
if (port) {
form.setFieldValue('url', `http://localhost:${port.value}`);
}
@@ -111,6 +112,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
apiKey: props.apiKey ?? (undefined as unknown as string),
username: props.username ?? (undefined as unknown as string),
password: props.password ?? (undefined as unknown as string),
+ openedUrl: props.openedUrl ?? (undefined as unknown as string),
},
validate: {
apiKey: () => null,
@@ -134,6 +136,14 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
},
});
+ const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
+ useEffect(() => {
+ if (form.values.name !== debounced || props.name || props.type) return;
+ MatchIcon(form.values.name, form);
+ MatchService(form.values.name, form);
+ MatchPort(form.values.name, form);
+ }, [debounced]);
+
// Try to set const hostname to new URL(form.values.url).hostname)
// If it fails, set it to the form.values.url
let hostname = form.values.url;
@@ -186,28 +196,26 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
required
label="Service name"
placeholder="Plex"
- value={form.values.name}
- onChange={(event) => {
- form.setFieldValue('name', event.currentTarget.value);
- MatchIcon(event.currentTarget.value, form);
- MatchService(event.currentTarget.value, form);
- MatchPort(event.currentTarget.value, form);
- }}
- error={form.errors.name && 'Invalid icon url'}
+ {...form.getInputProps('name')}
/>
+
void } &
/>
>
)}
- {form.values.type === 'Deluge' && (
+ {(form.values.type === 'Deluge' || form.values.type === 'Transmission') && (
<>
{
form.setFieldValue('password', event.currentTarget.value);
diff --git a/src/components/AppShelf/AppShelf.tsx b/src/components/AppShelf/AppShelf.tsx
index 21793972dc2..97b02899750 100644
--- a/src/components/AppShelf/AppShelf.tsx
+++ b/src/components/AppShelf/AppShelf.tsx
@@ -1,24 +1,50 @@
import React, { useState } from 'react';
-import { Grid, Group, Title } from '@mantine/core';
+import { Accordion, createStyles, Grid, Group, Paper, useMantineColorScheme } from '@mantine/core';
import {
closestCenter,
DndContext,
DragOverlay,
MouseSensor,
+ TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
+import { useLocalStorage } from '@mantine/hooks';
import { useConfig } from '../../tools/state';
import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
-import { ModuleWrapper } from '../modules/moduleWrapper';
+import { ModuleMenu, ModuleWrapper } from '../modules/moduleWrapper';
import { DownloadsModule } from '../modules';
+import DownloadComponent from '../modules/downloads/DownloadsModule';
+
+const useStyles = createStyles((theme, _params) => ({
+ item: {
+ borderBottom: 0,
+ overflow: 'hidden',
+ border: '1px solid transparent',
+ borderRadius: theme.radius.lg,
+ marginTop: theme.spacing.md,
+ },
+
+ itemOpened: {
+ borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[3],
+ },
+}));
const AppShelf = (props: any) => {
+ const { classes, cx } = useStyles(props);
+ const [toggledCategories, settoggledCategories] = useLocalStorage({
+ key: 'app-shelf-toggled',
+ // This is a bit of a hack to get the 5 first categories to be toggled on by default
+ defaultValue: { 0: true, 1: true, 2: true, 3: true, 4: true } as Record,
+ });
const [activeId, setActiveId] = useState(null);
const { config, setConfig } = useConfig();
+ const { colorScheme } = useMantineColorScheme();
+
const sensors = useSensors(
+ useSensor(TouchSensor, {}),
useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
@@ -99,26 +125,51 @@ const AppShelf = (props: any) => {
const noCategory = config.services.filter(
(e) => e.category === undefined || e.category === null
);
-
+ // Create an item with 0: true, 1: true, 2: true... For each category
return (
// Return one item for each category
- {categoryList.map((category) => (
- <>
-
- {category}
-
- {item(category)}
- >
- ))}
- {/* Return the item for all services without category */}
- {noCategory && noCategory.length > 0 ? (
- <>
- Other
- {item()}
- >
- ) : null}
-
+ settoggledCategories(idx)}
+ >
+ {categoryList.map((category, idx) => (
+
+ {item(category)}
+
+ ))}
+ {/* Return the item for all services without category */}
+ {noCategory && noCategory.length > 0 ? (
+
+ {item()}
+
+ ) : null}
+
+
+
+
+
+
+
);
}
diff --git a/src/components/AppShelf/AppShelfItem.tsx b/src/components/AppShelf/AppShelfItem.tsx
index 31bc16349ca..8ad389a190c 100644
--- a/src/components/AppShelf/AppShelfItem.tsx
+++ b/src/components/AppShelf/AppShelfItem.tsx
@@ -1,4 +1,13 @@
-import { Text, Card, Anchor, AspectRatio, Image, Center, createStyles } from '@mantine/core';
+import {
+ Text,
+ Card,
+ Anchor,
+ AspectRatio,
+ Image,
+ Center,
+ createStyles,
+ useMantineColorScheme,
+} from '@mantine/core';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
@@ -6,6 +15,7 @@ import { CSS } from '@dnd-kit/utilities';
import { serviceItem } from '../../tools/types';
import PingComponent from '../modules/ping/PingModule';
import AppShelfMenu from './AppShelfMenu';
+import { useConfig } from '../../tools/state';
const useStyles = createStyles((theme) => ({
item: {
@@ -15,6 +25,9 @@ const useStyles = createStyles((theme) => ({
boxShadow: `${theme.shadows.md} !important`,
transform: 'scale(1.05)',
},
+ [theme.fn.smallerThan('sm')]: {
+ WebkitUserSelect: 'none',
+ },
},
}));
@@ -38,7 +51,9 @@ export function SortableAppShelfItem(props: any) {
export function AppShelfItem(props: any) {
const { service }: { service: serviceItem } = props;
const [hovering, setHovering] = useState(false);
- const { classes, theme } = useStyles();
+ const { config } = useConfig();
+ const { colorScheme } = useMantineColorScheme();
+ const { classes } = useStyles();
return (
-
+
{
- window.open(service.url);
+ if (service.openedUrl) window.open(service.openedUrl, '_blank');
+ else window.open(service.url);
}}
/>
diff --git a/src/components/AppShelf/AppShelfMenu.tsx b/src/components/AppShelf/AppShelfMenu.tsx
index 8a51d2d2df5..824b9ae7a27 100644
--- a/src/components/AppShelf/AppShelfMenu.tsx
+++ b/src/components/AppShelf/AppShelfMenu.tsx
@@ -31,6 +31,7 @@ export default function AppShelfMenu(props: any) {
apiKey={service.apiKey}
username={service.username}
password={service.password}
+ openedUrl={service.openedUrl}
message="Save service"
/>
diff --git a/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx b/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx
index 0da7ae998ef..410dcbaf2ec 100644
--- a/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx
+++ b/src/components/ColorSchemeToggle/ColorSchemeSwitch.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { createStyles, Switch, Group, useMantineColorScheme, Kbd } from '@mantine/core';
import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons';
+import { useConfig } from '../../tools/state';
const useStyles = createStyles((theme) => ({
root: {
@@ -29,6 +30,7 @@ const useStyles = createStyles((theme) => ({
}));
export function ColorSchemeSwitch() {
+ const { config } = useConfig();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const { classes, cx } = useStyles();
diff --git a/src/components/Config/ConfigChanger.tsx b/src/components/Config/ConfigChanger.tsx
index 5b883680336..a3484655604 100644
--- a/src/components/Config/ConfigChanger.tsx
+++ b/src/components/Config/ConfigChanger.tsx
@@ -26,7 +26,10 @@ export default function ConfigChanger() {
label="Config loader"
onChange={(e) => {
loadConfig(e ?? 'default');
- setCookies('config-name', e ?? 'default', { maxAge: 60 * 60 * 24 * 30 });
+ setCookies('config-name', e ?? 'default', {
+ maxAge: 60 * 60 * 24 * 30,
+ sameSite: 'strict',
+ });
}}
data={
// If config list is empty, return the current config
diff --git a/src/components/Config/LoadConfig.tsx b/src/components/Config/LoadConfig.tsx
index 1dbed59882a..e98550b6abe 100644
--- a/src/components/Config/LoadConfig.tsx
+++ b/src/components/Config/LoadConfig.tsx
@@ -90,7 +90,10 @@ export default function LoadConfigComponent(props: any) {
icon: ,
message: undefined,
});
- setCookies('config-name', newConfig.name, { maxAge: 60 * 60 * 24 * 30 });
+ setCookies('config-name', newConfig.name, {
+ maxAge: 60 * 60 * 24 * 30,
+ sameSite: 'strict',
+ });
const migratedConfig = migrateToIdConfig(newConfig);
setConfig(migratedConfig);
});
diff --git a/src/components/Config/SaveConfig.tsx b/src/components/Config/SaveConfig.tsx
index 2348bd6089c..d18b38ae2ee 100644
--- a/src/components/Config/SaveConfig.tsx
+++ b/src/components/Config/SaveConfig.tsx
@@ -27,7 +27,7 @@ export default function SaveConfigComponent(props: any) {
}
}
return (
-
+
- } variant="outline" onClick={onClick}>
+ } variant="outline" onClick={onClick}>
Download config
}
variant="outline"
onClick={() => {
@@ -93,7 +94,7 @@ export default function SaveConfigComponent(props: any) {
>
Delete config
- } variant="outline" onClick={() => setOpened(true)}>
+ } variant="outline" onClick={() => setOpened(true)}>
Save a copy
diff --git a/src/components/Settings/AdvancedSettings.tsx b/src/components/Settings/AdvancedSettings.tsx
new file mode 100644
index 00000000000..b00a1193335
--- /dev/null
+++ b/src/components/Settings/AdvancedSettings.tsx
@@ -0,0 +1,63 @@
+import { TextInput, Group, Button } from '@mantine/core';
+import { useForm } from '@mantine/form';
+import { useConfig } from '../../tools/state';
+import { ColorSelector } from './ColorSelector';
+import { OpacitySelector } from './OpacitySelector';
+import { ShadeSelector } from './ShadeSelector';
+
+export default function TitleChanger() {
+ const { config, setConfig } = useConfig();
+
+ const form = useForm({
+ initialValues: {
+ title: config.settings.title,
+ logo: config.settings.logo,
+ favicon: config.settings.favicon,
+ background: config.settings.background,
+ },
+ });
+
+ const saveChanges = (values: {
+ title?: string;
+ logo?: string;
+ favicon?: string;
+ background?: string;
+ }) => {
+ setConfig({
+ ...config,
+ settings: {
+ ...config.settings,
+ title: values.title,
+ logo: values.logo,
+ favicon: values.favicon,
+ background: values.background,
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Settings/ColorSelector.tsx b/src/components/Settings/ColorSelector.tsx
new file mode 100644
index 00000000000..e7f175b3dbf
--- /dev/null
+++ b/src/components/Settings/ColorSelector.tsx
@@ -0,0 +1,96 @@
+import React, { useState } from 'react';
+import { ColorSwatch, Group, Popover, Text, useMantineTheme } from '@mantine/core';
+import { useConfig } from '../../tools/state';
+import { useColorTheme } from '../../tools/color';
+
+interface ColorControlProps {
+ type: string;
+}
+
+export function ColorSelector({ type }: ColorControlProps) {
+ const { config, setConfig } = useConfig();
+ const [opened, setOpened] = useState(false);
+
+ const { primaryColor, secondaryColor, setPrimaryColor, setSecondaryColor } = useColorTheme();
+
+ const theme = useMantineTheme();
+ const colors = Object.keys(theme.colors).map((color) => ({
+ swatch: theme.colors[color][6],
+ color,
+ }));
+
+ const configColor = type === 'primary' ? primaryColor : secondaryColor;
+
+ const setConfigColor = (color: string) => {
+ if (type === 'primary') {
+ setPrimaryColor(color);
+ setConfig({
+ ...config,
+ settings: {
+ ...config.settings,
+ primaryColor: color,
+ },
+ });
+ } else {
+ setSecondaryColor(color);
+ setConfig({
+ ...config,
+ settings: {
+ ...config.settings,
+ secondaryColor: color,
+ },
+ });
+ }
+ };
+
+ const swatches = colors.map(({ color, swatch }) => (
+ setConfigColor(color)}
+ key={color}
+ color={swatch}
+ size={22}
+ style={{ color: theme.white, cursor: 'pointer' }}
+ />
+ ));
+
+ return (
+
+ setOpened(false)}
+ transitionDuration={0}
+ target={
+ setOpened((o) => !o)}
+ size={22}
+ style={{ display: 'block', cursor: 'pointer' }}
+ />
+ }
+ styles={{
+ root: {
+ marginRight: theme.spacing.xs,
+ },
+ body: {
+ width: 152,
+ backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
+ },
+ arrow: {
+ backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
+ },
+ }}
+ position="bottom"
+ placement="end"
+ withArrow
+ arrowSize={3}
+ >
+ {swatches}
+
+ {type[0].toUpperCase() + type.slice(1)} color
+
+ );
+}
diff --git a/src/components/Settings/CommonSettings.tsx b/src/components/Settings/CommonSettings.tsx
new file mode 100644
index 00000000000..7b7665490c9
--- /dev/null
+++ b/src/components/Settings/CommonSettings.tsx
@@ -0,0 +1,119 @@
+import { ActionIcon, Group, Text, SegmentedControl, TextInput, Anchor } from '@mantine/core';
+import { useState } from 'react';
+import { IconBrandGithub as BrandGithub } from '@tabler/icons';
+import { CURRENT_VERSION } from '../../../data/constants';
+import { useConfig } from '../../tools/state';
+import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
+import { WidgetsPositionSwitch } from '../WidgetsPositionSwitch/WidgetsPositionSwitch';
+import ConfigChanger from '../Config/ConfigChanger';
+import SaveConfigComponent from '../Config/SaveConfig';
+import ModuleEnabler from './ModuleEnabler';
+
+export default function CommonSettings(args: any) {
+ const { config, setConfig } = useConfig();
+
+ const matches = [
+ { label: 'Google', value: 'https://google.com/search?q=' },
+ { label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' },
+ { label: 'Bing', value: 'https://bing.com/search?q=' },
+ { label: 'Custom', value: 'Custom' },
+ ];
+
+ const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl);
+ const [searchUrl, setSearchUrl] = useState(
+ matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom'
+ );
+
+ return (
+
+
+ Search engine
+ {
+ setSearchUrl(e);
+ setConfig({
+ ...config,
+ settings: {
+ ...config.settings,
+ searchUrl: e,
+ },
+ });
+ }
+ }
+ data={matches}
+ />
+ {searchUrl === 'Custom' && (
+ {
+ setCustomSearchUrl(event.currentTarget.value);
+ setConfig({
+ ...config,
+ settings: {
+ ...config.settings,
+ searchUrl: event.currentTarget.value,
+ },
+ });
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+ Tip: You can upload your config file by dragging and dropping it onto the page!
+
+
+
+ component="a" href="https://github.com/ajnart/homarr" size="lg">
+
+
+
+ {CURRENT_VERSION}
+
+
+
+ Made with ❤️ by @
+
+ ajnart
+
+
+
+
+ );
+}
diff --git a/src/components/Settings/ModuleEnabler.tsx b/src/components/Settings/ModuleEnabler.tsx
index 14d8a2f0831..ab07327b371 100644
--- a/src/components/Settings/ModuleEnabler.tsx
+++ b/src/components/Settings/ModuleEnabler.tsx
@@ -1,4 +1,4 @@
-import { Group, Switch } from '@mantine/core';
+import { Checkbox, Group, SimpleGrid, Title } from '@mantine/core';
import * as Modules from '../modules';
import { useConfig } from '../../tools/state';
@@ -7,26 +7,29 @@ export default function ModuleEnabler(props: any) {
const modules = Object.values(Modules).map((module) => module);
return (
- {modules.map((module) => (
- {
- setConfig({
- ...config,
- modules: {
- ...config.modules,
- [module.title]: {
- ...config.modules?.[module.title],
- enabled: e.currentTarget.checked,
+ Module enabler
+
+ {modules.map((module) => (
+ {
+ setConfig({
+ ...config,
+ modules: {
+ ...config.modules,
+ [module.title]: {
+ ...config.modules?.[module.title],
+ enabled: e.currentTarget.checked,
+ },
},
- },
- });
- }}
- />
- ))}
+ });
+ }}
+ />
+ ))}
+
);
}
diff --git a/src/components/Settings/OpacitySelector.tsx b/src/components/Settings/OpacitySelector.tsx
new file mode 100644
index 00000000000..f94225cd8fe
--- /dev/null
+++ b/src/components/Settings/OpacitySelector.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { Group, Text, Slider } from '@mantine/core';
+import { useConfig } from '../../tools/state';
+
+export function OpacitySelector() {
+ const { config, setConfig } = useConfig();
+
+ const MARKS = [
+ { value: 10, label: '10' },
+ { value: 20, label: '20' },
+ { value: 30, label: '30' },
+ { value: 40, label: '40' },
+ { value: 50, label: '50' },
+ { value: 60, label: '60' },
+ { value: 70, label: '70' },
+ { value: 80, label: '80' },
+ { value: 90, label: '90' },
+ { value: 100, label: '100' },
+ ];
+
+ const setConfigOpacity = (opacity: number) => {
+ setConfig({
+ ...config,
+ settings: {
+ ...config.settings,
+ appOpacity: opacity,
+ },
+ });
+ };
+
+ return (
+
+ App Opacity
+ setConfigOpacity(value)}
+ />
+
+ );
+}
diff --git a/src/components/Settings/SettingsMenu.tsx b/src/components/Settings/SettingsMenu.tsx
index 956db2ec70d..e6bcb2bed2d 100644
--- a/src/components/Settings/SettingsMenu.tsx
+++ b/src/components/Settings/SettingsMenu.tsx
@@ -1,131 +1,20 @@
-import {
- ActionIcon,
- Group,
- Title,
- Text,
- Tooltip,
- SegmentedControl,
- TextInput,
- Drawer,
- Anchor,
-} from '@mantine/core';
-import { useColorScheme, useHotkeys } from '@mantine/hooks';
+import { ActionIcon, Title, Tooltip, Drawer, Tabs } from '@mantine/core';
+import { useHotkeys } from '@mantine/hooks';
import { useState } from 'react';
-import { IconBrandGithub as BrandGithub, IconSettings } from '@tabler/icons';
-import { CURRENT_VERSION } from '../../../data/constants';
-import { useConfig } from '../../tools/state';
-import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
-import ConfigChanger from '../Config/ConfigChanger';
-import SaveConfigComponent from '../Config/SaveConfig';
-import ModuleEnabler from './ModuleEnabler';
+import { IconSettings } from '@tabler/icons';
+import AdvancedSettings from './AdvancedSettings';
+import CommonSettings from './CommonSettings';
function SettingsMenu(props: any) {
- const { config, setConfig } = useConfig();
- const colorScheme = useColorScheme();
- const { current, latest } = props;
-
- const matches = [
- { label: 'Google', value: 'https://google.com/search?q=' },
- { label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' },
- { label: 'Bing', value: 'https://bing.com/search?q=' },
- { label: 'Custom', value: 'Custom' },
- ];
-
- const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl);
- const [searchUrl, setSearchUrl] = useState(
- matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom'
- );
-
return (
-
-
- Search engine
- {
- setSearchUrl(e);
- setConfig({
- ...config,
- settings: {
- ...config.settings,
- searchUrl: e,
- },
- });
- }
- }
- data={matches}
- />
- {searchUrl === 'Custom' && (
- {
- setCustomSearchUrl(event.currentTarget.value);
- setConfig({
- ...config,
- settings: {
- ...config.settings,
- searchUrl: event.currentTarget.value,
- },
- });
- }}
- />
- )}
-
-
-
-
-
-
- Tip: You can upload your config file by dragging and dropping it onto the page!
-
-
-
- component="a" href="https://github.com/ajnart/homarr" size="lg">
-
-
-
- {CURRENT_VERSION}
-
-
-
- Made with ❤️ by @
-
- ajnart
-
-
-
-
+
+
+
+
+
+
+
+
);
}
@@ -136,7 +25,7 @@ export function SettingsMenuButton(props: any) {
return (
<>
Settings}
diff --git a/src/components/Settings/ShadeSelector.tsx b/src/components/Settings/ShadeSelector.tsx
new file mode 100644
index 00000000000..ebd55e84dd5
--- /dev/null
+++ b/src/components/Settings/ShadeSelector.tsx
@@ -0,0 +1,97 @@
+import React, { useState } from 'react';
+import { ColorSwatch, Group, Popover, Text, useMantineTheme, MantineTheme } from '@mantine/core';
+import { useConfig } from '../../tools/state';
+import { useColorTheme } from '../../tools/color';
+
+export function ShadeSelector() {
+ const { config, setConfig } = useConfig();
+ const [opened, setOpened] = useState(false);
+
+ const { primaryColor, secondaryColor, primaryShade, setPrimaryShade } = useColorTheme();
+
+ const theme = useMantineTheme();
+ const primaryShades = theme.colors[primaryColor].map((s, i) => ({
+ swatch: theme.colors[primaryColor][i],
+ shade: i as MantineTheme['primaryShade'],
+ }));
+ const secondaryShades = theme.colors[secondaryColor].map((s, i) => ({
+ swatch: theme.colors[secondaryColor][i],
+ shade: i as MantineTheme['primaryShade'],
+ }));
+
+ const setConfigShade = (shade: MantineTheme['primaryShade']) => {
+ setPrimaryShade(shade);
+ setConfig({
+ ...config,
+ settings: {
+ ...config.settings,
+ primaryShade: shade,
+ },
+ });
+ };
+
+ const primarySwatches = primaryShades.map(({ swatch, shade }) => (
+ setConfigShade(shade)}
+ key={Number(shade)}
+ color={swatch}
+ size={22}
+ style={{ color: theme.white, cursor: 'pointer' }}
+ />
+ ));
+
+ const secondarySwatches = secondaryShades.map(({ swatch, shade }) => (
+ setConfigShade(shade)}
+ key={Number(shade)}
+ color={swatch}
+ size={22}
+ style={{ color: theme.white, cursor: 'pointer' }}
+ />
+ ));
+
+ return (
+
+ setOpened(false)}
+ transitionDuration={0}
+ target={
+ setOpened((o) => !o)}
+ size={22}
+ style={{ display: 'block', cursor: 'pointer' }}
+ />
+ }
+ styles={{
+ root: {
+ marginRight: theme.spacing.xs,
+ },
+ body: {
+ backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
+ },
+ arrow: {
+ backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
+ },
+ }}
+ position="bottom"
+ placement="end"
+ withArrow
+ arrowSize={3}
+ >
+
+ {primarySwatches}
+ {secondarySwatches}
+
+
+ Shade
+
+ );
+}
diff --git a/src/components/WidgetsPositionSwitch/WidgetsPositionSwitch.tsx b/src/components/WidgetsPositionSwitch/WidgetsPositionSwitch.tsx
new file mode 100644
index 00000000000..5fe24efe94f
--- /dev/null
+++ b/src/components/WidgetsPositionSwitch/WidgetsPositionSwitch.tsx
@@ -0,0 +1,60 @@
+import React, { useState } from 'react';
+import { createStyles, Switch, Group } from '@mantine/core';
+import { useConfig } from '../../tools/state';
+
+const useStyles = createStyles((theme) => ({
+ root: {
+ position: 'relative',
+ '& *': {
+ cursor: 'pointer',
+ },
+ },
+
+ icon: {
+ pointerEvents: 'none',
+ position: 'absolute',
+ zIndex: 1,
+ top: 3,
+ },
+
+ iconLight: {
+ left: 4,
+ color: theme.white,
+ },
+
+ iconDark: {
+ right: 4,
+ color: theme.colors.gray[6],
+ },
+}));
+
+export function WidgetsPositionSwitch() {
+ const { config, setConfig } = useConfig();
+ const { classes, cx } = useStyles();
+ const defaultPosition = config?.settings?.widgetPosition || 'right';
+ const [widgetPosition, setWidgetPosition] = useState(defaultPosition);
+ const toggleWidgetPosition = () => {
+ const position = widgetPosition === 'right' ? 'left' : 'right';
+ setWidgetPosition(position);
+ setConfig({
+ ...config,
+ settings: {
+ ...config.settings,
+ widgetPosition: position,
+ },
+ });
+ };
+
+ return (
+
+
+ toggleWidgetPosition()}
+ size="md"
+ />
+
+ Position widgets on left
+
+ );
+}
diff --git a/src/components/layout/Aside.tsx b/src/components/layout/Aside.tsx
index 9154fea10f5..6c481057291 100644
--- a/src/components/layout/Aside.tsx
+++ b/src/components/layout/Aside.tsx
@@ -1,33 +1,36 @@
-import { Aside as MantineAside, Group } from '@mantine/core';
-import {
- WeatherModule,
- DateModule,
- CalendarModule,
- TotalDownloadsModule,
- SystemModule,
-} from '../modules';
-import { ModuleWrapper } from '../modules/moduleWrapper';
+import { Aside as MantineAside, createStyles } from '@mantine/core';
+import Widgets from './Widgets';
+
+const useStyles = createStyles((theme) => ({
+ hide: {
+ [theme.fn.smallerThan('xs')]: {
+ display: 'none',
+ },
+ },
+ burger: {
+ [theme.fn.largerThan('sm')]: {
+ display: 'none',
+ },
+ },
+}));
export default function Aside(props: any) {
+ const { classes, cx } = useStyles();
return (
-
-
-
-
-
-
-
+
);
}
diff --git a/src/components/layout/Background.tsx b/src/components/layout/Background.tsx
new file mode 100644
index 00000000000..741bf9389cb
--- /dev/null
+++ b/src/components/layout/Background.tsx
@@ -0,0 +1,20 @@
+import { Global } from '@mantine/core';
+import { useConfig } from '../../tools/state';
+
+export function Background() {
+ const { config } = useConfig();
+
+ return (
+
+ );
+}
diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx
index 573c299978a..f1b58cd2a0b 100644
--- a/src/components/layout/Footer.tsx
+++ b/src/components/layout/Footer.tsx
@@ -1,8 +1,8 @@
import React, { useEffect } from 'react';
import { createStyles, Footer as FooterComponent } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
-import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
import { IconAlertCircle as AlertCircle } from '@tabler/icons';
+import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
const useStyles = createStyles((theme) => ({
footer: {
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 28ed95f4603..20374f60995 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -1,9 +1,23 @@
import React from 'react';
-import { createStyles, Header as Head, Group, Box } from '@mantine/core';
+import {
+ createStyles,
+ Header as Head,
+ Group,
+ Box,
+ Burger,
+ Drawer,
+ Title,
+ ScrollArea,
+ ActionIcon,
+ Transition,
+} from '@mantine/core';
+import { useBooleanToggle } from '@mantine/hooks';
import { Logo } from './Logo';
import SearchBar from '../modules/search/SearchModule';
import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem';
import { SettingsMenuButton } from '../Settings/SettingsMenu';
+import { ModuleWrapper } from '../modules/moduleWrapper';
+import { CalendarModule, TotalDownloadsModule, WeatherModule, DateModule } from '../modules';
const HEADER_HEIGHT = 60;
@@ -13,14 +27,21 @@ const useStyles = createStyles((theme) => ({
display: 'none',
},
},
+ burger: {
+ [theme.fn.largerThan('sm')]: {
+ display: 'none',
+ },
+ },
}));
export function Header(props: any) {
+ const [opened, toggleOpened] = useBooleanToggle(false);
const { classes, cx } = useStyles();
+ const [hidden, toggleHidden] = useBooleanToggle(true);
return (
-
+
@@ -28,6 +49,47 @@ export function Header(props: any) {
+
+ {
+ toggleHidden();
+ toggleOpened();
+ }}
+ />
+
+ Modules}
+ opened
+ onClose={() => {
+ toggleHidden();
+ }}
+ >
+ toggleOpened()}
+ >
+ {(styles) => (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
diff --git a/src/components/layout/HeaderConfig.tsx b/src/components/layout/HeaderConfig.tsx
new file mode 100644
index 00000000000..ed5a7804fbe
--- /dev/null
+++ b/src/components/layout/HeaderConfig.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import Head from 'next/head';
+import { useConfig } from '../../tools/state';
+
+export function HeaderConfig(props: any) {
+ const { config } = useConfig();
+
+ return (
+
+ {config.settings.title || 'Homarr 🦞'}
+
+
+ );
+}
diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx
index ac2c3c74296..a40c6a80180 100644
--- a/src/components/layout/Layout.tsx
+++ b/src/components/layout/Layout.tsx
@@ -2,6 +2,10 @@ import { AppShell, createStyles } from '@mantine/core';
import { Header } from './Header';
import { Footer } from './Footer';
import Aside from './Aside';
+import Navbar from './Navbar';
+import { HeaderConfig } from './HeaderConfig';
+import { Background } from './Background';
+import { useConfig } from '../../tools/state';
const useStyles = createStyles((theme) => ({
main: {},
@@ -9,8 +13,18 @@ const useStyles = createStyles((theme) => ({
export default function Layout({ children, style }: any) {
const { classes, cx } = useStyles();
+ const { config } = useConfig();
+ const widgetPosition = config?.settings?.widgetPosition === 'left';
+
return (
- } header={} footer={}>
+ }
+ navbar={widgetPosition ? : <>>}
+ aside={widgetPosition ? <>> : }
+ footer={}
+ >
+
+
- Homarr
+ {config.settings.title || 'Homarr'}
diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx
index cd6e80098a5..0bf44237b2b 100644
--- a/src/components/layout/Navbar.tsx
+++ b/src/components/layout/Navbar.tsx
@@ -1,24 +1,37 @@
-import { Group, Navbar as MantineNavbar } from '@mantine/core';
-import { WeatherModule, DateModule } from '../modules';
-import { ModuleWrapper } from '../modules/moduleWrapper';
+import { createStyles, Navbar as MantineNavbar } from '@mantine/core';
+import Widgets from './Widgets';
+
+const useStyles = createStyles((theme) => ({
+ hide: {
+ [theme.fn.smallerThan('xs')]: {
+ display: 'none',
+ },
+ },
+ burger: {
+ [theme.fn.largerThan('sm')]: {
+ display: 'none',
+ },
+ },
+}));
export default function Navbar() {
+ const { classes, cx } = useStyles();
+
return (
-
-
-
-
-
+
);
}
diff --git a/src/components/layout/Widgets.tsx b/src/components/layout/Widgets.tsx
new file mode 100644
index 00000000000..b82f489a9ec
--- /dev/null
+++ b/src/components/layout/Widgets.tsx
@@ -0,0 +1,21 @@
+import { Group } from '@mantine/core';
+import { useMediaQuery } from '@mantine/hooks';
+import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../modules';
+import { ModuleWrapper } from '../modules/moduleWrapper';
+
+export default function Widgets(props: any) {
+ const matches = useMediaQuery('(min-width: 800px)');
+
+ return (
+ <>
+ {matches && (
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/modules/calendar/CalendarModule.tsx b/src/components/modules/calendar/CalendarModule.tsx
index 058a81e7d0a..a0b6574d178 100644
--- a/src/components/modules/calendar/CalendarModule.tsx
+++ b/src/components/modules/calendar/CalendarModule.tsx
@@ -1,5 +1,13 @@
/* eslint-disable react/no-children-prop */
-import { Box, Divider, Indicator, Popover, ScrollArea } from '@mantine/core';
+import {
+ Box,
+ Divider,
+ Indicator,
+ Popover,
+ ScrollArea,
+ createStyles,
+ useMantineTheme,
+} from '@mantine/core';
import React, { useEffect, useState } from 'react';
import { Calendar } from '@mantine/dates';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
@@ -13,6 +21,7 @@ import {
ReadarrMediaDisplay,
} from '../common';
import { serviceItem } from '../../../tools/types';
+import { useColorTheme } from '../../../tools/color';
export const CalendarModule: IModule = {
title: 'Calendar',
@@ -24,14 +33,25 @@ export const CalendarModule: IModule = {
export default function CalendarComponent(props: any) {
const { config } = useConfig();
+ const theme = useMantineTheme();
+ const { secondaryColor } = useColorTheme();
+ const useStyles = createStyles((theme) => ({
+ weekend: {
+ color: `${secondaryColor} !important`,
+ },
+ }));
+
const [sonarrMedias, setSonarrMedias] = useState([] as any);
const [lidarrMedias, setLidarrMedias] = useState([] as any);
const [radarrMedias, setRadarrMedias] = useState([] as any);
const [readarrMedias, setReadarrMedias] = useState([] as any);
- const sonarrService = config.services.filter((service) => service.type === 'Sonarr').at(0);
- const radarrService = config.services.filter((service) => service.type === 'Radarr').at(0);
- const lidarrService = config.services.filter((service) => service.type === 'Lidarr').at(0);
- const readarrService = config.services.filter((service) => service.type === 'Readarr').at(0);
+ const sonarrServices = config.services.filter((service) => service.type === 'Sonarr');
+ const radarrServices = config.services.filter((service) => service.type === 'Radarr');
+ const lidarrServices = config.services.filter((service) => service.type === 'Lidarr');
+ const readarrServices = config.services.filter((service) => service.type === 'Readarr');
+ const today = new Date();
+
+ const { classes, cx } = useStyles();
function getMedias(service: serviceItem | undefined, type: string) {
if (!service || !service.apiKey) {
@@ -41,18 +61,61 @@ export default function CalendarComponent(props: any) {
}
useEffect(() => {
- // Filter only sonarr and radarr services
-
- // Get the url and apiKey for all Sonarr and Radarr services
- getMedias(sonarrService, 'sonarr').then((res) => setSonarrMedias(res.data));
- getMedias(radarrService, 'radarr').then((res) => setRadarrMedias(res.data));
- getMedias(lidarrService, 'lidarr').then((res) => setLidarrMedias(res.data));
- getMedias(readarrService, 'readarr').then((res) => setReadarrMedias(res.data));
+ // Create each Sonarr service and get the medias
+ const currentSonarrMedias: any[] = [...sonarrMedias];
+ Promise.all(
+ sonarrServices.map((service) =>
+ getMedias(service, 'sonarr').then((res) => {
+ currentSonarrMedias.push(...res.data);
+ })
+ )
+ ).then(() => {
+ setSonarrMedias(currentSonarrMedias);
+ });
+ const currentRadarrMedias: any[] = [...radarrMedias];
+ Promise.all(
+ radarrServices.map((service) =>
+ getMedias(service, 'radarr').then((res) => {
+ currentRadarrMedias.push(...res.data);
+ })
+ )
+ ).then(() => {
+ setRadarrMedias(currentRadarrMedias);
+ });
+ const currentLidarrMedias: any[] = [...lidarrMedias];
+ Promise.all(
+ lidarrServices.map((service) =>
+ getMedias(service, 'lidarr').then((res) => {
+ currentLidarrMedias.push(...res.data);
+ })
+ )
+ ).then(() => {
+ setLidarrMedias(currentLidarrMedias);
+ });
+ const currentReadarrMedias: any[] = [...readarrMedias];
+ Promise.all(
+ readarrServices.map((service) =>
+ getMedias(service, 'readarr').then((res) => {
+ currentReadarrMedias.push(...res.data);
+ })
+ )
+ ).then(() => {
+ setReadarrMedias(currentReadarrMedias);
+ });
}, [config.services]);
return (
{}}
+ dayStyle={(date) =>
+ date.getDay() === today.getDay() && date.getDate() === today.getDate()
+ ? {
+ backgroundColor:
+ theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0],
+ }
+ : {}
+ }
+ dayClassName={(date, modifiers) => cx({ [classes.weekend]: modifiers.weekend })}
renderDay={(renderdate) => (
{
const date = new Date(media.releaseDate);
- return date.getDate() === day && date.getMonth() === renderdate.getMonth();
+ return date.toDateString() === renderdate.toDateString();
});
const lidarrFiltered = lidarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
- // Return true if the date is renerdate without counting hours and minutes
- return date.getDate() === day && date.getMonth() === renderdate.getMonth();
+ return date.toDateString() === renderdate.toDateString();
});
const sonarrFiltered = sonarrmedias.filter((media: any) => {
- const date = new Date(media.airDate);
- // Return true if the date is renerdate without counting hours and minutes
- return date.getDate() === day && date.getMonth() === renderdate.getMonth();
+ const date = new Date(media.airDateUtc);
+ return date.toDateString() === renderdate.toDateString();
});
const radarrFiltered = radarrmedias.filter((media: any) => {
const date = new Date(media.inCinemas);
- // Return true if the date is renerdate without counting hours and minutes
- return date.getDate() === day && date.getMonth() === renderdate.getMonth();
+ return date.toDateString() === renderdate.toDateString();
});
if (
sonarrFiltered.length === 0 &&
@@ -167,7 +227,7 @@ function DayComponent(props: any) {
/>
)}
setOpened(false)}
opened={opened}
target={day}
@@ -197,12 +257,18 @@ function DayComponent(props: any) {
{index < radarrFiltered.length - 1 && }
))}
+ {sonarrFiltered.length > 0 && lidarrFiltered.length > 0 && (
+
+ )}
{lidarrFiltered.map((media: any, index: number) => (
{index < lidarrFiltered.length - 1 && }
))}
+ {lidarrFiltered.length > 0 && readarrFiltered.length > 0 && (
+
+ )}
{readarrFiltered.map((media: any, index: number) => (
diff --git a/src/components/modules/common/MediaDisplay.tsx b/src/components/modules/common/MediaDisplay.tsx
index d8d945d67df..74373a6ccb6 100644
--- a/src/components/modules/common/MediaDisplay.tsx
+++ b/src/components/modules/common/MediaDisplay.tsx
@@ -1,4 +1,15 @@
-import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea } from '@mantine/core';
+import {
+ Image,
+ Group,
+ Title,
+ Badge,
+ Text,
+ ActionIcon,
+ Anchor,
+ ScrollArea,
+ createStyles,
+} from '@mantine/core';
+import { useMediaQuery } from '@mantine/hooks';
import { IconLink as Link } from '@tabler/icons';
import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types';
@@ -14,13 +25,25 @@ export interface IMedia {
episodeNumber?: number;
}
+const useStyles = createStyles((theme) => ({
+ overview: {
+ [theme.fn.largerThan('sm')]: {
+ width: 400,
+ },
+ },
+}));
+
export function MediaDisplay(props: { media: IMedia }) {
const { media }: { media: IMedia } = props;
+ const { classes, cx } = useStyles();
+ const phone = useMediaQuery('(min-width: 800px)');
return (
{media.poster && (
)}
-
-
+
+
{media.title}
{media.imdbId && (
@@ -65,9 +86,9 @@ export function MediaDisplay(props: { media: IMedia }) {
)}
- {media.overview}
+ {media.overview}
- {media.genres.map((genre: string, i: number) => (
+ {media.genres.slice(-5).map((genre: string, i: number) => (
{genre}
diff --git a/src/components/modules/date/DateModule.tsx b/src/components/modules/date/DateModule.tsx
index d84e42b9bf0..ad599173682 100644
--- a/src/components/modules/date/DateModule.tsx
+++ b/src/components/modules/date/DateModule.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { IconClock as Clock } from '@tabler/icons';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
+import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
export const DateModule: IModule = {
title: 'Date',
@@ -20,13 +21,14 @@ export const DateModule: IModule = {
export default function DateComponent(props: any) {
const [date, setDate] = useState(new Date());
+ const setSafeInterval = useSetSafeInterval();
const { config } = useConfig();
const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? false;
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
// Change date on minute change
// Note: Using 10 000ms instead of 1000ms to chill a little :)
useEffect(() => {
- setInterval(() => {
+ setSafeInterval(() => {
setDate(new Date());
}, 1000 * 60);
}, []);
diff --git a/src/components/modules/downloads/DownloadsModule.tsx b/src/components/modules/downloads/DownloadsModule.tsx
index a9fc4fe2c43..f6db402ab70 100644
--- a/src/components/modules/downloads/DownloadsModule.tsx
+++ b/src/components/modules/downloads/DownloadsModule.tsx
@@ -1,11 +1,25 @@
-import { Table, Text, Tooltip, Title, Group, Progress, Skeleton, ScrollArea } from '@mantine/core';
+import {
+ Table,
+ Text,
+ Tooltip,
+ Title,
+ Group,
+ Progress,
+ Skeleton,
+ ScrollArea,
+ Center,
+ Image,
+} from '@mantine/core';
import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
+import { useViewportSize } from '@mantine/hooks';
import { IModule } from '../modules';
import { useConfig } from '../../../tools/state';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
+import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
+import { humanFileSize } from '../../../tools/humanFileSize';
export const DownloadsModule: IModule = {
title: 'Torrent',
@@ -22,36 +36,32 @@ export const DownloadsModule: IModule = {
export default function DownloadComponent() {
const { config } = useConfig();
- const qBittorrentService = config.services
- .filter((service) => service.type === 'qBittorrent')
- .at(0);
- const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
+ const { height, width } = useViewportSize();
+ const downloadServices =
+ config.services.filter(
+ (service) =>
+ service.type === 'qBittorrent' ||
+ service.type === 'Transmission' ||
+ service.type === 'Deluge'
+ ) ?? [];
const hideComplete: boolean =
(config?.modules?.[DownloadsModule.title]?.options?.hidecomplete?.value as boolean) ?? false;
-
- const [delugeTorrents, setDelugeTorrents] = useState([]);
- const [qBittorrentTorrents, setqBittorrentTorrents] = useState([]);
-
+ const [torrents, setTorrents] = useState([]);
+ const setSafeInterval = useSetSafeInterval();
+ const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
- if (qBittorrentService) {
- setInterval(() => {
- axios
- .post('/api/modules/downloads?dlclient=qbit', { ...qBittorrentService })
- .then((res) => {
- setqBittorrentTorrents(res.data.torrents);
- });
- }, 3000);
- }
- if (delugeService) {
- setInterval(() => {
- axios.post('/api/modules/downloads?dlclient=deluge', { ...delugeService }).then((res) => {
- setDelugeTorrents(res.data.torrents);
- });
- }, 3000);
- }
- }, [config.modules]);
+ setIsLoading(true);
+ if (downloadServices.length === 0) return;
+ setSafeInterval(() => {
+ // Send one request with each download service inside
+ axios.post('/api/modules/downloads', { config }).then((response) => {
+ setTorrents(response.data);
+ setIsLoading(false);
+ });
+ }, 5000);
+ }, [config.services]);
- if (!qBittorrentService && !delugeService) {
+ if (downloadServices.length === 0) {
return (
No supported download clients found!
@@ -63,7 +73,7 @@ export default function DownloadComponent() {
);
}
- if (qBittorrentTorrents.length === 0 && delugeTorrents.length === 0) {
+ if (isLoading) {
return (
<>
@@ -74,68 +84,106 @@ export default function DownloadComponent() {
>
);
}
-
+ const DEVICE_WIDTH = 576;
const ths = (
Name
- Download
- Upload
+ Size
+ {width > 576 ? Down : ''}
+ {width > 576 ? Up : ''}
+ ETA
Progress
);
+ // Convert Seconds to readable format.
+ function calculateETA(givenSeconds: number) {
+ // If its superior than one day return > 1 day
+ if (givenSeconds > 86400) {
+ return '> 1 day';
+ }
+ // Transform the givenSeconds into a readable format. e.g. 1h 2m 3s
+ const hours = Math.floor(givenSeconds / 3600);
+ const minutes = Math.floor((givenSeconds % 3600) / 60);
+ const seconds = Math.floor(givenSeconds % 60);
+ // Only show hours if it's greater than 0.
+ const hoursString = hours > 0 ? `${hours}h ` : '';
+ const minutesString = minutes > 0 ? `${minutes}m ` : '';
+ const secondsString = seconds > 0 ? `${seconds}s` : '';
+ return `${hoursString}${minutesString}${secondsString}`;
+ }
// Loop over qBittorrent torrents merging with deluge torrents
- const torrents: NormalizedTorrent[] = [];
- delugeTorrents.forEach((delugeTorrent) =>
- torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
+ const rows = torrents
+ .filter((torrent) => !(torrent.progress === 1 && hideComplete))
+ .map((torrent) => {
+ const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
+ const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
+ const size = torrent.totalSelected;
+ return (
+
+
+
+
+ {torrent.name}
+
+
+
+
+ {humanFileSize(size)}
+
+ {width > 576 ? (
+
+ {downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}
+
+ ) : (
+ ''
+ )}
+ {width > 576 ? (
+
+ {uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}
+
+ ) : (
+ ''
+ )}
+
+ {torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}
+
+
+ {(torrent.progress * 100).toFixed(1)}%
+
+
+
+ );
+ });
+
+ const easteregg = (
+
+
+
);
- qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
- const rows = torrents.map((torrent) => {
- if (torrent.progress === 1 && hideComplete) {
- return [];
- }
- const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
- const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
- return (
-
-
-
-
- {torrent.name}
-
-
-
-
- {downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}
-
-
- {uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}
-
-
- {(torrent.progress * 100).toFixed(1)}%
-
-
-
- );
- });
return (
-
- Your torrents
+
-
+ {rows.length > 0 ? (
+
+ ) : (
+ easteregg
+ )}
);
diff --git a/src/components/modules/downloads/TotalDownloadsModule.tsx b/src/components/modules/downloads/TotalDownloadsModule.tsx
index 915e4691249..55f9be34dd4 100644
--- a/src/components/modules/downloads/TotalDownloadsModule.tsx
+++ b/src/components/modules/downloads/TotalDownloadsModule.tsx
@@ -8,39 +8,9 @@ import { Datum, ResponsiveLine } from '@nivo/line';
import { useListState } from '@mantine/hooks';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
import { useConfig } from '../../../tools/state';
+import { humanFileSize } from '../../../tools/humanFileSize';
import { IModule } from '../modules';
-
-/**
- * Format bytes as human-readable text.
- *
- * @param bytes Number of bytes.
- * @param si True to use metric (SI) units, aka powers of 1000. False to use
- * binary (IEC), aka powers of 1024.
- * @param dp Number of decimal places to display.
- *
- * @return Formatted string.
- */
-function humanFileSize(initialBytes: number, si = true, dp = 1) {
- const thresh = si ? 1000 : 1024;
- let bytes = initialBytes;
-
- if (Math.abs(bytes) < thresh) {
- return `${bytes} B`;
- }
-
- const units = si
- ? ['kb', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
- : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
- let u = -1;
- const r = 10 ** dp;
-
- do {
- bytes /= thresh;
- u += 1;
- } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
-
- return `${bytes.toFixed(dp)} ${units[u]}`;
-}
+import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
export const TotalDownloadsModule: IModule = {
title: 'Download Speed',
@@ -56,42 +26,29 @@ interface torrentHistory {
}
export default function TotalDownloadsComponent() {
+ const setSafeInterval = useSetSafeInterval();
const { config } = useConfig();
- const qBittorrentService = config.services
- .filter((service) => service.type === 'qBittorrent')
- .at(0);
- const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
+ const downloadServices =
+ config.services.filter(
+ (service) =>
+ service.type === 'qBittorrent' ||
+ service.type === 'Transmission' ||
+ service.type === 'Deluge'
+ ) ?? [];
- const [delugeTorrents, setDelugeTorrents] = useState([]);
const [torrentHistory, torrentHistoryHandlers] = useListState([]);
- const [qBittorrentTorrents, setqBittorrentTorrents] = useState([]);
-
- const torrents: NormalizedTorrent[] = [];
- delugeTorrents.forEach((delugeTorrent) =>
- torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
- );
- qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
+ const [torrents, setTorrents] = useState([]);
const totalDownloadSpeed = torrents.reduce((acc, torrent) => acc + torrent.downloadSpeed, 0);
const totalUploadSpeed = torrents.reduce((acc, torrent) => acc + torrent.uploadSpeed, 0);
-
useEffect(() => {
- const interval = setInterval(() => {
- // Get the current download speed of qBittorrent.
- if (qBittorrentService) {
- axios
- .post('/api/modules/downloads?dlclient=qbit', { ...qBittorrentService })
- .then((res) => {
- setqBittorrentTorrents(res.data.torrents);
- });
- if (delugeService) {
- axios.post('/api/modules/downloads?dlclient=deluge', { ...delugeService }).then((res) => {
- setDelugeTorrents(res.data.torrents);
- });
- }
- }
+ if (downloadServices.length === 0) return;
+ setSafeInterval(() => {
+ axios.post('/api/modules/downloads', { config }).then((response) => {
+ setTorrents(response.data);
+ });
}, 1000);
- }, [config.modules]);
+ }, [config.services]);
useEffect(() => {
torrentHistoryHandlers.append({
@@ -101,7 +58,7 @@ export default function TotalDownloadsComponent() {
});
}, [totalDownloadSpeed, totalUploadSpeed]);
- if (!qBittorrentService && !delugeService) {
+ if (downloadServices.length === 0) {
return (
No supported download clients found!
diff --git a/src/components/modules/index.ts b/src/components/modules/index.ts
index f7396ed66c0..410bf3b5616 100644
--- a/src/components/modules/index.ts
+++ b/src/components/modules/index.ts
@@ -4,4 +4,3 @@ export * from './search';
export * from './ping';
export * from './weather';
export * from './downloads';
-export * from './system';
diff --git a/src/components/modules/moduleWrapper.tsx b/src/components/modules/moduleWrapper.tsx
index 15a8df738a7..8d965941d3c 100644
--- a/src/components/modules/moduleWrapper.tsx
+++ b/src/components/modules/moduleWrapper.tsx
@@ -1,4 +1,4 @@
-import { Button, Card, Group, Menu, Switch, TextInput, useMantineTheme } from '@mantine/core';
+import { Button, Card, Group, Menu, Switch, TextInput, useMantineColorScheme } from '@mantine/core';
import { useConfig } from '../../tools/state';
import { IModule } from './modules';
@@ -91,18 +91,50 @@ function getItems(module: IModule) {
export function ModuleWrapper(props: any) {
const { module }: { module: IModule } = props;
+ const { colorScheme } = useMantineColorScheme();
const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules[module.title]?.enabled ?? false;
- const theme = useMantineTheme();
- const items: JSX.Element[] = getItems(module);
if (!isShown) {
return null;
}
+
return (
-
+
+
+
+
+ );
+}
+
+export function ModuleMenu(props: any) {
+ const { module, styles } = props;
+ const items: JSX.Element[] = getItems(module);
+ return (
+ <>
{module.options && (
)}
-
-
+ >
);
}
diff --git a/src/components/modules/ping/PingModule.tsx b/src/components/modules/ping/PingModule.tsx
index af9cd3a77e2..c7586f538aa 100644
--- a/src/components/modules/ping/PingModule.tsx
+++ b/src/components/modules/ping/PingModule.tsx
@@ -32,7 +32,7 @@ export default function PingComponent(props: any) {
.catch(() => {
setOnline('down');
});
- }, []);
+ }, [config.modules?.[PingModule.title]?.enabled]);
if (!exists) {
return null;
}
diff --git a/src/components/modules/search/SearchModule.tsx b/src/components/modules/search/SearchModule.tsx
index 3f31534655f..85ca6adf5f9 100644
--- a/src/components/modules/search/SearchModule.tsx
+++ b/src/components/modules/search/SearchModule.tsx
@@ -1,11 +1,12 @@
-import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core';
-import { useForm, useHotkeys } from '@mantine/hooks';
-import { useRef, useState } from 'react';
+import { Kbd, createStyles, Text, Popover, Autocomplete } from '@mantine/core';
+import { useDebouncedValue, useForm, useHotkeys } from '@mantine/hooks';
+import { useEffect, useRef, useState } from 'react';
import {
IconSearch as Search,
IconBrandYoutube as BrandYoutube,
IconDownload as Download,
} from '@tabler/icons';
+import axios from 'axios';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
@@ -32,8 +33,22 @@ export default function SearchBar(props: any) {
const [icon, setIcon] = useState( );
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
const textInput = useRef();
- useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
+ // Find a service with the type of 'Overseerr'
+ const form = useForm({
+ initialValues: {
+ query: '',
+ },
+ });
+ const [debounced, cancel] = useDebouncedValue(form.values.query, 250);
+ const [results, setResults] = useState([]);
+ useEffect(() => {
+ if (form.values.query !== debounced || form.values.query === '') return;
+ axios
+ .get(`/api/modules/search?q=${form.values.query}`)
+ .then((res) => setResults(res.data ?? []));
+ }, [debounced]);
+ useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
const { classes, cx } = useStyles();
const rightSection = (
@@ -43,12 +58,6 @@ export default function SearchBar(props: any) {
);
- const form = useForm({
- initialValues: {
- query: '',
- },
- });
-
// If enabled modules doesn't contain the module, return null
// If module in enabled
@@ -57,6 +66,10 @@ export default function SearchBar(props: any) {
return null;
}
+ const autocompleteData = results.map((result) => ({
+ label: result.phrase,
+ value: result.phrase,
+ }));
return (