Compare commits

...

129 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
Thomas Camlong
0a476f648a v0.5.1 : Readarr and Lidarr integrations !
### New Features
-  Lidarr and Readarr integrations
-  Add a way to delete a config via the API
-  Add a way to save a config and delete it
-  Add a key bind to open settings (CTRL + L)

### Bug Fixes
- 🐛 Fixing date issues with weather module
- 🐛 Fix Readarr date match

### UI Changes
- 💄 Totally rework how the media previews work!
- 💄 Make the settings menu a drawer instead
- 💄 Change the way the footer is displayed

### GitHub Changes
- 📝 (README): Updates documentation & Move to Wiki

### Other Changes
- 🧑‍💻 Added strings as an option type for modules
- 🏗️ Make the max notifications to 4
2022-05-25 13:17:54 +02:00
ajnart
3f2aa50f85 Totally rework how the media previews work! 2022-05-25 13:13:36 +02:00
ajnart
fbaaa389c2 🐛 Fix Readarr date match 2022-05-25 13:13:17 +02:00
ajnart
af83695d81 🏗️ Make the max notifications to 4 2022-05-25 13:12:12 +02:00
ajnart
2cb6781a94 Lidarr and Readarr integrations 2022-05-25 10:50:57 +02:00
ajnart
4f68f7e395 Add a keybind to open settings, CTRL + L 2022-05-24 23:02:27 +02:00
ajnart
6a14937112 Merge branch 'dev' of github.com:/ajnart/homarr into dev 2022-05-24 22:55:50 +02:00
ajnart
9eef4988e7 Add a way to save a config and delete it 2022-05-24 22:55:47 +02:00
ajnart
3855673787 Add a way to delete a config via the API 2022-05-24 22:55:28 +02:00
ajnart
a89b0746ba 💄 Make the settings menu a drawer instead 2022-05-24 22:55:10 +02:00
Bjorn Lammers
09dd5d7907 🔖 Update version to v0.5.1 2022-05-24 21:51:08 +02:00
Bjorn Lammers
f029483f1e 🔖 Update version to v0.5.1 2022-05-24 21:50:57 +02:00
Bjorn Lammers
364055b9b6 📝 Oopsie doopsie hehe 2022-05-24 21:48:06 +02:00
Thomas Camlong
8775ad249c Merge pull request #152 from ajnart/docs
📝 (README): Updates documentation & Move to Wiki
2022-05-24 21:39:50 +02:00
Bjorn Lammers
3249d766b3 📝 Add "Read the Wiki" 2022-05-24 21:39:34 +02:00
Bjorn Lammers
fd65dc8943 📝 Fix some bugs 2022-05-24 21:26:10 +02:00
WalkxCode
fd73c7f70d 📝 (README): Updates documentation & Move to Wiki 2022-05-24 21:21:20 +02:00
ajnart
4984866fb3 🚨 Linting and add icons
Adds future support for self hosted icons
2022-05-24 20:15:07 +02:00
ajnart
4ae4b224c7 🐛 Fixing issues with weahter module 2022-05-24 20:14:26 +02:00
ajnart
802f7fd6c7 🧑‍💻 Added strings as an option type for modules 2022-05-24 20:14:07 +02:00
ajnart
bbb35b236f 💄 Change the way the footer is displayed 2022-05-24 20:13:01 +02:00
Bjorn Lammers
2eb3b18499 Merge pull request #144 from ajnart/dev
v0.5.0 : Quality of life and dev experience
2022-05-23 22:24:24 +02:00
Bjorn Lammers
553be7da33 Merge pull request #144 from ajnart/dev
v0.5.0 : Quality of life and dev experience
2022-05-23 22:22:08 +02:00
Bjorn Lammers
260b850e1a 📝 Add star mention 2022-05-23 21:49:03 +02:00
Bjorn Lammers
726a4fddd3 🔥Remove fixed issues 2022-05-23 21:09:48 +02:00
Bjorn Lammers
318c094f27 📝 Update Docs to match new release 2022-05-23 18:30:11 +02:00
Thomas Camlong
6e0d3807e4 Merge pull request #142 from ajnart/dnd
Drag and drop ! (v0.5.0)
2022-05-23 17:02:18 +02:00
ajnart
10e9dc06dd ⚰️ Remove dead code 2022-05-23 16:52:43 +02:00
ajnart
e84687e5fc 🔖 Version v0.5.0 2022-05-23 14:44:01 +02:00
Thomas Camlong
361d41065c Merge branch 'dev' into dnd 2022-05-23 14:39:17 +02:00
ajnart
4c0fbc0b42 ⚰️ Remove dead code 2022-05-23 14:38:39 +02:00
ajnart
ef8e380956 🔥 Remove some other default configuration files 2022-05-23 14:34:17 +02:00
ajnart
5db28b1607 🚨 Fix storybook compilation 2022-05-23 14:23:05 +02:00
ajnart
dbfd4cf050 🐛 Fix search module default queryUrl 2022-05-23 12:38:10 +02:00
ajnart
ffd298a2b6 🐛 Fix line clamping in media display 2022-05-23 12:37:36 +02:00
ajnart
9b1b5906e7 ⬆️ Upgrade and remove dependencies 2022-05-23 11:48:25 +02:00
Thomas Camlong
19bd14c63d Merge branch 'dev' into dnd 2022-05-23 11:24:31 +02:00
ajnart
b69343af56 Introduce DND in main app shelf! 2022-05-23 11:20:08 +02:00
ajnart
94ee90eebb ⚰️ Remove dead code 2022-05-23 11:19:40 +02:00
ajnart
72b3097ad1 ⚰️ Remove dead code 2022-05-23 11:19:26 +02:00
Thomas Camlong
225f910fe8 Merge pull request #139 from ajnart/New-Config-Format
 Add new config format
2022-05-23 10:48:46 +02:00
ajnart
10d9ffc740 🚨 Fix compilation for types 2022-05-23 10:44:31 +02:00
ajnart
4202d25d62 📦 Add type definitions for UUID 2022-05-23 10:26:17 +02:00
ajnart
6a905e1b49 🚨 Lint code and prettier 2022-05-23 10:24:54 +02:00
ajnart
72e08f484f 🚑 Use different type of UUID 2022-05-23 10:23:10 +02:00
ajnart
64dbb9c025 Add drag and drop, fixes #88 2022-05-23 00:04:14 +02:00
ajnart
af2e0235bf Add new config format
Should be WAAAAY easier to work with modules now
2022-05-22 20:42:10 +02:00
ajnart
bf85818f8b 🐛 Fix #133 2022-05-22 20:40:10 +02:00
ajnart
1840713179 Basic drag and drop 2022-05-21 10:32:54 +02:00
ajnart
b11bffb7cf 🐛 Exclude stories from tsconfig 2022-05-21 10:32:35 +02:00
ajnart
bfb26a9402 🚑 Fix API url for services 2022-05-21 01:26:55 +02:00
ajnart
c3b11be2d0 🚑 Fix UUID by using crypto 2022-05-21 01:26:24 +02:00
ajnart
ecfb89de40 🏷️ Fix types
Fixed the apiKey field for a service
2022-05-21 01:02:45 +02:00
ajnart
e1eab70f93 Match config with URL typed
Homarr will now match a config with the URL used or return a 404 if not found
2022-05-21 01:01:20 +02:00
ajnart
adb341c0fa Add default icon, fix URL parsing
Fixes #121 and Fixes #132
2022-05-21 00:54:36 +02:00
ajnart
25ccdffeb9 Make logo clickable 2022-05-21 00:52:55 +02:00
ajnart
b98d399a9c Change 404 message 2022-05-21 00:52:39 +02:00
ajnart
f36e7b8abb Made service name clickable
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-20 23:03:42 +02:00
Thomas "ajnart" Camlong
667322d14e Use ID instead of only names 2022-05-20 22:34:36 +02:00
Thomas "ajnart" Camlong
9b440c0da3 🚧 Add basic BASE_URL and PORT env utilisation #76 2022-05-19 02:05:23 +02:00
69 changed files with 19417 additions and 13456 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

12
.gitignore vendored
View File

@@ -36,4 +36,14 @@ 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

View File

