v0.6.0 Categories and current download graphs ! 🥳

This commit is contained in:
Thomas Camlong
2022-06-03 14:05:31 +02:00
committed by ajnart
48 changed files with 18123 additions and 12070 deletions

View File

@@ -52,7 +52,7 @@ jobs:
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache. # If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --frozen-lockfile - run: yarn install --immutable
- run: yarn build - run: yarn build
- name: Cache build output - name: Cache build output
# to copy needed files to docker build job # to copy needed files to docker build job

View File

@@ -62,7 +62,7 @@ jobs:
# If source files changed but packages didn't, rebuild from a prior cache. # If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --frozen-lockfile - run: yarn install --immutable
- run: yarn build - run: yarn build
- name: Cache build output - name: Cache build output

10
.gitignore vendored
View File

@@ -37,3 +37,13 @@ yarn-error.log*
# storybook # storybook
storybook-static storybook-static
data/configs data/configs
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
# Yarn v2
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

786
.yarn/releases/yarn-3.2.1.cjs vendored Normal file

File diff suppressed because one or more lines are too long

5
.yarnrc Normal file
View File

@@ -0,0 +1,5 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
yarn-path ".yarn/releases/yarn-1.22.19.cjs"

3
.yarnrc.yml Normal file
View File

@@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.2.1.cjs

View File

@@ -21,7 +21,7 @@
<i>Join the discord! — Don't forget to star the repo if you are enjoying the project!</i> <i>Join the discord! — Don't forget to star the repo if you are enjoying the project!</i>
</p> </p>
<p align="center"> <p align="center">
<a href="https://homarr.netlify.app/"><strong> Demo ↗️ </strong></a> • <a href="#-installation"><strong> Install ➡️ </strong></a> • <a href="https://github.com/ajnart/homarr/wiki"><strong> Read the Wiki 📄 </strong></a> <a href="https://homarr.ajnart.fr/"><strong> Demo ↗️ </strong></a> • <a href="#-installation"><strong> Install ➡️ </strong></a> • <a href="https://github.com/ajnart/homarr/wiki"><strong> Read the Wiki 📄 </strong></a>
</p> </p>
--- ---

View File

@@ -1,2 +1,2 @@
export const REPO_URL = 'ajnart/homarr'; export const REPO_URL = 'ajnart/homarr';
export const CURRENT_VERSION = 'v0.5.2'; export const CURRENT_VERSION = 'v0.6.0';

View File

@@ -1,7 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "0.5.2", "version": "0.6.0",
"private": "false",
"description": "Homarr - A homepage for your server.", "description": "Homarr - A homepage for your server.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -27,8 +26,10 @@
"dependencies": { "dependencies": {
"@ctrl/deluge": "^4.0.0", "@ctrl/deluge": "^4.0.0",
"@ctrl/qbittorrent": "^4.0.0", "@ctrl/qbittorrent": "^4.0.0",
"@ctrl/shared-torrent": "^4.1.0",
"@dnd-kit/core": "^6.0.1", "@dnd-kit/core": "^6.0.1",
"@dnd-kit/sortable": "^7.0.0", "@dnd-kit/sortable": "^7.0.0",
"@dnd-kit/utilities": "^3.2.0",
"@mantine/core": "^4.2.6", "@mantine/core": "^4.2.6",
"@mantine/dates": "^4.2.6", "@mantine/dates": "^4.2.6",
"@mantine/dropzone": "^4.2.6", "@mantine/dropzone": "^4.2.6",
@@ -37,6 +38,9 @@
"@mantine/next": "^4.2.6", "@mantine/next": "^4.2.6",
"@mantine/notifications": "^4.2.6", "@mantine/notifications": "^4.2.6",
"@mantine/prism": "^4.2.6", "@mantine/prism": "^4.2.6",
"@nivo/core": "^0.79.0",
"@nivo/line": "^0.79.1",
"@tabler/icons": "^1.68.0",
"axios": "^0.27.2", "axios": "^0.27.2",
"cookies-next": "^2.0.4", "cookies-next": "^2.0.4",
"dayjs": "^1.11.2", "dayjs": "^1.11.2",
@@ -46,7 +50,7 @@
"prism-react-renderer": "^1.3.1", "prism-react-renderer": "^1.3.1",
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"tabler-icons-react": "^1.46.0", "systeminformation": "^5.11.16",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
@@ -78,5 +82,6 @@
}, },
"resolutions": { "resolutions": {
"@types/react": "17.0.30" "@types/react": "17.0.30"
} },
"packageManager": "yarn@3.2.1"
} }

View File

