Compare commits

...

145 Commits

Author SHA1 Message Date
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
Thomas Camlong
2586733a98 v0.4.0
Add Weather and Ping module
2022-05-18 23:22:14 +02:00
ajnart
7bc779b296 ⚰️ Remove dead code
Used to test the weather module
2022-05-18 23:13:32 +02:00
ajnart
6064dcb6a6 💄 Footer styling 2022-05-18 23:12:52 +02:00
ajnart
7c7b0cc970 💫 Add animations to the AppShelf 2022-05-18 23:12:34 +02:00
ajnart
c182397dd9 💫 Add animations to the PingModule 2022-05-18 23:11:58 +02:00
ajnart
dc5ee3bdf3 Add animations to the AppShelf 2022-05-18 22:51:12 +02:00
ajnart
c8e1295a4b Improve date module am/pm 2022-05-18 22:50:53 +02:00
ajnart
331c55240b Added Freedom units setting 2022-05-18 22:50:33 +02:00
Thomas Camlong
65037f9b56 Add Weather module (beta)
Shows the current weather !
2022-05-18 22:17:58 +02:00
Bjorn L
39853d79ce 🔧 Change versions to v0.4.0 2022-05-18 22:15:03 +02:00
Bjorn L
8530550347 🔧 Change versions to v0.4.0 2022-05-18 22:14:27 +02:00
Thomas Camlong
ba8e9ef63c Merge branch 'dev' into weather-module 2022-05-18 22:14:01 +02:00
ajnart
119f2d7e51 Add a proceudally generated options manager
This allows for options in settings generated based on their name in module config. Very important change 🧙
2022-05-18 22:11:37 +02:00
ajnart
b0be26300e 💄 Update AppShell menu and item styling
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-18 22:10:31 +02:00
ajnart
0400188ea7 🚚 Move the update indicator to the Footer
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-18 22:09:13 +02:00
ajnart
879581224a 🔥 Remove update indicator from settings
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-18 22:08:09 +02:00
ajnart
7e5602c881 🚨 Update eslint config
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-18 22:07:28 +02:00
Bjorn L
4870ea3e40 📝 Adds Docs for the Weather Module 2022-05-18 16:58:06 +02:00
Bjorn L
61c55acd50 📝 Adds Request Icons section 2022-05-18 16:55:48 +02:00
Thomas Camlong
c45421d27e Merge branch 'dev' into weather-module 2022-05-18 10:24:16 +02:00
Thomas "ajnart" Camlong
b396d2604f 🚑 Critical hotfix : Compilation failed 2022-05-18 10:23:18 +02:00
Thomas "ajnart" Camlong
28b6dcd1db 📦 Update deps 2022-05-18 10:10:42 +02:00
Thomas "ajnart" Camlong
1dd74ea7da 🐛 Try to fix module compilation 2022-05-17 22:59:02 +02:00
Thomas "ajnart" Camlong
64923b03d9 🎨 Fix architecture for CI 2022-05-17 22:59:02 +02:00
Thomas "ajnart" Camlong
2ba9d517a8 Improve weather module 2022-05-17 22:59:02 +02:00
Aj - Thomas
471a9f7407 Update page title 2022-05-17 22:59:02 +02:00
Aj - Thomas
bdf871b476 💄 � Update weather module styling 2022-05-17 22:59:02 +02:00
Aj - Thomas
ab860eeea1 � Weather module improvements 2022-05-17 22:59:02 +02:00
Thomas "ajnart" Camlong
50d760f3b8 Prepare for v0.3.2 2022-05-17 21:24:10 +02:00
Thomas "ajnart" Camlong
73d06e15fb Update tests for storybook 2022-05-17 21:04:19 +02:00
Thomas "ajnart" Camlong
49d57024b9 Advancement on the weather widget 2022-05-17 21:04:19 +02:00
Thomas "ajnart" Camlong
31deb5010f 💄 Improve styling of modules 2022-05-17 21:04:18 +02:00
Thomas "ajnart" Camlong
e86eb7798f 🚧 Set up the structure for the weather module 2022-05-17 21:04:16 +02:00
Thomas Camlong
2896423766 Add ping service module
 Add ping service module resolves #78
2022-05-17 20:59:44 +02:00
Thomas "ajnart" Camlong
696d0c582d 🐛 Clear the search input on search
Resolves #125
2022-05-17 20:58:55 +02:00
ajnart
e94cae620a Rever b7e8c51b29
Does not work. Apparently
2022-05-17 04:19:59 +02:00
ajnart
c9c6f2b0c9 Add ping service module
Resolves #78
2022-05-17 04:02:14 +02:00
ajnart
b8fe799ac6 ⚰️ Remove dead code for the settings
I turned the settings into a module in 4cb8539143
2022-05-17 02:07:38 +02:00
ajnart
4cb8539143 Make the search bar a module
Resolves #118
2022-05-17 02:04:44 +02:00
ajnart
16b86870c4 🏗️ Fix small bug in code arch, forgot the key 2022-05-17 02:03:52 +02:00
ajnart
d4ce2a3ed6 🏷️ Update types for the SearchBar 2022-05-17 01:52:43 +02:00
ajnart
a474f3e4ee 🥅 Add 404 to catch errors
Reduce the ammount of visible errors by adding a 404 page.
2022-05-17 01:44:26 +02:00
ajnart
9a49fbb747 💄 Update AppShelf UI 2022-05-17 01:43:40 +02:00
ajnart
e3d47d78e0 🐛 Add a delay before opening search results
Resolves #115
2022-05-17 01:23:19 +02:00
ajnart
d62189f086 💄 Remove version from logo and add it in footer
resolves #116
2022-05-17 01:01:26 +02:00
ajnart
bb1b3d7d9a Merge branch 'dev' of github.com:/ajnart/homarr into dev 2022-05-17 00:55:44 +02:00
ajnart
13aeeefb22 🐛 Fix AddAppShelfItem image fit not properly set
Resolves #117
2022-05-17 00:55:24 +02:00
ajnart
8cdc9c3e29 🎨 Use user prefered theme 2022-05-17 00:42:27 +02:00
ajnart
3e31a4d38e 💄 Better style events in the calendar 2022-05-17 00:42:27 +02:00
ajnart
0cb3db6b89 📦 Upgrade package 2022-05-17 00:42:27 +02:00
ajnart
b7e8c51b29 🎨 Use user prefered theme 2022-05-17 00:19:41 +02:00
ajnart
e60db9f57a 💄 Better style events in the calendar 2022-05-17 00:19:24 +02:00
ajnart
2c707e86aa 📦 Upgrade package 2022-05-17 00:18:22 +02:00
59 changed files with 4732 additions and 4302 deletions

View File

