Compare commits

...

69 Commits

Author SHA1 Message Date
Thomas Camlong
e718fd6b80 v0.6.0 Categories and current download graphs ! 🥳 2022-06-03 14:14:47 +02:00
Thomas Camlong
44a7df5ae0 Merge pull request #170 from ajnart/system-info
System info
2022-06-03 13:56:45 +02:00
ajnart
25fa376c2d Update display for current CPU 2022-06-03 13:56:15 +02:00
Thomas "ajnart" Camlong
de3792fb6b 🐛 Fixing a bug related to imports 2022-06-03 13:56:14 +02:00
ajnart
64b1679b03 🐛 Fixing bugs in system info 2022-06-03 13:56:14 +02:00
ajnart
8da0b38662 Working on system info 2022-06-03 13:56:13 +02:00
ajnart
13fd1a9fc0 System-info WIP 2022-06-03 13:55:43 +02:00
Thomas Camlong
04c1b41015 Merge pull request #169 from ajnart/qol
🚸 Improve UX and QoL
2022-06-03 13:55:29 +02:00
Thomas Camlong
6a32b80098 📝 Update demo link 2022-06-02 17:51:21 +02:00
WalkxCode
759e02f74a 🔥 Remove modified files from pull request" 2022-06-01 20:00:21 +02:00
WalkxCode
5758019923 🚸 Improve UX and QoL 2022-06-01 19:53:57 +02:00
Thomas "ajnart" Camlong
cad160010d Make proxied requests for calendar 2022-06-01 16:19:32 +02:00
Thomas "ajnart" Camlong
56b6347824 🚧 Trying to improve calendar module 2022-06-01 15:32:29 +02:00
ajnart
c258003ec5 🐛 Fixed a bug in the Lidarr image display 2022-05-30 21:43:33 +02:00
ajnart
5ac5098a2a 💄 Small UI changes
Changed the color to use one of mantine's
2022-05-30 09:20:16 +02:00
ajnart
3c96053b7f Add a ScrollArea to the Downloads module 2022-05-30 09:19:49 +02:00
Thomas Camlong
67a89ba61a Merge pull request #167 from ajnart/build-optimization
Build optimization
2022-05-29 19:04:19 +02:00
ajnart
4c0a3ce48c 💚 Update CI 2022-05-29 18:57:03 +02:00
ajnart
2d2f9d8d19 🏗️ Update Yarn install 2022-05-29 18:55:22 +02:00
ajnart
0a7f98dd80 🏗️ Change packageManager 2022-05-29 18:47:12 +02:00
ajnart
5b4d302c17 ⬆️ Upgrade dependencies 2022-05-29 18:43:31 +02:00
ajnart
31d23852f7 Migrate from tabler-icons-react to @tabler/icons 2022-05-29 18:42:58 +02:00
ajnart
a9e8db5018 🔖 Bumb version to v0.6.0 2022-05-29 15:34:31 +02:00
ajnart
f3d1767daf 🚨 Linting 2022-05-29 15:33:44 +02:00
ajnart
b229aacba5 Add download module to the AppShelf 2022-05-29 15:32:39 +02:00
ajnart
174ed140ae Add total downloads module 2022-05-29 15:31:25 +02:00
ajnart
62635bffe9 💄 Style DownloadsModule (torrents) 2022-05-29 15:31:04 +02:00
ajnart
63e6efab1f Add TotalDownloadsModule to module exports 2022-05-29 15:30:50 +02:00
ajnart
ad1af0e07d 🧱 Move components in infrastructure 2022-05-29 15:30:23 +02:00
ajnart
cfd9eb94b5 💄 Improve boxShadows of menus
Makes them look better
2022-05-29 15:30:03 +02:00
ajnart
c6762281ef 📦 Add nivo for charts 💹 2022-05-29 15:29:11 +02:00
ajnart
7d09a0064a 🔥 Delete Download module from index 2022-05-29 15:28:45 +02:00
ajnart
2d6b9522c5 Make Calendar module fetch way more data 2022-05-29 11:25:53 +02:00
ajnart
1a2e752281 Add categories! 2022-05-29 10:45:49 +02:00
ajnart
c7c76ee22b 💄 Styling backgrounds of widgets 2022-05-29 09:11:46 +02:00
ajnart
0457c91ede 🚸 Improve add service autofill capabilities 2022-05-27 13:14:09 +02:00
ajnart
1a420c3b8b 🚑 Hotfix deluge torrent progress 2022-05-27 09:45:51 +02:00
Thomas Camlong
c993d32dd3 v0.5.2 : Torrents module : Deluge and qBittorrent integrations ! 🥳
v0.5.2 adds a bunch of QOL changes and the long awaited Deluge and qBittorrent integrations. More feature to come with the torrent module soon.
2022-05-26 21:29:48 +02:00
ajnart
1f66d64f24 Add deluge integration
Fixes #122
2022-05-26 21:08:16 +02:00
ajnart
54ce138475 Add deluge password saving 2022-05-26 21:07:01 +02:00
ajnart
6173c20616 🏷️ Update types for AppShelfMenu 2022-05-26 21:06:44 +02:00
ajnart
e3d22c6d3a 🏷️ Add deluge types 2022-05-26 21:06:17 +02:00
ajnart
fd44fbb208 📦 Add Deluge package for future deluge integration 2022-05-26 21:06:04 +02:00
ajnart
3dc0208a73 💄 Styling changes 2022-05-26 20:07:54 +02:00
ajnart
b6fcabc270 🐛 Fix footer display issues 2022-05-26 20:07:22 +02:00
ajnart
ee2e36bdfa Rename the download module 2022-05-26 19:45:18 +02:00
ajnart
6bc16a51f1 🔖 Bump version to v0.5.2 2022-05-26 19:16:33 +02:00
ajnart
b0c92c9951 💄 Style DownloadModule 2022-05-26 19:14:36 +02:00
ajnart
72fddda411 Make the Footer update a notification 2022-05-26 19:14:19 +02:00
ajnart
949379e6e6 🎨 Change default config 2022-05-26 18:35:28 +02:00
Thomas Camlong
17736fc432 Update README.md 2022-05-26 18:29:24 +02:00
ajnart
da31832a1e 💫 Add download module on the main page 2022-05-26 18:19:32 +02:00
ajnart
3a358a229d Add Download Module!
Shows current downloads from qbittorrent at the moment, transmission coming soon 😉
2022-05-26 18:19:12 +02:00
ajnart
a6875abfe3 💬 Update API to support getting downloads 2022-05-26 18:18:30 +02:00
ajnart
2aad3d3eb0 Add support for qBittorrent in AddAppShelfItem 2022-05-26 18:17:59 +02:00
ajnart
8e2d347ab5 🏗️ Rework moduleWrapper architecture 2022-05-26 18:16:57 +02:00
ajnart
8b055bc3b6 💄 Style the notifications to the bottom right 2022-05-26 18:16:24 +02:00
ajnart
54a68f1d74 🏷️ Update types to support qBittorrent login 2022-05-26 18:16:00 +02:00
ajnart
2fabd1908d 📦 Add @ctrl/qbittorrent as a dependency 2022-05-26 18:15:42 +02:00
ajnart
789e0510ea 🐛 Fix a bug with strings as module settings 2022-05-26 18:15:00 +02:00
ajnart
2c16075413 💄 WeatherModule styling 2022-05-26 18:13:51 +02:00
ajnart
96f58288ac 💄 DateModule styling 2022-05-26 18:13:35 +02:00
ajnart
d4168dcdf4 💄 Styling and UI changes 2022-05-26 18:13:23 +02:00
Thomas Camlong
c044da2b55 Update Crowdin configuration file 2022-05-26 00:44:24 +02:00
ajnart
1ec8f1db19 🚑 Critical hotfix for various bugs 2022-05-26 00:10:48 +02:00
ajnart
c725559e9b 🔥 Remove Crowdin 2022-05-25 18:32:55 +02:00
ajnart
044c3fdf4c Merge branch 'dev' of github.com:/ajnart/homarr into dev 2022-05-25 18:26:17 +02:00
ajnart
4026d0b6be 💄 Update poster styling 2022-05-25 18:26:13 +02:00
Thomas Camlong
151e37c282 Update Crowdin configuration file 2022-05-25 18:13:29 +02:00
53 changed files with 18443 additions and 11895 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>
--- ---
@@ -99,8 +99,14 @@ _Requirements_:
- [Docker](https://docs.docker.com/get-docker/) - [Docker](https://docs.docker.com/get-docker/)
**Standard Docker Install** **Standard Docker Install**
```sh ```bash
docker run --name homarr --restart unless-stopped -p 7575:7575 -v /data/docker/homarr:/app/data/configs -d ghcr.io/ajnart/homarr:latest docker run \
--name homarr \
--restart unless-stopped \
-p 7575:7575 \
-v ./homarr/configs:/app/data/configs \
-v ./homarr/icons:/app/public/icons \
-d ghcr.io/ajnart/homarr:latest
``` ```
**Docker Compose** **Docker Compose**
@@ -116,7 +122,8 @@ services:
image: ghcr.io/ajnart/homarr:latest image: ghcr.io/ajnart/homarr:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /data/docker/homarr:/app/data/configs - ./homarr/configs:/app/data/configs
- ./homarr/icons:/app/public/icons
ports: ports:
- '7575:7575' - '7575:7575'
``` ```

3
crowdin.yml Normal file
View File

@@ -0,0 +1,3 @@
files:
- source: /public/locales/en/*.json
translation: /public/locales/%two_letters_code%/%original_file_name%.json

View File

@@ -10,6 +10,14 @@
} }
], ],
"settings": { "settings": {
"searchUrl": "https://bing.com/search?q=" "searchUrl": "https://google.com/search?q="
},
"modules": {
"Search Bar": {
"enabled": true
},
"Date": {
"enabled": false
}
} }
} }

View File

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

View File

@@ -1,7 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "0.5.1", "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",
@@ -25,8 +24,12 @@
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write" "ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.0.1", "@ctrl/deluge": "^4.0.0",
"@dnd-kit/sortable": "^7.0.0", "@ctrl/qbittorrent": "^4.0.0",
"@ctrl/shared-torrent": "^4.1.0",
"@dnd-kit/core": "^6.0.1",
"@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",
@@ -35,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",
@@ -44,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": {
@@ -76,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,19 +63,54 @@ 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 ?? '',
apiKey: props.apiKey ?? (undefined as unknown as string), apiKey: props.apiKey ?? (undefined as unknown as string),
username: props.username ?? (undefined as unknown as string),
password: props.password ?? (undefined as unknown as string),
}, },
validate: { validate: {
apiKey: () => null, apiKey: () => null,
@@ -97,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>
@@ -143,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'}
/> />
@@ -164,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
@@ -172,21 +217,94 @@ 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' && (
<>
<TextInput
required
label="Username"
placeholder="admin"
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={form.errors.username && 'Invalid username'}
/>
<TextInput
required
label="Password"
placeholder="adminadmin"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
{form.values.type === 'Deluge' && (
<>
<TextInput
required
label="Password"
placeholder="deluge"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)} )}
</Group> </Group>

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,12 +1,13 @@
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 { AddAppShelfItemForm } from './AddAppShelfItem'; import { AddAppShelfItemForm } from './AddAppShelfItem';
export default function AppShelfMenu(props: any) { export default function AppShelfMenu(props: any) {
const { service } = props; const { service }: { service: serviceItem } = props;
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const theme = useMantineTheme(); const theme = useMantineTheme();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
@@ -23,27 +24,31 @@ 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}
apiKey={service.apiKey} apiKey={service.apiKey}
username={service.username}
password={service.password}
message="Save service" message="Save service"
/> />
</Modal> </Modal>
<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)}
> >
@@ -61,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',
@@ -69,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

@@ -9,7 +9,7 @@ export default function ConfigChanger() {
useEffect(() => { useEffect(() => {
getConfigs().then((configs) => setConfigList(configs)); getConfigs().then((configs) => setConfigList(configs));
// setConfig(initialConfig); // setConfig(initialConfig);
}, [config.name]); }, [config]);
// If configlist is empty, return a loading indicator // If configlist is empty, return a loading indicator
if (configList.length === 0) { if (configList.length === 0) {
return ( return (

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) {
@@ -44,7 +50,6 @@ export default function SaveConfigComponent(props: any) {
> >
<TextInput <TextInput
required required
defaultValue={config.name}
label="Config name" label="Config name"
placeholder="Your new config name" placeholder="Your new config name"
{...form.getInputProps('configName')} {...form.getInputProps('configName')}
@@ -55,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 />}
@@ -86,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

@@ -1,16 +0,0 @@
import { Select } from '@mantine/core';
import { useState } from 'react';
export default function SelectConfig(props: any) {
const [value, setValue] = useState<string | null>('');
return (
<Select
value={value}
onChange={setValue}
data={[
{ value: 'default', label: 'Default' },
{ value: 'yourmom', label: 'Your mom' },
]}
/>
);
}

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

@@ -7,10 +7,12 @@ import {
SegmentedControl, SegmentedControl,
TextInput, TextInput,
Drawer, Drawer,
Anchor,
} 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 { Settings as SettingsIcon } from 'tabler-icons-react'; import { IconBrandGithub as BrandGithub, IconSettings } from '@tabler/icons';
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';
import ConfigChanger from '../Config/ConfigChanger'; import ConfigChanger from '../Config/ConfigChanger';
@@ -87,11 +89,42 @@ 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 spacing={0}>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<BrandGithub size={18} />
</ActionIcon>
<Text
style={{
position: 'relative',
fontSize: '0.90rem',
color: 'gray',
}}
>
{CURRENT_VERSION}
</Text>
</Group>
<Text
style={{
fontSize: '0.90rem',
textAlign: 'center',
color: 'gray',
}}
>
Made with by @
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
ajnart
</Anchor>
</Text>
</Group>
</Group> </Group>
); );
} }
@@ -121,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,10 +1,17 @@
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) {
return ( return (
<MantineAside <MantineAside
pr="md"
hiddenBreakpoint="md" hiddenBreakpoint="md"
hidden hidden
style={{ style={{
@@ -14,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

@@ -1,16 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { import { createStyles, Footer as FooterComponent } from '@mantine/core';
createStyles, import { showNotification } from '@mantine/notifications';
Anchor,
Text,
Group,
ActionIcon,
Footer as FooterComponent,
Alert,
useMantineTheme,
} from '@mantine/core';
import { AlertCircle, BrandGithub } from 'tabler-icons-react';
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: {
@@ -43,31 +35,26 @@ interface FooterCenteredProps {
} }
export function Footer({ links }: FooterCenteredProps) { export function Footer({ links }: FooterCenteredProps) {
const [update, setUpdate] = useState(false);
const theme = useMantineTheme();
const { classes } = useStyles();
const items = links.map((link) => (
<Anchor<'a'>
color="dimmed"
key={link.label}
href={link.link}
sx={{ lineHeight: 1 }}
onClick={(event) => event.preventDefault()}
size="sm"
>
{link.label}
</Anchor>
));
const [latestVersion, setLatestVersion] = useState(CURRENT_VERSION);
const [isOpen, setOpen] = useState(true);
useEffect(() => { useEffect(() => {
// 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) => {
setLatestVersion(data.tag_name); if (data.tag_name > CURRENT_VERSION) {
if (data.tag_name !== CURRENT_VERSION) { showNotification({
setUpdate(true); color: 'yellow',
autoClose: false,
title: 'New version available',
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 🐛',
});
} }
}); });
}); });
@@ -75,69 +62,13 @@ export function Footer({ links }: FooterCenteredProps) {
return ( return (
<FooterComponent <FooterComponent
p={5}
height="auto" height="auto"
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',
clear: 'both', clear: 'both',
}} }}
> children={undefined}
<Group position="apart" direction="row" style={{ alignItems: 'end' }} mr="xs" mb="xs"> />
<Group position="left">
<Alert
// onClick open latest release page
onClose={() => setOpen(false)}
icon={<AlertCircle size={16} />}
title={`Updated version: ${latestVersion} is available. Current version: ${CURRENT_VERSION}`}
withCloseButton
radius="lg"
hidden={CURRENT_VERSION === latestVersion || !isOpen}
variant="outline"
styles={{
root: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
},
closeButton: {
marginLeft: '5px',
},
}}
children={undefined}
/>
</Group>
<Group position="right">
<Group spacing={0}>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<BrandGithub size={18} />
</ActionIcon>
<Text
style={{
position: 'relative',
fontSize: '0.90rem',
color: 'gray',
}}
>
{CURRENT_VERSION}
</Text>
</Group>
<Text
style={{
fontSize: '0.90rem',
textAlign: 'center',
color: '#a0aec0',
}}
>
Made with by @
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
ajnart
</Anchor>
</Text>
</Group>
</Group>
</FooterComponent>
); );
} }

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">
@@ -32,24 +32,22 @@ function MediaDisplay(props: { media: IMedia }) {
height={400} height={400}
/> />
)} )}
<Group direction="row"> <Group direction="column">
<Title order={3}>{media.title}</Title> <Group noWrap mr="sm" style={{ minWidth: 400 }}>
{media.imdbId && ( <Title order={3}>{media.title}</Title>
<Anchor {media.imdbId && (
href={`https://www.imdb.com/title/${media.imdbId}`} <Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
target="_blank" <ActionIcon>
rel="noopener noreferrer" <Link />
> </ActionIcon>
<ActionIcon> </Anchor>
<Link /> )}
</ActionIcon> </Group>
</Anchor>
)}
{media.artist && ( {media.artist && (
<Text <Text
style={{ style={{
textAlign: 'center', textAlign: 'center',
color: '#a0aec0', color: 'gray',
}} }}
> >
New release from {media.artist} New release from {media.artist}
@@ -59,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}
@@ -93,7 +91,6 @@ export function ReadarrMediaDisplay(props: any) {
} }
const baseUrl = new URL(readarr.url).origin; const baseUrl = new URL(readarr.url).origin;
// Remove '/' from the end of the lidarr url // Remove '/' from the end of the lidarr url
console.log(poster);
const fullLink = `${baseUrl}${poster.url}`; const fullLink = `${baseUrl}${poster.url}`;
// Return a movie poster containting the title and the description // Return a movie poster containting the title and the description
return ( return (
@@ -121,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';
@@ -32,7 +32,7 @@ export default function DateComponent(props: any) {
}, []); }, []);
return ( return (
<Group p="sm" direction="column"> <Group p="sm" spacing="xs" direction="column">
<Title>{dayjs(date).format(formatString)}</Title> <Title>{dayjs(date).format(formatString)}</Title>
<Text size="xl">{dayjs(date).format('dddd, MMMM D')}</Text> <Text size="xl">{dayjs(date).format('dddd, MMMM D')}</Text>
</Group> </Group>

View File

@@ -0,0 +1,142 @@
import { Table, Text, Tooltip, Title, Group, Progress, Skeleton, ScrollArea } 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 { IModule } from '../modules';
import { useConfig } from '../../../tools/state';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
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',
value: false,
},
},
};
export default function DownloadComponent() {
const { config } = useConfig();
const qBittorrentService = config.services
.filter((service) => service.type === 'qBittorrent')
.at(0);
const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
const hideComplete: boolean =
(config?.modules?.[DownloadsModule.title]?.options?.hidecomplete?.value as boolean) ?? false;
const [delugeTorrents, setDelugeTorrents] = useState<NormalizedTorrent[]>([]);
const [qBittorrentTorrents, setqBittorrentTorrents] = useState<NormalizedTorrent[]>([]);
useEffect(() => {
if (qBittorrentService) {
setInterval(() => {
axios
.post('/api/modules/downloads?dlclient=qbit', { ...qBittorrentService })
.then((res) => {
setqBittorrentTorrents(res.data.torrents);
});
}, 3000);
}
if (delugeService) {
setInterval(() => {
axios.post('/api/modules/downloads?dlclient=deluge', { ...delugeService }).then((res) => {
setDelugeTorrents(res.data.torrents);
});
}, 3000);
}
}, [config.modules]);
if (!qBittorrentService && !delugeService) {
return (
<Group direction="column">
<Title order={3}>No supported download clients found!</Title>
<Group>
<Text>Add a download service to view your current downloads...</Text>
<AddItemShelfButton />
</Group>
</Group>
);
}
if (qBittorrentTorrents.length === 0 && delugeTorrents.length === 0) {
return (
<>
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
</>
);
}
const ths = (
<tr>
<th>Name</th>
<th>Download</th>
<th>Upload</th>
<th>Progress</th>
</tr>
);
// Loop over qBittorrent torrents merging with deluge torrents
const torrents: NormalizedTorrent[] = [];
delugeTorrents.forEach((delugeTorrent) =>
torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
);
qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
const rows = torrents.map((torrent) => {
if (torrent.progress === 1 && hideComplete) {
return [];
}
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
return (
<tr key={torrent.id}>
<td>
<Tooltip position="top" label={torrent.name}>
<Text
style={{
maxWidth: '30vw',
}}
size="xs"
lineClamp={1}
>
{torrent.name}
</Text>
</Tooltip>
</td>
<td>
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td>
<td>
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td>
<td>
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
<Progress
radius="lg"
color={torrent.progress === 1 ? 'green' : 'blue'}
value={torrent.progress * 100}
size="lg"
/>
</td>
</tr>
);
});
return (
<Group noWrap grow direction="column">
<Title order={4}>Your torrents</Title>
<ScrollArea sx={{ height: 300 }}>
<Table highlightOnHover>
<thead>{ths}</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
</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

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

View File

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

View File

@@ -2,13 +2,9 @@ import { Button, Card, Group, Menu, Switch, TextInput, useMantineTheme } from '@
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { IModule } from './modules'; import { IModule } from './modules';
export function ModuleWrapper(props: any) { function getItems(module: IModule) {
const { module }: { module: IModule } = props;
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {}; const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules[module.title]?.enabled ?? false;
const theme = useMantineTheme();
const items: JSX.Element[] = []; const items: JSX.Element[] = [];
if (module.options) { if (module.options) {
const keys = Object.keys(module.options); const keys = Object.keys(module.options);
@@ -33,7 +29,7 @@ export function ModuleWrapper(props: any) {
options: { options: {
...config.modules[module.title].options, ...config.modules[module.title].options,
[keys[index]]: { [keys[index]]: {
...config.modules[module.title].options[keys[index]], ...config.modules[module.title].options?.[keys[index]],
value: (e.target as any)[0].value, value: (e.target as any)[0].value,
}, },
}, },
@@ -90,11 +86,23 @@ export function ModuleWrapper(props: any) {
} }
}); });
} }
return items;
}
export function ModuleWrapper(props: any) {
const { module }: { module: IModule } = props;
const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules[module.title]?.enabled ?? false;
const theme = useMantineTheme();
const items: JSX.Element[] = getItems(module);
if (!isShown) { if (!isShown) {
return null; return null;
} }
return ( return (
<Card hidden={!isShown} mx="sm" withBorder radius="lg" shadow="sm"> <Card {...props} hidden={!isShown} withBorder radius="lg" shadow="sm">
{module.options && ( {module.options && (
<Menu <Menu
size="lg" size="lg"
@@ -109,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';
@@ -163,7 +163,7 @@ export default function WeatherComponent(props: any) {
return isFahrenheit ? `${(value * (9 / 5)).toFixed(1)}°F` : `${value.toFixed(1)}°C`; return isFahrenheit ? `${(value * (9 / 5)).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
} }
return ( return (
<Group position="left" direction="column"> <Group p="sm" spacing="xs" direction="column">
<Title>{usePerferedUnit(weather.current_weather.temperature)}</Title> <Title>{usePerferedUnit(weather.current_weather.temperature)}</Title>
<Group spacing={0}> <Group spacing={0}>
<WeatherIcon code={weather.current_weather.weathercode} /> <WeatherIcon code={weather.current_weather.weathercode} />

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,10 +36,13 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) {
...theme, ...theme,
colorScheme, colorScheme,
}} }}
styles={{
...styles,
}}
withGlobalStyles withGlobalStyles
withNormalizeCSS withNormalizeCSS
> >
<NotificationsProvider limit={4} position="top-right"> <NotificationsProvider limit={4} position="bottom-left">
<ConfigProvider> <ConfigProvider>
<Layout> <Layout>
<Component {...pageProps} /> <Component {...pageProps} />

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,51 @@
import { Deluge } from '@ctrl/deluge';
import { QBittorrent } from '@ctrl/qbittorrent';
import { NextApiRequest, NextApiResponse } from 'next';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Get the type of service from the request url
const { dlclient } = req.query;
const { body } = req;
// Get login, password and url from the body
const { username, password, url } = body;
if (!dlclient || (!username && !password) || !url) {
return res.status(400).json({
error: 'Wrong request',
});
}
let client: Deluge | QBittorrent;
switch (dlclient) {
case 'qbit':
client = new QBittorrent({
baseUrl: new URL(url).origin,
username,
password,
});
break;
case 'deluge':
client = new Deluge({
baseUrl: new URL(url).origin,
password,
});
break;
default:
return res.status(400).json({
error: 'Wrong request',
});
}
const data = await client.getAllData();
res.status(200).json({
torrents: data.torrents,
});
}
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,13 +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 { getConfig } from '../tools/getConfig';
export async function getServerSideProps({ export async function getServerSideProps({
req, req,
@@ -15,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);

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 = {
@@ -51,7 +51,7 @@ export function ConfigProvider({ children }: Props) {
async function loadConfig(configName: string) { async function loadConfig(configName: string) {
try { try {
const response = await axios.get(`/api/configs/${configName}`); const response = await axios.get(`/api/configs/${configName}`);
setConfigInternal(response.data); setConfigInternal(JSON.parse(response.data));
showNotification({ showNotification({
title: 'Config', title: 'Config',
icon: <Check />, icon: <Check />,

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

@@ -24,6 +24,7 @@ interface ConfigModule {
export const ServiceTypeList = [ export const ServiceTypeList = [
'Other', 'Other',
'Emby', 'Emby',
'Deluge',
'Lidarr', 'Lidarr',
'Plex', 'Plex',
'Radarr', 'Radarr',
@@ -34,6 +35,7 @@ export const ServiceTypeList = [
export type ServiceType = export type ServiceType =
| 'Other' | 'Other'
| 'Emby' | 'Emby'
| 'Deluge'
| 'Lidarr' | 'Lidarr'
| 'Plex' | 'Plex'
| 'Radarr' | 'Radarr'
@@ -47,5 +49,8 @@ export interface serviceItem {
type: string; type: string;
url: string; url: string;
icon: string; icon: string;
category?: string;
apiKey?: string; apiKey?: string;
password?: 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"
]
} }

28076
yarn.lock

File diff suppressed because it is too large Load Diff