@@ -10,10 +10,12 @@ import {
ActionIcon, ActionIcon,
Tooltip, Tooltip,
Title, Title,
Anchor,
Text,
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useState } from 'react'; import { useState } from 'react';
import { Apps } from 'tabler-icons-react'; import { IconApps as Apps } from '@tabler/icons';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { ServiceTypeList } from '../../tools/types'; import { ServiceTypeList } from '../../tools/types';
@@ -61,15 +63,48 @@ function MatchIcon(name: string, form: any) {
return false; return false;
} }
function MatchService(name: string, form: any) {
const service = ServiceTypeList.find((s) => s === name);
if (service) {
form.setFieldValue('type', service);
}
}
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' },
];
// Match name with portmap key
const port = portmap.find((p) => p.name === name);
if (port) {
form.setFieldValue('url', `http://localhost:${port.value}`);
}
}
export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) { export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) {
const { setOpened } = props; const { setOpened } = props;
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const [isLoading, setLoading] = useState(false); const [isLoading, setLoading] = useState(false);
// Extract all the categories from the services in config
const categoryList = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
id: props.id ?? uuidv4(), id: props.id ?? uuidv4(),
type: props.type ?? 'Other', type: props.type ?? 'Other',
category: props.category ?? undefined,
name: props.name ?? '', name: props.name ?? '',
icon: props.icon ?? '/favicon.svg', icon: props.icon ?? '/favicon.svg',
url: props.url ?? '', url: props.url ?? '',
@@ -99,6 +134,15 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
}, },
}); });
// 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;
try {
hostname = new URL(form.values.url).origin;
} catch (e) {
// Do nothing
}
return ( return (
<> <>
<Center> <Center>
@@ -145,10 +189,9 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
value={form.values.name} value={form.values.name}
onChange={(event) => { onChange={(event) => {
form.setFieldValue('name', event.currentTarget.value); form.setFieldValue('name', event.currentTarget.value);
const match = MatchIcon(event.currentTarget.value, form); MatchIcon(event.currentTarget.value, form);
if (match) { MatchService(event.currentTarget.value, form);
form.setFieldValue('icon', match); MatchPort(event.currentTarget.value, form);
}
}} }}
error={form.errors.name && 'Invalid icon url'} error={form.errors.name && 'Invalid icon url'}
/> />
@@ -166,7 +209,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
{...form.getInputProps('url')} {...form.getInputProps('url')}
/> />
<Select <Select
label="Select the type of service (used for API calls)" label="Service type"
defaultValue="Other" defaultValue="Other"
placeholder="Pick one" placeholder="Pick one"
required required
@@ -174,21 +217,56 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
data={ServiceTypeList} data={ServiceTypeList}
{...form.getInputProps('type')} {...form.getInputProps('type')}
/> />
<Select
label="Category"
data={categoryList}
placeholder="Select a category or create a new one"
nothingFound="Nothing found"
searchable
clearable
creatable
onClick={(e) => {
e.preventDefault();
}}
getCreateLabel={(query) => `+ Create "${query}"`}
onCreate={(query) => {}}
{...form.getInputProps('category')}
/>
<LoadingOverlay visible={isLoading} /> <LoadingOverlay visible={isLoading} />
{(form.values.type === 'Sonarr' || {(form.values.type === 'Sonarr' ||
form.values.type === 'Radarr' || form.values.type === 'Radarr' ||
form.values.type === 'Lidarr' || form.values.type === 'Lidarr' ||
form.values.type === 'Readarr') && ( form.values.type === 'Readarr') && (
<TextInput <>
required <TextInput
label="API key" required
placeholder="Your API key" label="API key"
value={form.values.apiKey} placeholder="Your API key"
onChange={(event) => { value={form.values.apiKey}
form.setFieldValue('apiKey', event.currentTarget.value); onChange={(event) => {
}} form.setFieldValue('apiKey', event.currentTarget.value);
error={form.errors.apiKey && 'Invalid API key'} }}
/> error={form.errors.apiKey && 'Invalid API key'}
/>
<Text
style={{
alignSelf: 'center',
fontSize: '0.75rem',
textAlign: 'center',
color: 'gray',
}}
>
Tip: Get your API key{' '}
<Anchor
target="_blank"
weight="bold"
style={{ fontStyle: 'inherit', fontSize: 'inherit' }}
href={`${hostname}/settings/general`}
>
here.
</Anchor>
</Text>
</>
)} )}
{form.values.type === 'qBittorrent' && ( {form.values.type === 'qBittorrent' && (
<> <>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Grid } from '@mantine/core'; import { Grid, Group, Title } from '@mantine/core';
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
@@ -12,6 +12,8 @@ import { arrayMove, SortableContext } from '@dnd-kit/sortable';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem'; import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
import { ModuleWrapper } from '../modules/moduleWrapper';
import { DownloadsModule } from '../modules';
const AppShelf = (props: any) => { const AppShelf = (props: any) => {
const [activeId, setActiveId] = useState(null); const [activeId, setActiveId] = useState(null);
@@ -45,34 +47,86 @@ const AppShelf = (props: any) => {
setActiveId(null); setActiveId(null);
} }
// Extract all the categories from the services in config
const categoryList = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
return ( const item = (filter?: string) => {
<DndContext // If filter is not set, return all the services without a category or a null category
sensors={sensors} let filtered = config.services;
collisionDetection={closestCenter} if (!filter) {
onDragStart={handleDragStart} filtered = config.services.filter((e) => !e.category || e.category === null);
onDragEnd={handleDragEnd} }
> if (filter) {
<SortableContext items={config.services}> filtered = config.services.filter((e) => e.category === filter);
<Grid gutter="xl" align="center"> }
{config.services.map((service) => (
<Grid.Col key={service.id} span={6} xl={2} xs={4} sm={3} md={3}> return (
<SortableAppShelfItem service={service} key={service.id} id={service.id} /> <DndContext
</Grid.Col> sensors={sensors}
))} collisionDetection={closestCenter}
</Grid> onDragStart={handleDragStart}
</SortableContext> onDragEnd={handleDragEnd}
<DragOverlay
style={{
// Add a shadow to the drag overlay
boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)',
}}
> >
{activeId ? ( <SortableContext items={config.services}>
<AppShelfItem service={config.services.find((e) => e.id === activeId)} id={activeId} /> <Grid gutter="xl" align="center">
{filtered.map((service) => (
<Grid.Col key={service.id} span={6} xl={2} xs={4} sm={3} md={3}>
<SortableAppShelfItem service={service} key={service.id} id={service.id} />
</Grid.Col>
))}
</Grid>
</SortableContext>
<DragOverlay
style={{
// Add a shadow to the drag overlay
boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)',
}}
>
{activeId ? (
<AppShelfItem service={config.services.find((e) => e.id === activeId)} id={activeId} />
) : null}
</DragOverlay>
</DndContext>
);
};
if (categoryList.length > 0) {
const noCategory = config.services.filter(
(e) => e.category === undefined || e.category === null
);
return (
// Return one item for each category
<Group grow direction="column">
{categoryList.map((category) => (
<>
<Title order={3} key={category}>
{category}
</Title>
{item(category)}
</>
))}
{/* Return the item for all services without category */}
{noCategory && noCategory.length > 0 ? (
<>
<Title order={3}>Other</Title>
{item()}
</>
) : null} ) : null}
</DragOverlay> <ModuleWrapper mt="xl" module={DownloadsModule} />
</DndContext> </Group>
);
}
return (
<Group grow direction="column">
{item()}
<ModuleWrapper mt="xl" module={DownloadsModule} />
</Group>
); );
}; };

View File

@@ -91,11 +91,11 @@ export function AppShelfItem(props: any) {
> >
<motion.i <motion.i
whileHover={{ whileHover={{
cursor: 'pointer',
scale: 1.1, scale: 1.1,
}} }}
> >
<Image <Image
styles={{ root: { cursor: 'pointer' } }}
width={80} width={80}
height={80} height={80}
src={service.icon} src={service.icon}

View File

@@ -1,7 +1,7 @@
import { Menu, Modal, Text, useMantineTheme } from '@mantine/core'; import { Menu, Modal, Text, useMantineTheme } from '@mantine/core';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { useState } from 'react'; import { useState } from 'react';
import { Check, Edit, Trash } from 'tabler-icons-react'; import { IconCheck as Check, IconEdit as Edit, IconTrash as Trash } from '@tabler/icons';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types'; import { serviceItem } from '../../tools/types';
import { AddAppShelfItemForm } from './AddAppShelfItem'; import { AddAppShelfItemForm } from './AddAppShelfItem';
@@ -24,6 +24,7 @@ export default function AppShelfMenu(props: any) {
setOpened={setOpened} setOpened={setOpened}
name={service.name} name={service.name}
id={service.id} id={service.id}
category={service.category}
type={service.type} type={service.type}
url={service.url} url={service.url}
icon={service.icon} icon={service.icon}
@@ -36,17 +37,18 @@ export default function AppShelfMenu(props: any) {
<Menu <Menu
position="right" position="right"
radius="md" radius="md"
shadow="xl"
styles={{ styles={{
body: { body: {
backgroundColor: // Add shadow and elevation to the body
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
}, },
}} }}
> >
<Menu.Label>Settings</Menu.Label> <Menu.Label>Settings</Menu.Label>
<Menu.Item <Menu.Item
color="primary" color="primary"
icon={<Edit size={14} />} icon={<Edit />}
// TODO: #2 Add the ability to edit the service. // TODO: #2 Add the ability to edit the service.
onClick={() => setOpened(true)} onClick={() => setOpened(true)}
> >
@@ -64,7 +66,7 @@ export default function AppShelfMenu(props: any) {
autoClose: 5000, autoClose: 5000,
title: ( title: (
<Text> <Text>
Service <b>{service.name}</b> removed successfully Service <b>{service.name}</b> removed successfully!
</Text> </Text>
), ),
color: 'green', color: 'green',
@@ -72,7 +74,7 @@ export default function AppShelfMenu(props: any) {
message: undefined, message: undefined,
}); });
}} }}
icon={<Trash size={14} />} icon={<Trash />}
> >
Delete Delete
</Menu.Item> </Menu.Item>

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { createStyles, Switch, Group, useMantineColorScheme, Kbd } from '@mantine/core'; import { createStyles, Switch, Group, useMantineColorScheme, Kbd } from '@mantine/core';
import { Sun, MoonStars } from 'tabler-icons-react'; import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
root: { root: {

View File

@@ -1,5 +1,5 @@
import { Box, useMantineColorScheme } from '@mantine/core'; import { Box, useMantineColorScheme } from '@mantine/core';
import { Sun, MoonStars } from 'tabler-icons-react'; import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
export function ColorSchemeToggle() { export function ColorSchemeToggle() {

View File

@@ -1,5 +1,11 @@
import { Group, Text, useMantineTheme, MantineTheme } from '@mantine/core'; import { Group, Text, useMantineTheme, MantineTheme } from '@mantine/core';
import { Upload, Photo, X, Icon as TablerIcon, Check } from 'tabler-icons-react'; import {
IconUpload as Upload,
IconPhoto as Photo,
IconX as X,
IconCheck as Check,
TablerIcon,
} from '@tabler/icons';
import { DropzoneStatus, FullScreenDropzone } from '@mantine/dropzone'; import { DropzoneStatus, FullScreenDropzone } from '@mantine/dropzone';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { useRef } from 'react'; import { useRef } from 'react';

View File

@@ -4,7 +4,13 @@ import { showNotification } from '@mantine/notifications';
import axios from 'axios'; import axios from 'axios';
import fileDownload from 'js-file-download'; import fileDownload from 'js-file-download';
import { useState } from 'react'; import { useState } from 'react';
import { Check, Download, Plus, Trash, X } from 'tabler-icons-react'; import {
IconCheck as Check,
IconDownload as Download,
IconPlus as Plus,
IconTrash as Trash,
IconX as X,
} from '@tabler/icons';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
export default function SaveConfigComponent(props: any) { export default function SaveConfigComponent(props: any) {
@@ -54,7 +60,7 @@ export default function SaveConfigComponent(props: any) {
</form> </form>
</Modal> </Modal>
<Button leftIcon={<Download />} variant="outline" onClick={onClick}> <Button leftIcon={<Download />} variant="outline" onClick={onClick}>
Download your config Download config
</Button> </Button>
<Button <Button
leftIcon={<Trash />} leftIcon={<Trash />}
@@ -85,10 +91,10 @@ export default function SaveConfigComponent(props: any) {
setConfig({ ...config, name: 'default' }); setConfig({ ...config, name: 'default' });
}} }}
> >
Delete current config Delete config
</Button> </Button>
<Button leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}> <Button leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
Save a copy of your config Save a copy
</Button> </Button>
</Group> </Group>
); );

View File

@@ -12,7 +12,7 @@ export default function ModuleEnabler(props: any) {
key={module.title} key={module.title}
size="md" size="md"
checked={config.modules?.[module.title]?.enabled ?? false} checked={config.modules?.[module.title]?.enabled ?? false}
label={`Enable ${module.title} module`} label={`Enable ${module.title}`}
onChange={(e) => { onChange={(e) => {
setConfig({ setConfig({
...config, ...config,

View File

@@ -11,7 +11,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useColorScheme, useHotkeys } from '@mantine/hooks'; import { useColorScheme, useHotkeys } from '@mantine/hooks';
import { useState } from 'react'; import { useState } from 'react';
import { BrandGithub, Settings as SettingsIcon } from 'tabler-icons-react'; import { IconBrandGithub as BrandGithub, IconSettings } from '@tabler/icons';
import { CURRENT_VERSION } from '../../../data/constants'; import { CURRENT_VERSION } from '../../../data/constants';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch'; import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
@@ -89,10 +89,10 @@ function SettingsMenu(props: any) {
alignSelf: 'center', alignSelf: 'center',
fontSize: '0.75rem', fontSize: '0.75rem',
textAlign: 'center', textAlign: 'center',
color: '#a0aec0', color: 'gray',
}} }}
> >
tip: You can upload your config file by dragging and dropping it onto the page Tip: You can upload your config file by dragging and dropping it onto the page!
</Text> </Text>
<Group position="center" direction="row" mr="xs"> <Group position="center" direction="row" mr="xs">
<Group spacing={0}> <Group spacing={0}>
@@ -113,7 +113,7 @@ function SettingsMenu(props: any) {
style={{ style={{
fontSize: '0.90rem', fontSize: '0.90rem',
textAlign: 'center', textAlign: 'center',
color: '#a0aec0', color: 'gray',
}} }}
> >
Made with by @ Made with by @
@@ -154,7 +154,7 @@ export function SettingsMenuButton(props: any) {
onClick={() => setOpened(true)} onClick={() => setOpened(true)}
> >
<Tooltip label="Settings"> <Tooltip label="Settings">
<SettingsIcon /> <IconSettings />
</Tooltip> </Tooltip>
</ActionIcon> </ActionIcon>
</> </>

View File

@@ -1,5 +1,11 @@
import { Aside as MantineAside, Group } from '@mantine/core'; import { Aside as MantineAside, Group } from '@mantine/core';
import { WeatherModule, DateModule, CalendarModule } from '../modules'; import {
WeatherModule,
DateModule,
CalendarModule,
TotalDownloadsModule,
SystemModule,
} from '../modules';
import { ModuleWrapper } from '../modules/moduleWrapper'; import { ModuleWrapper } from '../modules/moduleWrapper';
export default function Aside(props: any) { export default function Aside(props: any) {
@@ -15,10 +21,12 @@ export default function Aside(props: any) {
base: 'auto', base: 'auto',
}} }}
> >
<Group mt="sm" grow direction="column"> <Group my="sm" grow direction="column" style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} /> <ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={DateModule} /> <ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} /> <ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={SystemModule} />
</Group> </Group>
</MantineAside> </MantineAside>
); );

View File

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import { createStyles, Footer as FooterComponent } from '@mantine/core'; import { createStyles, Footer as FooterComponent } from '@mantine/core';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants'; import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
import { IconAlertCircle as AlertCircle } from '@tabler/icons';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
footer: { footer: {
@@ -38,12 +39,21 @@ export function Footer({ links }: FooterCenteredProps) {
// Fetch Data here when component first mounted // Fetch Data here when component first mounted
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => { fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
res.json().then((data) => { res.json().then((data) => {
if (data.tag_name !== CURRENT_VERSION) { if (data.tag_name > CURRENT_VERSION) {
showNotification({ showNotification({
color: 'yellow', color: 'yellow',
autoClose: false, autoClose: false,
title: 'New version available', title: 'New version available',
message: `Version ${data.tag_name} is available, update now! 😡`, icon: <AlertCircle />,
message: `Version ${data.tag_name} is available, update now!`,
});
} else if (data.tag_name < CURRENT_VERSION) {
showNotification({
color: 'orange',
autoClose: 5000,
title: 'You are using a development version',
icon: <AlertCircle />,
message: 'This version of Homarr is still in development! Bugs are expected 🐛',
}); });
} }
}); });

View File

@@ -13,11 +13,11 @@ export function Logo({ style }: any) {
}} }}
/> />
<NextLink <NextLink
href="/"
style={{ style={{
textDecoration: 'none', textDecoration: 'none',
position: 'relative', position: 'relative',
}} }}
href="/"
> >
<Text <Text
sx={style} sx={style}

View File

@@ -1,9 +1,9 @@
/* eslint-disable react/no-children-prop */ /* eslint-disable react/no-children-prop */
import { Box, Divider, Indicator, Popover, ScrollArea, useMantineTheme } from '@mantine/core'; import { Box, Divider, Indicator, Popover, ScrollArea } from '@mantine/core';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Calendar } from '@mantine/dates'; import { Calendar } from '@mantine/dates';
import { showNotification } from '@mantine/notifications'; import { IconCalendar as CalendarIcon } from '@tabler/icons';
import { Calendar as CalendarIcon, Check } from 'tabler-icons-react'; import axios from 'axios';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { IModule } from '../modules'; import { IModule } from '../modules';
import { import {
@@ -11,7 +11,8 @@ import {
RadarrMediaDisplay, RadarrMediaDisplay,
LidarrMediaDisplay, LidarrMediaDisplay,
ReadarrMediaDisplay, ReadarrMediaDisplay,
} from './MediaDisplay'; } from '../common';
import { serviceItem } from '../../../tools/types';
export const CalendarModule: IModule = { export const CalendarModule: IModule = {
title: 'Calendar', title: 'Calendar',
@@ -27,104 +28,28 @@ export default function CalendarComponent(props: any) {
const [lidarrMedias, setLidarrMedias] = useState([] as any); const [lidarrMedias, setLidarrMedias] = useState([] as any);
const [radarrMedias, setRadarrMedias] = useState([] as any); const [radarrMedias, setRadarrMedias] = useState([] as any);
const [readarrMedias, setReadarrMedias] = 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);
function getMedias(service: serviceItem | undefined, type: string) {
if (!service || !service.apiKey) {
return Promise.resolve({ data: [] });
}
return axios.post(`/api/modules/calendar?type=${type}`, { ...service });
}
useEffect(() => { useEffect(() => {
// Filter only sonarr and radarr services // Filter only sonarr and radarr services
const filtered = config.services.filter(
(service) =>
service.type === 'Sonarr' ||
service.type === 'Radarr' ||
service.type === 'Lidarr' ||
service.type === 'Readarr'
);
// Get the url and apiKey for all Sonarr and Radarr services // Get the url and apiKey for all Sonarr and Radarr services
const sonarrService = filtered.filter((service) => service.type === 'Sonarr').at(0); getMedias(sonarrService, 'sonarr').then((res) => setSonarrMedias(res.data));
const radarrService = filtered.filter((service) => service.type === 'Radarr').at(0); getMedias(radarrService, 'radarr').then((res) => setRadarrMedias(res.data));
const lidarrService = filtered.filter((service) => service.type === 'Lidarr').at(0); getMedias(lidarrService, 'lidarr').then((res) => setLidarrMedias(res.data));
const readarrService = filtered.filter((service) => service.type === 'Readarr').at(0); getMedias(readarrService, 'readarr').then((res) => setReadarrMedias(res.data));
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
if (sonarrService && sonarrService.apiKey) {
const baseUrl = new URL(sonarrService.url).origin;
fetch(`${baseUrl}/api/calendar?apikey=${sonarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setSonarrMedias(data);
showNotification({
title: 'Sonarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
}
);
}
if (radarrService && radarrService.apiKey) {
const baseUrl = new URL(radarrService.url).origin;
fetch(`${baseUrl}/api/v3/calendar?apikey=${radarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setRadarrMedias(data);
showNotification({
title: 'Radarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
}
);
}
if (lidarrService && lidarrService.apiKey) {
const baseUrl = new URL(lidarrService.url).origin;
fetch(`${baseUrl}/api/v1/calendar?apikey=${lidarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setLidarrMedias(data);
showNotification({
title: 'Lidarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
}
);
}
if (readarrService && readarrService.apiKey) {
const baseUrl = new URL(readarrService.url).origin;
fetch(`${baseUrl}/api/v1/calendar?apikey=${readarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setReadarrMedias(data);
showNotification({
title: 'Readarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
}
);
}
}, [config.services]); }, [config.services]);
if (sonarrMedias === undefined && radarrMedias === undefined) {
return <Calendar />;
}
return ( return (
<Calendar <Calendar
onChange={(day: any) => {}} onChange={(day: any) => {}}
@@ -151,7 +76,6 @@ function DayComponent(props: any) {
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } = }: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
props; props;
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const theme = useMantineTheme();
const day = renderdate.getDate(); const day = renderdate.getDate();
@@ -247,6 +171,11 @@ function DayComponent(props: any) {
radius="lg" radius="lg"
shadow="xl" shadow="xl"
transition="pop" transition="pop"
styles={{
body: {
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.1), 0 14px 11px rgba(0, 0, 0, 0.1)',
},
}}
width={700} width={700}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
opened={opened} opened={opened}

View File

@@ -1,5 +1,5 @@
import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea } from '@mantine/core'; import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea } from '@mantine/core';
import { Link } from 'tabler-icons-react'; import { IconLink as Link } from '@tabler/icons';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types'; import { serviceItem } from '../../../tools/types';
@@ -14,7 +14,7 @@ export interface IMedia {
episodeNumber?: number; episodeNumber?: number;
} }
function MediaDisplay(props: { media: IMedia }) { export function MediaDisplay(props: { media: IMedia }) {
const { media }: { media: IMedia } = props; const { media }: { media: IMedia } = props;
return ( return (
<Group position="apart"> <Group position="apart">
@@ -33,7 +33,7 @@ function MediaDisplay(props: { media: IMedia }) {
/> />
)} )}
<Group direction="column"> <Group direction="column">
<Group style={{ minWidth: 400 }}> <Group noWrap mr="sm" style={{ minWidth: 400 }}>
<Title order={3}>{media.title}</Title> <Title order={3}>{media.title}</Title>
{media.imdbId && ( {media.imdbId && (
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank"> <Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
@@ -47,7 +47,7 @@ function MediaDisplay(props: { media: IMedia }) {
<Text <Text
style={{ style={{
textAlign: 'center', textAlign: 'center',
color: '#a0aec0', color: 'gray',
}} }}
> >
New release from {media.artist} New release from {media.artist}
@@ -57,7 +57,7 @@ function MediaDisplay(props: { media: IMedia }) {
<Text <Text
style={{ style={{
textAlign: 'center', textAlign: 'center',
color: '#a0aec0', color: 'gray',
}} }}
> >
Season {media.seasonNumber} episode {media.episodeNumber} Season {media.seasonNumber} episode {media.episodeNumber}
@@ -118,7 +118,7 @@ export function LidarrMediaDisplay(props: any) {
} }
const baseUrl = new URL(lidarr.url).origin; const baseUrl = new URL(lidarr.url).origin;
// Remove '/' from the end of the lidarr url // Remove '/' from the end of the lidarr url
const fullLink = `${baseUrl}${poster.url}`; const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
// Return a movie poster containting the title and the description // Return a movie poster containting the title and the description
return ( return (
<MediaDisplay <MediaDisplay

View File

@@ -0,0 +1 @@
export * from './MediaDisplay';

View File

@@ -1,7 +1,7 @@
import { Group, Text, Title } from '@mantine/core'; import { Group, Text, Title } from '@mantine/core';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Clock } from 'tabler-icons-react'; import { IconClock as Clock } from '@tabler/icons';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { IModule } from '../modules'; import { IModule } from '../modules';

View File

@@ -1,5 +1,5 @@
import { Loader, Table, Text, Tooltip, Title, Group, Progress, Center } from '@mantine/core'; import { Table, Text, Tooltip, Title, Group, Progress, Skeleton, ScrollArea } from '@mantine/core';
import { Download } from 'tabler-icons-react'; import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import axios from 'axios'; import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { NormalizedTorrent } from '@ctrl/shared-torrent';
@@ -54,9 +54,9 @@ export default function DownloadComponent() {
if (!qBittorrentService && !delugeService) { if (!qBittorrentService && !delugeService) {
return ( return (
<Group direction="column"> <Group direction="column">
<Title>Critical: No qBittorrent/Deluge instance found in services.</Title> <Title order={3}>No supported download clients found!</Title>
<Group> <Group>
<Title order={3}>Add a qBittorrent/Deluge service to view current downloads</Title> <Text>Add a download service to view your current downloads...</Text>
<AddItemShelfButton /> <AddItemShelfButton />
</Group> </Group>
</Group> </Group>
@@ -65,9 +65,13 @@ export default function DownloadComponent() {
if (qBittorrentTorrents.length === 0 && delugeTorrents.length === 0) { if (qBittorrentTorrents.length === 0 && delugeTorrents.length === 0) {
return ( return (
<Center> <>
<Loader /> <Skeleton height={40} mt={10} />
</Center> <Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
</>
); );
} }
@@ -81,12 +85,13 @@ export default function DownloadComponent() {
); );
// Loop over qBittorrent torrents merging with deluge torrents // Loop over qBittorrent torrents merging with deluge torrents
const torrents: NormalizedTorrent[] = []; const torrents: NormalizedTorrent[] = [];
delugeTorrents.forEach((torrent) => torrents.push(torrent)); delugeTorrents.forEach((delugeTorrent) =>
torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
);
qBittorrentTorrents.forEach((torrent) => torrents.push(torrent)); qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
const rows = torrents.map((torrent) => { const rows = torrents.map((torrent) => {
if (torrent.progress === 1 && hideComplete) { if (torrent.progress === 1 && hideComplete) {
return null; return [];
} }
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024; const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024; const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
@@ -123,14 +128,15 @@ export default function DownloadComponent() {
</tr> </tr>
); );
}); });
return ( return (
<Group noWrap direction="column"> <Group noWrap grow direction="column">
<Title order={4}>Your torrents</Title> <Title order={4}>Your torrents</Title>
<Table highlightOnHover> <ScrollArea sx={{ height: 300 }}>
<thead>{ths}</thead> <Table highlightOnHover>
<tbody>{rows}</tbody> <thead>{ths}</thead>
</Table> <tbody>{rows}</tbody>
</Table>
</ScrollArea>
</Group> </Group>
); );
} }

View File

@@ -0,0 +1,211 @@
import { Text, Title, Group, useMantineTheme, Box, Card, ColorSwatch } 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 { linearGradientDef } from '@nivo/core';
import { Datum, ResponsiveLine } from '@nivo/line';
import { useListState } from '@mantine/hooks';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
import { useConfig } from '../../../tools/state';
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]}`;
}
export const TotalDownloadsModule: IModule = {
title: 'Download Speed',
description: 'Show the current download speed of supported services',
icon: Download,
component: TotalDownloadsComponent,
};
interface torrentHistory {
x: number;
up: number;
down: number;
}
export default function TotalDownloadsComponent() {
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 [delugeTorrents, setDelugeTorrents] = useState<NormalizedTorrent[]>([]);
const [torrentHistory, torrentHistoryHandlers] = useListState<torrentHistory>([]);
const [qBittorrentTorrents, setqBittorrentTorrents] = useState<NormalizedTorrent[]>([]);
const torrents: NormalizedTorrent[] = [];
delugeTorrents.forEach((delugeTorrent) =>
torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
);
qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
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);
});
}
}
}, 1000);
}, [config.modules]);
useEffect(() => {
torrentHistoryHandlers.append({
x: Date.now(),
down: totalDownloadSpeed,
up: totalUploadSpeed,
});
}, [totalDownloadSpeed, totalUploadSpeed]);
if (!qBittorrentService && !delugeService) {
return (
<Group direction="column">
<Title order={4}>No supported download clients found!</Title>
<Group noWrap>
<Text>Add a download service to view your current downloads...</Text>
<AddItemShelfButton />
</Group>
</Group>
);
}
const theme = useMantineTheme();
// Load the last 10 values from the history
const history = torrentHistory.slice(-10);
const chartDataUp = history.map((load, i) => ({
x: load.x,
y: load.up,
})) as Datum[];
const chartDataDown = history.map((load, i) => ({
x: load.x,
y: load.down,
})) as Datum[];
return (
<Group noWrap direction="column" grow>
<Title order={4}>Current download speed</Title>
<Group direction="column">
<Group>
<ColorSwatch size={12} color={theme.colors.green[5]} />
<Text>Download: {humanFileSize(totalDownloadSpeed)}/s</Text>
</Group>
<Group>
<ColorSwatch size={12} color={theme.colors.blue[5]} />
<Text>Upload: {humanFileSize(totalUploadSpeed)}/s</Text>
</Group>
</Group>
<Box
style={{
height: 200,
width: '100%',
}}
>
<ResponsiveLine
isInteractive
enableSlices="x"
sliceTooltip={({ slice }) => {
const Download = slice.points[0].data.y as number;
const Upload = slice.points[1].data.y as number;
// Get the number of seconds since the last update.
const seconds = (Date.now() - (slice.points[0].data.x as number)) / 1000;
// Round to the nearest second.
const roundedSeconds = Math.round(seconds);
return (
<Card p="sm" radius="md" withBorder>
<Text size="md">{roundedSeconds} seconds ago</Text>
<Card.Section p="sm">
<Group direction="column">
<Group>
<ColorSwatch size={10} color={theme.colors.green[5]} />
<Text size="md">Download: {humanFileSize(Download)}</Text>
</Group>
<Group>
<ColorSwatch size={10} color={theme.colors.blue[5]} />
<Text size="md">Upload: {humanFileSize(Upload)}</Text>
</Group>
</Group>
</Card.Section>
</Card>
);
}}
data={[
{
id: 'downloads',
data: chartDataUp,
},
{
id: 'uploads',
data: chartDataDown,
},
]}
curve="monotoneX"
yFormat=" >-.2f"
axisTop={null}
axisRight={null}
enablePoints={false}
animate={false}
enableGridX={false}
enableGridY={false}
enableArea
defs={[
linearGradientDef('gradientA', [
{ offset: 0, color: 'inherit' },
{ offset: 100, color: 'inherit', opacity: 0 },
]),
]}
fill={[{ match: '*', id: 'gradientA' }]}
colors={[
// Blue
theme.colors.blue[5],
// Green
theme.colors.green[5],
]}
/>
</Box>
</Group>
);
}

View File

@@ -1 +1,2 @@
export { DownloadsModule } from './DownloadsModule'; export { DownloadsModule } from './DownloadsModule';
export { TotalDownloadsModule } from './TotalDownloadsModule';

View File

@@ -4,3 +4,4 @@ export * from './search';
export * from './ping'; export * from './ping';
export * from './weather'; export * from './weather';
export * from './downloads'; export * from './downloads';
export * from './system';

View File

@@ -117,8 +117,8 @@ export function ModuleWrapper(props: any) {
right: 15, right: 15,
}, },
body: { body: {
backgroundColor: // Add shadow and elevation to the body
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
}, },
}} }}
> >

View File

@@ -2,7 +2,7 @@ import { Indicator, Tooltip } from '@mantine/core';
import axios from 'axios'; import axios from 'axios';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Plug } from 'tabler-icons-react'; import { IconPlug as Plug } from '@tabler/icons';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { IModule } from '../modules'; import { IModule } from '../modules';

View File

@@ -1,7 +1,11 @@
import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core'; import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core';
import { useForm, useHotkeys } from '@mantine/hooks'; import { useForm, useHotkeys } from '@mantine/hooks';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Search, BrandYoutube, Download } from 'tabler-icons-react'; import {
IconSearch as Search,
IconBrandYoutube as BrandYoutube,
IconDownload as Download,
} from '@tabler/icons';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { IModule } from '../modules'; import { IModule } from '../modules';
@@ -112,7 +116,7 @@ export default function SearchBar(props: any) {
} }
> >
<Text> <Text>
tip: Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on YouTube Tip: Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on YouTube
or for a Torrent respectively. or for a Torrent respectively.
</Text> </Text>
</Popover> </Popover>

View File

@@ -0,0 +1,64 @@
import {
Center,
Group,
RingProgress,
Title,
useMantineTheme,
} from '@mantine/core';
import { IconCpu } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import si from 'systeminformation';
import { useListState } from '@mantine/hooks';
import { IModule } from '../modules';
export const SystemModule: IModule = {
title: 'System info',
description: 'Show the current CPU usage and memory usage',
icon: IconCpu,
component: SystemInfo,
};
interface ApiResponse {
cpu: si.Systeminformation.CpuData;
os: si.Systeminformation.OsData;
memory: si.Systeminformation.MemData;
load: si.Systeminformation.CurrentLoadData;
}
export default function SystemInfo(args: any) {
const [data, setData] = useState<ApiResponse>();
// Refresh data every second
useEffect(() => {
setInterval(() => {
axios.get('/api/modules/systeminfo').then((res) => setData(res.data));
}, 1000);
}, [args]);
// Update data every time data changes
const [cpuLoadHistory, cpuLoadHistoryHandlers] =
useListState<si.Systeminformation.CurrentLoadData>([]);
// useEffect(() => {
// }, [data]);
const theme = useMantineTheme();
const currentLoad = data?.load?.currentLoad ?? 0;
return (
<Center>
<Group p="sm" direction="column" align="center">
<Title order={3}>Current CPU load</Title>
<RingProgress
size={150}
label={<Center>{`${currentLoad.toFixed(2)}%`}</Center>}
thickness={15}
roundCaps
sections={[{ value: currentLoad ?? 0, color: 'cyan' }]}
/>
</Group>
</Center>
);
}

View File

@@ -0,0 +1 @@
export { SystemModule } from './SystemModule';

View File

@@ -2,17 +2,17 @@ import { Group, Space, Title, Tooltip } from '@mantine/core';
import axios from 'axios'; import axios from 'axios';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
ArrowDownRight, IconArrowDownRight as ArrowDownRight,
ArrowUpRight, IconArrowUpRight as ArrowUpRight,
Cloud, IconCloud as Cloud,
CloudFog, IconCloudFog as CloudFog,
CloudRain, IconCloudRain as CloudRain,
CloudSnow, IconCloudSnow as CloudSnow,
CloudStorm, IconCloudStorm as CloudStorm,
QuestionMark, IconQuestionMark as QuestionMark,
Snowflake, IconSnowflake as Snowflake,
Sun, IconSun as Sun,
} from 'tabler-icons-react'; } from '@tabler/icons';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { IModule } from '../modules'; import { IModule } from '../modules';
import { WeatherResponse } from './WeatherInterface'; import { WeatherResponse } from './WeatherInterface';

View File

@@ -9,6 +9,7 @@ import { useHotkeys } from '@mantine/hooks';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
import { ConfigProvider } from '../tools/state'; import { ConfigProvider } from '../tools/state';
import { theme } from '../tools/theme'; import { theme } from '../tools/theme';
import { styles } from '../tools/styles';
export default function App(props: AppProps & { colorScheme: ColorScheme }) { export default function App(props: AppProps & { colorScheme: ColorScheme }) {
const { Component, pageProps } = props; const { Component, pageProps } = props;
@@ -35,6 +36,9 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) {
...theme, ...theme,
colorScheme, colorScheme,
}} }}
styles={{
...styles,
}}
withGlobalStyles withGlobalStyles
withNormalizeCSS withNormalizeCSS
> >

View File

@@ -0,0 +1,67 @@
import axios from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';
import { serviceItem } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Parse req.body as a ServiceItem
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
const lastMonth = new Date(new Date().setMonth(new Date().getMonth() - 2)).toISOString();
const TypeToUrl: { service: string; url: string }[] = [
{
service: 'sonarr',
url: '/api/calendar',
},
{
service: 'radarr',
url: '/api/v3/calendar',
},
{
service: 'lidarr',
url: '/api/v1/calendar',
},
{
service: 'readarr',
url: '/api/v1/calendar',
},
];
const service: serviceItem = req.body;
const { type } = req.query;
if (!type) {
return res.status(400).json({
message: 'Missing required parameter in url: type',
});
}
if (!service) {
return res.status(400).json({
message: 'Missing required parameter in body: service',
});
}
// Match the type to the correct url
const url = TypeToUrl.find((x) => x.service === type);
if (!url) {
return res.status(400).json({
message: 'Invalid type',
});
}
// Get the origin URL
const { origin } = new URL(service.url);
const pined = `${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`;
const data = await axios.get(
`${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`
);
return res.status(200).json(data.data);
// // Make a request to the URL
// const response = await axios.get(url);
// // Return the response
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'POST') {
return Post(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -0,0 +1,30 @@
import { NextApiRequest, NextApiResponse } from 'next';
import si from 'systeminformation';
async function Get(req: NextApiRequest, res: NextApiResponse) {
const [osInfo, cpuInfo, memInfo, cpuLoad] = await Promise.all([
si.osInfo(),
si.cpu(),
si.mem(),
si.currentLoad(),
]);
const sysinfo = {
cpu: cpuInfo,
os: osInfo,
mem: memInfo,
load: cpuLoad,
};
res.status(200).json(sysinfo);
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -1,15 +1,12 @@
import { getCookie, setCookies } from 'cookies-next'; import { getCookie, setCookies } from 'cookies-next';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
import path from 'path';
import fs from 'fs';
import { useEffect } from 'react'; import { useEffect } from 'react';
import AppShelf from '../components/AppShelf/AppShelf'; import AppShelf from '../components/AppShelf/AppShelf';
import LoadConfigComponent from '../components/Config/LoadConfig'; import LoadConfigComponent from '../components/Config/LoadConfig';
import { Config } from '../tools/types'; import { Config } from '../tools/types';
import { useConfig } from '../tools/state'; import { useConfig } from '../tools/state';
import { migrateToIdConfig } from '../tools/migrate'; import { migrateToIdConfig } from '../tools/migrate';
import { ModuleWrapper } from '../components/modules/moduleWrapper'; import { getConfig } from '../tools/getConfig';
import { DownloadsModule } from '../components/modules';
export async function getServerSideProps({ export async function getServerSideProps({
req, req,
@@ -17,38 +14,20 @@ export async function getServerSideProps({
}: GetServerSidePropsContext): Promise<{ props: { config: Config } }> { }: GetServerSidePropsContext): Promise<{ props: { config: Config } }> {
let cookie = getCookie('config-name', { req, res }); let cookie = getCookie('config-name', { req, res });
if (!cookie) { if (!cookie) {
setCookies('config-name', 'default', { req, res, maxAge: 60 * 60 * 24 * 30 }); setCookies('config-name', 'default', {
req,
res,
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
cookie = 'default'; cookie = 'default';
} }
// Check if the config file exists return getConfig(cookie as string);
const configPath = path.join(process.cwd(), 'data/configs', `${cookie}.json`);
if (!fs.existsSync(configPath)) {
return {
props: {
config: {
name: cookie.toString(),
services: [],
settings: {
searchUrl: 'https://www.google.com/search?q=',
},
modules: {},
},
},
};
}
const config = fs.readFileSync(configPath, 'utf8');
// Print loaded config
return {
props: {
config: JSON.parse(config),
},
};
} }
export default function HomePage(props: any) { export default function HomePage(props: any) {
const { config: initialConfig }: { config: Config } = props; const { config: initialConfig }: { config: Config } = props;
const { config, loadConfig, setConfig, getConfigs } = useConfig(); const { setConfig } = useConfig();
useEffect(() => { useEffect(() => {
const migratedConfig = migrateToIdConfig(initialConfig); const migratedConfig = migrateToIdConfig(initialConfig);
setConfig(migratedConfig); setConfig(migratedConfig);
@@ -57,7 +36,6 @@ export default function HomePage(props: any) {
<> <>
<AppShelf /> <AppShelf />
<LoadConfigComponent /> <LoadConfigComponent />
<ModuleWrapper mt="xl" module={DownloadsModule} />
</> </>
); );
} }

31
src/tools/getConfig.ts Normal file
View File

@@ -0,0 +1,31 @@
import path from 'path';
import fs from 'fs';
export function getConfig(name: string) {
// Check if the config file exists
const configPath = path.join(process.cwd(), 'data/configs', `${name}.json`);
if (!fs.existsSync(configPath)) {
return {
props: {
configName: name,
config: {
name: name.toString(),
services: [],
settings: {
searchUrl: 'https://www.google.com/search?q=',
},
modules: {},
},
},
};
}
const config = fs.readFileSync(configPath, 'utf8');
// Print loaded config
return {
props: {
configName: name,
config: JSON.parse(config),
},
};
}

View File

@@ -2,7 +2,7 @@
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import axios from 'axios'; import axios from 'axios';
import { createContext, ReactNode, useContext, useState } from 'react'; import { createContext, ReactNode, useContext, useState } from 'react';
import { Check, X } from 'tabler-icons-react'; import { IconCheck as Check, IconX as X } from '@tabler/icons';
import { Config } from './types'; import { Config } from './types';
type configContextType = { type configContextType = {

12
src/tools/styles.ts Normal file
View File

@@ -0,0 +1,12 @@
import { MantineProviderProps } from '@mantine/core';
export const styles: MantineProviderProps['styles'] = {
Checkbox: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
Switch: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
};

View File

@@ -1,3 +1,6 @@
import { MantineProviderProps } from '@mantine/core'; import { MantineProviderProps } from '@mantine/core';
export const theme: MantineProviderProps['theme'] = {}; export const theme: MantineProviderProps['theme'] = {
primaryColor: 'red',
primaryShade: 6,
};

View File

@@ -49,6 +49,7 @@ export interface serviceItem {
type: string; type: string;
url: string; url: string;
icon: string; icon: string;
category?: string;
apiKey?: string; apiKey?: string;
password?: string; password?: string;
username?: string; username?: string;

View File

@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -15,6 +19,13 @@
"jsx": "preserve", "jsx": "preserve",
"incremental": true "incremental": true
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"next.config.js"
],
"exclude": [
"node_modules"
]
} }

28369
yarn.lock

File diff suppressed because it is too large Load Diff