@@ -20,6 +20,7 @@ module.exports = {
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/no-children-prop': 'off',
"unused-imports/no-unused-imports": "warn",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-imports": "off",

View File

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

View File

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

281
README.md
View File

@@ -1,64 +1,97 @@
<h3 align="center">Homarr</h3>
<br>
<!-- Project Title -->
<h1 align="center">Homarr</h1>
<!-- Badges -->
<p align="center">
<a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml">
<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">
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/ajnart/homarr"></a>
<a href="https://github.com/ajnart/homarr/pkgs/container/homarr">
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/ajnart/homarr?label=Downloads%20"></a>
</p>
<p align="center">
<img align="end" width=600 src="https://user-images.githubusercontent.com/49837342/168315259-b778c816-10fe-44db-bd25-3eea6f31b233.png" />
</p>
<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/>
<img src="https://img.shields.io/github/stars/ajnart/homarr?label=%E2%AD%90%20Stars&style=flat-square?branch=master&kill_cache=1%22">
<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">
</a>
<a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml">
<img title="Docker CI Status" src="https://github.com/ajnart/homarr/actions/workflows/docker.yml/badge.svg" alt="CI Status">
</a>
<a href="https://discord.gg/aCsmEV5RgA">
<img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield">
</a>
</p>
# 📃 Table of Contents
- [📃 Table of Contents](#-table-of-contents)
- [🚀 Getting Started](#-getting-started)
- [ About](#-about)
- [💥 Known Issues](#-known-issues)
- [⚡ Installation](#-installation)
- [🐳 Deploying from Docker Image](#-deploying-from-docker-image)
- [🛠️ 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)
<!-- Links -->
<p align="center">
<i>Join the discord! — Don't forget to star the repo if you are enjoying the project!</i>
</p>
<p align="center">
<a href="https://homarr.netlify.app/"><strong> Demo ↗️ </strong></a> • <a href="#-installation"><strong> Install ➡️ </strong></a> • <a href="https://github.com/ajnart/homarr/wiki"><strong> Read the Wiki 📄 </strong></a>
</p>
---
<!-- Getting Started -->
# 🚀 Getting Started
## About
<!-- Homarr Description -->
<img align="right" width=250 src="public/imgs/logo-color.svg" />
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
- 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
> Supported architectures: x86-64, ARM, ARM64
@@ -66,29 +99,42 @@ _Requirements_:
- [Docker](https://docs.docker.com/get-docker/)
**Standard Docker Install**
```sh
docker run --name homarr -p 7575:7575 -v /data/docker/homarr:/app/data/configs -d ghcr.io/ajnart/homarr:latest
```bash
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**
```yml
---
version: '3'
#--------------------------------------------------------------------------------------------#
# Homarr - A homepage for your server. #
#--------------------------------------------------------------------------------------------#
#---------------------------------------------------------------------#
# Homarr - A homepage for your server. #
#---------------------------------------------------------------------#
services:
homarr:
container_name: homarr
image: ghcr.io/ajnart/homarr:latest
restart: unless-stopped
volumes:
- /data/docker/homarr:/app/data/configs
- ./homarr/configs:/app/data/configs
- ./homarr/icons:/app/public/icons
ports:
- '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
@@ -105,87 +151,54 @@ _Requirements_:
- Start the NextJS web server: ``yarn start``
- *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.
**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.
**[⤴️ 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
## 💖 Contributing
**Please read our [Contribution Guidelines](/CONTRIBUTING.md)**
All contributions are highly appreciated.
**[⤴️ Back to Top](#-table-of-contents)**
**[⤴️ Back to Top](#homarr)**
---
## 📜 License
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",
"services": [
{
"type": "Other",
"name": "example",
"id": "09c45847-8afc-4c1a-9697-f03192de948a",
"type": "Other",
"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://bing.com/search?q=",
"enabledModules": []
"searchUrl": "https://google.com/search?q="
},
"modules": {
"Search Bar": {
"enabled": true
},
"Date": {
"enabled": false
}
}
}

View File

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

View File

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

View File

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

0
public/icons/.gitkeep Normal file
View File

View File

@@ -6,21 +6,17 @@ import {
Image,
Button,
Select,
AspectRatio,
Text,
Card,
LoadingOverlay,
ActionIcon,
Tooltip,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { Apps } from 'tabler-icons-react';
import { v4 as uuidv4 } from 'uuid';
import { useConfig } from '../../tools/state';
import { ServiceTypeList } from '../../tools/types';
import { AppShelfItemWrapper } from './AppShelfItemWrapper';
export function AddItemShelfButton(props: any) {
const [opened, setOpened] = useState(false);
@@ -51,70 +47,16 @@ 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) {
fetch(
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()}.png`
)
.then((res) => {
if (res.status === 200) {
form.setFieldValue('icon', res.url);
}
})
.catch(() => {
// Do nothing
});
).then((res) => {
if (res.ok) {
form.setFieldValue('icon', res.url);
}
});
return false;
}
@@ -126,25 +68,31 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
const form = useForm({
initialValues: {
id: props.id ?? uuidv4(),
type: props.type ?? 'Other',
name: props.name ?? '',
icon: props.icon ?? '',
icon: props.icon ?? '/favicon.svg',
url: props.url ?? '',
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: {
apiKey: () => null,
// Validate icon with a regex
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 null;
},
// Validate url with a regex http/https
url: (value: string) => {
if (!value.match(/^https?:\/\/.+\/$/)) {
return 'Please enter a valid URL (that ends with a /)';
try {
const _isValid = new URL(value);
} catch (e) {
return 'Please enter a valid URL';
}
return null;
},
@@ -154,16 +102,24 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
return (
<>
<Center>
<Image height={120} width={120} src={form.values.icon} alt="Placeholder" withPlaceholder />
<Image
height={120}
width={120}
fit="contain"
src={form.values.icon}
alt="Placeholder"
withPlaceholder
/>
</Center>
<form
onSubmit={form.onSubmit(() => {
// 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({
...config,
// replace the found item by matching ID
services: config.services.map((s) => {
if (s.name === form.values.name) {
if (s.id === form.values.id) {
return {
...form.values,
};
@@ -206,7 +162,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
<TextInput
required
label="Service url"
placeholder="http://localhost:8989"
placeholder="http://localhost:7575"
{...form.getInputProps('url')}
/>
<Select
@@ -219,7 +175,10 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
{...form.getInputProps('type')}
/>
<LoadingOverlay visible={isLoading} />
{(form.values.type === 'Sonarr' || form.values.type === 'Radarr') && (
{(form.values.type === 'Sonarr' ||
form.values.type === 'Radarr' ||
form.values.type === 'Lidarr' ||
form.values.type === 'Readarr') && (
<TextInput
required
label="API key"
@@ -231,6 +190,44 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
error={form.errors.apiKey && 'Invalid API key'}
/>
)}
{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 grow position="center" mt="xl">

View File

@@ -1,5 +1,6 @@
import { SimpleGrid } from '@mantine/core';
import AppShelf, { AppShelfItem } from './AppShelf';
import AppShelf from './AppShelf';
import { AppShelfItem } from './AppShelfItem';
export default {
title: 'Item Shelf',

View File

@@ -1,89 +1,79 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Text, AspectRatio, Card, Image, useMantineTheme, Center, Grid } from '@mantine/core';
import { Grid } 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 { serviceItem } from '../../tools/types';
import AppShelfMenu from './AppShelfMenu';
import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
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);
}
return (
<Grid gutter="xl" align="center">
{config.services.map((service) => (
<Grid.Col span={6} xl={2} xs={4} sm={3} md={3}>
<AppShelfItem key={service.name} service={service} />
</Grid.Col>
))}
</Grid>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={config.services}>
<Grid gutter="xl" align="center">
{config.services.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>
);
};
export function AppShelfItem(props: any) {
const { service }: { service: serviceItem } = props;
const [hovering, setHovering] = useState(false);
const theme = useMantineTheme();
return (
<motion.div
key={service.name}
onHoverStart={() => {
setHovering(true);
}}
onHoverEnd={() => {
setHovering(false);
}}
>
<Card withBorder radius="lg" shadow="md">
<Card.Section>
<Text mt="sm" align="center" lineClamp={1} weight={550}>
{service.name}
</Text>
<motion.div
style={{
position: 'absolute',
top: 5,
right: 5,
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>
</Card.Section>
</Center>
</Card>
</motion.div>
);
}
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={{
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>
);
}

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,19 +1,21 @@
import { Menu, Modal, Text } from '@mantine/core';
import { Menu, Modal, Text, useMantineTheme } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { useState } from 'react';
import { Check, Edit, Trash } from 'tabler-icons-react';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { AddAppShelfItemForm } from './AddAppShelfItem';
export default function AppShelfMenu(props: any) {
const { service } = props;
const { service }: { service: serviceItem } = props;
const { config, setConfig } = useConfig();
const theme = useMantineTheme();
const [opened, setOpened] = useState(false);
return (
<>
<Modal
size="xl"
radius="lg"
radius="md"
opened={props.opened || opened}
onClose={() => setOpened(false)}
title="Modify a service"
@@ -21,14 +23,26 @@ export default function AppShelfMenu(props: any) {
<AddAppShelfItemForm
setOpened={setOpened}
name={service.name}
id={service.id}
type={service.type}
url={service.url}
icon={service.icon}
apiKey={service.apiKey}
username={service.username}
password={service.password}
message="Save service"
/>
</Modal>
<Menu position="right">
<Menu
position="right"
radius="md"
styles={{
body: {
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
},
}}
>
<Menu.Label>Settings</Menu.Label>
<Menu.Item
color="primary"
@@ -44,7 +58,7 @@ export default function AppShelfMenu(props: any) {
onClick={(e: any) => {
setConfig({
...config,
services: config.services.filter((s) => s.name !== service.name),
services: config.services.filter((s) => s.id !== service.id),
});
showNotification({
autoClose: 5000,

View File

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

View File

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

View File

@@ -1,18 +1,95 @@
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 { Download } from 'tabler-icons-react';
import { useState } from 'react';
import { Check, Download, Plus, Trash, X } from 'tabler-icons-react';
import { useConfig } from '../../tools/state';
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) {
if (config) {
fileDownload(JSON.stringify(config, null, '\t'), `${config.name}.json`);
}
}
return (
<Button leftIcon={<Download />} variant="outline" onClick={onClick}>
Download your config
</Button>
<Group>
<Modal
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 your 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 current config
</Button>
<Button leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
Save a copy of your config
</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) {
const { config, setConfig } = useConfig();
const modules = Object.values(Modules).map((module) => module);
const enabledModules = config.settings.enabledModules ?? [];
modules.filter((module) => enabledModules.includes(module.title));
return (
<Group direction="column">
{modules.map((module) => (
<Switch
key={module.title}
size="md"
checked={enabledModules.includes(module.title)}
checked={config.modules?.[module.title]?.enabled ?? false}
label={`Enable ${module.title} module`}
onChange={(e) => {
if (e.currentTarget.checked) {
setConfig({
...config,
settings: {
...config.settings,
enabledModules: [...enabledModules, module.title],
setConfig({
...config,
modules: {
...config.modules,
[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,20 +1,18 @@
import {
ActionIcon,
Group,
Modal,
Switch,
Title,
Text,
Tooltip,
SegmentedControl,
Indicator,
Alert,
TextInput,
Drawer,
Anchor,
} from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { useEffect, useState } from 'react';
import { AlertCircle, Settings as SettingsIcon } from 'tabler-icons-react';
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
import { useColorScheme, useHotkeys } from '@mantine/hooks';
import { useState } from 'react';
import { BrandGithub, Settings as SettingsIcon } from 'tabler-icons-react';
import { CURRENT_VERSION } from '../../../data/constants';
import { useConfig } from '../../tools/state';
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
import ConfigChanger from '../Config/ConfigChanger';
@@ -40,14 +38,6 @@ function SettingsMenu(props: any) {
return (
<Group direction="column" grow>
<Alert
icon={<AlertCircle size={16} />}
title="Update available"
radius="lg"
hidden={current === latest}
>
Version {latest} is available. Current: {current}
</Alert>
<Group grow direction="column" spacing={0}>
<Text>Search engine</Text>
<SegmentedControl
@@ -90,22 +80,6 @@ function SettingsMenu(props: any) {
/>
)}
</Group>
<Group direction="column">
<Switch
size="md"
onChange={(e) =>
setConfig({
...config,
settings: {
...config.settings,
searchBar: e.currentTarget.checked,
},
})
}
checked={config.settings.searchBar}
label="Enable search bar"
/>
</Group>
<ModuleEnabler />
<ColorSchemeSwitch />
<ConfigChanger />
@@ -120,36 +94,57 @@ function SettingsMenu(props: any) {
>
tip: You can upload your config file by dragging and dropping it onto the page
</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: '#a0aec0',
}}
>
Made with by @
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
ajnart
</Anchor>
</Text>
</Group>
</Group>
);
}
export function SettingsMenuButton(props: any) {
const [update, setUpdate] = useState(false);
useHotkeys([['ctrl+L', () => setOpened(!opened)]]);
const [opened, setOpened] = useState(false);
const [latestVersion, setLatestVersion] = useState(CURRENT_VERSION);
useEffect(() => {
// Fetch Data here when component first mounted
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
res.json().then((data) => {
setLatestVersion(data.tag_name);
if (data.tag_name !== CURRENT_VERSION) {
setUpdate(true);
}
});
});
}, []);
return (
<>
<Modal
size="xl"
radius="md"
<Drawer
size="auto"
padding="xl"
position="right"
title={<Title order={3}>Settings</Title>}
opened={props.opened || opened}
onClose={() => setOpened(false)}
>
<SettingsMenu current={CURRENT_VERSION} latest={latestVersion} />
</Modal>
<SettingsMenu />
</Drawer>
<ActionIcon
variant="default"
radius="md"
@@ -159,14 +154,7 @@ export function SettingsMenuButton(props: any) {
onClick={() => setOpened(true)}
>
<Tooltip label="Settings">
<Indicator
size={12}
disabled={CURRENT_VERSION === latestVersion}
offset={-3}
position="top-end"
>
<SettingsIcon />
</Indicator>
<SettingsIcon />
</Tooltip>
</ActionIcon>
</>

View File

@@ -1,11 +1,11 @@
import { Aside as MantineAside, Group } from '@mantine/core';
import { DateModule } from '../modules';
import { CalendarModule } from '../modules/calendar/CalendarModule';
import ModuleWrapper from '../modules/moduleWrapper';
import { WeatherModule, DateModule, CalendarModule } from '../modules';
import { ModuleWrapper } from '../modules/moduleWrapper';
export default function Aside(props: any) {
return (
<MantineAside
pr="md"
hiddenBreakpoint="md"
hidden
style={{
@@ -18,6 +18,7 @@ export default function Aside(props: any) {
<Group mt="sm" grow direction="column">
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={WeatherModule} />
</Group>
</MantineAside>
);

View File

@@ -1,13 +1,7 @@
import React from 'react';
import {
createStyles,
Anchor,
Text,
Group,
ActionIcon,
Footer as FooterComponent,
} from '@mantine/core';
import { BrandGithub } from 'tabler-icons-react';
import React, { useEffect } from 'react';
import { createStyles, Footer as FooterComponent } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
const useStyles = createStyles((theme) => ({
footer: {
@@ -40,42 +34,31 @@ interface FooterCenteredProps {
}
export function Footer({ links }: FooterCenteredProps) {
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>
));
useEffect(() => {
// Fetch Data here when component first mounted
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
res.json().then((data) => {
if (data.tag_name !== CURRENT_VERSION) {
showNotification({
color: 'yellow',
autoClose: false,
title: 'New version available',
message: `Version ${data.tag_name} is available, update now! 😡`,
});
}
});
});
}, []);
return (
<FooterComponent p={5} height="auto" style={{ border: 'none', position: 'fixed', bottom: 0, right: 0 }}>
<Group position="right" mr="xs" mb="xs">
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<BrandGithub size={18} />
</ActionIcon>
<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>
</FooterComponent>
return (
<FooterComponent
height="auto"
style={{
background: 'none',
border: 'none',
clear: 'both',
}}
children={undefined}
/>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { createStyles, Header as Head, Group, Box } from '@mantine/core';
import { Logo } from './Logo';
import SearchBar from '../SearchBar/SearchBar';
import SearchBar from '../modules/search/SearchModule';
import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem';
import { SettingsMenuButton } from '../Settings/SettingsMenu';

View File

@@ -10,11 +10,7 @@ const useStyles = createStyles((theme) => ({
export default function Layout({ children, style }: any) {
const { classes, cx } = useStyles();
return (
<AppShell
aside={<Aside />}
header={<Header />}
footer={<Footer links={[]} />}
>
<AppShell aside={<Aside />} header={<Header />} footer={<Footer links={[]} />}>
<main
className={cx(classes.main)}
style={{

View File

@@ -1,40 +1,33 @@
import { Group, Image, Text } from '@mantine/core';
import { NextLink } from '@mantine/next';
import * as React from 'react';
import { CURRENT_VERSION } from '../../../data/constants';
export function Logo({ style }: any) {
return (
<Group>
<Group spacing="xs">
<Image
width={50}
src="/imgs/logo.png"
style={{
position: 'relative',
left: 15,
}}
/>
<Text
sx={style}
weight="bold"
variant="gradient"
gradient={{ from: 'red', to: 'orange', deg: 145 }}
>
Homarr
</Text>
<Text
<NextLink
style={{
textDecoration: 'none',
position: 'relative',
left: -14,
bottom: -2,
color: 'gray',
fontStyle: 'inherit',
fontSize: 'inherit',
alignSelf: 'center',
alignContent: 'center',
}}
href="/"
>
{CURRENT_VERSION}
</Text>
<Text
sx={style}
weight="bold"
variant="gradient"
gradient={{ from: 'red', to: 'orange', deg: 145 }}
>
Homarr
</Text>
</NextLink>
</Group>
);
}

View File

@@ -1,6 +1,6 @@
import { Group, Navbar as MantineNavbar } from '@mantine/core';
import { DateModule } from '../modules/date/DateModule';
import ModuleWrapper from '../modules/moduleWrapper';
import { WeatherModule, DateModule } from '../modules';
import { ModuleWrapper } from '../modules/moduleWrapper';
export default function Navbar() {
return (
@@ -16,6 +16,8 @@ export default function Navbar() {
>
<Group mt="sm" direction="column" align="center">
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={WeatherModule} />
</Group>
</MantineNavbar>
);

View File

@@ -1,12 +1,17 @@
/* eslint-disable react/no-children-prop */
import { Popover, Box, ScrollArea, Divider, Indicator } from '@mantine/core';
import { Box, Divider, Indicator, Popover, ScrollArea, useMantineTheme } from '@mantine/core';
import React, { useEffect, useState } from 'react';
import { Calendar } from '@mantine/dates';
import { showNotification } from '@mantine/notifications';
import { Calendar as CalendarIcon, Check } from 'tabler-icons-react';
import { RadarrMediaDisplay, SonarrMediaDisplay } from './MediaDisplay';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
import {
SonarrMediaDisplay,
RadarrMediaDisplay,
LidarrMediaDisplay,
ReadarrMediaDisplay,
} from './MediaDisplay';
export const CalendarModule: IModule = {
title: 'Calendar',
@@ -19,53 +24,101 @@ export const CalendarModule: IModule = {
export default function CalendarComponent(props: any) {
const { config } = useConfig();
const [sonarrMedias, setSonarrMedias] = useState([] as any);
const [lidarrMedias, setLidarrMedias] = useState([] as any);
const [radarrMedias, setRadarrMedias] = useState([] as any);
const [readarrMedias, setReadarrMedias] = useState([] as any);
useEffect(() => {
// Filter only sonarr and radarr services
const filtered = config.services.filter(
(service) => service.type === 'Sonarr' || service.type === 'Radarr'
(service) =>
service.type === 'Sonarr' ||
service.type === 'Radarr' ||
service.type === 'Lidarr' ||
service.type === 'Readarr'
);
// Get the url and apiKey for all Sonarr and Radarr services
const sonarrService = filtered.filter((service) => service.type === 'Sonarr').at(0);
const radarrService = filtered.filter((service) => service.type === 'Radarr').at(0);
const lidarrService = filtered.filter((service) => service.type === 'Lidarr').at(0);
const readarrService = filtered.filter((service) => service.type === 'Readarr').at(0);
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
if (sonarrService && sonarrService.apiKey) {
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`,
const baseUrl = new URL(sonarrService.url).origin;
fetch(`${baseUrl}/api/calendar?apikey=${sonarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setSonarrMedias(data);
showNotification({
title: 'Sonarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
});
});
}
);
}
if (radarrService && radarrService.apiKey) {
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`,
const baseUrl = new URL(radarrService.url).origin;
fetch(`${baseUrl}/api/v3/calendar?apikey=${radarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setRadarrMedias(data);
showNotification({
title: 'Radarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
});
});
}
);
}
if (lidarrService && lidarrService.apiKey) {
const baseUrl = new URL(lidarrService.url).origin;
fetch(`${baseUrl}/api/v1/calendar?apikey=${lidarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setLidarrMedias(data);
showNotification({
title: 'Lidarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
}
);
}
if (readarrService && readarrService.apiKey) {
const baseUrl = new URL(readarrService.url).origin;
fetch(`${baseUrl}/api/v1/calendar?apikey=${readarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setReadarrMedias(data);
showNotification({
title: 'Readarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
}
);
}
}, [config.services]);
@@ -80,6 +133,8 @@ export default function CalendarComponent(props: any) {
renderdate={renderdate}
sonarrmedias={sonarrMedias}
radarrmedias={radarrMedias}
lidarrmedias={lidarrMedias}
readarrmedias={readarrMedias}
/>
)}
/>
@@ -91,11 +146,25 @@ function DayComponent(props: any) {
renderdate,
sonarrmedias,
radarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: [] } = props;
lidarrmedias,
readarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
props;
const [opened, setOpened] = useState(false);
const theme = useMantineTheme();
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 date = new Date(media.airDate);
// Return true if the date is renerdate without counting hours and minutes
@@ -106,7 +175,12 @@ function DayComponent(props: any) {
// Return true if the date is renerdate without counting hours and minutes
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>;
}
@@ -116,8 +190,58 @@ function DayComponent(props: any) {
setOpened(true);
}}
>
{radarrFiltered.length > 0 && <Indicator size={7} color="yellow" children={null} />}
{sonarrFiltered.length > 0 && <Indicator size={7} offset={8} color="blue" children={null} />}
{readarrFiltered.length > 0 && (
<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
position="left"
radius="lg"
@@ -126,8 +250,7 @@ function DayComponent(props: any) {
width={700}
onClose={() => setOpened(false)}
opened={opened}
// TODO: Fix this !! WTF ?
target={` ${day}`}
target={day}
>
<ScrollArea style={{ height: 400 }}>
{sonarrFiltered.map((media: any, index: number) => (
@@ -145,6 +268,18 @@ function DayComponent(props: any) {
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</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>
</Popover>
</Box>

View File

@@ -1,11 +1,14 @@
import { Stack, Image, Group, Title, Badge, Text, ActionIcon, Anchor } from '@mantine/core';
import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea } from '@mantine/core';
import { Link } from 'tabler-icons-react';
import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types';
export interface IMedia {
overview: string;
imdbId: any;
imdbId?: any;
artist?: string;
title: string;
poster: string;
poster?: string;
genres: string[];
seasonNumber?: number;
episodeNumber?: number;
@@ -14,30 +17,42 @@ export interface IMedia {
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 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>
<Group style={{ minWidth: 400 }}>
<Title order={3}>{media.title}</Title>
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
<ActionIcon>
<Link />
</ActionIcon>
</Anchor>
{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: '#a0aec0',
}}
>
New release from {media.artist}
</Text>
)}
{media.episodeNumber && media.seasonNumber && (
<Text
style={{
@@ -48,19 +63,76 @@ function MediaDisplay(props: { media: IMedia }) {
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 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>
</Stack>
</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 = `${baseUrl}${poster.url}`;
// 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

View File

@@ -2,6 +2,7 @@ import { Group, Text, Title } from '@mantine/core';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import { Clock } from 'tabler-icons-react';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
export const DateModule: IModule = {
@@ -9,33 +10,31 @@ export const DateModule: IModule = {
description: 'Show the current time and date in a card',
icon: Clock,
component: DateComponent,
options: {
full: {
name: 'Display full time (24-hour)',
value: true,
},
},
};
export default function DateComponent(props: any) {
const [date, setDate] = useState(new Date());
const hours = date.getHours();
const minutes = date.getMinutes();
const { config } = useConfig();
const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? false;
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
// Change date on minute change
// Note: Using 10 000ms instead of 1000ms to chill a little :)
useEffect(() => {
setInterval(() => {
setDate(new Date());
}, 10000);
}, 1000 * 60);
}, []);
return (
<Group p="sm" direction="column">
<Title>
{hours < 10 ? `0${hours}` : hours}:{minutes < 10 ? `0${minutes}` : minutes}
</Title>
<Text size="xl">
{
// Use dayjs to format the date
// https://day.js.org/en/getting-started/installation/
dayjs(date).format('dddd, MMMM D')
}
</Text>
<Group p="sm" spacing="xs" direction="column">
<Title>{dayjs(date).format(formatString)}</Title>
<Text size="xl">{dayjs(date).format('dddd, MMMM D')}</Text>
</Group>
);
}

View File

@@ -0,0 +1,136 @@
import { Loader, Table, Text, Tooltip, Title, Group, Progress, Center } from '@mantine/core';
import { Download } from 'tabler-icons-react';
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>Critical: No qBittorrent/Deluge instance found in services.</Title>
<Group>
<Title order={3}>Add a qBittorrent/Deluge service to view current downloads</Title>
<AddItemShelfButton />
</Group>
</Group>
);
}
if (qBittorrentTorrents.length === 0 && delugeTorrents.length === 0) {
return (
<Center>
<Loader />
</Center>
);
}
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((torrent) => torrents.push(torrent));
qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
const rows = torrents.map((torrent) => {
if (torrent.progress === 1 && hideComplete) {
return null;
}
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 direction="column">
<Title order={4}>Your torrents</Title>
<Table highlightOnHover>
<thead>{ths}</thead>
<tbody>{rows}</tbody>
</Table>
</Group>
);
}

View File

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

View File

@@ -1,2 +1,6 @@
export * from './date';
export * from './calendar';
export * from './search';
export * from './ping';
export * from './weather';
export * from './downloads';

View File

@@ -1,19 +1,133 @@
import { Card, useMantineTheme } from '@mantine/core';
import { Button, Card, Group, Menu, Switch, TextInput, useMantineTheme } from '@mantine/core';
import { useConfig } from '../../tools/state';
import { IModule } from './modules';
export default function ModuleWrapper(props: any) {
function getItems(module: IModule) {
const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {};
const items: JSX.Element[] = [];
if (module.options) {
const keys = Object.keys(module.options);
const values = Object.values(module.options);
// Get the value and the name of the option
const types = values.map((v) => typeof v.value);
// Loop over all the types with a for each loop
types.forEach((type, index) => {
const optionName = `${module.title}.${keys[index]}`;
const moduleInConfig = config.modules?.[module.title];
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
if (type === 'boolean') {
items.push(
<Switch
defaultChecked={
// Set default checked to the value of the option if it exists
(moduleInConfig?.options?.[keys[index]]?.value as boolean) ?? false
}
key={keys[index]}
onClick={(e) => {
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.currentTarget.checked,
},
},
},
},
});
}}
label={values[index].name}
/>
);
}
});
}
return items;
}
export function ModuleWrapper(props: any) {
const { module }: { module: IModule } = props;
const { config } = useConfig();
const enabledModules = config.settings.enabledModules ?? [];
const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules.includes(module.title);
const isShown = enabledModules[module.title]?.enabled ?? false;
const theme = useMantineTheme();
const items: JSX.Element[] = getItems(module);
if (!isShown) {
return null;
}
return (
<Card hidden={!isShown} mx="sm" withBorder radius="lg" shadow="sm">
<Card {...props} hidden={!isShown} withBorder radius="lg" shadow="sm">
{module.options && (
<Menu
size="lg"
shadow="xl"
closeOnItemClick={false}
radius="md"
position="left"
styles={{
root: {
position: 'absolute',
top: 15,
right: 15,
},
body: {
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
},
}}
>
<Menu.Label>Settings</Menu.Label>
{items.map((item) => (
<Menu.Item key={item.key}>{item}</Menu.Item>
))}
</Menu>
)}
<module.component />
</Card>
);

View File

@@ -7,5 +7,14 @@ export interface IModule {
description: string;
icon: React.ReactNode;
component: React.ComponentType;
props?: any;
options?: Option;
}
interface Option {
[x: string]: OptionValues;
}
export interface OptionValues {
name: string;
value: boolean | string;
}

View File

@@ -0,0 +1,16 @@
import { serviceItem } from '../../../tools/types';
import PingComponent from './PingModule';
export default {
title: 'Modules/Search bar',
};
const service: serviceItem = {
id: '1',
type: 'Other',
name: 'YouTube',
icon: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png',
url: 'https://youtube.com/',
};
export const Default = (args: any) => <PingComponent service={service} />;

View File

@@ -0,0 +1,60 @@
import { Indicator, Tooltip } from '@mantine/core';
import axios from 'axios';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { Plug } from 'tabler-icons-react';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
export const PingModule: IModule = {
title: 'Ping Services',
description: 'Pings your services and shows their status as an indicator',
icon: Plug,
component: PingComponent,
};
export default function PingComponent(props: any) {
type State = 'loading' | 'down' | 'online';
const { config } = useConfig();
const { url }: { url: string } = props;
const [isOnline, setOnline] = useState<State>('loading');
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
useEffect(() => {
if (!exists) {
return;
}
axios
.get('/api/modules/ping', { params: { url } })
.then(() => {
setOnline('online');
})
.catch(() => {
setOnline('down');
});
}, []);
if (!exists) {
return null;
}
return (
<Tooltip
radius="lg"
style={{ position: 'absolute', bottom: 20, right: 20 }}
label={isOnline === 'loading' ? 'Loading...' : isOnline === 'online' ? 'Online' : 'Offline'}
>
<motion.div
animate={{
scale: isOnline === 'online' ? [1, 0.8, 1] : 1,
}}
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
>
<Indicator
size={13}
color={isOnline === 'online' ? 'green' : isOnline === 'down' ? 'red' : 'yellow'}
>
{null}
</Indicator>
</motion.div>
</Tooltip>
);
}

View File

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

View File

@@ -1,4 +1,4 @@
import SearchBar from './SearchBar';
import SearchBar from './SearchModule';
export default {
title: 'Search bar',

View File

@@ -1,8 +1,9 @@
import { TextInput, Kbd, createStyles, useMantineTheme, Text, Popover } from '@mantine/core';
import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core';
import { useForm, useHotkeys } from '@mantine/hooks';
import { useRef, useState } from 'react';
import { Search, BrandYoutube, Download } from 'tabler-icons-react';
import { useConfig } from '../../tools/state';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
const useStyles = createStyles((theme) => ({
hide: {
@@ -14,16 +15,22 @@ const useStyles = createStyles((theme) => ({
},
}));
export const SearchModule: IModule = {
title: 'Search Bar',
description: 'Show the current time and date in a card',
icon: Search,
component: SearchBar,
};
export default function SearchBar(props: any) {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
const [icon, setIcon] = useState(<Search />);
const queryUrl = config.settings.searchUrl || 'https://www.google.com/search?q=';
const textInput: any = useRef(null);
useHotkeys([['ctrl+K', () => textInput.current.focus()]]);
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
const textInput = useRef<HTMLInputElement>();
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
const { classes, cx } = useStyles();
const theme = useMantineTheme();
const rightSection = (
<div className={classes.hide}>
<Kbd>Ctrl</Kbd>
@@ -38,7 +45,11 @@ export default function SearchBar(props: any) {
},
});
if (config.settings.searchBar === false) {
// If enabled modules doesn't contain the module, return null
// If module in enabled
const exists = config.modules?.[SearchModule.title]?.enabled ?? false;
if (!exists) {
return null;
}
@@ -58,17 +69,19 @@ export default function SearchBar(props: any) {
}
}}
onSubmit={form.onSubmit((values) => {
// Find if query is prefixed by !yt or !t
const query = values.query.trim();
const isYoutube = query.startsWith('!yt');
const isTorrent = query.startsWith('!t');
if (isYoutube) {
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
} else if (isTorrent) {
window.open(`https://bitsearch.to/search?q=${query.substring(3)}`);
} else {
window.open(`${queryUrl}${values.query}`);
}
form.setValues({ query: '' });
setTimeout(() => {
if (isYoutube) {
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
} else if (isTorrent) {
window.open(`https://bitsearch.to/search?q=${query.substring(3)}`);
} else {
window.open(`${queryUrl}${values.query}`);
}
}, 20);
})}
>
<Popover

View File

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

View File

@@ -0,0 +1,41 @@
// To parse this data:
//
// import { Convert, WeatherResponse } from "./file";
//
// const weatherResponse = Convert.toWeatherResponse(json);
//
// These functions will throw an error if the JSON doesn't
// match the expected interface, even if the JSON is valid.
export interface WeatherResponse {
current_weather: CurrentWeather;
utc_offset_seconds: number;
latitude: number;
elevation: number;
longitude: number;
generationtime_ms: number;
daily_units: DailyUnits;
daily: Daily;
}
export interface CurrentWeather {
winddirection: number;
windspeed: number;
time: string;
weathercode: number;
temperature: number;
}
export interface Daily {
temperature_2m_max: number[];
time: Date[];
temperature_2m_min: number[];
weathercode: number[];
}
export interface DailyUnits {
temperature_2m_max: string;
temperature_2m_min: string;
time: string;
weathercode: string;
}

View File

@@ -0,0 +1,179 @@
import { Group, Space, Title, Tooltip } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import {
ArrowDownRight,
ArrowUpRight,
Cloud,
CloudFog,
CloudRain,
CloudSnow,
CloudStorm,
QuestionMark,
Snowflake,
Sun,
} from 'tabler-icons-react';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
import { WeatherResponse } from './WeatherInterface';
export const WeatherModule: IModule = {
title: 'Weather (beta)',
description: 'Look up the current weather in your location',
icon: Sun,
component: WeatherComponent,
options: {
freedomunit: {
name: 'Display in Fahrenheit',
value: false,
},
location: {
name: 'Current location',
value: '',
},
},
};
// 0 Clear sky
// 1, 2, 3 Mainly clear, partly cloudy, and overcast
// 45, 48 Fog and depositing rime fog
// 51, 53, 55 Drizzle: Light, moderate, and dense intensity
// 56, 57 Freezing Drizzle: Light and dense intensity
// 61, 63, 65 Rain: Slight, moderate and heavy intensity
// 66, 67 Freezing Rain: Light and heavy intensity
// 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity
// 77 Snow grains
// 80, 81, 82 Rain showers: Slight, moderate, and violent
// 85, 86Snow showers slight and heavy
// 95 *Thunderstorm: Slight or moderate
// 96, 99 *Thunderstorm with slight and heavy hail
export function WeatherIcon(props: any) {
const { code } = props;
let data: { icon: any; name: string };
switch (code) {
case 0: {
data = { icon: Sun, name: 'Clear' };
break;
}
case 1:
case 2:
case 3: {
data = { icon: Cloud, name: 'Mainly clear' };
break;
}
case 45:
case 48: {
data = { icon: CloudFog, name: 'Fog' };
break;
}
case 51:
case 53:
case 55: {
data = { icon: Cloud, name: 'Drizzle' };
break;
}
case 56:
case 57: {
data = { icon: Snowflake, name: 'Freezing drizzle' };
break;
}
case 61:
case 63:
case 65: {
data = { icon: CloudRain, name: 'Rain' };
break;
}
case 66:
case 67: {
data = { icon: CloudRain, name: 'Freezing rain' };
break;
}
case 71:
case 73:
case 75: {
data = { icon: CloudSnow, name: 'Snow fall' };
break;
}
case 77: {
data = { icon: CloudSnow, name: 'Snow grains' };
break;
}
case 80:
case 81:
case 82: {
data = { icon: CloudRain, name: 'Rain showers' };
break;
}
case 85:
case 86: {
data = { icon: CloudSnow, name: 'Snow showers' };
break;
}
case 95: {
data = { icon: CloudStorm, name: 'Thunderstorm' };
break;
}
case 96:
case 99: {
data = { icon: CloudStorm, name: 'Thunderstorm with hail' };
break;
}
default: {
data = { icon: QuestionMark, name: 'Unknown' };
}
}
return (
<Tooltip label={data.name}>
<data.icon size={50} />
</Tooltip>
);
}
export default function WeatherComponent(props: any) {
// Get location from browser
const { config } = useConfig();
const [weather, setWeather] = useState({} as WeatherResponse);
const cityInput: string =
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? '';
const isFahrenheit: boolean =
(config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value as boolean) ?? false;
useEffect(() => {
axios
.get(`https://geocoding-api.open-meteo.com/v1/search?name=${cityInput}`)
.then((response) => {
// Check if results exists
const { latitude, longitude } = response.data.results
? 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) {
return null;
}
function usePerferedUnit(value: number): string {
return isFahrenheit ? `${(value * (9 / 5)).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
}
return (
<Group p="sm" spacing="xs" direction="column">
<Title>{usePerferedUnit(weather.current_weather.temperature)}</Title>
<Group spacing={0}>
<WeatherIcon code={weather.current_weather.weathercode} />
<Space mx="sm" />
<span>{usePerferedUnit(weather.daily.temperature_2m_max[0])}</span>
<ArrowUpRight size={16} style={{ right: 15 }} />
<Space mx="sm" />
<span>{usePerferedUnit(weather.daily.temperature_2m_min[0])}</span>
<ArrowDownRight size={16} />
</Group>
</Group>
);
}

View File

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

94
src/pages/404.tsx Normal file
View File

@@ -0,0 +1,94 @@
import React from 'react';
import {
createStyles,
Container,
Title,
Text,
Button,
Group,
useMantineTheme,
} from '@mantine/core';
import { NextLink } from '@mantine/next';
const useStyles = createStyles((theme) => ({
root: {
paddingTop: 80,
paddingBottom: 80,
},
inner: {
position: 'relative',
},
image: {
position: 'absolute',
top: 0,
right: 0,
left: 0,
zIndex: 0,
opacity: 0.75,
},
content: {
paddingTop: 220,
position: 'relative',
zIndex: 1,
[theme.fn.smallerThan('sm')]: {
paddingTop: 120,
},
},
title: {
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
textAlign: 'center',
fontWeight: 900,
fontSize: 38,
[theme.fn.smallerThan('sm')]: {
fontSize: 32,
},
},
description: {
maxWidth: 540,
margin: 'auto',
marginTop: theme.spacing.xl,
marginBottom: theme.spacing.xl * 1.5,
},
}));
export function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
const theme = useMantineTheme();
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362 145" {...props}>
<path
fill={theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0]}
d="M62.6 142c-2.133 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2L58.2 4c.8-1.333 2.067-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .667.533 1 1.267 1 2.2v21.2c0 .933-.333 1.733-1 2.4-.667.533-1.467.8-2.4.8H93v20.8c0 2.133-1.067 3.2-3.2 3.2H62.6zM33 90.4h26.4V51.2L33 90.4zM181.67 144.6c-7.333 0-14.333-1.333-21-4-6.666-2.667-12.866-6.733-18.6-12.2-5.733-5.467-10.266-13-13.6-22.6-3.333-9.6-5-20.667-5-33.2 0-12.533 1.667-23.6 5-33.2 3.334-9.6 7.867-17.133 13.6-22.6 5.734-5.467 11.934-9.533 18.6-12.2 6.667-2.8 13.667-4.2 21-4.2 7.467 0 14.534 1.4 21.2 4.2 6.667 2.667 12.8 6.733 18.4 12.2 5.734 5.467 10.267 13 13.6 22.6 3.334 9.6 5 20.667 5 33.2 0 12.533-1.666 23.6-5 33.2-3.333 9.6-7.866 17.133-13.6 22.6-5.6 5.467-11.733 9.533-18.4 12.2-6.666 2.667-13.733 4-21.2 4zm0-31c9.067 0 15.6-3.733 19.6-11.2 4.134-7.6 6.2-17.533 6.2-29.8s-2.066-22.2-6.2-29.8c-4.133-7.6-10.666-11.4-19.6-11.4-8.933 0-15.466 3.8-19.6 11.4-4 7.6-6 17.533-6 29.8s2 22.2 6 29.8c4.134 7.467 10.667 11.2 19.6 11.2zM316.116 142c-2.134 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2l56.6-84.6c.8-1.333 2.066-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .666.533 1 1.267 1 2.2v21.2c0 .933-.334 1.733-1 2.4-.667.533-1.467.8-2.4.8h-11.2v20.8c0 2.133-1.067 3.2-3.2 3.2h-27.2zm-29.6-51.6h26.4V51.2l-26.4 39.2z"
/>
</svg>
);
}
export default function NothingFoundBackground() {
const { classes } = useStyles();
return (
<Container className={classes.root}>
<div className={classes.inner}>
<Illustration className={classes.image} />
<div className={classes.content}>
<Title className={classes.title}>Config not found</Title>
<Text color="dimmed" size="lg" align="center" className={classes.description}>
The config you are trying to access does not exist. Please check the URL and try again.
</Text>
<Group position="center">
<NextLink href="/">
<Button size="md">Take me back to home page</Button>
</NextLink>
</Group>
</div>
</div>
</Container>
);
}

54
src/pages/[slug].tsx Normal file
View File

@@ -0,0 +1,54 @@
import { GetServerSidePropsContext } from 'next';
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 async function getServerSideProps(
context: GetServerSidePropsContext
): Promise<{ props: { config: Config } }> {
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

@@ -24,7 +24,7 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) {
return (
<>
<Head>
<title>Homarr - A homepage for your server!</title>
<title>Homarr 🦞</title>
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
<link rel="shortcut icon" href="/favicon.svg" />
</Head>
@@ -38,7 +38,7 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) {
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider limit={2} position="top-right">
<NotificationsProvider limit={4} position="bottom-left">
<ConfigProvider>
<Layout>
<Component {...pageProps} />

View File

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

View File

@@ -51,6 +51,9 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'PUT') {
return Put(req, res);
}
if (req.method === 'DELETE') {
return Delete(req, res);
}
if (req.method === 'GET') {
return Get(req, res);
}
@@ -59,3 +62,28 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
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,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,29 @@
import axios from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Parse req.body as a ServiceItem
const { url } = req.query;
await axios
.get(url as string)
.then((response) => {
res.status(200).json(response.data);
})
.catch((error) => {
res.status(500).json(error);
});
// // 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 === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -7,6 +7,9 @@ import AppShelf from '../components/AppShelf/AppShelf';
import LoadConfigComponent from '../components/Config/LoadConfig';
import { Config } from '../tools/types';
import { useConfig } from '../tools/state';
import { migrateToIdConfig } from '../tools/migrate';
import { ModuleWrapper } from '../components/modules/moduleWrapper';
import { DownloadsModule } from '../components/modules';
export async function getServerSideProps({
req,
@@ -26,10 +29,9 @@ export async function getServerSideProps({
name: cookie.toString(),
services: [],
settings: {
enabledModules: [],
searchBar: true,
searchUrl: 'https://www.google.com/search?q=',
},
modules: {},
},
},
};
@@ -48,12 +50,14 @@ export default function HomePage(props: any) {
const { config: initialConfig }: { config: Config } = props;
const { config, loadConfig, setConfig, getConfigs } = useConfig();
useEffect(() => {
setConfig(initialConfig);
const migratedConfig = migrateToIdConfig(initialConfig);
setConfig(migratedConfig);
}, [initialConfig]);
return (
<>
<AppShelf />
<LoadConfigComponent />
<ModuleWrapper mt="xl" module={DownloadsModule} />
</>
);
}

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

@@ -17,10 +17,9 @@ const configContext = createContext<configContextType>({
name: 'default',
services: [],
settings: {
searchBar: true,
searchUrl: 'https://www.google.com/search?q=',
enabledModules: [],
searchUrl: 'https://google.com/search?q=',
},
modules: {},
},
setConfig: () => {},
loadConfig: async (name: string) => {},
@@ -44,16 +43,15 @@ export function ConfigProvider({ children }: Props) {
name: 'default',
services: [],
settings: {
searchBar: true,
searchUrl: 'https://www.google.com/search?q=',
enabledModules: [],
},
modules: {},
});
async function loadConfig(configName: string) {
try {
const response = await axios.get(`/api/configs/${configName}`);
setConfigInternal(response.data);
setConfigInternal(JSON.parse(response.data));
showNotification({
title: 'Config',
icon: <Check />,

View File

@@ -1,38 +1,55 @@
import { OptionValues } from '../components/modules/modules';
export interface Settings {
searchUrl: string;
searchBar: boolean;
enabledModules: string[];
[key: string]: any;
}
export interface Config {
name: string;
services: serviceItem[];
settings: Settings;
modules: {
[key: string]: ConfigModule;
};
}
interface ConfigModule {
title: string;
enabled: boolean;
options: {
[key: string]: OptionValues;
};
}
export const ServiceTypeList = [
'Other',
'Sonarr',
'Radarr',
'Lidarr',
'qBittorrent',
'Plex',
'Emby',
'Deluge',
'Lidarr',
'Plex',
'Radarr',
'Readarr',
'Sonarr',
'qBittorrent',
];
export type ServiceType =
| 'Other'
| 'Sonarr'
| 'Radarr'
| 'Emby'
| 'Deluge'
| 'Lidarr'
| 'qBittorrent'
| 'Plex'
| 'Emby';
| 'Radarr'
| 'Readarr'
| 'Sonarr'
| 'qBittorrent';
export interface serviceItem {
[x: string]: any;
id: string;
name: string;
type: string;
url: string;
icon: string;
apiKey?: string;
password?: string;
username?: string;
}

6358
yarn.lock

File diff suppressed because it is too large Load Diff