🔀 merged branch dev

This commit is contained in:
Noan
2022-08-27 01:16:18 +02:00
339 changed files with 6718 additions and 296 deletions

View File

@@ -6,8 +6,8 @@ import { TablerIcon } from '@tabler/icons';
// Note: Maybe use context to keep track of the modules
export interface IModule {
id: string;
title: string;
description: string;
icon: TablerIcon;
component: React.ComponentType;
options?: Option;

View File

@@ -26,16 +26,15 @@ import { useColorTheme } from '../../tools/color';
export const CalendarModule: IModule = {
title: 'Calendar',
description:
'A calendar module for displaying upcoming releases. It interacts with the Sonarr and Radarr API.',
icon: CalendarIcon,
component: CalendarComponent,
options: {
sundaystart: {
name: 'Start the week on Sunday',
name: 'descriptor.settings.sundayStart.label',
value: false,
},
},
id: 'calendar',
};
export default function CalendarComponent(props: any) {
@@ -128,7 +127,7 @@ export default function CalendarComponent(props: any) {
}, [config.services]);
const weekStartsAtSunday =
(config?.modules?.[CalendarModule.title]?.options?.sundaystart?.value as boolean) ?? false;
(config?.modules?.[CalendarModule.id]?.options?.sundaystart?.value as boolean) ?? false;
return (
<Calendar
firstDayOfWeek={weekStartsAtSunday ? 'sunday' : 'monday'}

View File

@@ -1,5 +1,6 @@
import { Badge, Button, Group, Image, Stack, Text, Title } from '@mantine/core';
import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useColorTheme } from '../../tools/color';
import { useConfig } from '../../tools/state';
@@ -159,6 +160,7 @@ export function SonarrMediaDisplay(props: any) {
export function MediaDisplay({ media }: { media: IMedia }) {
const [opened, setOpened] = useState(false);
const { secondaryColor } = useColorTheme();
const { t } = useTranslation('modules/common-media-cards');
return (
<Group mr="xs" align="stretch" noWrap style={{ maxHeight: 200 }}>
@@ -209,7 +211,7 @@ export function MediaDisplay({ media }: { media: IMedia }) {
size="sm"
rightIcon={<IconPlayerPlay size={15} />}
>
Play
{t('buttons.play')}
</Button>
)}
{media.imdbId && (
@@ -249,7 +251,7 @@ export function MediaDisplay({ media }: { media: IMedia }) {
size="sm"
rightIcon={<IconDownload size={15} />}
>
Request
{t('buttons.request')}
</Button>
</>
)}

View File

@@ -1,6 +1,7 @@
import { createStyles, Stack, Title, useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
@@ -9,32 +10,38 @@ import { IModule } from '../ModuleTypes';
const asModule = <T extends IModule>(t: T) => t;
export const DashdotModule = asModule({
title: 'Dash.',
description: 'A module for displaying the graphs of your running Dash. instance.',
icon: CalendarIcon,
component: DashdotComponent,
options: {
cpuMultiView: {
name: 'CPU Multi-Core View',
name: 'descriptor.settings.cpuMultiView.label',
value: false,
},
storageMultiView: {
name: 'Storage Multi-Drive View',
name: 'descriptor.settings.storageMultiView.label',
value: false,
},
useCompactView: {
name: 'Use Compact View',
name: 'descriptor.settings.useCompactView.label',
value: false,
},
graphs: {
name: 'Graphs',
name: 'descriptor.settings.graphs.label',
value: ['CPU', 'RAM', 'Storage', 'Network'],
options: ['CPU', 'RAM', 'Storage', 'Network', 'GPU'],
options: [
'descriptor.settings.graphs.options.cpu',
'descriptor.settings.graphs.options.ram',
'descriptor.settings.graphs.options.storage',
'descriptor.settings.graphs.options.network',
'descriptor.settings.graphs.options.GPU',
],
},
url: {
name: 'Dash. URL',
name: 'descriptor.settings.url.label',
value: '',
},
},
id: 'dashdot',
});
const useStyles = createStyles((theme, _params) => ({
@@ -119,7 +126,7 @@ export function DashdotComponent() {
const { classes } = useStyles();
const { colorScheme } = useMantineColorScheme();
const dashConfig = config.modules?.[DashdotModule.title]
const dashConfig = config.modules?.[DashdotModule.id]
.options as typeof DashdotModule['options'];
const isCompact = dashConfig?.useCompactView?.value ?? false;
const dashdotService: serviceItem | undefined = config.services.filter(
@@ -141,32 +148,34 @@ export function DashdotComponent() {
const totalSize =
(info?.storage?.layout as any[])?.reduce((acc, curr) => (curr.size ?? 0) + acc, 0) ?? 0;
const { t } = useTranslation('modules/dashdot');
const graphs = [
{
name: 'CPU',
name: t('card.graphs.cpu.title'),
enabled: cpuEnabled,
params: {
multiView: dashConfig?.cpuMultiView?.value ?? false,
},
},
{
name: 'Storage',
name: t('card.graphs.cpu.title'),
enabled: storageEnabled && !isCompact,
params: {
multiView: dashConfig?.storageMultiView?.value ?? false,
},
},
{
name: 'RAM',
name: t('card.graphs.memory.title'),
enabled: ramEnabled,
},
{
name: 'Network',
name: t('card.graphs.network.title'),
enabled: networkEnabled,
spanTwo: true,
},
{
name: 'GPU',
name: t('card.graphs.gpu.title'),
enabled: gpuEnabled,
spanTwo: true,
},
@@ -175,27 +184,24 @@ export function DashdotComponent() {
if (dashdotUrl === '') {
return (
<div>
<h2 className={classes.heading}>Dash.</h2>
<p>
No dash. service found. Please add one to your Homarr dashboard or set a dashdot URL in
the module options
</p>
<h2 className={classes.heading}>{t('card.title')}</h2>
<p>{t('card.errors.noService')}</p>
</div>
);
}
return (
<div>
<h2 className={classes.heading}>Dash.</h2>
<h2 className={classes.heading}>{t('card.title')}</h2>
{!info ? (
<p>Cannot acquire information from dash. - are you running the latest version?</p>
<p>{t('card.errors.noInformation')}</p>
) : (
<div className={classes.graphsContainer}>
<div className={classes.table}>
{storageEnabled && isCompact && (
<div className={classes.tableRow}>
<p className={classes.tableLabel}>Storage:</p>
<p className={classes.tableLabel}>{t('card.graphs.storage.label')}</p>
<p className={classes.tableValue}>
{((100 * totalUsed) / (totalSize || 1)).toFixed(1)}%{'\n'}
{bytePrettyPrint(totalUsed)} / {bytePrettyPrint(totalSize)}
@@ -204,10 +210,12 @@ export function DashdotComponent() {
)}
{networkEnabled && (
<div className={classes.tableRow}>
<p className={classes.tableLabel}>Network:</p>
<p className={classes.tableLabel}>{t('card.graphs.network.label')}</p>
<p className={classes.tableValue}>
{bpsPrettyPrint(info?.network?.speedUp)} Up{'\n'}
{bpsPrettyPrint(info?.network?.speedDown)} Down
{bpsPrettyPrint(info?.network?.speedUp)} {t('card.graphs.network.metrics.upload')}
{'\n'}
{bpsPrettyPrint(info?.network?.speedDown)}
{t('card.graphs.network.metrics.download')}
</p>
</div>
)}

View File

@@ -8,22 +8,22 @@ import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
export const DateModule: IModule = {
title: 'Date',
description: 'Show the current time and date in a card',
icon: Clock,
component: DateComponent,
options: {
full: {
name: 'Display full time (24-hour)',
name: 'descriptor.settings.display24HourFormat.label',
value: true,
},
},
id: 'date',
};
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 ?? true;
const isFullTime = config?.modules?.[DateModule.id]?.options?.full?.value ?? true;
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
// Change date on minute change
// Note: Using 10 000ms instead of 1000ms to chill a little :)

View File

@@ -10,6 +10,10 @@ import {
} from '@tabler/icons';
import axios from 'axios';
import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { TFunction } from 'react-i18next';
let t: TFunction<'modules/docker', undefined>;
function sendDockerCommand(
action: string,
@@ -30,8 +34,8 @@ function sendDockerCommand(
.then((res) => {
updateNotification({
id: containerId,
title: `Container ${containerName} ${action}ed`,
message: `Your container was successfully ${action}ed`,
title: t('messages.successfullyExecuted.message', { containerName, action }),
message: t('messages.successfullyExecuted.message', { action }),
icon: <IconCheck />,
autoClose: 2000,
});
@@ -40,7 +44,7 @@ function sendDockerCommand(
updateNotification({
id: containerId,
color: 'red',
title: 'There was an error',
title: t('errors.unknownError.title'),
message: err.response.data.reason,
autoClose: 2000,
});
@@ -56,6 +60,8 @@ export interface ContainerActionBarProps {
}
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
t = useTranslation('modules/docker').t;
return (
<Group>
<Button
@@ -72,7 +78,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
radius="md"
disabled={selected.length === 0}
>
Restart
{t('actionBar.restart.title')}
</Button>
<Button
leftIcon={<IconPlayerStop />}
@@ -88,7 +94,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
radius="md"
disabled={selected.length === 0}
>
Stop
{t('actionBar.stop.title')}
</Button>
<Button
leftIcon={<IconPlayerPlay />}
@@ -104,10 +110,10 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
radius="md"
disabled={selected.length === 0}
>
Start
{t('actionBar.start.title')}
</Button>
<Button leftIcon={<IconRefresh />} onClick={() => reload()} variant="light" color="violet" radius="md">
Refresh data
{t('actionBar.refreshData.title')}
</Button>
<Button
leftIcon={<IconTrash />}
@@ -123,7 +129,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
}
disabled={selected.length === 0}
>
Remove
{t('actionBar.remove.title')}
</Button>
</Group>
);

View File

@@ -1,4 +1,5 @@
import { Badge, BadgeVariant, MantineSize } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import Dockerode from 'dockerode';
export interface ContainerStateProps {
@@ -7,6 +8,9 @@ export interface ContainerStateProps {
export default function ContainerState(props: ContainerStateProps) {
const { state } = props;
const { t } = useTranslation('modules/docker');
const options: {
size: MantineSize;
radius: MantineSize;
@@ -20,28 +24,28 @@ export default function ContainerState(props: ContainerStateProps) {
case 'running': {
return (
<Badge color="green" {...options}>
Running
{t('table.states.running')}
</Badge>
);
}
case 'created': {
return (
<Badge color="cyan" {...options}>
Created
{t('table.states.created')}
</Badge>
);
}
case 'exited': {
return (
<Badge color="red" {...options}>
Stopped
{t('table.states.stopped')}
</Badge>
);
}
default: {
return (
<Badge color="purple" {...options}>
Unknown
{t('table.states.unknown')}
</Badge>
);
}

View File

@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
import Docker from 'dockerode';
import { IconBrandDocker, IconX } from '@tabler/icons';
import { showNotification } from '@mantine/notifications';
import { useTranslation } from 'next-i18next';
import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
import { useConfig } from '../../tools/state';
@@ -11,9 +13,9 @@ import { IModule } from '../ModuleTypes';
export const DockerModule: IModule = {
title: 'Docker',
description: 'Allows you to easily manage your torrents',
icon: IconBrandDocker,
component: DockerMenuButton,
id: 'docker',
};
export default function DockerMenuButton(props: any) {
@@ -21,7 +23,9 @@ export default function DockerMenuButton(props: any) {
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const { config } = useConfig();
const moduleEnabled = config.modules?.[DockerModule.title]?.enabled ?? false;
const moduleEnabled = config.modules?.[DockerModule.id]?.enabled ?? false;
const { t } = useTranslation('modules/docker');
useEffect(() => {
reload();
@@ -44,15 +48,15 @@ export default function DockerMenuButton(props: any) {
// Send an Error notification
showNotification({
autoClose: 1500,
title: <Text>Docker integration failed</Text>,
title: <Text>{t('errors.integrationFailed.title')}</Text>,
color: 'red',
icon: <IconX />,
message: 'Did you forget to mount the docker socket ?',
message: t('errors.integrationFailed.message'),
});
});
});
}, 300);
}
const exists = config.modules?.[DockerModule.title]?.enabled ?? false;
const exists = config.modules?.[DockerModule.id]?.enabled ?? false;
if (!exists) {
return null;
}
@@ -69,7 +73,7 @@ export default function DockerMenuButton(props: any) {
>
<DockerTable containers={containers} selection={selection} setSelection={setSelection} />
</Drawer>
<Tooltip label="Docker">
<Tooltip label={t('actionIcon.tooltip')}>
<ActionIcon
variant="default"
radius="md"

View File

@@ -1,6 +1,7 @@
import { Table, Checkbox, Group, Badge, createStyles, ScrollArea, TextInput, Modal, ActionIcon, Tooltip } from '@mantine/core';
import { IconPlus, IconSearch } from '@tabler/icons';
import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { AddAppShelfItemForm } from '../../components/AppShelf/AddAppShelfItem';
import { tryMatchService } from '../../tools/addToHomarr';
@@ -30,6 +31,8 @@ export default function DockerTable({
const [search, setSearch] = useState('');
const [opened, setOpened] = useState<boolean>(false);
const { t } = useTranslation('modules/docker');
useEffect(() => {
setContainers(containers);
}, [containers]);
@@ -86,7 +89,9 @@ export default function DockerTable({
</Badge>
))}
{element.Ports.length > 3 && (
<Badge variant="filled">{element.Ports.length - 3} more</Badge>
<Badge variant="filled">
{t('table.body.portCollapse', { ports: element.Ports.length - 3 })}
</Badge>
)}
</Group>
</td>
@@ -95,7 +100,7 @@ export default function DockerTable({
</td>
<td>
<Group>
<Tooltip label="Add to Homarr">
<Tooltip label={t('table.body.action.addToHomarr')}>
<ActionIcon
color="indigo"
variant="light"
@@ -121,16 +126,16 @@ export default function DockerTable({
radius="md"
opened={opened}
onClose={() => setOpened(false)}
title="Add service"
title={t('actionBar.addService.title')}
>
<AddAppShelfItemForm
setOpened={setOpened}
{...tryMatchService(rowSelected)}
message="Add service to homarr"
message={t('actionBar.addService.message')}
/>
</Modal>
<TextInput
placeholder="Search by container or image name"
placeholder={t('search.placeholder')}
mt="md"
icon={<IconSearch size={14} />}
value={search}
@@ -149,11 +154,11 @@ export default function DockerTable({
disabled={usedContainers.length === 0}
/>
</th>
<th>Name</th>
<th>Image</th>
<th>Ports</th>
<th>State</th>
<th>Action</th>
<th>{t('table.header.name')}</th>
<th>{t('table.header.image')}</th>
<th>{t('table.header.ports')}</th>
<th>{t('table.header.state')}</th>
<th>{t('table.header.action')}</th>
</tr>
</thead>
<tbody>{rows}</tbody>

View File

@@ -8,6 +8,7 @@ import {
Skeleton,
ScrollArea,
Center,
Stack,
} from '@mantine/core';
import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react';
@@ -15,6 +16,7 @@ import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { useViewportSize } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { useTranslation } from 'next-i18next';
import { IModule } from '../ModuleTypes';
import { useConfig } from '../../tools/state';
import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem';
@@ -23,15 +25,15 @@ import { humanFileSize } from '../../tools/humanFileSize';
export const DownloadsModule: IModule = {
title: 'Torrent',
description: 'Show the current download speed of supported services',
icon: Download,
component: DownloadComponent,
options: {
hidecomplete: {
name: 'Hide completed torrents',
name: 'descriptor.settings.hideComplete',
value: false,
},
},
id: 'torrents-status',
};
export default function DownloadComponent() {
@@ -45,10 +47,13 @@ export default function DownloadComponent() {
service.type === 'Deluge'
) ?? [];
const hideComplete: boolean =
(config?.modules?.[DownloadsModule.title]?.options?.hidecomplete?.value as boolean) ?? false;
(config?.modules?.[DownloadsModule.id]?.options?.hidecomplete?.value as boolean) ?? false;
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
const setSafeInterval = useSetSafeInterval();
const [isLoading, setIsLoading] = useState(true);
const { t } = useTranslation(`modules/${DownloadsModule.id}`);
useEffect(() => {
setIsLoading(true);
if (downloadServices.length === 0) return;
@@ -81,13 +86,13 @@ export default function DownloadComponent() {
if (downloadServices.length === 0) {
return (
<Group>
<Title order={3}>No supported download clients found!</Title>
<Stack>
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
<Group>
<Text>Add a download service to view your current downloads</Text>
<Text>{t('card.errors.noDownloadClients.text')}</Text>
<AddItemShelfButton />
</Group>
</Group>
</Stack>
);
}
@@ -105,12 +110,12 @@ export default function DownloadComponent() {
const DEVICE_WIDTH = 576;
const ths = (
<tr>
<th>Name</th>
<th>Size</th>
{width > 576 ? <th>Down</th> : ''}
{width > 576 ? <th>Up</th> : ''}
<th>ETA</th>
<th>Progress</th>
<th>{t('card.table.header.name')}</th>
<th>{t('card.table.header.size')}</th>
{width > 576 ? <th>{t('card.table.header.download')}</th> : ''}
{width > 576 ? <th>{t('card.table.header.upload')}</th> : ''}
<th>{t('card.table.header.estimatedTimeOfArrival')}</th>
<th>{t('card.table.header.progress')}</th>
</tr>
);
// Convert Seconds to readable format.
@@ -195,7 +200,7 @@ export default function DownloadComponent() {
</Table>
) : (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>No torrents found</Title>
<Title order={3}>{t('card.table.body.nothingFound')}</Title>
</Center>
)}
</ScrollArea>

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { linearGradientDef } from '@nivo/core';
import { useTranslation } from 'next-i18next';
import { Datum, ResponsiveLine } from '@nivo/line';
import { useListState } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
@@ -15,9 +16,9 @@ import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
export const TotalDownloadsModule: IModule = {
title: 'Download Speed',
description: 'Show the current download speed of supported services',
icon: Download,
component: TotalDownloadsComponent,
id: 'dlspeed',
};
interface torrentHistory {
@@ -36,6 +37,7 @@ export default function TotalDownloadsComponent() {
service.type === 'Transmission' ||
service.type === 'Deluge'
) ?? [];
const { t } = useTranslation(`modules/${TotalDownloadsModule.id}`);
const [torrentHistory, torrentHistoryHandlers] = useListState<torrentHistory>([]);
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
@@ -80,14 +82,14 @@ export default function TotalDownloadsComponent() {
if (downloadServices.length === 0) {
return (
<Group>
<Title order={4}>No supported download clients found!</Title>
<Title order={4}>{t('card.errors.noDownloadClients.title')}</Title>
<div>
<AddItemShelfButton
style={{
float: 'inline-end',
}}
/>
Add a download service to view your current downloads
{t('card.errors.noDownloadClients.text')}
</div>
</Group>
);
@@ -107,15 +109,19 @@ export default function TotalDownloadsComponent() {
return (
<Stack>
<Title order={4}>Current download speed</Title>
<Title order={4}>{t('card.lineChart.title')}</Title>
<Stack>
<Group>
<ColorSwatch size={12} color={theme.colors.green[5]} />
<Text>Download: {humanFileSize(totalDownloadSpeed)}/s</Text>
<Text>
{t('card.lineChart.totalDownload', { download: humanFileSize(totalDownloadSpeed) })}
</Text>
</Group>
<Group>
<ColorSwatch size={12} color={theme.colors.blue[5]} />
<Text>Upload: {humanFileSize(totalUploadSpeed)}/s</Text>
<Text>
{t('card.lineChart.totalUpload', { upload: humanFileSize(totalUploadSpeed) })}
</Text>
</Group>
</Stack>
<Box
@@ -136,16 +142,20 @@ export default function TotalDownloadsComponent() {
const roundedSeconds = Math.round(seconds);
return (
<Card p="sm" radius="md" withBorder>
<Text size="md">{roundedSeconds} seconds ago</Text>
<Text size="md">{t('card.lineChart.timeSpan', { seconds: roundedSeconds })}</Text>
<Card.Section p="sm">
<Stack>
<Group>
<ColorSwatch size={10} color={theme.colors.green[5]} />
<Text size="md">Download: {humanFileSize(Download)}</Text>
<Text size="md">
{t('card.lineChart.download', { download: humanFileSize(Download) })}
</Text>
</Group>
<Group>
<ColorSwatch size={10} color={theme.colors.blue[5]} />
<Text size="md">Upload: {humanFileSize(Upload)}</Text>
<Text size="md">
{t('card.lineChart.upload', { upload: humanFileSize(Upload) })}
</Text>
</Group>
</Stack>
</Card.Section>

View File

@@ -11,12 +11,15 @@ import {
} from '@mantine/core';
import { IconAdjustments } from '@tabler/icons';
import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfig } from '../tools/state';
import { IModule } from './ModuleTypes';
function getItems(module: IModule) {
const { config, setConfig } = useConfig();
const { t } = useTranslation([module.id, 'common']);
const items: JSX.Element[] = [];
if (module.options) {
const keys = Object.keys(module.options);
@@ -25,8 +28,8 @@ function getItems(module: IModule) {
const types = values.map((v) => typeof v.value);
// Loop over all the types with a for each loop
types.forEach((type, index) => {
const optionName = `${module.title}.${keys[index]}`;
const moduleInConfig = config.modules?.[module.title];
const optionName = `${module.id}.${keys[index]}`;
const moduleInConfig = config.modules?.[module.id];
if (type === 'object') {
items.push(
<MultiSelect
@@ -43,7 +46,7 @@ function getItems(module: IModule) {
...config,
modules: {
...config.modules,
[module.title]: {
[module.id]: {
...moduleInConfig,
options: {
...moduleInConfig?.options,
@@ -68,12 +71,12 @@ function getItems(module: IModule) {
...config,
modules: {
...config.modules,
[module.title]: {
...config.modules[module.title],
[module.id]: {
...config.modules[module.id],
options: {
...config.modules[module.title].options,
...config.modules[module.id].options,
[keys[index]]: {
...config.modules[module.title].options?.[keys[index]],
...config.modules[module.id].options?.[keys[index]],
value: (e.target as any)[0].value,
},
},
@@ -96,7 +99,7 @@ function getItems(module: IModule) {
onChange={(e) => {}}
/>
<Button type="submit">Save</Button>
<Button type="submit">{t('actions.save')}</Button>
</Group>
</form>
);
@@ -117,12 +120,12 @@ function getItems(module: IModule) {
...config,
modules: {
...config.modules,
[module.title]: {
...config.modules[module.title],
[module.id]: {
...config.modules[module.id],
options: {
...config.modules[module.title].options,
...config.modules[module.id].options,
[keys[index]]: {
...config.modules[module.title].options?.[keys[index]],
...config.modules[module.id].options?.[keys[index]],
value: e.currentTarget.checked,
},
},
@@ -130,7 +133,7 @@ function getItems(module: IModule) {
},
});
}}
label={values[index].name}
label={t(values[index].name)}
/>
);
}
@@ -145,9 +148,10 @@ export function ModuleWrapper(props: any) {
const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules[module.title]?.enabled ?? false;
const isShown = enabledModules[module.id]?.enabled ?? false;
//TODO: fix the hover problem
const [hovering, setHovering] = useState(false);
const { t } = useTranslation('modules');
if (!isShown) {
return null;
@@ -156,7 +160,7 @@ export function ModuleWrapper(props: any) {
return (
<Card
{...props}
key={module.title}
key={module.id}
hidden={!isShown}
withBorder
radius="lg"
@@ -186,11 +190,12 @@ export function ModuleWrapper(props: any) {
export function ModuleMenu(props: any) {
const { module, styles, hovered } = props;
const items: JSX.Element[] = getItems(module);
const { t } = useTranslation('modules/common');
return (
<>
{module.options && (
<Menu
key={module.title}
key={module.id}
withinPortal
width="lg"
shadow="xl"
@@ -217,7 +222,7 @@ export function ModuleMenu(props: any) {
</motion.div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Settings</Menu.Label>
<Menu.Label>{t('settings.label')}</Menu.Label>
{items.map((item) => (
<Menu.Item key={item.key}>{item}</Menu.Item>
))}

View File

@@ -4,9 +4,9 @@ import { IModule } from '../ModuleTypes';
export const OverseerrModule: IModule = {
title: 'Overseerr',
description: 'Allows you to search and add media from Overseerr/Jellyseerr',
icon: IconEyeglass,
component: OverseerrMediaDisplay,
id: 'overseerr',
};
export interface OverseerSearchProps {

View File

@@ -3,6 +3,8 @@ import { showNotification, updateNotification } from '@mantine/notifications';
import { IconAlertCircle, IconCheck, IconDownload } from '@tabler/icons';
import axios from 'axios';
import Consola from 'consola';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useColorTheme } from '../../tools/color';
import { MovieResult } from './Movie.d';
@@ -27,6 +29,7 @@ const useStyles = createStyles((theme) => ({
export function RequestModal({ base, opened, setOpened }: RequestModalProps) {
const [result, setResult] = useState<MovieResult | TvShowResult>();
const { secondaryColor } = useColorTheme();
function getResults(base: Result) {
axios.get(`/api/modules/overseerr/${base.id}?type=${base.mediaType}`).then((res) => {
setResult(res.data);
@@ -55,6 +58,8 @@ export function MovieRequestModal({
setOpened: (opened: boolean) => void;
}) {
const { secondaryColor } = useColorTheme();
const { t } = useTranslation('modules/overseerr');
return (
<Modal
onClose={() => setOpened(false)}
@@ -67,23 +72,23 @@ export function MovieRequestModal({
title={
<Group>
<IconDownload />
Ask for {result.title}
{t('popup.item.buttons.askFor', { title: result.title })}
</Group>
}
>
<Stack>
<Alert
icon={<IconAlertCircle size={16} />}
title="Using API key"
title={t('popup.item.alerts.automaticApproval.title')}
color={secondaryColor}
radius="md"
variant="filled"
>
This request will be automatically approved
{t('popup.item.alerts.automaticApproval.text')}
</Alert>
<Group>
<Button variant="outline" color="gray" onClick={() => setOpened(false)}>
Cancel
{t('popup.item.buttons.cancel')}
</Button>
<Button
variant="outline"
@@ -91,7 +96,7 @@ export function MovieRequestModal({
askForMedia(MediaType.Movie, result.id, result.title, []);
}}
>
Request
{t('popup.item.buttons.request')}
</Button>
</Group>
</Stack>
@@ -110,6 +115,7 @@ export function TvRequestModal({
}) {
const [selection, setSelection] = useState<TvShowResultSeason[]>(result.seasons);
const { classes, cx } = useStyles();
const { t } = useTranslation('modules/overseerr');
const toggleRow = (container: TvShowResultSeason) =>
setSelection((current: TvShowResultSeason[]) =>
@@ -148,22 +154,24 @@ export function TvRequestModal({
title={
<Group>
<IconDownload />
Ask for {result.name ?? result.originalName ?? 'a TV show'}
{t('popup.item.buttons.askFor', {
title: result.name ?? result.originalName ?? 'a TV show',
})}
</Group>
}
>
<Stack>
<Alert
icon={<IconAlertCircle size={16} />}
title="Using API key"
title={t('popup.item.alerts.automaticApproval.title')}
color={secondaryColor}
radius="md"
variant="filled"
>
This request will be automatically approved
{t('popup.item.alerts.automaticApproval.text')}
</Alert>
<Table captionSide="bottom" highlightOnHover>
<caption>Tick the seasons that you want to be downloaded</caption>
<caption>{t('popup.seasonSelector.caption')}</caption>
<thead>
<tr>
<th>
@@ -174,15 +182,15 @@ export function TvRequestModal({
transitionDuration={0}
/>
</th>
<th>Season</th>
<th>Number of episodes</th>
<th>{t('popup.seasonSelector.table.header.season')}</th>
<th>{t('popup.seasonSelector.table.header.numberOfEpisodes')}</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<Group>
<Button variant="outline" color="gray" onClick={() => setOpened(false)}>
Cancel
{t('popup.item.buttons.cancel')}
</Button>
<Button
variant="outline"
@@ -196,7 +204,7 @@ export function TvRequestModal({
);
}}
>
Request
{t('popup.item.buttons.request')}
</Button>
</Group>
</Stack>

View File

@@ -3,14 +3,15 @@ import axios, { AxiosResponse } from 'axios';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { IconPlug as Plug } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
export const PingModule: IModule = {
title: 'Ping Services',
description: 'Pings your services and shows their status as an indicator',
icon: Plug,
component: PingComponent,
id: 'ping',
};
export default function PingComponent(props: any) {
@@ -20,7 +21,9 @@ export default function PingComponent(props: any) {
const { url }: { url: string } = props;
const [isOnline, setOnline] = useState<State>('loading');
const [response, setResponse] = useState(500);
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
const exists = config.modules?.[PingModule.id]?.enabled ?? false;
const { t } = useTranslation('modules/ping');
function statusCheck(response: AxiosResponse) {
const { status }: { status: string[] } = props;
@@ -51,7 +54,7 @@ export default function PingComponent(props: any) {
.catch((error) => {
statusCheck(error.response);
});
}, [config.modules?.[PingModule.title]?.enabled]);
}, [config.modules?.[PingModule.id]?.enabled]);
if (!exists) {
return null;
}
@@ -68,10 +71,10 @@ export default function PingComponent(props: any) {
radius="lg"
label={
isOnline === 'loading'
? 'Loading...'
? t('states.loading')
: isOnline === 'online'
? `Online - ${response}`
: `Offline - ${response}`
? t('states.online', { response })
: t('states.offline', { response })
}
>
<Indicator

View File

@@ -1,19 +1,21 @@
import { Kbd, createStyles, Autocomplete, Popover, ScrollArea, Divider } from '@mantine/core';
import { useClickOutside, useDebouncedValue, useHotkeys } from '@mantine/hooks';
import { useForm } from '@mantine/form';
import React, { useEffect, useRef, useState } from 'react';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import {
IconSearch as Search,
IconBrandYoutube as BrandYoutube,
IconDownload as Download,
IconMovie,
} from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import axios from 'axios';
import { showNotification } from '@mantine/notifications';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import { OverseerrModule } from '../overseerr';
import { OverseerrMediaDisplay } from '../common';
import SmallServiceItem from '../../components/AppShelf/SmallServiceItem';
const useStyles = createStyles((theme) => ({
hide: {
@@ -26,18 +28,18 @@ const useStyles = createStyles((theme) => ({
}));
export const SearchModule: IModule = {
title: 'Search Bar',
description: 'Search bar to search the web, youtube, torrents or overseerr',
title: 'Search',
icon: Search,
component: SearchBar,
id: 'search',
};
export default function SearchBar(props: any) {
const { classes, cx } = useStyles();
// Config
const { config } = useConfig();
const isModuleEnabled = config.modules?.[SearchModule.title]?.enabled ?? false;
const isOverseerrEnabled = config.modules?.[OverseerrModule.title]?.enabled ?? false;
const isModuleEnabled = config.modules?.[SearchModule.id]?.enabled ?? false;
const isOverseerrEnabled = config.modules?.[OverseerrModule.id]?.enabled ?? false;
const OverseerrService = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
);
@@ -59,6 +61,7 @@ export default function SearchBar(props: any) {
},
});
const [debounced, cancel] = useDebouncedValue(form.values.query, 250);
const { t } = useTranslation('modules/search');
useEffect(() => {
if (OverseerrService === undefined && isOverseerrEnabled) {
@@ -98,11 +101,36 @@ export default function SearchBar(props: any) {
if (!isModuleEnabled) {
return null;
}
const autocompleteData = results.map((result) => ({
// Match all the services that contain the query in their name if the query is not empty
const matchingServices = config.services.filter((service) => {
if (form.values.query === '' || form.values.query === undefined) {
return false;
}
return service.name.toLowerCase().includes(form.values.query.toLowerCase());
});
const autocompleteData = matchingServices.map((service) => ({
label: service.name,
value: service.name,
icon: service.icon,
url: service.openedUrl ?? service.url,
}));
// Append the matching results to the autocomplete data
const autoCompleteResults = results.map((result) => ({
label: result.phrase,
value: result.phrase,
icon: result.icon,
url: result.url,
}));
autocompleteData.push(...autoCompleteResults);
const AutoCompleteItem = forwardRef<HTMLDivElement, any>(
({ label, value, icon, url, ...others }: any, ref) => (
<div ref={ref} {...others}>
<SmallServiceItem service={{ label, value, icon, url }} />
</div>
)
);
return (
<form
onChange={() => {
@@ -161,6 +189,15 @@ export default function SearchBar(props: any) {
onFocusCapture={() => setOpened(true)}
autoFocus
variant="filled"
itemComponent={AutoCompleteItem}
onItemSubmit={(item) => {
setOpened(false);
if (item.url) {
results.splice(0, autocompleteData.length);
form.reset();
window.open(item.url);
}
}}
data={autocompleteData}
icon={icon}
ref={textInput}
@@ -175,7 +212,7 @@ export default function SearchBar(props: any) {
radius="md"
size="md"
styles={{ rightSection: { pointerEvents: 'none' } }}
placeholder="Search the web..."
placeholder={t('input.placeholder')}
{...props}
{...form.getInputProps('query')}
/>

View File

@@ -13,25 +13,26 @@ import {
IconSnowflake as Snowflake,
IconSun as Sun,
} from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import { WeatherResponse } from './WeatherInterface';
export const WeatherModule: IModule = {
title: 'Weather',
description: 'Look up the current weather in your location',
icon: Sun,
component: WeatherComponent,
options: {
freedomunit: {
name: 'Display in Fahrenheit',
name: 'descriptor.settings.displayInFahrenheit.label',
value: false,
},
location: {
name: 'Current location',
name: 'descriptor.settings.location.label',
value: 'Paris',
},
},
id: 'weather',
};
// 0 Clear sky
@@ -48,79 +49,87 @@ export const WeatherModule: IModule = {
// 95 *Thunderstorm: Slight or moderate
// 96, 99 *Thunderstorm with slight and heavy hail
export function WeatherIcon(props: any) {
const { t } = useTranslation('modules/weather');
const { code } = props;
let data: { icon: any; name: string };
switch (code) {
case 0: {
data = { icon: Sun, name: 'Clear' };
data = { icon: Sun, name: t('card.weatherDescriptions.clear') };
break;
}
case 1:
case 2:
case 3: {
data = { icon: Cloud, name: 'Mainly clear' };
data = { icon: Cloud, name: t('card.weatherDescriptions.mainlyClear') };
break;
}
case 45:
case 48: {
data = { icon: CloudFog, name: 'Fog' };
data = { icon: CloudFog, name: t('card.weatherDescriptions.fog') };
break;
}
case 51:
case 53:
case 55: {
data = { icon: Cloud, name: 'Drizzle' };
data = { icon: Cloud, name: t('card.weatherDescriptions.drizzle') };
break;
}
case 56:
case 57: {
data = { icon: Snowflake, name: 'Freezing drizzle' };
data = {
icon: Snowflake,
name: t('card.weatherDescriptions.freezingDrizzle'),
};
break;
}
case 61:
case 63:
case 65: {
data = { icon: CloudRain, name: 'Rain' };
data = { icon: CloudRain, name: t('card.weatherDescriptions.rain') };
break;
}
case 66:
case 67: {
data = { icon: CloudRain, name: 'Freezing rain' };
data = { icon: CloudRain, name: t('card.weatherDescriptions.freezingRain') };
break;
}
case 71:
case 73:
case 75: {
data = { icon: CloudSnow, name: 'Snow fall' };
data = { icon: CloudSnow, name: t('card.weatherDescriptions.snowFall') };
break;
}
case 77: {
data = { icon: CloudSnow, name: 'Snow grains' };
data = { icon: CloudSnow, name: t('card.weatherDescriptions.snowGrains') };
break;
}
case 80:
case 81:
case 82: {
data = { icon: CloudRain, name: 'Rain showers' };
data = { icon: CloudRain, name: t('card.weatherDescriptions.rainShowers') };
break;
}
case 85:
case 86: {
data = { icon: CloudSnow, name: 'Snow showers' };
data = { icon: CloudSnow, name: t('card.weatherDescriptions.snowShowers') };
break;
}
case 95: {
data = { icon: CloudStorm, name: 'Thunderstorm' };
data = { icon: CloudStorm, name: t('card.weatherDescriptions.thunderstorm') };
break;
}
case 96:
case 99: {
data = { icon: CloudStorm, name: 'Thunderstorm with hail' };
data = {
icon: CloudStorm,
name: t('card.weatherDescriptions.thunderstormWithHail'),
};
break;
}
default: {
data = { icon: QuestionMark, name: 'Unknown' };
data = { icon: QuestionMark, name: t('card.weatherDescriptions.unknown') };
}
}
return (
@@ -137,9 +146,9 @@ export default function WeatherComponent(props: any) {
const { config } = useConfig();
const [weather, setWeather] = useState({} as WeatherResponse);
const cityInput: string =
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? 'Paris';
(config?.modules?.[WeatherModule.id]?.options?.location?.value as string) ?? 'Paris';
const isFahrenheit: boolean =
(config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value as boolean) ?? false;
(config?.modules?.[WeatherModule.id]?.options?.freedomunit?.value as boolean) ?? false;
useEffect(() => {
axios