@@ -1,14 +1,9 @@
module.exports = { module.exports = {
stories: ['../src/components/**/*.story.mdx', '../src/components/**/*.story.*'], stories: ['../src/components/**/*.story.mdx', '../src/components/**/*.story.*'],
addons: [ addons: [
'storybook-dark-mode',
'@storybook/addon-links', '@storybook/addon-links',
'storybook-addon-mock/register', 'storybook-addon-mock/register',
'@storybook/addon-essentials', '@storybook/addon-essentials',
{
name: 'storybook-addon-turbo-build',
options: { optimizationLevel: 2 },
},
], ],
typescript: { typescript: {
check: false, check: false,

View File

@@ -1,4 +1,3 @@
import { useDarkMode } from 'storybook-dark-mode';
import { MantineProvider, ColorSchemeProvider } from '@mantine/core'; import { MantineProvider, ColorSchemeProvider } from '@mantine/core';
import { NotificationsProvider } from '@mantine/notifications'; import { NotificationsProvider } from '@mantine/notifications';
@@ -7,11 +6,7 @@ export const parameters = { layout: 'fullscreen' };
function ThemeWrapper(props: { children: React.ReactNode }) { function ThemeWrapper(props: { children: React.ReactNode }) {
return ( return (
<ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}> <ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}>
<MantineProvider <MantineProvider withGlobalStyles withNormalizeCSS>
theme={{ colorScheme: useDarkMode() ? 'dark' : 'light' }}
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider>{props.children}</NotificationsProvider> <NotificationsProvider>{props.children}</NotificationsProvider>
</MantineProvider> </MantineProvider>
</ColorSchemeProvider> </ColorSchemeProvider>

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

285
README.md
View File

@@ -1,65 +1,97 @@
<h3 align="center">Homarr</h3>
<br> <!-- Project Title -->
<h1 align="center">Homarr</h1>
<!-- Badges -->
<p align="center"> <p align="center">
<a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml"> <img src="https://img.shields.io/github/stars/ajnart/homarr?label=%E2%AD%90%20Stars&style=flat-square?branch=master&kill_cache=1%22">
<img title="Docker CI Status" src="https://github.com/ajnart/homarr/actions/workflows/docker.yml/badge.svg" alt="CI Status"></a> <a href="https://github.com/ajnart/homarr/releases/latest">
<a href="https://github.com/ajnart/homarr/releases/latest"> <img alt="Latest Release (Semver)" src="https://img.shields.io/github/v/release/ajnart/homarr?label=%F0%9F%9A%80%20Release">
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/ajnart/homarr"></a> </a>
<a href="https://github.com/ajnart/homarr/pkgs/container/homarr"> <a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml">
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/ajnart/homarr?label=Downloads%20"></a> <img title="Docker CI Status" src="https://github.com/ajnart/homarr/actions/workflows/docker.yml/badge.svg" alt="CI Status">
</p> </a>
<p align="center"> <a href="https://discord.gg/aCsmEV5RgA">
<img align="end" width=600 src="https://user-images.githubusercontent.com/49837342/168315259-b778c816-10fe-44db-bd25-3eea6f31b233.png" /> <img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield">
</p> </a>
<p align = "center">
A homepage for <i>your</i> server.
<br/>
<a href = "https://homarr.netlify.app/" > <strong> Demo ↗️ </strong> </a> • <a href = "#-installation" > <strong> Install ➡️ </strong> </a>
<br />
<br />
<i>Join the discord!</i>
<br />
<a href = "https://discord.gg/aCsmEV5RgA" > <img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield" > </a>
<br/>
<br/>
</p> </p>
# 📃 Table of Contents <!-- Links -->
- [📃 Table of Contents](#-table-of-contents) <p align="center">
- [🚀 Getting Started](#-getting-started) <i>Join the discord! — Don't forget to star the repo if you are enjoying the project!</i>
- [ About](#-about) </p>
- [💥 Known Issues](#-known-issues) <p align="center">
- [⚡ Installation](#-installation) <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>
- [🐳 Deploying from Docker Image](#-deploying-from-docker-image) </p>
- [🛠️ Building from Source](#%EF%B8%8F-building-from-source)
- [🔧 Configuration](#-configuration)
- [🧩 Integrations](#--integrations)
- [🧑‍🤝‍🧑 Multiple Configs](#-multiple-configs)
- [🐻 Icons](#-icons)
- [📊 Modules](#-modules)
- [🔍 Search Bar](#-search-bar)
- [💖 Contributing](#-contributing)
- [🍏 Request Icons](#-request-icons)
---
<!-- Getting Started --> <!-- Homarr Description -->
# 🚀 Getting Started <img align="right" width=250 src="public/imgs/logo-color.svg" />
## About
Homarr is a simple and lightweight homepage for your server, that helps you easily access all of your services in one place. Homarr is a simple and lightweight homepage for your server, that helps you easily access all of your services in one place.
**[⤴️ Back to Top](#-table-of-contents)** It integrates with the services you use to display information on the homepage (E.g. Show upcoming Sonarr/Radarr releases).
For a full list of integrations look at: [wiki/integrations](#).
If you have any questions about Homarr or want to share information with us, please go to one of the following places:
- [Github Discussions](https://github.com/ajnart/homarr/discussions)
- [Discord Server](https://discord.gg/aCsmEV5RgA)
*Before you file an [issue](https://github.com/ajnart/homarr/issues/new/choose), make sure you have the read [known issues](#-known-issues) section.*
**For more information, [read the wiki!](https://github.com/ajnart/homarr/wiki)**
<details>
<summary><b>Table of Contents</b></summary>
<p>
- [✨ Features](#-features)
- [👀 Preview](#-preview)
- [💥 Known Issues](#-known-issues)
- [🚀 Installation](#-installation)
- [🐳 Deploying from Docker Image](#-deploying-from-docker-image)
- [🛠️ Building from Source](#-building-from-source)
- [💖 Contributing](#-contributing)
- [📜 License](#-license)
</p>
</details>
---
## ✨ Features
- Integrates with services you use.
- Search the web direcetly from your homepage.
- Real-time status indicator for every service.
- Automatically finds icons while you type the name of a serivce.
- Widgets that can display all types of information.
- Easy deployment with Docker.
- Very light-weight and fast.
- Free and Open-Source.
- And more...
**[⤴️ Back to Top](#homarr)**
---
## 👀 Preview
<img alt="Homarr Preview" align="center" width="100%" src="https://user-images.githubusercontent.com/71191962/169860380-856634fb-4f41-47cb-ba54-6a9e7b3b9c81.gif" />
**[⤴️ Back to Top](#homarr)**
---
## 💥 Known Issues ## 💥 Known Issues
- Posters on the Calendar get blocked by adblockers. (IMDb posters) - Posters on the Calendar get blocked by adblockers. (IMDb posters)
- Editing a service creates a duplicate (#97)
- Used search engine not properly selected (#35)
**[⤴️ Back to Top](#-table-of-contents)** **[⤴️ Back to Top](#homarr)**
## ⚡ Installation ---
## 🚀 Installation
### 🐳 Deploying from Docker Image ### 🐳 Deploying from Docker Image
> Supported architectures: x86-64, ARM, ARM64 > Supported architectures: x86-64, ARM, ARM64
@@ -67,29 +99,42 @@ _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 -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**
```yml ```yml
--- ---
version: '3' version: '3'
#--------------------------------------------------------------------------------------------# #---------------------------------------------------------------------#
# Homarr - A homepage for your server. # # Homarr - A homepage for your server. #
#--------------------------------------------------------------------------------------------# #---------------------------------------------------------------------#
services: services:
homarr: homarr:
container_name: homarr container_name: homarr
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'
``` ```
***Getting EACCESS errors in the logs? Try running `sudo chmod 775 /directory-you-mounted-to`!*** ```sh
docker compose up -d
```
*Getting EACCESS errors in the logs? Try running `sudo chmod 777 /directory-you-mounted-to`!*
**[⤴️ Back to Top](#homarr)**
### 🛠️ Building from Source ### 🛠️ Building from Source
@@ -106,96 +151,54 @@ _Requirements_:
- Start the NextJS web server: ``yarn start`` - Start the NextJS web server: ``yarn start``
- *Note: If you want to update the code in real time, launch with ``yarn dev``* - *Note: If you want to update the code in real time, launch with ``yarn dev``*
## 🔧 Configuration **[⤴️ Back to Top](#homarr)**
### 🧩 Integrations ---
Homarr natively integrates with your services. Here is a list of all supported services. ## 💖 Contributing
**Emby**
*The Emby integration is still in development.*
**Lidarr**
*The Lidarr integration is still in development.*
**Sonarr**
*Sonarr needs an API key.*<br>
Make a new API key in `Advanced > Security > Create new API key`<br>
**Current integration:** Upcoming media is displayed in the **Calendar** module.
**Plex**
*The Plex integration is still in development.*
**Radarr**
*Radarr needs an API key.*<br>
Make a new API key in `Advanced > Security > Create new API key`<br>
**Current integration:** Upcoming media is displayed in the **Calendar** module.
**qBittorent**
*The qBittorent integration is still in development.*
**[⤴️ Back to Top](#-table-of-contents)**
### 🧑‍🤝‍🧑 Multiple Configs
Homarr allows the usage of multiple configs. You can add a new config in two ways.
**Drag-and-Drop**
1. Download your config from the Homarr settings.
2. Change the name of the `.json` file and the name in the `.json` file to any name you want *(just make sure it's different)*.
3. Drag-and-Drop the file into the Homarr tab in your browser.
4. Change the config in settings.
**Using a filebrowser**
1. Locate your mounted `default.json` file.
2. Duplicate your `default.json` file.
3. Change the name of the `.json` file and the name in the `.json` file to any name you want *(just make sure it's different)*.
4. Refresh the Homarr tab in your browser.
5. Change the config in settings.
**[⤴️ Back to Top](#-table-of-contents)**
### 🐻 Icons
The icons used in Homarr are automatically requested from the [dashboard-icons](https://github.com/walkxhub/dashboard-icons) repo.
Icons are requested in the following way: <br>
`Grab name > Replace ' ' with '-' > .toLower() > https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/{name}.png`
**[⤴️ Back to Top](#-table-of-contents)**
### 📊 Modules
Modules are blocks shown on the sides of the Homarr dashboard that display information. They can be enabled in settings.
**Clock Module**
The Clock Module will display your current time and date.
**Calendar Module**
The Calendar Module uses [integrations](#--integrations-1) to display new content.
**Weather Module**
The Weather Module uses your devices location to display the current, highest, and lowest temperature.
**[⤴️ Back to Top](#-table-of-contents)**
### 🔍 Search Bar
The Search Bar will open any Search Query after the Query URL you've specified in settings.
*(Eg. `https://www.google.com/search?q=*Your Query will be inserted here*`)*
**[⤴️ Back to Top](#-table-of-contents)**
# 💖 Contributing
**Please read our [Contribution Guidelines](/CONTRIBUTING.md)** **Please read our [Contribution Guidelines](/CONTRIBUTING.md)**
All contributions are highly appreciated. All contributions are highly appreciated.
**[⤴️ Back to Top](#-table-of-contents)** **[⤴️ Back to Top](#homarr)**
## 🍏 Request Icons ---
The icons used in Homarr are automatically requested from the [dashboard-icons](https://github.com/walkxhub/dashboard-icons) repo. You can make a icon request by creating an [issue](https://github.com/walkxhub/dashboard-icons/issues/new/choose).
## 📜 License
**[⤴️ Back to Top](#-table-of-contents)** Homarr is Licensed under [MIT](https://en.wikipedia.org/wiki/MIT_License)
```txt
Copyright © 2022 Thomas "ajnart" Camlong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**[⤴️ Back to Top](#homarr)**
---
<p align="center">
<i>Thank you for visiting! <b>For more information <a href="https://github.com/ajnart/homarr/wiki">read the wiki!</a></b></i>
<br/>
<br/>
<a href="https://trackgit.com">
<img src="https://us-central1-trackgit-analytics.cloudfunctions.net/token/ping/l3khzc3a3pexzw5w5whl" alt="trackgit-views" />
</a>
</p>

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

@@ -1,26 +0,0 @@
{
"name": "config",
"services": [
{
"type": "Other",
"name": "YouTube",
"icon": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png",
"url": "https://youtube.com/"
},
{
"type": "Other",
"name": "YouTube ",
"icon": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png",
"url": "https://youtube.com/"
}
],
"settings": {
"searchBar": true,
"searchUrl": "Custom",
"enabledModules": [
"Date",
"Calendar",
"Weather"
]
}
}

View File

@@ -1,16 +0,0 @@
{
"name": "config_new",
"services": [
{
"type": "Other",
"name": "example",
"icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
}
],
"settings": {
"searchBar": true,
"searchUrl": "https://duckduckgo.com/?q=",
"enabledModules": []
}
}

View File

@@ -2,15 +2,22 @@
"name": "default", "name": "default",
"services": [ "services": [
{ {
"type": "Other",
"name": "example", "name": "example",
"id": "09c45847-8afc-4c1a-9697-f03192de948a",
"type": "Other",
"icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif", "icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
} }
], ],
"settings": { "settings": {
"searchBar": true, "searchUrl": "https://google.com/search?q="
"searchUrl": "https://bing.com/search?q=", },
"enabledModules": [] "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.4.0'; export const CURRENT_VERSION = 'v0.6.0';

View File

@@ -1,13 +1,16 @@
const { env } = require('process');
const withBundleAnalyzer = require('@next/bundle-analyzer')({ const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true', enabled: process.env.ANALYZE === 'true',
}); });
module.exports = withBundleAnalyzer({ module.exports = withBundleAnalyzer({
reactStrictMode: true, reactStrictMode: false,
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
experimental: { experimental: {
outputStandalone: true, outputStandalone: true,
}, },
basePath: env.BASE_URL,
}); });

View File

@@ -1,91 +1,87 @@
{ {
"name": "homarr", "name": "homarr",
"version": "0.4.0", "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",
"url": "https://github.com/ajnart/homarr" "url": "https://github.com/ajnart/homarr"
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
"start": "next start --port 7575", "start": "next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"export": "next build && next export", "export": "next build && next export",
"lint": "next lint", "lint": "next lint",
"jest": "jest", "jest": "jest",
"jest:watch": "jest --watch", "jest:watch": "jest --watch",
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"", "prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"", "prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest", "test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
"storybook": "start-storybook -p 7001", "storybook": "start-storybook -p 7001",
"storybook:build": "build-storybook", "storybook:build": "build-storybook",
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write" "ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^4.2.4", "@ctrl/deluge": "^4.0.0",
"@mantine/dates": "^4.2.4", "@ctrl/qbittorrent": "^4.0.0",
"@mantine/dropzone": "^4.2.4", "@ctrl/shared-torrent": "^4.1.0",
"@mantine/form": "^4.2.4", "@dnd-kit/core": "^6.0.1",
"@mantine/hooks": "^4.2.4", "@dnd-kit/sortable": "^7.0.0",
"@mantine/modals": "^4.2.4", "@dnd-kit/utilities": "^3.2.0",
"@mantine/next": "^4.2.4", "@mantine/core": "^4.2.6",
"@mantine/notifications": "^4.2.4", "@mantine/dates": "^4.2.6",
"@mantine/prism": "^4.2.4", "@mantine/dropzone": "^4.2.6",
"@mantine/rte": "^4.2.4", "@mantine/form": "^4.2.6",
"@mantine/spotlight": "^4.2.4", "@mantine/hooks": "^4.2.6",
"@modulz/radix-icons": "^4.0.0", "@mantine/next": "^4.2.6",
"axios": "^0.27.2", "@mantine/notifications": "^4.2.6",
"cookies-next": "^2.0.4", "@mantine/prism": "^4.2.6",
"dayjs": "^1.11.2", "@nivo/core": "^0.79.0",
"framer-motion": "^6.3.1", "@nivo/line": "^0.79.1",
"js-file-download": "^0.4.12", "@tabler/icons": "^1.68.0",
"next": "12.1.5-canary.4", "axios": "^0.27.2",
"prism-react-renderer": "^1.3.1", "cookies-next": "^2.0.4",
"react": "18.0.0", "dayjs": "^1.11.2",
"react-dom": "18.0.0", "framer-motion": "^6.3.1",
"tabler-icons-react": "^1.46.0" "js-file-download": "^0.4.12",
}, "next": "12.1.6",
"devDependencies": { "prism-react-renderer": "^1.3.1",
"@babel/core": "^7.17.8", "react": "^17.0.1",
"@next/bundle-analyzer": "^12.1.4", "react-dom": "^17.0.1",
"@next/eslint-plugin-next": "^12.1.4", "systeminformation": "^5.11.16",
"@storybook/addon-essentials": "^6.4.22", "uuid": "^8.3.2"
"@storybook/addon-links": "^6.4.22", },
"@storybook/react": "^6.4.22", "devDependencies": {
"@testing-library/dom": "^8.12.0", "@babel/core": "^7.17.8",
"@testing-library/jest-dom": "^5.16.3", "@next/bundle-analyzer": "^12.1.4",
"@testing-library/react": "^13.0.0", "@next/eslint-plugin-next": "^12.1.4",
"@testing-library/user-event": "^14.0.4", "@storybook/react": "^6.5.4",
"@types/jest": "^27.4.1", "@types/node": "^17.0.23",
"@types/node": "^17.0.23", "@types/react": "17.0.43",
"@types/react": "17.0.43", "@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.16.0", "@typescript-eslint/eslint-plugin": "^5.16.0",
"@typescript-eslint/parser": "^5.16.0", "@typescript-eslint/parser": "^5.16.0",
"babel-loader": "^8.2.4", "eslint": "^8.11.0",
"eslint": "^8.11.0", "eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb": "19.0.4", "eslint-config-airbnb-typescript": "^16.1.0",
"eslint-config-airbnb-typescript": "^16.1.4", "eslint-config-mantine": "1.1.0",
"eslint-config-mantine": "1.1.0", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jest": "^26.1.3", "eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-react": "^7.29.4",
"eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-storybook": "^0.5.11",
"eslint-plugin-storybook": "^0.5.11", "eslint-plugin-testing-library": "^5.2.0",
"eslint-plugin-testing-library": "^5.2.0", "eslint-plugin-unused-imports": "^2.0.0",
"eslint-plugin-unused-imports": "^2.0.0", "jest": "^28.1.0",
"jest": "^27.5.1", "prettier": "^2.6.2",
"prettier": "^2.6.2", "require-from-string": "^2.0.2",
"storybook-addon-mock": "^2.3.2", "typescript": "4.6.4"
"storybook-addon-turbo-build": "^1.1.0", },
"storybook-dark-mode": "^1.0.9", "resolutions": {
"ts-jest": "^27.1.4", "@types/react": "17.0.30"
"typescript": "4.6.3" },
}, "packageManager": "yarn@3.2.1"
"resolutions": {
"@types/react": "17.0.30"
}
} }

0
public/icons/.gitkeep Normal file
View File

View File

@@ -6,21 +6,19 @@ import {
Image, Image,
Button, Button,
Select, Select,
AspectRatio,
Text,
Card,
LoadingOverlay, LoadingOverlay,
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 { motion } from 'framer-motion';
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 { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { ServiceTypeList } from '../../tools/types'; import { ServiceTypeList } from '../../tools/types';
import { AppShelfItemWrapper } from './AppShelfItemWrapper';
export function AddItemShelfButton(props: any) { export function AddItemShelfButton(props: any) {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
@@ -51,106 +49,100 @@ export function AddItemShelfButton(props: any) {
); );
} }
export default function AddItemShelfItem(props: any) {
const [opened, setOpened] = useState(false);
return (
<>
<Modal
size="xl"
radius="md"
opened={props.opened || opened}
onClose={() => setOpened(false)}
title="Add a service"
>
<AddAppShelfItemForm setOpened={setOpened} />
</Modal>
<AppShelfItemWrapper>
<Card.Section>
<Group position="center" mx="lg">
<Text
// TODO: #1 Remove this hack to get the text to be centered.
ml={15}
style={{
alignSelf: 'center',
alignContent: 'center',
alignItems: 'center',
justifyContent: 'center',
justifyItems: 'center',
}}
mt="sm"
weight={500}
>
Add a service
</Text>
</Group>
</Card.Section>
<Card.Section>
<AspectRatio ratio={5 / 3} m="xl">
<motion.i
whileHover={{
cursor: 'pointer',
scale: 1.1,
}}
>
<Apps style={{ cursor: 'pointer' }} onClick={() => setOpened(true)} size={60} />
</motion.i>
</AspectRatio>
</Card.Section>
</AppShelfItemWrapper>
</>
);
}
function MatchIcon(name: string, form: any) { function MatchIcon(name: string, form: any) {
fetch( fetch(
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name `https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.toLowerCase()}.png` .toLowerCase()}.png`
) ).then((res) => {
.then((res) => { if (res.ok) {
if (res.status === 200) { form.setFieldValue('icon', res.url);
form.setFieldValue('icon', res.url); }
} });
})
.catch(() => {
// Do nothing
});
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(),
type: props.type ?? 'Other', type: props.type ?? 'Other',
category: props.category ?? undefined,
name: props.name ?? '', name: props.name ?? '',
icon: props.icon ?? '', 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,
// Validate icon with a regex // Validate icon with a regex
icon: (value: string) => { icon: (value: string) => {
if (!value.match(/^https?:\/\/.+\.(png|jpg|jpeg|gif)$/)) { // Regex to match everything that ends with and icon extension
if (!value.match(/\.(png|jpg|jpeg|gif|svg)$/)) {
return 'Please enter a valid icon URL'; return 'Please enter a valid icon URL';
} }
return null; return null;
}, },
// Validate url with a regex http/https // Validate url with a regex http/https
url: (value: string) => { url: (value: string) => {
if (!value.match(/^https?:\/\/.+\/$/)) { try {
return 'Please enter a valid URL (that ends with a /)'; const _isValid = new URL(value);
} catch (e) {
return 'Please enter a valid URL';
} }
return null; return null;
}, },
}, },
}); });
// 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>
@@ -166,11 +158,12 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
<form <form
onSubmit={form.onSubmit(() => { onSubmit={form.onSubmit(() => {
// If service already exists, update it. // If service already exists, update it.
if (config.services && config.services.find((s) => s.name === form.values.name)) { if (config.services && config.services.find((s) => s.id === form.values.id)) {
setConfig({ setConfig({
...config, ...config,
// replace the found item by matching ID
services: config.services.map((s) => { services: config.services.map((s) => {
if (s.name === form.values.name) { if (s.id === form.values.id) {
return { return {
...form.values, ...form.values,
}; };
@@ -196,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'}
/> />
@@ -213,11 +205,11 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
<TextInput <TextInput
required required
label="Service url" label="Service url"
placeholder="http://localhost:8989" placeholder="http://localhost:7575"
{...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
@@ -225,18 +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 === 'Radarr') && ( {(form.values.type === 'Sonarr' ||
<TextInput form.values.type === 'Radarr' ||
required form.values.type === 'Lidarr' ||
label="API key" form.values.type === 'Readarr') && (
placeholder="Your API key" <>
value={form.values.apiKey} <TextInput
onChange={(event) => { required
form.setFieldValue('apiKey', event.currentTarget.value); label="API key"
}} placeholder="Your API key"
error={form.errors.apiKey && 'Invalid API key'} value={form.values.apiKey}
/> onChange={(event) => {
form.setFieldValue('apiKey', event.currentTarget.value);
}}
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,6 @@
import { SimpleGrid } from '@mantine/core'; import { SimpleGrid } from '@mantine/core';
import AppShelf, { AppShelfItem } from './AppShelf'; import AppShelf from './AppShelf';
import { AppShelfItem } from './AppShelfItem';
export default { export default {
title: 'Item Shelf', title: 'Item Shelf',

View File

@@ -1,106 +1,133 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion } from 'framer-motion'; import { Grid, Group, Title } from '@mantine/core';
import { Text, AspectRatio, Card, Image, Center, Grid, createStyles } from '@mantine/core'; import {
closestCenter,
DndContext,
DragOverlay,
MouseSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import AppShelfMenu from './AppShelfMenu';
import PingComponent from '../modules/ping/PingModule';
const useStyles = createStyles((theme) => ({ import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
item: { import { ModuleWrapper } from '../modules/moduleWrapper';
transition: 'box-shadow 150ms ease, transform 100ms ease', import { DownloadsModule } from '../modules';
'&:hover': {
boxShadow: `${theme.shadows.md} !important`,
transform: 'scale(1.05)',
},
},
}));
const AppShelf = (props: any) => { const AppShelf = (props: any) => {
const { config } = useConfig(); const [activeId, setActiveId] = useState(null);
const { config, setConfig } = useConfig();
const sensors = useSensors(
useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
function handleDragStart(event: any) {
const { active } = event;
setActiveId(active.id);
}
function handleDragEnd(event: any) {
const { active, over } = event;
if (active.id !== over.id) {
const newConfig = { ...config };
const activeIndex = newConfig.services.findIndex((e) => e.id === active.id);
const overIndex = newConfig.services.findIndex((e) => e.id === over.id);
newConfig.services = arrayMove(newConfig.services, activeIndex, overIndex);
setConfig(newConfig);
}
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[]);
const item = (filter?: string) => {
// If filter is not set, return all the services without a category or a null category
let filtered = config.services;
if (!filter) {
filtered = config.services.filter((e) => !e.category || e.category === null);
}
if (filter) {
filtered = config.services.filter((e) => e.category === filter);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={config.services}>
<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}
<ModuleWrapper mt="xl" module={DownloadsModule} />
</Group>
);
}
return ( return (
<Grid gutter="xl" align="center"> <Group grow direction="column">
{config.services.map((service) => ( {item()}
<Grid.Col key={service.name} span={6} xl={2} xs={4} sm={3} md={3}> <ModuleWrapper mt="xl" module={DownloadsModule} />
<AppShelfItem key={service.name} service={service} /> </Group>
</Grid.Col>
))}
</Grid>
); );
}; };
export function AppShelfItem(props: any) {
const { service }: { service: serviceItem } = props;
const [hovering, setHovering] = useState(false);
const { classes, theme } = useStyles();
return (
<motion.div
animate={{
scale: [0.9, 1.06, 1],
rotate: [0, 5, 0],
}}
transition={{ duration: 0.6, type: 'spring', damping: 10, mass: 0.75, stiffness: 100 }}
key={service.name}
onHoverStart={() => {
setHovering(true);
}}
onHoverEnd={() => {
setHovering(false);
}}
>
<Card withBorder radius="lg" shadow="md" className={classes.item}>
<Card.Section>
<Text mt="sm" align="center" lineClamp={1} weight={550}>
{service.name}
</Text>
<motion.div
style={{
position: 'absolute',
top: 15,
right: 15,
alignSelf: 'flex-end',
}}
animate={{
opacity: hovering ? 1 : 0,
}}
>
<AppShelfMenu service={service} />
</motion.div>
</Card.Section>
<Center>
<Card.Section>
<AspectRatio
ratio={3 / 5}
m="xl"
style={{
width: 150,
height: 90,
}}
>
<motion.i
whileHover={{
cursor: 'pointer',
scale: 1.1,
}}
>
<Image
width={80}
height={80}
src={service.icon}
fit="contain"
onClick={() => {
window.open(service.url);
}}
/>
</motion.i>
</AspectRatio>
<PingComponent url={service.url} />
</Card.Section>
</Center>
</Card>
</motion.div>
);
}
export default AppShelf; export default AppShelf;

View File

@@ -0,0 +1,115 @@
import { Text, Card, Anchor, AspectRatio, Image, Center, createStyles } from '@mantine/core';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { serviceItem } from '../../tools/types';
import PingComponent from '../modules/ping/PingModule';
import AppShelfMenu from './AppShelfMenu';
const useStyles = createStyles((theme) => ({
item: {
transition: 'box-shadow 150ms ease, transform 100ms ease',
'&:hover': {
boxShadow: `${theme.shadows.md} !important`,
transform: 'scale(1.05)',
},
},
}));
export function SortableAppShelfItem(props: any) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: props.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<AppShelfItem service={props.service} />
</div>
);
}
export function AppShelfItem(props: any) {
const { service }: { service: serviceItem } = props;
const [hovering, setHovering] = useState(false);
const { classes, theme } = useStyles();
return (
<motion.div
animate={{
scale: [0.9, 1.06, 1],
rotate: [0, 5, 0],
}}
transition={{ duration: 0.6, type: 'spring', damping: 10, mass: 0.75, stiffness: 100 }}
key={service.name}
onHoverStart={() => {
setHovering(true);
}}
onHoverEnd={() => {
setHovering(false);
}}
>
<Card withBorder radius="lg" shadow="md" className={classes.item}>
<Card.Section>
<Anchor
target="_blank"
href={service.url}
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
<Text mt="sm" align="center" lineClamp={1} weight={550}>
{service.name}
</Text>
</Anchor>
<motion.div
style={{
position: 'absolute',
top: 15,
right: 15,
alignSelf: 'flex-end',
}}
animate={{
opacity: hovering ? 1 : 0,
}}
>
<AppShelfMenu service={service} />
</motion.div>
</Card.Section>
<Center>
<Card.Section>
<AspectRatio
ratio={3 / 5}
m="xl"
style={{
width: 150,
height: 90,
}}
>
<motion.i
whileHover={{
scale: 1.1,
}}
>
<Image
styles={{ root: { cursor: 'pointer' } }}
width={80}
height={80}
src={service.icon}
fit="contain"
onClick={() => {
window.open(service.url);
}}
/>
</motion.i>
</AspectRatio>
<PingComponent url={service.url} />
</Card.Section>
</Center>
</Card>
</motion.div>
);
}

View File

@@ -1,17 +0,0 @@
import { useMantineTheme, Card } from '@mantine/core';
export function AppShelfItemWrapper(props: any) {
const { children, hovering } = props;
const theme = useMantineTheme();
return (
<Card
style={{
boxShadow: hovering ? '0px 0px 3px rgba(0, 0, 0, 0.5)' : '0px 0px 1px rgba(0, 0, 0, 0.5)',
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
}}
radius="md"
>
{children}
</Card>
);
}

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);
@@ -22,27 +23,32 @@ export default function AppShelfMenu(props: any) {
<AddAppShelfItemForm <AddAppShelfItemForm
setOpened={setOpened} setOpened={setOpened}
name={service.name} name={service.name}
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)}
> >
@@ -54,13 +60,13 @@ export default function AppShelfMenu(props: any) {
onClick={(e: any) => { onClick={(e: any) => {
setConfig({ setConfig({
...config, ...config,
services: config.services.filter((s) => s.name !== service.name), services: config.services.filter((s) => s.id !== service.id),
}); });
showNotification({ showNotification({
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',
@@ -68,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

@@ -0,0 +1,2 @@
export { default as AppShelf } from './AppShelf';
export * from './AppShelfItem';

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';
@@ -7,6 +13,7 @@ import { useRouter } from 'next/router';
import { setCookies } from 'cookies-next'; import { setCookies } from 'cookies-next';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { Config } from '../../tools/types'; import { Config } from '../../tools/types';
import { migrateToIdConfig } from '../../tools/migrate';
function getIconColor(status: DropzoneStatus, theme: MantineTheme) { function getIconColor(status: DropzoneStatus, theme: MantineTheme) {
return status.accepted return status.accepted
@@ -84,7 +91,8 @@ export default function LoadConfigComponent(props: any) {
message: undefined, message: undefined,
}); });
setCookies('config-name', newConfig.name, { maxAge: 60 * 60 * 24 * 30 }); setCookies('config-name', newConfig.name, { maxAge: 60 * 60 * 24 * 30 });
setConfig(newConfig); const migratedConfig = migrateToIdConfig(newConfig);
setConfig(migratedConfig);
}); });
}} }}
accept={['application/json']} accept={['application/json']}

View File

@@ -1,18 +1,101 @@
import { Button } from '@mantine/core'; import { Button, Group, Modal, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import axios from 'axios';
import fileDownload from 'js-file-download'; import fileDownload from 'js-file-download';
import { Download } from 'tabler-icons-react'; import { useState } from '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) {
const { config } = useConfig(); const [opened, setOpened] = useState(false);
const { config, setConfig } = useConfig();
const form = useForm({
initialValues: {
configName: config.name,
},
});
function onClick(e: any) { function onClick(e: any) {
if (config) { if (config) {
fileDownload(JSON.stringify(config, null, '\t'), `${config.name}.json`); fileDownload(JSON.stringify(config, null, '\t'), `${config.name}.json`);
} }
} }
return ( return (
<Button leftIcon={<Download />} variant="outline" onClick={onClick}> <Group>
Download your config <Modal
</Button> radius="md"
opened={opened}
onClose={() => setOpened(false)}
title="Choose the name of your new config"
>
<form
onSubmit={form.onSubmit((values) => {
setConfig({ ...config, name: values.configName });
setOpened(false);
showNotification({
title: 'Config saved',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Config saved as ${values.configName}`,
});
})}
>
<TextInput
required
label="Config name"
placeholder="Your new config name"
{...form.getInputProps('configName')}
/>
<Group position="right" mt="md">
<Button type="submit">Confirm</Button>
</Group>
</form>
</Modal>
<Button leftIcon={<Download />} variant="outline" onClick={onClick}>
Download config
</Button>
<Button
leftIcon={<Trash />}
variant="outline"
onClick={() => {
axios
.delete(`/api/configs/${config.name}`)
.then(() => {
showNotification({
title: 'Config deleted',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: 'Config deleted',
});
})
.catch(() => {
showNotification({
title: 'Config delete failed',
icon: <X />,
color: 'red',
autoClose: 1500,
radius: 'md',
message: 'Config delete failed',
});
});
setConfig({ ...config, name: 'default' });
}}
>
Delete config
</Button>
<Button leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
Save a copy
</Button>
</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

@@ -5,34 +5,25 @@ import { useConfig } from '../../tools/state';
export default function ModuleEnabler(props: any) { export default function ModuleEnabler(props: any) {
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const modules = Object.values(Modules).map((module) => module); const modules = Object.values(Modules).map((module) => module);
const enabledModules = config.settings.enabledModules ?? [];
modules.filter((module) => enabledModules.includes(module.title));
return ( return (
<Group direction="column"> <Group direction="column">
{modules.map((module) => ( {modules.map((module) => (
<Switch <Switch
key={module.title} key={module.title}
size="md" size="md"
checked={enabledModules.includes(module.title)} checked={config.modules?.[module.title]?.enabled ?? false}
label={`Enable ${module.title} module`} label={`Enable ${module.title}`}
onChange={(e) => { onChange={(e) => {
if (e.currentTarget.checked) { setConfig({
setConfig({ ...config,
...config, modules: {
settings: { ...config.modules,
...config.settings, [module.title]: {
enabledModules: [...enabledModules, module.title], ...config.modules?.[module.title],
enabled: e.currentTarget.checked,
}, },
}); },
} else { });
setConfig({
...config,
settings: {
...config.settings,
enabledModules: enabledModules.filter((m) => m !== module.title),
},
});
}
}} }}
/> />
))} ))}

View File

@@ -1,16 +1,18 @@
import { import {
ActionIcon, ActionIcon,
Group, Group,
Modal,
Title, Title,
Text, Text,
Tooltip, Tooltip,
SegmentedControl, SegmentedControl,
TextInput, TextInput,
Drawer,
Anchor,
} from '@mantine/core'; } from '@mantine/core';
import { useColorScheme } 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,28 +89,62 @@ 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>
); );
} }
export function SettingsMenuButton(props: any) { export function SettingsMenuButton(props: any) {
useHotkeys([['ctrl+L', () => setOpened(!opened)]]);
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
return ( return (
<> <>
<Modal <Drawer
size="xl" size="auto"
radius="md" padding="xl"
position="right"
title={<Title order={3}>Settings</Title>} title={<Title order={3}>Settings</Title>}
opened={props.opened || opened} opened={props.opened || opened}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
> >
<SettingsMenu /> <SettingsMenu />
</Modal> </Drawer>
<ActionIcon <ActionIcon
variant="default" variant="default"
radius="md" radius="md"
@@ -118,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,72 +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',
position: 'fixed',
bottom: '0',
left: '0',
}} }}
> 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

@@ -1,4 +1,5 @@
import { Group, Image, Text } from '@mantine/core'; import { Group, Image, Text } from '@mantine/core';
import { NextLink } from '@mantine/next';
import * as React from 'react'; import * as React from 'react';
export function Logo({ style }: any) { export function Logo({ style }: any) {
@@ -11,14 +12,22 @@ export function Logo({ style }: any) {
position: 'relative', position: 'relative',
}} }}
/> />
<Text <NextLink
sx={style} href="/"
weight="bold" style={{
variant="gradient" textDecoration: 'none',
gradient={{ from: 'red', to: 'orange', deg: 145 }} position: 'relative',
}}
> >
Homarr <Text
</Text> sx={style}
weight="bold"
variant="gradient"
gradient={{ from: 'red', to: 'orange', deg: 145 }}
>
Homarr
</Text>
</NextLink>
</Group> </Group>
); );
} }

View File

@@ -1,12 +1,18 @@
/* eslint-disable react/no-children-prop */ /* eslint-disable react/no-children-prop */
import { Popover, Box, ScrollArea, Divider, Indicator, 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 { RadarrMediaDisplay, SonarrMediaDisplay } from './MediaDisplay';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { IModule } from '../modules'; import { IModule } from '../modules';
import {
SonarrMediaDisplay,
RadarrMediaDisplay,
LidarrMediaDisplay,
ReadarrMediaDisplay,
} from '../common';
import { serviceItem } from '../../../tools/types';
export const CalendarModule: IModule = { export const CalendarModule: IModule = {
title: 'Calendar', title: 'Calendar',
@@ -19,59 +25,31 @@ export const CalendarModule: IModule = {
export default function CalendarComponent(props: any) { export default function CalendarComponent(props: any) {
const { config } = useConfig(); const { config } = useConfig();
const [sonarrMedias, setSonarrMedias] = useState([] as any); const [sonarrMedias, setSonarrMedias] = 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 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'
);
// 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 nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString(); getMedias(lidarrService, 'lidarr').then((res) => setLidarrMedias(res.data));
if (sonarrService && sonarrService.apiKey) { getMedias(readarrService, 'readarr').then((res) => setReadarrMedias(res.data));
fetch(
`${sonarrService?.url}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) {
fetch(
`${radarrService?.url}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`,
});
});
});
}
}, [config.services]); }, [config.services]);
if (sonarrMedias === undefined && radarrMedias === undefined) {
return <Calendar />;
}
return ( return (
<Calendar <Calendar
onChange={(day: any) => {}} onChange={(day: any) => {}}
@@ -80,6 +58,8 @@ export default function CalendarComponent(props: any) {
renderdate={renderdate} renderdate={renderdate}
sonarrmedias={sonarrMedias} sonarrmedias={sonarrMedias}
radarrmedias={radarrMedias} radarrmedias={radarrMedias}
lidarrmedias={lidarrMedias}
readarrmedias={readarrMedias}
/> />
)} )}
/> />
@@ -91,12 +71,24 @@ function DayComponent(props: any) {
renderdate, renderdate,
sonarrmedias, sonarrmedias,
radarrmedias, radarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: [] } = props; lidarrmedias,
readarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
props;
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const theme = useMantineTheme();
const day = renderdate.getDate(); const day = renderdate.getDate();
// Itterate over the medias and filter the ones that are on the same day
const readarrFiltered = readarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
});
const lidarrFiltered = lidarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
// Return true if the date is renerdate without counting hours and minutes
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
});
const sonarrFiltered = sonarrmedias.filter((media: any) => { const sonarrFiltered = sonarrmedias.filter((media: any) => {
const date = new Date(media.airDate); const date = new Date(media.airDate);
// Return true if the date is renerdate without counting hours and minutes // Return true if the date is renerdate without counting hours and minutes
@@ -107,7 +99,12 @@ function DayComponent(props: any) {
// Return true if the date is renerdate without counting hours and minutes // Return true if the date is renerdate without counting hours and minutes
return date.getDate() === day && date.getMonth() === renderdate.getMonth(); return date.getDate() === day && date.getMonth() === renderdate.getMonth();
}); });
if (sonarrFiltered.length === 0 && radarrFiltered.length === 0) { if (
sonarrFiltered.length === 0 &&
radarrFiltered.length === 0 &&
lidarrFiltered.length === 0 &&
readarrFiltered.length === 0
) {
return <div>{day}</div>; return <div>{day}</div>;
} }
@@ -117,13 +114,68 @@ function DayComponent(props: any) {
setOpened(true); setOpened(true);
}} }}
> >
{radarrFiltered.length > 0 && <Indicator size={7} color="yellow" children={null} />} {readarrFiltered.length > 0 && (
{sonarrFiltered.length > 0 && <Indicator size={7} offset={8} color="blue" children={null} />} <Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
left: 8,
}}
color="red"
children={null}
/>
)}
{radarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
left: 8,
}}
color="yellow"
children={null}
/>
)}
{sonarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
right: 8,
}}
color="blue"
children={null}
/>
)}
{lidarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
right: 8,
}}
color="green"
children={undefined}
/>
)}
<Popover <Popover
position="left" position="left"
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}
@@ -145,6 +197,18 @@ function DayComponent(props: any) {
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />} {index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment> </React.Fragment>
))} ))}
{lidarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<LidarrMediaDisplay media={media} />
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment>
))}
{readarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<ReadarrMediaDisplay media={media} />
{index < readarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment>
))}
</ScrollArea> </ScrollArea>
</Popover> </Popover>
</Box> </Box>

View File

@@ -1,100 +0,0 @@
import { Stack, Image, Group, Title, Badge, Text, ActionIcon, Anchor } from '@mantine/core';
import { Link } from 'tabler-icons-react';
export interface IMedia {
overview: string;
imdbId: any;
title: string;
poster: string;
genres: string[];
seasonNumber?: number;
episodeNumber?: number;
}
function MediaDisplay(props: { media: IMedia }) {
const { media }: { media: IMedia } = props;
return (
<Group noWrap align="self-start" mr={15}>
<Image
radius="md"
fit="cover"
src={media.poster}
alt={media.title}
width={300}
height={400}
/>
<Stack
justify="space-between"
sx={(theme) => ({
height: 400,
})}
>
<Group direction="column">
<Group>
<Title order={3}>{media.title}</Title>
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
<ActionIcon>
<Link />
</ActionIcon>
</Anchor>
</Group>
{media.episodeNumber && media.seasonNumber && (
<Text
style={{
textAlign: 'center',
color: '#a0aec0',
}}
>
Season {media.seasonNumber} episode {media.episodeNumber}
</Text>
)}
<Text align="justify">{media.overview}</Text>
</Group>
{/*Add the genres at the bottom of the poster*/}
<Group>
{media.genres.map((genre: string, i: number) => (
<Badge key={i}>{genre}</Badge>
))}
</Group>
</Stack>
</Group>
);
}
export function RadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'poster');
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
imdbId: media.imdbId,
title: media.title,
overview: media.overview,
poster: poster.url,
genres: media.genres,
}}
/>
);
}
export function SonarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
const poster = media.series.images.find((image: any) => image.coverType === 'poster');
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
imdbId: media.series.imdbId,
title: media.series.title,
overview: media.series.overview,
poster: poster.url,
genres: media.series.genres,
seasonNumber: media.seasonNumber,
episodeNumber: media.episodeNumber,
}}
/>
);
}

View File

@@ -0,0 +1,172 @@
import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea } from '@mantine/core';
import { IconLink as Link } from '@tabler/icons';
import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types';
export interface IMedia {
overview: string;
imdbId?: any;
artist?: string;
title: string;
poster?: string;
genres: string[];
seasonNumber?: number;
episodeNumber?: number;
}
export function MediaDisplay(props: { media: IMedia }) {
const { media }: { media: IMedia } = props;
return (
<Group position="apart">
<Text>
{media.poster && (
<Image
style={{
float: 'right',
}}
radius="md"
fit="cover"
src={media.poster}
alt={media.title}
width={250}
height={400}
/>
)}
<Group direction="column">
<Group noWrap mr="sm" style={{ minWidth: 400 }}>
<Title order={3}>{media.title}</Title>
{media.imdbId && (
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
<ActionIcon>
<Link />
</ActionIcon>
</Anchor>
)}
</Group>
{media.artist && (
<Text
style={{
textAlign: 'center',
color: 'gray',
}}
>
New release from {media.artist}
</Text>
)}
{media.episodeNumber && media.seasonNumber && (
<Text
style={{
textAlign: 'center',
color: 'gray',
}}
>
Season {media.seasonNumber} episode {media.episodeNumber}
</Text>
)}
</Group>
<Group direction="column" position="apart">
<ScrollArea style={{ height: 250 }}>{media.overview}</ScrollArea>
<Group align="center" position="center" spacing="xs">
{media.genres.map((genre: string, i: number) => (
<Badge size="sm" key={i}>
{genre}
</Badge>
))}
</Group>
</Group>
</Text>
</Group>
);
}
export function ReadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const readarr = config.services.find((service: serviceItem) => service.type === 'Readarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!readarr) {
return null;
}
const baseUrl = new URL(readarr.url).origin;
// Remove '/' from the end of the lidarr url
const fullLink = `${baseUrl}${poster.url}`;
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
title: media.title,
poster: fullLink,
artist: media.author.authorName,
overview: media.overview,
genres: media.genres,
}}
/>
);
}
export function LidarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const lidarr = config.services.find((service: serviceItem) => service.type === 'Lidarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!lidarr) {
return null;
}
const baseUrl = new URL(lidarr.url).origin;
// Remove '/' from the end of the lidarr url
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
title: media.title,
poster: fullLink,
artist: media.artist.artistName,
overview: media.overview,
genres: media.genres,
}}
/>
);
}
export function RadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'poster');
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
imdbId: media.imdbId,
title: media.title,
overview: media.overview,
poster: poster.url,
genres: media.genres,
}}
/>
);
}
export function SonarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
const poster = media.series.images.find((image: any) => image.coverType === 'poster');
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
imdbId: media.series.imdbId,
title: media.series.title,
overview: media.series.overview,
poster: poster.url,
genres: media.series.genres,
seasonNumber: media.seasonNumber,
episodeNumber: media.episodeNumber,
}}
/>
);
}

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';
@@ -21,13 +21,8 @@ export const DateModule: IModule = {
export default function DateComponent(props: any) { export default function DateComponent(props: any) {
const [date, setDate] = useState(new Date()); const [date, setDate] = useState(new Date());
const { config } = useConfig(); const { config } = useConfig();
const hours = date.getHours(); const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? false;
const minutes = date.getMinutes(); const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
const isFullTime =
config.settings[`${DateModule.title}.full`] === undefined
? true
: config.settings[`${DateModule.title}.full`];
const formatString = isFullTime ? 'HH:mm' : 'h:mm a';
// Change date on minute change // Change date on minute change
// Note: Using 10 000ms instead of 1000ms to chill a little :) // Note: Using 10 000ms instead of 1000ms to chill a little :)
useEffect(() => { useEffect(() => {
@@ -37,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

@@ -1,14 +1,10 @@
import { Card, Menu, Switch, useMantineTheme } from '@mantine/core'; import { Button, Card, Group, Menu, Switch, TextInput, useMantineTheme } from '@mantine/core';
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.settings.enabledModules ?? []; const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules.includes(module.title);
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);
@@ -18,25 +14,69 @@ export function ModuleWrapper(props: any) {
// Loop over all the types with a for each loop // Loop over all the types with a for each loop
types.forEach((type, index) => { types.forEach((type, index) => {
const optionName = `${module.title}.${keys[index]}`; const optionName = `${module.title}.${keys[index]}`;
const moduleInConfig = config.modules?.[module.title];
if (type === 'string') {
items.push(
<form
onSubmit={(e) => {
e.preventDefault();
setConfig({
...config,
modules: {
...config.modules,
[module.title]: {
...config.modules[module.title],
options: {
...config.modules[module.title].options,
[keys[index]]: {
...config.modules[module.title].options?.[keys[index]],
value: (e.target as any)[0].value,
},
},
},
},
});
}}
>
<Group noWrap align="end" position="center" mt={0}>
<TextInput
key={optionName}
id={optionName}
name={optionName}
label={values[index].name}
defaultValue={(moduleInConfig?.options?.[keys[index]]?.value as string) ?? ''}
onChange={(e) => {}}
/>
<Button type="submit">Save</Button>
</Group>
</form>
);
}
// TODO: Add support for other types // TODO: Add support for other types
if (type === 'boolean') { if (type === 'boolean') {
items.push( items.push(
<Switch <Switch
defaultChecked={ defaultChecked={
// Set default checked to the value of the option if it exists // Set default checked to the value of the option if it exists
config.settings[optionName] ?? (moduleInConfig?.options?.[keys[index]]?.value as boolean) ?? false
(module.options && module.options[keys[index]].value) ??
false
} }
defaultValue={config.settings[optionName] ?? false}
key={keys[index]} key={keys[index]}
onClick={(e) => { onClick={(e) => {
setConfig({ setConfig({
...config, ...config,
settings: { modules: {
...config.settings, ...config.modules,
enabledModules: [...config.settings.enabledModules], [module.title]: {
[optionName]: e.currentTarget.checked, ...config.modules[module.title],
options: {
...config.modules[module.title].options,
[keys[index]]: {
...config.modules[module.title].options?.[keys[index]],
value: e.currentTarget.checked,
},
},
},
}, },
}); });
}} }}
@@ -46,15 +86,26 @@ export function ModuleWrapper(props: any) {
} }
}); });
} }
// Sussy baka 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="md" size="lg"
shadow="xl" shadow="xl"
closeOnItemClick={false} closeOnItemClick={false}
radius="md" radius="md"
@@ -66,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

@@ -14,7 +14,7 @@ interface Option {
[x: string]: OptionValues; [x: string]: OptionValues;
} }
interface OptionValues { export interface OptionValues {
name: string; name: string;
value: boolean; value: boolean | string;
} }

View File

@@ -6,6 +6,7 @@ export default {
}; };
const service: serviceItem = { const service: serviceItem = {
id: '1',
type: 'Other', type: 'Other',
name: 'YouTube', name: 'YouTube',
icon: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png', icon: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png',

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';
@@ -19,8 +19,9 @@ export default function PingComponent(props: any) {
const { url }: { url: string } = props; const { url }: { url: string } = props;
const [isOnline, setOnline] = useState<State>('loading'); const [isOnline, setOnline] = useState<State>('loading');
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
useEffect(() => { useEffect(() => {
if (!config.settings.enabledModules.includes('Ping Services')) { if (!exists) {
return; return;
} }
axios axios
@@ -32,7 +33,7 @@ export default function PingComponent(props: any) {
setOnline('down'); setOnline('down');
}); });
}, []); }, []);
if (!config.settings.enabledModules.includes('Ping Services')) { if (!exists) {
return null; return null;
} }
return ( return (

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';
@@ -26,7 +30,7 @@ export default function SearchBar(props: any) {
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [icon, setIcon] = useState(<Search />); const [icon, setIcon] = useState(<Search />);
const queryUrl = config.settings.searchUrl || 'https://www.google.com/search?q='; const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
const textInput = useRef<HTMLInputElement>(); const textInput = useRef<HTMLInputElement>();
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]); useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
@@ -46,7 +50,10 @@ export default function SearchBar(props: any) {
}); });
// If enabled modules doesn't contain the module, return null // If enabled modules doesn't contain the module, return null
if (!config.settings.enabledModules.includes(SearchModule.title)) { // If module in enabled
const exists = config.modules?.[SearchModule.title]?.enabled ?? false;
if (!exists) {
return null; return null;
} }
@@ -109,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';
@@ -27,6 +27,10 @@ export const WeatherModule: IModule = {
name: 'Display in Fahrenheit', name: 'Display in Fahrenheit',
value: false, value: false,
}, },
location: {
name: 'Current location',
value: '',
},
}, },
}; };
@@ -128,29 +132,30 @@ export function WeatherIcon(props: any) {
export default function WeatherComponent(props: any) { export default function WeatherComponent(props: any) {
// Get location from browser // Get location from browser
const [location, setLocation] = useState({ lat: 0, lng: 0 });
const { config } = useConfig(); const { config } = useConfig();
const [weather, setWeather] = useState({} as WeatherResponse); const [weather, setWeather] = useState({} as WeatherResponse);
const cityInput: string =
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? '';
const isFahrenheit: boolean = const isFahrenheit: boolean =
config.settings[`${WeatherModule.title}.freedomunit`] === undefined (config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value as boolean) ?? false;
? false
: config.settings[`${WeatherModule.title}.freedomunit`];
if ('geolocation' in navigator && location.lat === 0 && location.lng === 0) {
navigator.geolocation.getCurrentPosition((position) => {
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude });
});
}
useEffect(() => { useEffect(() => {
axios axios
.get( .get(`https://geocoding-api.open-meteo.com/v1/search?name=${cityInput}`)
`https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lng}&daily=weathercode,temperature_2m_max,temperature_2m_min&current_weather=true&timezone=Europe%2FLondon` .then((response) => {
) // Check if results exists
.then((res) => { const { latitude, longitude } = response.data.results
setWeather(res.data); ? response.data.results[0]
: { latitude: 0, longitude: 0 };
axios
.get(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min&current_weather=true&timezone=Europe%2FLondon`
)
.then((res) => {
setWeather(res.data);
});
}); });
}, []); }, [cityInput]);
if (!weather.current_weather) { if (!weather.current_weather) {
return null; return null;
} }
@@ -158,15 +163,15 @@ 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} />
<Space mx="sm" /> <Space mx="sm" />
<span>{weather.daily.temperature_2m_max[0]}°C</span> <span>{usePerferedUnit(weather.daily.temperature_2m_max[0])}</span>
<ArrowUpRight size={16} style={{ right: 15 }} /> <ArrowUpRight size={16} style={{ right: 15 }} />
<Space mx="sm" /> <Space mx="sm" />
<span>{weather.daily.temperature_2m_min[0]}°C</span> <span>{usePerferedUnit(weather.daily.temperature_2m_min[0])}</span>
<ArrowDownRight size={16} /> <ArrowDownRight size={16} />
</Group> </Group>
</Group> </Group>

View File

@@ -78,10 +78,9 @@ export default function NothingFoundBackground() {
<div className={classes.inner}> <div className={classes.inner}>
<Illustration className={classes.image} /> <Illustration className={classes.image} />
<div className={classes.content}> <div className={classes.content}>
<Title className={classes.title}>Nothing to see here</Title> <Title className={classes.title}>Config not found</Title>
<Text color="dimmed" size="lg" align="center" className={classes.description}> <Text color="dimmed" size="lg" align="center" className={classes.description}>
Page you are trying to open does not exist. You may have mistyped the address, or the The config you are trying to access does not exist. Please check the URL and try again.
page has been moved to another URL. If you think this is an error contact support.
</Text> </Text>
<Group position="center"> <Group position="center">
<NextLink href="/"> <NextLink href="/">

View File

@@ -1,8 +1,54 @@
import { Title } from '@mantine/core'; import { GetServerSidePropsContext } from 'next';
import { useRouter } from 'next/router'; import path from 'path';
import fs from 'fs';
import { useEffect } from 'react';
import AppShelf from '../components/AppShelf/AppShelf';
import LoadConfigComponent from '../components/Config/LoadConfig';
import { Config } from '../tools/types';
import { useConfig } from '../tools/state';
export default function SlugPage(props: any) { export async function getServerSideProps(
const router = useRouter(); context: GetServerSidePropsContext
const { slug } = router.query; ): Promise<{ props: { config: Config } }> {
return <Title>ok</Title>; const configByUrl = context.query.slug;
const configPath = path.join(process.cwd(), 'data/configs', `${configByUrl}.json`);
const configExists = fs.existsSync(configPath);
if (!configExists) {
// Redirect to 404
context.res.writeHead(301, { Location: '/404' });
context.res.end();
return {
props: {
config: {
name: 'Default config',
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) {
const { config: initialConfig }: { config: Config } = props;
const { setConfig } = useConfig();
useEffect(() => {
setConfig(initialConfig);
}, [initialConfig]);
return (
<>
<AppShelf />
<LoadConfigComponent />
</>
);
} }

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={2} position="top-right"> <NotificationsProvider limit={4} position="bottom-left">
<ConfigProvider> <ConfigProvider>
<Layout> <Layout>
<Component {...pageProps} /> <Component {...pageProps} />

View File

@@ -6,7 +6,6 @@ const stylesServer = createStylesServer();
export default class _Document extends Document { export default class _Document extends Document {
static async getInitialProps(ctx: DocumentContext) { static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx); const initialProps = await Document.getInitialProps(ctx);
// Add your app specific logic here // Add your app specific logic here
return { return {

View File

@@ -51,6 +51,9 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'PUT') { if (req.method === 'PUT') {
return Put(req, res); return Put(req, res);
} }
if (req.method === 'DELETE') {
return Delete(req, res);
}
if (req.method === 'GET') { if (req.method === 'GET') {
return Get(req, res); return Get(req, res);
} }
@@ -59,3 +62,28 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
message: 'Method not allowed', message: 'Method not allowed',
}); });
}; };
function Delete(req: NextApiRequest, res: NextApiResponse<any>) {
// Get the slug of the request
const { slug } = req.query as { slug: string };
if (!slug) {
return res.status(400).json({
message: 'Wrong request',
});
}
// Loop over all the files in the /data/configs directory
const files = fs.readdirSync('data/configs');
// Strip the .json extension from the file name
const configs = files.map((file) => file.replace('.json', ''));
// If the target is not in the list of files, return an error
if (!configs.includes(slug)) {
return res.status(404).json({
message: 'Target not found',
});
}
// Delete the file
fs.unlinkSync(path.join('data/configs', `${slug}.json`));
return res.status(200).json({
message: 'Configuration deleted with success',
});
}

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,12 +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 { getConfig } from '../tools/getConfig';
export async function getServerSideProps({ export async function getServerSideProps({
req, req,
@@ -14,41 +14,23 @@ 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: {
enabledModules: [],
searchBar: true,
searchUrl: 'https://www.google.com/search?q=',
},
},
},
};
}
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(() => {
setConfig(initialConfig); const migratedConfig = migrateToIdConfig(initialConfig);
setConfig(migratedConfig);
}, [initialConfig]); }, [initialConfig]);
return ( return (
<> <>

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),
},
};
}

14
src/tools/migrate.ts Normal file
View File

@@ -0,0 +1,14 @@
import { v4 as uuidv4 } from 'uuid';
import { Config } from './types';
export function migrateToIdConfig(config: Config): Config {
// Set the config and add an ID to all the services that don't have one
const services = config.services.map((service) => ({
...service,
id: service.id ?? uuidv4(),
}));
return {
...config,
services,
};
}

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 = {
@@ -17,10 +17,9 @@ const configContext = createContext<configContextType>({
name: 'default', name: 'default',
services: [], services: [],
settings: { settings: {
searchBar: true, searchUrl: 'https://google.com/search?q=',
searchUrl: 'https://www.google.com/search?q=',
enabledModules: [],
}, },
modules: {},
}, },
setConfig: () => {}, setConfig: () => {},
loadConfig: async (name: string) => {}, loadConfig: async (name: string) => {},
@@ -44,16 +43,15 @@ export function ConfigProvider({ children }: Props) {
name: 'default', name: 'default',
services: [], services: [],
settings: { settings: {
searchBar: true,
searchUrl: 'https://www.google.com/search?q=', searchUrl: 'https://www.google.com/search?q=',
enabledModules: [],
}, },
modules: {},
}); });
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

@@ -1,37 +1,56 @@
import { OptionValues } from '../components/modules/modules';
export interface Settings { export interface Settings {
searchUrl: string; searchUrl: string;
enabledModules: string[];
[key: string]: any;
} }
export interface Config { export interface Config {
name: string; name: string;
services: serviceItem[]; services: serviceItem[];
settings: Settings; settings: Settings;
modules: {
[key: string]: ConfigModule;
};
}
interface ConfigModule {
title: string;
enabled: boolean;
options: {
[key: string]: OptionValues;
};
} }
export const ServiceTypeList = [ export const ServiceTypeList = [
'Other', 'Other',
'Sonarr',
'Radarr',
'Lidarr',
'qBittorrent',
'Plex',
'Emby', 'Emby',
'Deluge',
'Lidarr',
'Plex',
'Radarr',
'Readarr',
'Sonarr',
'qBittorrent',
]; ];
export type ServiceType = export type ServiceType =
| 'Other' | 'Other'
| 'Sonarr' | 'Emby'
| 'Radarr' | 'Deluge'
| 'Lidarr' | 'Lidarr'
| 'qBittorrent'
| 'Plex' | 'Plex'
| 'Emby'; | 'Radarr'
| 'Readarr'
| 'Sonarr'
| 'qBittorrent';
export interface serviceItem { export interface serviceItem {
[x: string]: any; id: string;
name: string; name: string;
type: string; type: string;
url: string; url: string;
icon: string; icon: string;
category?: 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"
]
} }

29060
yarn.lock

File diff suppressed because it is too large Load Diff