Compare commits

...

254 Commits

Author SHA1 Message Date
Ben Phelps
16ddb2461b Update feature_request.md 2022-09-13 21:27:43 +03:00
Ben Phelps
f75827c4c6 Update bug_report.md 2022-09-13 21:27:05 +03:00
Ben Phelps
cf03e60186 Update bug_report.md 2022-09-13 21:26:20 +03:00
Ben Phelps
8ee071769a Update bug_report.md 2022-09-13 21:25:53 +03:00
Ben Phelps
b312183a7b Update issue templates 2022-09-13 21:22:14 +03:00
Ben Phelps
5baaf5faec Create CONTRIBUTING.md 2022-09-13 21:15:55 +03:00
Ben Phelps
d685bfd11d Merge pull request #148 from benphelps/add-code-of-conduct-1
Create CODE_OF_CONDUCT.md
2022-09-13 20:56:17 +03:00
Ben Phelps
cf4b230b7a Create CODE_OF_CONDUCT.md 2022-09-13 20:56:08 +03:00
Ben Phelps
d46f5f4613 reverse status icons for Tautulli 2022-09-13 20:48:08 +03:00
Ben Phelps
945ed854a4 remove experimental tag again 2022-09-13 12:07:20 +03:00
Anonymous
25f0672c18 Translated using Weblate (Chinese (Traditional))
Currently translated at 10.1% (9 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hant/
2022-09-13 07:08:13 +02:00
Anonymous
6f6c8b2ae0 Translated using Weblate (Dutch)
Currently translated at 69.6% (62 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-13 07:08:13 +02:00
Anonymous
7e62410f98 Translated using Weblate (Vietnamese)
Currently translated at 37.0% (33 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-13 07:08:13 +02:00
Anonymous
f49e8486c7 Translated using Weblate (Norwegian Bokmål)
Currently translated at 88.7% (79 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-13 07:08:12 +02:00
Anonymous
844bc23f8c Translated using Weblate (Italian)
Currently translated at 77.5% (69 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-13 07:08:12 +02:00
Anonymous
850226b260 Translated using Weblate (Chinese (Simplified))
Currently translated at 89.8% (80 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-13 07:08:12 +02:00
Anonymous
c5100567d6 Translated using Weblate (Russian)
Currently translated at 14.6% (13 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-13 07:08:12 +02:00
Anonymous
5344854199 Translated using Weblate (Portuguese)
Currently translated at 29.2% (26 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-13 07:08:11 +02:00
Anonymous
d790b17507 Translated using Weblate (French)
Currently translated at 95.5% (85 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-13 07:08:11 +02:00
Anonymous
667d3851ce Translated using Weblate (Spanish)
Currently translated at 88.7% (79 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-13 07:08:11 +02:00
Anonymous
ba4d345f4f Translated using Weblate (German)
Currently translated at 71.9% (64 of 89 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-13 07:08:10 +02:00
Ben Phelps
52816426fc Merge pull request #141 from JazzFisch/add-transmission-widget
Add transmission widget
2022-09-13 08:08:00 +03:00
nicedc
38a423cf2a Translated using Weblate (Chinese (Simplified))
Currently translated at 94.1% (80 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-13 06:46:00 +02:00
Anonymous
75ad7eb7e4 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (0 of 0 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hant/
2022-09-13 05:41:06 +02:00
Nonoss117
533c3b7b1b Translated using Weblate (French)
Currently translated at 100.0% (85 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-13 05:41:05 +02:00
Fallenzone
1c98999994 Added translation using Weblate (Chinese (Traditional)) 2022-09-13 05:41:00 +02:00
Jason Fischer
b19b4f047e Fix linting errors 2022-09-12 20:06:00 -07:00
Jason Fischer
95b6ea0e23 Merge main 2022-09-12 19:38:43 -07:00
Jason Fischer
b3db549a65 Add Transmission widget
- Update http.js to support writing request bodies
- Update http.js to support returning all response headers

resolves: #104
2022-09-12 19:35:47 -07:00
Allan Nordhøy
cd768000e9 Translated using Weblate (Norwegian Bokmål)
Currently translated at 92.9% (79 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-13 01:11:43 +02:00
Nonoss117
da6099c29d Translated using Weblate (French)
Currently translated at 98.8% (84 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-13 01:11:43 +02:00
Ben Phelps
d36e569ede Update README.md 2022-09-12 21:29:19 +03:00
Ben Phelps
eca3757af5 update features and attribution 2022-09-12 21:16:42 +03:00
Anonymous
7d8634ce5e Translated using Weblate (Dutch)
Currently translated at 72.9% (62 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-12 20:14:12 +02:00
Anonymous
d4f6785946 Translated using Weblate (Vietnamese)
Currently translated at 38.8% (33 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-12 20:14:12 +02:00
Anonymous
b468045039 Translated using Weblate (Norwegian Bokmål)
Currently translated at 75.2% (64 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-12 20:14:12 +02:00
Anonymous
f3b4f21c2e Translated using Weblate (Italian)
Currently translated at 81.1% (69 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-12 20:14:12 +02:00
Anonymous
a50ae64397 Translated using Weblate (Chinese (Simplified))
Currently translated at 92.9% (79 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-12 20:14:11 +02:00
Anonymous
2a5c58e138 Translated using Weblate (Russian)
Currently translated at 15.2% (13 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-12 20:14:11 +02:00
Anonymous
d21945a6e6 Translated using Weblate (Portuguese)
Currently translated at 30.5% (26 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-12 20:14:11 +02:00
Anonymous
0c572cb029 Translated using Weblate (French)
Currently translated at 47.0% (40 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-12 20:14:11 +02:00
Anonymous
9d30b952ee Translated using Weblate (Spanish)
Currently translated at 92.9% (79 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-12 20:14:11 +02:00
Anonymous
e32876d08d Translated using Weblate (German)
Currently translated at 75.2% (64 of 85 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-12 20:14:10 +02:00
Ben Phelps
340b138962 Add expanded view for resource widgets 2022-09-12 21:13:57 +03:00
Ben Phelps
7ae0ba31cb remove backdrop-blur until it can be made opt-in 2022-09-12 21:13:37 +03:00
Anonymous
f566671975 Translated using Weblate (Dutch)
Currently translated at 73.8% (62 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-12 20:11:38 +02:00
Anonymous
b7ff123e44 Translated using Weblate (Vietnamese)
Currently translated at 39.2% (33 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-12 20:11:38 +02:00
Anonymous
e1c34bc489 Translated using Weblate (Norwegian Bokmål)
Currently translated at 76.1% (64 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-12 20:11:38 +02:00
Anonymous
dedd341e02 Translated using Weblate (Italian)
Currently translated at 82.1% (69 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-12 20:11:37 +02:00
Anonymous
dc8fc04b57 Translated using Weblate (Chinese (Simplified))
Currently translated at 94.0% (79 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-12 20:11:37 +02:00
Anonymous
6a85859a35 Translated using Weblate (Russian)
Currently translated at 15.4% (13 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-12 20:11:37 +02:00
Anonymous
ff1e8d9e8c Translated using Weblate (Portuguese)
Currently translated at 30.9% (26 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-12 20:11:36 +02:00
Anonymous
16da998452 Translated using Weblate (French)
Currently translated at 47.6% (40 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-12 20:11:36 +02:00
Anonymous
2fc7c6ab99 Translated using Weblate (Spanish)
Currently translated at 94.0% (79 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-12 20:11:36 +02:00
Anonymous
834f33e5a5 Translated using Weblate (German)
Currently translated at 76.1% (64 of 84 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-12 20:11:36 +02:00
nicedc
90a13a4e83 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (79 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-12 20:11:29 +02:00
Ángel Fernández Sánchez
e4343a4f2f Translated using Weblate (Spanish)
Currently translated at 100.0% (79 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-12 20:11:29 +02:00
Ben Phelps
7852797bab Merge pull request #134 from xicopitz/main
Add Prowlarr widget
2022-09-12 21:11:25 +03:00
Francisco Coelho
4a93d2ba1e remove prowlarr locales 2022-09-12 18:27:34 +01:00
Francisco Coelho
9287d711dc Update prowlarr.jsx 2022-09-12 18:00:53 +01:00
Francisco Coelho
b5538655e0 Add Prowlarr widget 2022-09-12 17:56:04 +01:00
Ben Phelps
406358aae9 package coinmarketcap logo 2022-09-12 18:19:07 +03:00
Ben Phelps
a5d59e7e45 images/future is no longer experimental 2022-09-12 14:30:57 +03:00
Ben Phelps
92a4ad0c5e update packages 2022-09-12 14:29:55 +03:00
Ben Phelps
d963bcd0c4 Update README.md 2022-09-12 13:45:46 +03:00
Ben Phelps
2e4125c81c Update README.md 2022-09-12 13:34:27 +03:00
Ben Phelps
5293ff3580 reorganize contributions in readme 2022-09-12 13:33:55 +03:00
Anonymous
7a1349df83 Translated using Weblate (Dutch)
Currently translated at 78.4% (62 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-12 11:41:23 +02:00
Anonymous
de8de8f731 Translated using Weblate (Vietnamese)
Currently translated at 41.7% (33 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-12 11:41:23 +02:00
Anonymous
6d36382436 Translated using Weblate (Norwegian Bokmål)
Currently translated at 81.0% (64 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-12 11:41:23 +02:00
Anonymous
e31833b649 Translated using Weblate (Italian)
Currently translated at 87.3% (69 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-12 11:41:23 +02:00
Anonymous
2dce18563d Translated using Weblate (Chinese (Simplified))
Currently translated at 75.9% (60 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-12 11:41:22 +02:00
Anonymous
aa55c27ab0 Translated using Weblate (Russian)
Currently translated at 16.4% (13 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-12 11:41:22 +02:00
Anonymous
16e321af54 Translated using Weblate (French)
Currently translated at 50.6% (40 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-12 11:41:22 +02:00
Anonymous
c4edb29ff3 Translated using Weblate (Spanish)
Currently translated at 87.3% (69 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-12 11:41:21 +02:00
Anonymous
1d5cc05941 Translated using Weblate (German)
Currently translated at 81.0% (64 of 79 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-12 11:41:21 +02:00
Ben Phelps
9faae7cb67 allow Weblate to handle missing translations 2022-09-12 12:41:03 +03:00
Ben Phelps
ea06fbe666 js linting 2022-09-12 12:39:04 +03:00
Ben Phelps
cc0b4be50c cleanup coinmarketcap widget 2022-09-12 12:38:50 +03:00
Ben Phelps
ea55cde043 Merge pull request #121 from xicopitz/main
Gotify
2022-09-12 12:36:38 +03:00
Francisco Coelho
840c88db89 Update widget.jsx
duplicated widget name
2022-09-12 10:23:44 +01:00
Francisco Coelho
8e8c9755a3 Merge branch 'benphelps:main' into main 2022-09-12 10:07:06 +01:00
Francisco Coelho
ba3b48e8ce Gotify 2022-09-12 10:06:47 +01:00
Ben Phelps
d3806f7d5b better handle non-clickable service tiles 2022-09-12 11:55:01 +03:00
Francisco Coelho
0c9c1c599f Merge branch 'main' of https://github.com/xicopitz/homepage 2022-09-12 09:37:42 +01:00
Francisco Coelho
af02440c40 Revert "Update credentialed.js"
This reverts commit eeac1200e7.
2022-09-12 09:31:44 +01:00
Ben Phelps
cd53440eff fix search and weather widget spacing 2022-09-12 11:00:15 +03:00
Ben Phelps
3660140539 consolidate api handlers 2022-09-12 10:59:56 +03:00
Anonymous
7bf1bf5369 Translated using Weblate (Dutch)
Currently translated at 81.5% (62 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-12 09:41:25 +02:00
Anonymous
898e30d6de Translated using Weblate (Vietnamese)
Currently translated at 43.4% (33 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-12 09:41:25 +02:00
Anonymous
a792d213e9 Translated using Weblate (Norwegian Bokmål)
Currently translated at 84.2% (64 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-12 09:41:25 +02:00
Anonymous
ebee953ebc Translated using Weblate (Italian)
Currently translated at 90.7% (69 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-12 09:41:25 +02:00
Anonymous
200ab220e8 Translated using Weblate (Chinese (Simplified))
Currently translated at 78.9% (60 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-12 09:41:24 +02:00
Anonymous
2499d25ce6 Translated using Weblate (Russian)
Currently translated at 17.1% (13 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-12 09:41:24 +02:00
Anonymous
42356166c0 Translated using Weblate (Portuguese)
Currently translated at 30.2% (23 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-12 09:41:24 +02:00
Anonymous
80a31c8427 Translated using Weblate (French)
Currently translated at 52.6% (40 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-12 09:41:24 +02:00
Anonymous
867c6f9e97 Translated using Weblate (Spanish)
Currently translated at 90.7% (69 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-12 09:41:24 +02:00
Anonymous
ee9194fce1 Translated using Weblate (German)
Currently translated at 84.2% (64 of 76 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-12 09:41:23 +02:00
Ben Phelps
f6322077a4 Merge pull request #118 from ItsJustMeChris/main
Add coin market cap module
2022-09-12 10:41:17 +03:00
Ben Phelps
15a0e6cc54 Merge branch 'main' into main 2022-09-12 10:40:56 +03:00
Anonymous
5ee5adbb1e Translated using Weblate (Dutch)
Currently translated at 82.6% (62 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-12 09:33:05 +02:00
Anonymous
1d4b3eee9b Translated using Weblate (Vietnamese)
Currently translated at 44.0% (33 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-12 09:33:05 +02:00
Anonymous
fe971d23f8 Translated using Weblate (Norwegian Bokmål)
Currently translated at 85.3% (64 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-12 09:33:04 +02:00
Anonymous
34bf49845f Translated using Weblate (Italian)
Currently translated at 92.0% (69 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-12 09:33:04 +02:00
Anonymous
34468e5bb0 Translated using Weblate (Chinese (Simplified))
Currently translated at 80.0% (60 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-12 09:33:04 +02:00
Anonymous
d0dd52c5c2 Translated using Weblate (Russian)
Currently translated at 17.3% (13 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-12 09:33:04 +02:00
Anonymous
98cca4ca8b Translated using Weblate (Portuguese)
Currently translated at 30.6% (23 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-12 09:33:03 +02:00
Anonymous
b88463a785 Translated using Weblate (French)
Currently translated at 53.3% (40 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-12 09:33:03 +02:00
Anonymous
6409188de8 Translated using Weblate (Spanish)
Currently translated at 92.0% (69 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-12 09:33:03 +02:00
Anonymous
510973c761 Translated using Weblate (German)
Currently translated at 85.3% (64 of 75 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-12 09:33:02 +02:00
Ben Phelps
4480c26910 allow weblate to apply new strings 2022-09-12 10:32:40 +03:00
Ben Phelps
e778595296 Merge branch 'main' of github.com:benphelps/homepage
# Conflicts:
#	public/locales/it/common.json
2022-09-12 10:32:22 +03:00
Ben Phelps
f84ff7cedc allow weblate to apply new strings 2022-09-12 10:26:03 +03:00
Luca Pellegrino
04f98ae7a9 Translated using Weblate (Italian)
Currently translated at 100.0% (69 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-12 09:23:53 +02:00
Nonoss117
8ef3d7c20e Translated using Weblate (French)
Currently translated at 53.6% (37 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-12 09:23:53 +02:00
Ángel Fernández Sánchez
428fd6cbba Translated using Weblate (Spanish)
Currently translated at 100.0% (69 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-12 09:23:53 +02:00
Ben Phelps
ee79335eff Merge pull request #116 from JazzFisch/additional-widgets
Add Readarr and SABnzbd widgets
2022-09-12 10:23:49 +03:00
Ben Phelps
83d7100dd1 normalize media streaming widget padding 2022-09-12 09:41:44 +03:00
Chris McGravey
ccd9049806 Merge branch 'main' of https://github.com/ItsJustMeChris/homepage 2022-09-12 01:38:43 -05:00
Chris McGravey
769f36fa8e - Change block to return configure translation text 2022-09-12 01:38:29 -05:00
Chris
ffe89b02e9 Merge branch 'benphelps:main' into main 2022-09-12 01:32:09 -05:00
Chris McGravey
1c158f743c - Add CoinMarketCap widget 2022-09-12 01:30:42 -05:00
Ben Phelps
4531985032 fix standalone docker widget 2022-09-12 06:18:51 +03:00
Jason Fischer
f8aa1ba391 Add Readarr and SABnzbd widgets 2022-09-11 19:49:18 -07:00
Francisco Coelho
9d790894d5 Sabnzbd Support 2022-09-12 02:22:39 +01:00
Francisco Coelho
eeac1200e7 Update credentialed.js 2022-09-11 22:11:14 +01:00
Francisco Coelho
a304d87b8a Merge branch 'main' of https://github.com/xicopitz/homepage 2022-09-11 22:08:11 +01:00
Ben Phelps
ffbb1f5f0b tweak widget layouts for mobile 2022-09-11 21:02:33 +03:00
Ben Phelps
ad53119088 fix theme selector on mobile 2022-09-11 19:11:58 +03:00
Anonymous
fe1c525fb7 Translated using Weblate (Dutch)
Currently translated at 88.4% (61 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-11 16:24:48 +02:00
Anonymous
323375e8e4 Translated using Weblate (Vietnamese)
Currently translated at 46.3% (32 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-11 16:24:48 +02:00
Anonymous
8af27ea86d Translated using Weblate (Norwegian Bokmål)
Currently translated at 92.7% (64 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-11 16:24:48 +02:00
Anonymous
9335be0049 Translated using Weblate (Italian)
Currently translated at 13.0% (9 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-11 16:24:47 +02:00
Anonymous
8e0f265080 Translated using Weblate (Chinese (Simplified))
Currently translated at 86.9% (60 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-11 16:24:47 +02:00
Anonymous
ae357159ef Translated using Weblate (Russian)
Currently translated at 14.4% (10 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-11 16:24:47 +02:00
Anonymous
387d40910f Translated using Weblate (Portuguese)
Currently translated at 30.4% (21 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-11 16:24:47 +02:00
Anonymous
5344485a4f Translated using Weblate (French)
Currently translated at 26.0% (18 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-11 16:24:46 +02:00
Anonymous
21f2f2a215 Translated using Weblate (Spanish)
Currently translated at 92.7% (64 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-11 16:24:46 +02:00
Anonymous
9586fac665 Translated using Weblate (German)
Currently translated at 92.7% (64 of 69 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-11 16:24:46 +02:00
Ben Phelps
4aedda7ba2 add Overseerr widget 2022-09-11 17:24:33 +03:00
Ben Phelps
f79213c9d3 update language attributions 2022-09-11 17:24:12 +03:00
Anonymous
eeddcb26a0 Translated using Weblate (Dutch)
Currently translated at 92.4% (61 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-11 16:02:46 +02:00
Anonymous
725922db78 Translated using Weblate (Vietnamese)
Currently translated at 48.4% (32 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-11 16:02:46 +02:00
Anonymous
1846dcaba9 Translated using Weblate (Norwegian Bokmål)
Currently translated at 96.9% (64 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-11 16:02:45 +02:00
Anonymous
ab96d88ebe Translated using Weblate (Italian)
Currently translated at 13.6% (9 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-11 16:02:45 +02:00
Anonymous
a3bf28915d Translated using Weblate (Chinese (Simplified))
Currently translated at 90.9% (60 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-11 16:02:45 +02:00
Anonymous
47920a5f7a Translated using Weblate (Russian)
Currently translated at 15.1% (10 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-11 16:02:45 +02:00
Anonymous
1c10903823 Translated using Weblate (Portuguese)
Currently translated at 31.8% (21 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-11 16:02:45 +02:00
Anonymous
96e4133517 Translated using Weblate (French)
Currently translated at 27.2% (18 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-11 16:02:44 +02:00
Anonymous
8b5167a911 Translated using Weblate (Spanish)
Currently translated at 96.9% (64 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-11 16:02:44 +02:00
Anonymous
45edab5d88 Translated using Weblate (German)
Currently translated at 96.9% (64 of 66 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-11 16:02:44 +02:00
desolaris
b4375fb6fc Translated using Weblate (Russian)
Currently translated at 15.6% (10 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-11 16:02:39 +02:00
Ben Phelps
bd2b28a7ac redesigned media streaming widgets 2022-09-11 17:01:51 +03:00
Ben Phelps
53149df5f1 handle proxy methods other than GET 2022-09-11 14:30:28 +03:00
Ben Phelps
bc2025b3ba handle 204 and 304 proxy responses 2022-09-11 14:30:14 +03:00
Ben Phelps
236450f6f1 add error logging to services fetching 2022-09-11 14:28:29 +03:00
Ben Phelps
fb9e03b31d attempt to fix layout shift on resource widgets 2022-09-11 14:28:12 +03:00
Ben Phelps
31ccb9c933 fix no disk case 2022-09-11 14:21:16 +03:00
Ben Phelps
6e01a743df support array of disks, for disk resource widget 2022-09-11 14:13:58 +03:00
Ben Phelps
ed65c89516 blur backdrops for better background image support 2022-09-11 13:46:01 +03:00
Ben Phelps
b8b8dad9fb Update README.md 2022-09-11 11:35:00 +03:00
Ben Phelps
690d17e132 Merge branch 'main' of github.com:benphelps/homepage 2022-09-11 11:34:00 +03:00
Ben Phelps
a1e9912b36 update readme 2022-09-11 11:33:36 +03:00
deffcolony
26914bfb09 Translated using Weblate (Dutch)
Currently translated at 95.3% (61 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-11 10:23:22 +02:00
J. Lavoie
079fdb3011 Translated using Weblate (French)
Currently translated at 28.1% (18 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-11 10:23:22 +02:00
Ángel Fernández Sánchez
102cdbd53a Translated using Weblate (Spanish)
Currently translated at 100.0% (64 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-11 10:23:22 +02:00
Ben Phelps
d861264ecf fix error case cause failure to load anything 2022-09-11 11:13:54 +03:00
Francisco Coelho
9831df1427 Update proxy.js 2022-09-11 04:39:40 +01:00
Francisco Coelho
5e6312fe93 Add Gotify Service 2022-09-11 04:11:02 +01:00
Ben Phelps
e3237b9022 fix text alignment 2022-09-10 21:43:14 +03:00
Ben Phelps
3882dd4f5a fix cases where configurations are empty 2022-09-09 22:01:01 +03:00
Ben Phelps
d66326b41d implement docker service discovery via labels 2022-09-09 21:53:05 +03:00
Anonymous
ef1c5dbcc9 Translated using Weblate (Dutch)
Currently translated at 100.0% (0 of 0 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nl/
2022-09-09 14:59:59 +02:00
Allan Nordhøy
c45c8e93de Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (64 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-09 14:59:58 +02:00
Francisco Coelho
1e93bf3ec4 Translated using Weblate (Portuguese)
Currently translated at 32.8% (21 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-09 14:59:58 +02:00
Bernhard Großer
4bff209bd7 Translated using Weblate (German)
Currently translated at 100.0% (64 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-09 14:59:58 +02:00
Ben Phelps
e5ee937c38 Added translation using Weblate (Dutch) 2022-09-09 14:59:54 +02:00
Ben Phelps
c418efe007 fix fallback to / in disk resource widget 2022-09-09 15:27:42 +03:00
Ben Phelps
5677254b46 add new weather feature to readme 2022-09-09 13:07:20 +03:00
Ben Phelps
cb76a8165d pre-create settings.yaml for build process 2022-09-09 13:07:09 +03:00
Ben Phelps
a7a1eca0cd attempt to fix weird race condition in builds? 2022-09-09 12:59:43 +03:00
Ben Phelps
85bc078c46 always attempt location fetch
if it fails, then we just fallback to user interaction
2022-09-09 12:57:15 +03:00
Anonymous
5c347d9427 Translated using Weblate (Vietnamese)
Currently translated at 50.0% (32 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-09 11:44:50 +02:00
Anonymous
6c17efc2ab Translated using Weblate (Norwegian Bokmål)
Currently translated at 93.7% (60 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-09 11:44:50 +02:00
Anonymous
a6e929ba86 Translated using Weblate (Italian)
Currently translated at 14.0% (9 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-09 11:44:50 +02:00
Anonymous
f9886f7c63 Translated using Weblate (Chinese (Simplified))
Currently translated at 93.7% (60 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/zh_Hans/
2022-09-09 11:44:49 +02:00
Anonymous
ec937f6212 Translated using Weblate (Russian)
Currently translated at 14.0% (9 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-09 11:44:49 +02:00
Anonymous
b7427c3409 Translated using Weblate (Portuguese)
Currently translated at 14.0% (9 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-09 11:44:49 +02:00
Anonymous
5bd9cf46ea Translated using Weblate (French)
Currently translated at 14.0% (9 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-09 11:44:49 +02:00
Anonymous
c340c42ef3 Translated using Weblate (Spanish)
Currently translated at 93.7% (60 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-09 11:44:48 +02:00
Anonymous
27c5b4227d Translated using Weblate (German)
Currently translated at 93.7% (60 of 64 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/de/
2022-09-09 11:44:48 +02:00
Trung Le
914e869778 Translated using Weblate (Vietnamese)
Currently translated at 53.3% (32 of 60 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-09 11:44:42 +02:00
Ben Phelps
e4ea30becc implement weather geolocation 2022-09-09 12:44:34 +03:00
Ben Phelps
61f91f0e45 remove logging 2022-09-09 11:51:36 +03:00
Ben Phelps
c6d8668e69 fix jellyfin integration 2022-09-09 11:42:08 +03:00
Anonymous
3c73b000df Translated using Weblate (Vietnamese)
Currently translated at 100.0% (0 of 0 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/vi/
2022-09-09 08:11:36 +02:00
Trung Le
ed5a5ae86f Added translation using Weblate (Vietnamese) 2022-09-09 08:11:33 +02:00
Ben Phelps
036fbb0f49 fix build 2022-09-09 08:27:25 +03:00
Ben Phelps
13779c5618 update skeleton files 2022-09-09 07:10:45 +03:00
Ben Phelps
6802fd0c1d update readme 2022-09-09 07:10:39 +03:00
Ben Phelps
7b523501ad fix linting 2022-09-09 07:10:33 +03:00
Ben Phelps
0c8bbdf02b background images, document title 2022-09-09 06:45:43 +03:00
Ben Phelps
0b43f83daa cleanup bitrate/byterate i18n formatter 2022-09-08 20:17:58 +03:00
Ben Phelps
0f2f552e87 normalize resource widget styling 2022-09-08 20:17:39 +03:00
Ben Phelps
b3bedc7c31 update readme, contributors and formatting 2022-09-08 18:57:27 +03:00
Ben Phelps
32a2a3f484 Translated using Weblate (French)
Currently translated at 0.0% (0 of 60 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/fr/
2022-09-08 17:29:15 +02:00
Ángel Fernández Sánchez
27de7d1c84 Translated using Weblate (Spanish)
Currently translated at 100.0% (60 of 60 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/es/
2022-09-08 17:28:22 +02:00
Ben Phelps
c31c2a4c84 Translated using Weblate (Portuguese)
Currently translated at 0.0% (0 of 60 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/pt/
2022-09-08 13:41:33 +02:00
Ben Phelps
9fb88eb325 fix to BCP style language folder 2022-09-08 14:41:26 +03:00
Ben Phelps
7cdc2fa89a add Norwegian attribution to readme 2022-09-08 14:19:22 +03:00
Allan Nordhøy
c06dbddcea Translated using Weblate (Norwegian Bokmål)
Currently translated at 91.6% (55 of 60 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-08 13:13:17 +02:00
Ben Phelps
a4b17d9a8f remove readonly i18n keys from other languages 2022-09-08 14:00:53 +03:00
Anonymous
7ffea76b9e Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (0 of 0 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/nb_NO/
2022-09-08 12:55:38 +02:00
Allan Nordhøy
6a6db91dc9 Added translation using Weblate (Norwegian Bokmål) 2022-09-08 12:55:24 +02:00
Anonymous
9078fd2302 Translated using Weblate (Italian)
Currently translated at 15.0% (9 of 60 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/it/
2022-09-08 12:36:08 +02:00
Ben Phelps
39786c5dd4 Translated using Weblate (Russian)
Currently translated at 0.0% (0 of 60 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/ru/
2022-09-08 12:36:07 +02:00
Ben Phelps
7c35a88483 Merge branch 'main' of github.com:benphelps/homepage 2022-09-08 13:20:38 +03:00
Ben Phelps
1b885cb189 fix incorrect i18n keys 2022-09-08 13:19:49 +03:00
Ben Phelps
183bbbe6e7 Added translation using Weblate (Italian) 2022-09-08 12:13:31 +02:00
Ben Phelps
562318f2d3 Deleted translation using Weblate (Italian) 2022-09-08 12:13:09 +02:00
Ben Phelps
797401a7de Added translation using Weblate (Italian) 2022-09-08 12:08:29 +02:00
Ben Phelps
d4dbfebe72 fix english 2022-09-08 12:54:05 +03:00
Ben Phelps
575d233078 add translation info to readme 2022-09-08 12:52:28 +03:00
Ben Phelps
04a78c07a7 use ellipsis 2022-09-08 12:52:04 +03:00
Ben Phelps
59856bc753 Translated using Weblate (English)
Currently translated at 100.0% (60 of 60 strings)

Translation: Homepage/Homepage
Translate-URL: https://hosted.weblate.org/projects/homepage/homepage/en/
2022-09-08 11:48:12 +02:00
Ben Phelps
a8e7a5f912 add machine translated locales for
de, es, fr, pt, ru and zh-CN
2022-09-08 11:48:59 +03:00
Ben Phelps
c08d4b7b9c implement i18n 2022-09-08 11:48:16 +03:00
Ben Phelps
d25148c8ae revert to array key indexes, it’s the only option 2022-09-08 11:47:21 +03:00
Ben Phelps
b0e640fd03 remove unused component 2022-09-08 11:45:59 +03:00
Ben Phelps
7ae07cb1ee add Tautulli to list of services 2022-09-08 08:29:08 +03:00
Ben Phelps
c0b34faa79 Delete linter.yml 2022-09-07 17:47:19 +03:00
Ben Phelps
81e3b0bd1e cleanup resource widget design 2022-09-07 17:17:01 +03:00
Ben Phelps
f74e8b9d32 linting and cleanup 2022-09-07 16:53:24 +03:00
Ben Phelps
7f041e8303 Update linter.yml 2022-09-07 16:23:18 +03:00
Ben Phelps
db05a66f3a Create linter.yml 2022-09-07 16:18:52 +03:00
Ben Phelps
efc6e86991 add build attribution to readme 2022-09-07 12:29:02 +03:00
Ben Phelps
1af867d4b3 forcefully disable next-swc 2022-09-07 12:14:06 +03:00
Ben Phelps
43da60595c add @swc/core & cli packages
continued attempts to solve the SWC build step on arm platforms
2022-09-07 12:02:34 +03:00
Ben Phelps
c2849744b8 revert back to alpine 2022-09-07 12:01:53 +03:00
Ben Phelps
9bef2a8fcb trial run on non-alpine images
this is to test and see the size differences and if it’ll fix the SWC errors given by Next
2022-09-07 11:52:17 +03:00
Ben Phelps
6ec73362f2 fix docker, s/latest/current/ 2022-09-07 11:32:54 +03:00
Ben Phelps
f5bef651d8 bump node:alpine to latest 2022-09-07 11:31:31 +03:00
Ben Phelps
aa77a274c3 attempt to skip cache restore step 2022-09-07 11:22:12 +03:00
Ben Phelps
4e90bd8e46 remove apk cache as it seems to have issues 2022-09-07 11:10:56 +03:00
Ben Phelps
6a238948c2 Merge pull request #62 from modem7/dockerfile-optimise
Update Dockerfile
2022-09-07 11:09:56 +03:00
Alex
8e2bebcfd9 Updated EXPOSE to variable 2022-09-06 21:41:53 +01:00
Alex
b023801fe4 Update Dockerfile to include cache mounts + update healthcheck to new endpoint 2022-09-06 21:39:31 +01:00
modem7
516b812b2b Merge branch 'benphelps:main' into dockerfile-optimise 2022-09-06 21:25:19 +01:00
Ben Phelps
8373057758 add simple healthcheck endpoint 2022-09-06 09:13:48 +03:00
Ben Phelps
939f5d7c20 fix path.join import 2022-09-06 08:39:25 +03:00
Alex
36d1a9c738 Update healthcheck to work with PORT variable 2022-09-05 22:55:14 +01:00
Alex
fb845c3e03 Update Dockerfile and add healthcheck 2022-09-05 22:17:58 +01:00
Alex
78c52861c7 Update Dockerfile for --link 2022-09-05 19:44:58 +01:00
Alex
a265038bf6 Update Dockerfile 2022-09-05 16:42:10 +01:00
104 changed files with 4226 additions and 880 deletions

3
.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["next/babel"]
}

View File

@@ -1,3 +1,19 @@
{
"extends": "next/core-web-vitals"
"extends": ["airbnb", "next/core-web-vitals", "prettier"],
"plugins": ["prettier"],
"rules": {
"import/order": [
"error",
{
"newlines-between": "always"
}
]
},
"settings": {
"import/resolver": {
"node": {
"paths": ["src"]
}
}
}
}

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug] "
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Configuration**
If applicable,
```yaml
# Please provide your service, widget or otherwise related configuration here
```
**Additional context**
Add any other context about the problem here. This includes things like:
- Service version or API version
- Docker version
- Deployment method
- Sample YAML configurations

View File

@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature Request] "
labels: ''
assignees: ''
---
**Is your feature request related to a service? Please describe.**
A clear and concise description of what you would like to see from this service.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I would like it if [...]
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -54,13 +54,15 @@ jobs:
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v2
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
# This step is being disabled because the runner is on a self-hosted machine
# where the cache will stick between runs.
# - name: Cache Docker layers
# uses: actions/cache@v3
# with:
# path: /tmp/.buildx-cache
# key: ${{ runner.os }}-buildx-${{ github.sha }}
# restore-keys: |
# ${{ runner.os }}-buildx-
# Login against a Docker registry except on PR
# https://github.com/docker/login-action

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
ben@phelps.io.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

41
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,41 @@
# Contributing to Homepage
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
- Reporting a bug
- Discussing the current state of the project
- Submitting a fix
- Proposing new features
- Becoming a maintainer
## We Develop with Github
We use github to host code, to track issues and feature requests, as well as accept pull requests.
## Any contributions you make will be under the GNU General Public License v3.0
In short, when you submit code changes, your submissions are understood to be under the same [GNU General Public License v3.0](https://choosealicense.com/licenses/gpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](https://github.com/benphelps/homepage/issues)
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/benphelps/homepage/issues/new); it's that easy!
## Write bug reports with detail, background, and sample configurations
Homepage includes a lot of configuration options and is often deploying in larger systems. Please include as much information (configurations, deployment method, Docker & API versions, etc) as you can when reporting an issue.
**Great Bug Reports** tend to have:
- A quick summary and/or background
- Steps to reproduce
- Be specific!
- Give example configurations if you can.
- What you expected would happen
- What actually happens
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
People *love* thorough bug reports. I'm not even kidding.
## Use a Consistent Coding Style
This project follows the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript), please follow it when submitting pull requests.
## License
By contributing, you agree that your contributions will be licensed under its GNU General Public License.
## References
This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/main/CONTRIBUTING.md)

View File

@@ -1,4 +1,39 @@
FROM node:16-alpine AS base
# syntax = docker/dockerfile:latest
# Install dependencies only when needed
FROM node:current-alpine AS deps
WORKDIR /app
COPY --link package.json pnpm-lock.yaml* ./
RUN <<EOF
set -xe
apk add libc6-compat
apk add --virtual .gyp python3 make g++
yarn global add pnpm
EOF
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm fetch | grep -v "cross-device link not permitted\|Falling back to copying packages from store"
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm install -r --offline
# Rebuild the source code only when needed
FROM node:current-alpine AS builder
WORKDIR /app
COPY --link --from=deps /app/node_modules ./node_modules/
COPY . .
RUN <<EOF
set -xe
yarn next telemetry disable
mkdir config && echo '-' > config/settings.yaml
npm run build
EOF
# Production image, copy all the files and run next
FROM node:current-alpine AS runner
LABEL org.opencontainers.image.title "Homepage"
LABEL org.opencontainers.image.description "A self-hosted services landing page, with docker and service integrations."
LABEL org.opencontainers.image.url="https://github.com/benphelps/homepage"
@@ -6,33 +41,22 @@ LABEL org.opencontainers.image.documentation='https://github.com/benphelps/homep
LABEL org.opencontainers.image.source='https://github.com/benphelps/homepage'
LABEL org.opencontainers.image.licenses='Apache-2.0'
# Install dependencies only when needed
FROM node:16-alpine AS deps
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache --virtual .gyp python3 make g++
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN yarn global add pnpm
RUN pnpm install
RUN apk del .gyp
# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image, copy all the files and run next
FROM node:16-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
WORKDIR /app
# Copy files from context (this allows the files to copy before the builder stage is done).
COPY --link package.json next.config.js ./
COPY --link /public ./public
# Copy files from builder
COPY --link --from=builder /app/.next/standalone ./
COPY --link --from=builder /app/.next/static/ ./.next/static/
ENV PORT 3000
EXPOSE $PORT
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s \
CMD wget --no-verbose --tries=1 --spider --no-check-certificate http://localhost:$PORT/api/healthcheck || exit 1
CMD ["node", "server.js"]

View File

@@ -1,22 +1,34 @@
![Homepage Preview](/images/preview.png)
[![Docker](https://github.com/benphelps/homepage/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/benphelps/homepage/actions/workflows/docker-publish.yml)
[![Weblate](https://hosted.weblate.org/widgets/homepage/-/homepage/svg-badge.svg)](https://hosted.weblate.org/engage/homepage/)
## Features
* Web Bookmarks
* Service Bookmarks
- Docker Integration
- Status light + CPU, Memory & Network Reporting *(click on the status light)*
- Service Integration
- Currently supports Sonarr, Radarr, Ombi, Emby, Jellyfin, Jellyseerr ([by ilusi0n](https://github.com/benphelps/homepage/pull/34)), NZBGet, ruTorrent
- Portainer, Traefik, Speedtest Tracker, PiHole, Nginx Proxy Manager ([by aidenpwnz](https://github.com/benphelps/homepage/pull/45))
* Homepage Widgets
* Fast! The entire site is statically generated at build time, so you can expect instant load times
* Images built for AMD64 (x86_64), ARM64, ARMv7 and ARMv6
- Supports all Raspberry Pi's, most SBCs & Apple Silicon
* Full i18n support with automatic language detection
- Translations for Chinese, Dutch, French, German, Norwegian Bokmål, Portuguese, Russian and Spanish
- Want to help translate? [Join the Weblate project](https://hosted.weblate.org/engage/homepage/)
* Service & Web Bookmarks
* Docker Integration
- Container status (Running / Stopped) & statistics (CPU, Memory, Network)
- Automatic service discovery (via labels)
* Service Integration
- Sonarr, Radarr, Readarr, Prowlarr, Emby, Jellyfin, Tautulli (Plex)
- Ombi, Overseerr, Jellyseerr, NZBGet, SABnzbd, ruTorrent
- Portainer, Traefik, Speedtest Tracker, PiHole, Nginx Proxy Manager, Gotify
* Information Providers
- Coin Market Cap
* Information & Utility Widgets
- System Stats (Disk, CPU, Memory)
- Weather via WeatherAPI.com or OpenWeatherMap ([by AlexFullmoon](https://github.com/benphelps/homepage/pull/25))
- Search Bar ([by aidenpwnz](https://github.com/benphelps/homepage/pull/45))
- Weather via WeatherAPI.com or OpenWeatherMap
- Automatic location detection (with HTTPS), or manual location selection
- Search Bar
* Customizable
- 21 theme colors with light and dark mode support
- Background image support
## Support & Suggestions
@@ -66,6 +78,8 @@ pnpm install
pnpm build
```
If this is your first time starting, copy the `src/skeleton` directory to `config/` to populate initial example config files.
Finally, run the server:
```bash
@@ -79,6 +93,8 @@ Configuration files will be genereted and placed on the first request.
Configuration is done in the /config directory using .yaml files. Refer to each config for
the specific configuration options.
You may also check [the wiki](https://github.com/benphelps/homepage/wiki) for detailed configuration instructions, examples and more.
## Development
Install NPM packages, this project uses [pnpm](https://pnpm.io/) (and so should you!):
@@ -96,3 +112,25 @@ pnpm dev
Open [http://localhost:3000](http://localhost:3000) to start.
This is a [Next.js](https://nextjs.org/) application, see their doucmentation for more information:
## Contributors
Huge thanks to the all the contributors who have helped make this project what it is today! In alphabetical order:
* [aidenpwnz](https://github.com/benphelps/homepage/commits?author=aidenpwnz) - Nginx Proxy Manager, Search Bar Widget
* [AlexFullmoon](https://github.com/benphelps/homepage/commits?author=AlexFullmoon) - OpenWeatherMap Widget
* [AmadeusGraves](https://github.com/benphelps/homepage/commits?author=AmadeusGraves) - Spanish Translation
* [boerniee](https://github.com/benphelps/homepage/commits?author=boerniee) - German Translation
* [comradekingu](https://github.com/benphelps/homepage/commits?author=comradekingu) - Norwegian Bokmål Translation
* [deffcolony](https://github.com/benphelps/homepage/commits?author=deffcolony) - Dutch Translation
* [desolaris](https://github.com/benphelps/homepage/commits?author=desolaris) - Russian Translation
* [ilusi0n](https://github.com/benphelps/homepage/commits?author=ilusi0n) - Jellyseerr Integration
* [ItsJustMeChris](https://github.com/benphelps/homepage/commits?author=ItsJustMeChris) - Coin Market Cap Widget
* [jackblk](https://github.com/benphelps/homepage/commits?author=jackblk) - Vietnamese Translation
* [JazzFisch](https://github.com/benphelps/homepage/commits?author=JazzFisch) - Readarr, SABnzbd Integrations
* [modem7](https://github.com/benphelps/homepage/commits?author=modem7) - Impvoed Docker Image
* [nicedc](https://github.com/benphelps/homepage/commits?author=nicedc) - Chinese Translation
* [Nonoss117](https://github.com/benphelps/homepage/commits?author=Nonoss117) - French Translation
* [quod](https://github.com/benphelps/homepage/commits?author=quod) - Fixed Typos
* [schklom](https://github.com/benphelps/homepage/commits?author=schklom) - ARM64, ARMv7 and ARMv6
* [xicopitz](https://github.com/benphelps/homepage/commits?author=xicopitz) - Gotify & Prowlarr Integration

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -3,9 +3,9 @@ const nextConfig = {
reactStrictMode: true,
output: "standalone",
swcMinify: false,
experimental: { images: { allowFutureImage: true, unoptimized: true } },
images: {
domains: ["cdn.jsdelivr.net"],
unoptimized: true,
},
};

View File

@@ -9,27 +9,43 @@
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^1.6.6",
"@headlessui/react": "^1.7.0",
"@tailwindcss/forms": "^0.5.3",
"classnames": "^2.3.1",
"currency-symbol-map": "^5.1.0",
"dockerode": "^3.3.4",
"i18next": "^21.9.1",
"i18next-browser-languagedetector": "^6.1.5",
"i18next-http-backend": "^1.4.1",
"js-yaml": "^4.1.0",
"json-rpc-2.0": "^1.4.1",
"memory-cache": "^0.2.0",
"next": "12.2.5",
"next": "^12.3.0",
"node-os-utils": "^1.3.7",
"pretty-bytes": "^6.0.0",
"raw-body": "^2.5.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^11.18.6",
"react-icons": "^4.4.0",
"rutorrent-promise": "^2.0.0",
"shvl": "^3.0.0",
"swr": "^1.3.0"
},
"devDependencies": {
"autoprefixer": "^10.4.8",
"eslint": "8.22.0",
"eslint-config-next": "12.2.5",
"autoprefixer": "^10.4.9",
"eslint": "^8.23.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-next": "^12.3.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.8",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.16",
"prettier": "^2.7.1",
"tailwindcss": "^3.1.8",
"typescript": "^4.8.2"
"typescript": "^4.8.3"
}
}

587
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
{
"widget": {
"missing_type": "Fehlender Widget-Typ: {{type}}",
"api_error": "API-Fehler",
"status": "Status"
},
"search": {
"placeholder": "Suche…"
},
"resources": {
"total": "Gesamt",
"free": "Frei",
"used": "Gebraucht",
"load": "Load"
},
"docker": {
"rx": "Rx",
"tx": "Tx",
"mem": "Mem",
"cpu": "Prozessor",
"offline": "Offline"
},
"emby": {
"playing": "Spielen",
"transcoding": "Transcodierung",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"tautulli": {
"playing": "Spielen",
"transcoding": "Transcodierung",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"rutorrent": {
"active": "Aktiv",
"upload": "Hochladen",
"download": "Download"
},
"sonarr": {
"wanted": "Gesucht",
"queued": "In Warteschlange",
"series": "Serie"
},
"radarr": {
"wanted": "Gesucht",
"queued": "In Warteschlange",
"movies": "Filme"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "Ausstehend",
"approved": "Genehmigt",
"available": "Verfügbar"
},
"jellyseerr": {
"pending": "Ausstehend",
"approved": "Genehmigt",
"available": "Verfügbar"
},
"pihole": {
"queries": "Abfragen",
"blocked": "Blockiert",
"gravity": "Gravity"
},
"speedtest": {
"upload": "Upload",
"download": "Download",
"ping": "Ping"
},
"portainer": {
"running": "Betrieb",
"stopped": "Gestoppt",
"total": "Gesamt"
},
"traefik": {
"routers": "Router",
"services": "Services",
"middleware": "Middleware"
},
"npm": {
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"total": "Gesamt"
},
"weather": {
"current": "Aktueller Standort",
"allow": "Zum Zulassen anklicken",
"updating": "Aktualisieren",
"wait": "Bitte warten"
},
"overseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"nzbget": {
"rate": "Rate",
"remaining": "Remaining",
"downloaded": "Downloaded"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
},
"prowlarr": {
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,143 @@
{
"common": {
"bytes": "{{value, bytes}}",
"bits": "{{value, bytes(bits: true)}}",
"bbytes": "{{value, bytes(binary: true)}}",
"bbits": "{{value, bytes(bits: true, binary: true)}}",
"byterate": "{{value, rate}}",
"bitrate": "{{value, rate(bits: true)}}",
"percent": "{{value, percent}}",
"number": "{{value, number}}",
"ms": "{{value, number}}"
},
"widget": {
"missing_type": "Missing Widget Type: {{type}}",
"api_error": "API Error",
"status": "Status"
},
"weather": {
"current": "Current Location",
"allow": "Click to allow",
"updating": "Updating",
"wait": "Please wait"
},
"search": {
"placeholder": "Search…"
},
"resources": {
"total": "Total",
"free": "Free",
"used": "Used",
"load": "Load"
},
"docker": {
"rx": "RX",
"tx": "TX",
"mem": "MEM",
"cpu": "CPU",
"offline": "Offline"
},
"emby": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"tautulli": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"nzbget": {
"rate": "Rate",
"remaining": "Remaining",
"downloaded": "Downloaded"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"rutorrent": {
"active": "Active",
"upload": "Upload",
"download": "Download"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
},
"sonarr": {
"wanted": "Wanted",
"queued": "Queued",
"series": "Series"
},
"radarr": {
"wanted": "Wanted",
"queued": "Queued",
"movies": "Movies"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"jellyseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"overseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"pihole": {
"queries": "Queries",
"blocked": "Blocked",
"gravity": "Gravity"
},
"speedtest": {
"upload": "Upload",
"download": "Download",
"ping": "Ping"
},
"portainer": {
"running": "Running",
"stopped": "Stopped",
"total": "Total"
},
"traefik": {
"routers": "Routers",
"services": "Services",
"middleware": "Middleware"
},
"npm": {
"enabled": "Enabled",
"disabled": "Disabled",
"total": "Total"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
},
"prowlarr":{
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
}
}

View File

@@ -0,0 +1,132 @@
{
"widget": {
"missing_type": "Tipo de widget faltante: {{type}}",
"api_error": "Error de API",
"status": "Estado"
},
"search": {
"placeholder": "Buscar…"
},
"resources": {
"total": "Total",
"free": "Libre",
"used": "Usado",
"load": "Load"
},
"docker": {
"rx": "Recibido",
"tx": "Transmitido",
"mem": "Memoria",
"cpu": "Procesador",
"offline": "Desconectado"
},
"emby": {
"playing": "En ejecución",
"transcoding": "Transcodificando",
"bitrate": "Tasa de Bits",
"no_active": "No hay streams activos"
},
"tautulli": {
"playing": "En ejecución",
"transcoding": "Transcodificación",
"bitrate": "Tasa de bits",
"no_active": "No hay streams activos"
},
"rutorrent": {
"active": "Activo",
"upload": "Subir",
"download": "Descargar"
},
"sonarr": {
"wanted": "Más deseado",
"queued": "Puesto en cola",
"series": "Series"
},
"radarr": {
"wanted": "Más deseado",
"queued": "Puesto en cola",
"movies": "Películas"
},
"readarr": {
"wanted": "Más deseado",
"queued": "Puesto en cola",
"books": "Libros"
},
"ombi": {
"pending": "Pendiente",
"approved": "Aprobado",
"available": "Disponible"
},
"jellyseerr": {
"pending": "Pendiente",
"approved": "Aprobado",
"available": "Disponible"
},
"pihole": {
"queries": "Consultas",
"blocked": "Bloqueado",
"gravity": "Gravedad"
},
"speedtest": {
"upload": "Subir",
"download": "Descargar",
"ping": "Ping"
},
"portainer": {
"running": "En ejecución",
"stopped": "Detenido",
"total": "Total"
},
"traefik": {
"routers": "Enrutadores",
"services": "Servicios",
"middleware": "Middleware"
},
"npm": {
"enabled": "Activado",
"disabled": "Desactivado",
"total": "Total"
},
"weather": {
"current": "Ubicación Actual",
"allow": "Haga clic para permitir",
"updating": "Actualizando",
"wait": "Espere, por favor"
},
"overseerr": {
"pending": "Pendiente",
"approved": "Aprobado",
"available": "Disponible"
},
"sabnzbd": {
"rate": "Tasa de descarga",
"queue": "Puesto en cola",
"timeleft": "Tiempo Restante"
},
"nzbget": {
"rate": "Tasa de descarga",
"remaining": "Restante",
"downloaded": "Descargado"
},
"coinmarketcap": {
"configure": "Configurar una o varias criptomonedas para su seguimiento"
},
"gotify": {
"apps": "Aplicaciones",
"clients": "Clientes",
"messages": "Mensajes"
},
"prowlarr": {
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,143 @@
{
"widget": {
"missing_type": "Type de widget manquant: {{type}}",
"api_error": "Erreur de l'API",
"status": "Statut"
},
"search": {
"placeholder": "Recherche…"
},
"resources": {
"total": "Total",
"free": "Libre",
"used": "Utilisé",
"load": "Charge"
},
"docker": {
"rx": "Rx",
"tx": "Tx",
"mem": "Mém",
"cpu": "Cpu",
"offline": "Hors ligne"
},
"emby": {
"playing": "En lecture",
"transcoding": "Transcodage",
"bitrate": "Débit",
"no_active": "Aucun flux actif"
},
"tautulli": {
"playing": "En lecture",
"transcoding": "Transcodage",
"bitrate": "Débit",
"no_active": "Aucun flux actif"
},
"rutorrent": {
"active": "Actif",
"upload": "Téléverser",
"download": "Télécharger"
},
"sonarr": {
"wanted": "Demandé",
"queued": "En queue",
"series": "Séries"
},
"radarr": {
"wanted": "Demandé",
"queued": "En queue",
"movies": "Films"
},
"readarr": {
"wanted": "Demandé",
"queued": "En Queue",
"books": "Livres"
},
"ombi": {
"pending": "En attente",
"approved": "Validé",
"available": "Disponible"
},
"jellyseerr": {
"pending": "En attente",
"approved": "Validé",
"available": "Disponible"
},
"pihole": {
"queries": "Requêtes",
"blocked": "Bloqué",
"gravity": "Listes dom. bloqués"
},
"speedtest": {
"upload": "Téléversement",
"download": "Téléchargement",
"ping": "Ping"
},
"portainer": {
"running": "Démarré",
"stopped": "Arrêté",
"total": "Total"
},
"traefik": {
"routers": "Routeurs",
"services": "Services",
"middleware": "Middleware"
},
"npm": {
"enabled": "Activé",
"disabled": "Désactivé",
"total": "Total"
},
"common": {
"bbytes": "{{value, bytes(binary: true)}}",
"bytes": "{{value, bytes}}",
"bits": "{{value, bytes(bits: true)}}",
"bbits": "{{value, bytes(bits: true, binary: true)}}",
"number": "{{value, number}}",
"byterate": "{{value, bytes}}",
"bitrate": "{{value, bytes(bits: true)}}",
"percent": "{{value, percent}}",
"ms": "{{value, number}}"
},
"weather": {
"current": "Localisation actuelle",
"allow": "Cliquez pour autoriser",
"updating": "Mise à jour",
"wait": "Veuillez patienter"
},
"overseerr": {
"pending": "En attente",
"approved": "Validé",
"available": "Disponible"
},
"sabnzbd": {
"rate": "Taux",
"queue": "Queue",
"timeleft": "Temps restant"
},
"nzbget": {
"remaining": "Restant",
"downloaded": "Téléchargé",
"rate": "Évaluer"
},
"coinmarketcap": {
"configure": "Configurer une ou plusieurs crypto-monnaies à suivre"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
},
"prowlarr": {
"enableIndexers": "Indexeurs",
"numberOfGrabs": "Capture",
"numberOfQueries": "Demandes",
"numberOfFailGrabs": "Capture échouée",
"numberOfFailQueries": "Demande échouée"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,132 @@
{
"docker": {
"tx": "TX",
"mem": "MEM",
"cpu": "CPU",
"offline": "Offline",
"rx": "RX"
},
"emby": {
"playing": "In riproduzione",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "Nessuno Stream Attivo"
},
"tautulli": {
"playing": "In riproduzione",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "Nessuno Stream Attivo"
},
"speedtest": {
"upload": "Upload",
"download": "Download",
"ping": "Ping"
},
"portainer": {
"running": "In esecuzione",
"stopped": "Fermati",
"total": "Totali"
},
"traefik": {
"routers": "Routers",
"services": "Servizi",
"middleware": "Middleware"
},
"widget": {
"missing_type": "Missing Widget Type: {{type}}",
"api_error": "Errore API",
"status": "Stato"
},
"search": {
"placeholder": "Cerca…"
},
"resources": {
"total": "Totale",
"free": "Libero",
"used": "In utilizzo",
"load": "Load"
},
"rutorrent": {
"active": "Attivo",
"upload": "Upload",
"download": "Download"
},
"sonarr": {
"series": "Serie",
"wanted": "Rchiesti",
"queued": "In coda"
},
"radarr": {
"wanted": "Richiesti",
"queued": "In coda",
"movies": "Film"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "In attesa",
"approved": "Approvati",
"available": "Disponibili"
},
"jellyseerr": {
"pending": "In attesa",
"approved": "Approvati",
"available": "Disponibili"
},
"pihole": {
"queries": "Richieste",
"blocked": "Bloccati",
"gravity": "Severità"
},
"npm": {
"enabled": "Attivi",
"disabled": "Disabilitati",
"total": "Totali"
},
"weather": {
"current": "Posizione Attuale",
"allow": "Clicca per consentire",
"updating": "Aggiornamento in corso",
"wait": "Attendi per favore"
},
"overseerr": {
"pending": "In attesa",
"approved": "Approvati",
"available": "Disponibili"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"nzbget": {
"rate": "Rate",
"remaining": "Remaining",
"downloaded": "Downloaded"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
},
"prowlarr": {
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,132 @@
{
"widget": {
"missing_type": "Manglende miniprogramstype: {{type}}",
"api_error": "API-feil",
"status": "Status"
},
"search": {
"placeholder": "Søk …"
},
"resources": {
"total": "Totalt",
"free": "Ledig",
"used": "Brukt",
"load": "Last inn"
},
"docker": {
"rx": "Mottatt",
"tx": "Sendt",
"mem": "Minne",
"cpu": "Prosessor",
"offline": "Frakoblet"
},
"emby": {
"playing": "Spiller",
"transcoding": "Transkoding",
"bitrate": "Bitrate",
"no_active": "Ingen aktive strømmer"
},
"tautulli": {
"playing": "Spiller",
"transcoding": "Transkoding",
"bitrate": "Bitrate",
"no_active": "Ingen aktive strømmer"
},
"rutorrent": {
"active": "Aktiv",
"upload": "Opplasting",
"download": "Nedlasting"
},
"sonarr": {
"wanted": "Ønsket",
"queued": "I kø",
"series": "Serie"
},
"radarr": {
"wanted": "Ønsket",
"queued": "I kø",
"movies": "Filmer"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "Venter",
"approved": "Godkjent",
"available": "Tilgjengelig"
},
"jellyseerr": {
"pending": "Venter",
"approved": "Godkjent",
"available": "Tilgjengelig"
},
"pihole": {
"queries": "Spørringer",
"blocked": "Blokkert",
"gravity": "Gravitet"
},
"speedtest": {
"upload": "Opplasting",
"download": "Nedlasting",
"ping": "Ekkoforespørsel"
},
"portainer": {
"running": "Kjører",
"stopped": "Stoppet",
"total": "Totalt"
},
"traefik": {
"routers": "Rutere",
"services": "Tjenester",
"middleware": "Midtvare"
},
"npm": {
"enabled": "Påskrudd",
"disabled": "Avskrudd",
"total": "Totalt"
},
"weather": {
"allow": "Klikk for å tillate",
"updating": "Oppdaterer …",
"wait": "Vent litt …",
"current": "Nåværende posisjon"
},
"overseerr": {
"pending": "Venter",
"approved": "Godkjent",
"available": "Tilgjengelig"
},
"sabnzbd": {
"rate": "Takt",
"queue": "Kø",
"timeleft": "Gjenstående tid"
},
"nzbget": {
"rate": "Takt",
"downloaded": "Nedlastet",
"remaining": "Gjenstående"
},
"coinmarketcap": {
"configure": "Sett opp én eller flere kryptovalutaer å holde øye med"
},
"gotify": {
"apps": "Programmer",
"clients": "Klienter",
"messages": "Meldinger"
},
"prowlarr": {
"enableIndexers": "Indekserere",
"numberOfGrabs": "Hentninger",
"numberOfQueries": "Spørringer",
"numberOfFailGrabs": "Mislykkede hentinger",
"numberOfFailQueries": "Mislykkede spørringer"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,132 @@
{
"widget": {
"missing_type": "Missing Widget Type: {{type}}",
"api_error": "API Error",
"status": "Status"
},
"resources": {
"total": "Totaal",
"free": "Vrij",
"used": "Gebruikt",
"load": "Load"
},
"docker": {
"rx": "RX",
"tx": "TX",
"mem": "MEM",
"cpu": "CPU",
"offline": "Offline"
},
"speedtest": {
"upload": "Upload",
"download": "Download",
"ping": "Ping"
},
"portainer": {
"running": "Draaiend",
"stopped": "Gestopt",
"total": "Totaal"
},
"weather": {
"updating": "Updaten",
"wait": "Even geduld",
"current": "Huidige Locatie",
"allow": "Klik om toe te staan"
},
"search": {
"placeholder": "Zoeken…"
},
"emby": {
"playing": "Afspelen",
"transcoding": "Transcodering",
"bitrate": "Bitsnelheid",
"no_active": "No Active Streams"
},
"tautulli": {
"playing": "Afspelen",
"transcoding": "Transcodering",
"bitrate": "Bitsnelheid",
"no_active": "No Active Streams"
},
"rutorrent": {
"active": "Actief",
"upload": "Upload",
"download": "Download"
},
"sonarr": {
"wanted": "Gezocht",
"queued": "In de wachtrij",
"series": "Series"
},
"radarr": {
"movies": "Films",
"wanted": "Gezocht",
"queued": "In de wachtrij"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "In afwachting",
"approved": "Goedgekeurd",
"available": "Beschikbaar"
},
"jellyseerr": {
"pending": "In afwachting",
"approved": "Goedgekeurd",
"available": "Beschikbaar"
},
"pihole": {
"queries": "Queries",
"blocked": "Geblokkeerd",
"gravity": "Gravity"
},
"traefik": {
"routers": "Routers",
"services": "Services",
"middleware": "Middleware"
},
"npm": {
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"total": "Totaal"
},
"overseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"nzbget": {
"rate": "Rate",
"remaining": "Remaining",
"downloaded": "Downloaded"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
},
"prowlarr": {
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,143 @@
{
"widget": {
"missing_type": "Tipo de widget ausente: {{type}}",
"api_error": "Erro da API",
"status": "Status"
},
"search": {
"placeholder": "Pesquisar…"
},
"resources": {
"total": "Total",
"free": "Livre",
"used": "Usada",
"load": "Load"
},
"docker": {
"rx": "Rx",
"tx": "Tx",
"mem": "Mem",
"cpu": "CPU",
"offline": "Desligada"
},
"emby": {
"playing": "A reproduzir",
"transcoding": "Transcodificação",
"bitrate": "Taxa de bits",
"no_active": "No Active Streams"
},
"tautulli": {
"playing": "Reproduzindo",
"transcoding": "Transcodificação",
"bitrate": "Taxa de bits",
"no_active": "No Active Streams"
},
"rutorrent": {
"active": "Ativa",
"upload": "Envio",
"download": "ReceçãoDownload"
},
"sonarr": {
"wanted": "Desejada",
"queued": "Em fila",
"series": "Séries"
},
"radarr": {
"wanted": "Desejado",
"queued": "Enfileiradas",
"movies": "Filmes"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "Pendente",
"approved": "Aprovada",
"available": "Disponível"
},
"jellyseerr": {
"pending": "Pendente",
"approved": "Aprovada",
"available": "Disponível"
},
"pihole": {
"queries": "Consultas",
"blocked": "Bloqueado",
"gravity": "Gravidade"
},
"speedtest": {
"upload": "Envio",
"download": "Download",
"ping": "Ping"
},
"portainer": {
"running": "Corrida",
"stopped": "Parou",
"total": "Total"
},
"traefik": {
"routers": "Roteadores",
"services": "Serviços",
"middleware": "Middleware"
},
"npm": {
"enabled": "Habilitada",
"disabled": "Desabilitada",
"total": "Total"
},
"common": {
"bytes": "{{value, bytes}}",
"bbytes": "{{value, bytes(binary: true)}}",
"bits": "{{value, bytes(bits: true)}}",
"bbits": "{{value, bytes(bits: true, binary: true)}}",
"number": "{{value, number}}",
"byterate": "{{value, bytes}}",
"ms": "{{value, number}}",
"bitrate": "{{value, bytes(bits: true)}}",
"percent": "{{value, percent}}"
},
"weather": {
"current": "Localização atual",
"allow": "Clicar para permitir",
"updating": "A atualizar",
"wait": "Por favor aguarde"
},
"overseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"nzbget": {
"rate": "Rate",
"remaining": "Remaining",
"downloaded": "Downloaded"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Aplicações",
"clients": "Clientes",
"messages": "Mensagens"
},
"prowlarr": {
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,132 @@
{
"widget": {
"missing_type": "Отсутствует тип виджета: {{type}}",
"api_error": "Ошибка API",
"status": "Статус"
},
"search": {
"placeholder": "Поиск…"
},
"resources": {
"total": "Общий",
"free": "Свободно",
"used": "Использовано",
"load": "Load"
},
"docker": {
"rx": "Rx",
"tx": "Тx",
"mem": "Память",
"cpu": "Процессор",
"offline": "Не в сети"
},
"emby": {
"playing": "Воспроизведение",
"transcoding": "Транскодирование",
"bitrate": "Битрейт",
"no_active": "No Active Streams"
},
"tautulli": {
"playing": "Воспроизведение",
"transcoding": "Транскодирование",
"bitrate": "Битрейт",
"no_active": "No Active Streams"
},
"rutorrent": {
"active": "Активный",
"upload": "Загрузить",
"download": "Скачать"
},
"sonarr": {
"wanted": "Хотел",
"queued": "В очереди",
"series": "Серии"
},
"radarr": {
"wanted": "Хотел",
"queued": "В очереди",
"movies": "Фильмы"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "Ожидание",
"approved": "Одобрено",
"available": "Доступно"
},
"jellyseerr": {
"pending": "Ожидание",
"approved": "Одобрено",
"available": "Доступно"
},
"pihole": {
"queries": "Запросы",
"blocked": "Заблокировано",
"gravity": "Сила тяжести"
},
"speedtest": {
"upload": "Загрузка",
"download": "Скачать",
"ping": "пинг"
},
"portainer": {
"running": "Запущено",
"stopped": "Остановлено",
"total": "Всего"
},
"traefik": {
"routers": "Маршрутизаторы",
"services": "Сервисы",
"middleware": "Промежуточное программное обеспечение"
},
"npm": {
"enabled": "Включено",
"disabled": "Отключено",
"total": "Всего"
},
"weather": {
"wait": "Пожалуйста подождите",
"current": "Текущее местоположение",
"allow": "Click to allow",
"updating": "Обновление"
},
"overseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"nzbget": {
"rate": "Rate",
"remaining": "Remaining",
"downloaded": "Downloaded"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
},
"prowlarr": {
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,132 @@
{
"widget": {
"missing_type": "Thiếu loại Widget: {{type}}",
"api_error": "Lỗi API",
"status": "Trạng thái"
},
"search": {
"placeholder": "Tìm kiếm…"
},
"resources": {
"total": "Tổng",
"free": "Dư",
"used": "Đã dùng",
"load": "Load"
},
"docker": {
"rx": "RX",
"tx": "TX",
"mem": "BỘ NHỚ",
"cpu": "CPU",
"offline": "Ngoại tuyến"
},
"emby": {
"playing": "Đang chơi",
"transcoding": "Chuyển định dạng",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"tautulli": {
"playing": "Đang chơi",
"transcoding": "Chuyển định dạng",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"rutorrent": {
"active": "Hoạt động",
"upload": "Tải lên",
"download": "Tải xuống"
},
"sonarr": {
"wanted": "Wanted",
"queued": "Queued",
"series": "Series"
},
"radarr": {
"wanted": "Wanted",
"queued": "Queued",
"movies": "Movies"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"jellyseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"pihole": {
"queries": "Queries",
"blocked": "Blocked",
"gravity": "Gravity"
},
"speedtest": {
"upload": "Upload",
"download": "Download",
"ping": "Ping"
},
"portainer": {
"running": "Running",
"stopped": "Stopped",
"total": "Total"
},
"traefik": {
"routers": "Routers",
"services": "Services",
"middleware": "Middleware"
},
"npm": {
"enabled": "Enabled",
"disabled": "Disabled",
"total": "Total"
},
"weather": {
"current": "Current Location",
"allow": "Click to allow",
"updating": "Updating",
"wait": "Please wait"
},
"overseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"nzbget": {
"rate": "Rate",
"remaining": "Remaining",
"downloaded": "Downloaded"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
},
"prowlarr": {
"numberOfFailGrabs": "Fail Grabs",
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,132 @@
{
"widget": {
"missing_type": "缺少小部件类型:{{type}}",
"api_error": "API错误",
"status": "地位"
},
"search": {
"placeholder": "搜索…"
},
"resources": {
"total": "共",
"free": "空闲",
"used": "已用",
"load": "负载"
},
"docker": {
"rx": "接收",
"tx": "发送",
"mem": "内存",
"cpu": "处理器",
"offline": "离线"
},
"emby": {
"playing": "正在播放",
"transcoding": "转码",
"bitrate": "比特率",
"no_active": "暂无播放"
},
"tautulli": {
"playing": "正在播放",
"transcoding": "转码",
"bitrate": "比特率",
"no_active": "暂无播放"
},
"rutorrent": {
"active": "活动中",
"upload": "上传",
"download": "下载"
},
"sonarr": {
"wanted": "通缉",
"queued": "排队",
"series": "系列"
},
"radarr": {
"wanted": "订阅",
"queued": "队列",
"movies": "电影"
},
"readarr": {
"wanted": "订阅",
"queued": "队列",
"books": "书籍"
},
"ombi": {
"pending": "待办的",
"approved": "已批准",
"available": "可用的"
},
"jellyseerr": {
"pending": "待办的",
"approved": "得到正式认可的",
"available": "可用的"
},
"pihole": {
"queries": "查询",
"blocked": "阻止",
"gravity": "重力"
},
"speedtest": {
"upload": "上传",
"download": "下载",
"ping": "ping"
},
"portainer": {
"running": "运行中",
"stopped": "已停止",
"total": "总计"
},
"traefik": {
"routers": "路由器",
"services": "服务",
"middleware": "中间件"
},
"npm": {
"enabled": "已启用",
"disabled": "禁用",
"total": "全部的"
},
"weather": {
"current": "当前位置",
"allow": "点击并允许",
"updating": "更新中",
"wait": "请等待"
},
"overseerr": {
"pending": "待办",
"approved": "已批准",
"available": "可用"
},
"sabnzbd": {
"rate": "速率",
"queue": "队列",
"timeleft": "剩余时间"
},
"nzbget": {
"rate": "速率",
"remaining": "剩余",
"downloaded": "下载"
},
"coinmarketcap": {
"configure": "配置一个或多个需要追踪的加密"
},
"gotify": {
"apps": "应用",
"clients": "客户端",
"messages": "信息"
},
"prowlarr": {
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -0,0 +1,132 @@
{
"widget": {
"missing_type": "Missing Widget Type: {{type}}",
"api_error": "API Error",
"status": "Status"
},
"weather": {
"current": "Current Location",
"allow": "Click to allow",
"updating": "Updating",
"wait": "Please wait"
},
"docker": {
"rx": "RX",
"offline": "Offline",
"tx": "TX",
"mem": "MEM",
"cpu": "CPU"
},
"emby": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"tautulli": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"jellyseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"search": {
"placeholder": "Search…"
},
"resources": {
"total": "Total",
"free": "Free",
"used": "Used",
"load": "Load"
},
"nzbget": {
"rate": "Rate",
"remaining": "Remaining",
"downloaded": "Downloaded"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"rutorrent": {
"active": "Active",
"upload": "Upload",
"download": "Download"
},
"radarr": {
"movies": "Movies",
"wanted": "Wanted",
"queued": "Queued"
},
"sonarr": {
"wanted": "Wanted",
"queued": "Queued",
"series": "Series"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"overseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
},
"pihole": {
"queries": "Queries",
"blocked": "Blocked",
"gravity": "Gravity"
},
"speedtest": {
"upload": "Upload",
"download": "Download",
"ping": "Ping"
},
"portainer": {
"running": "Running",
"stopped": "Stopped",
"total": "Total"
},
"traefik": {
"routers": "Routers",
"services": "Services",
"middleware": "Middleware"
},
"gotify": {
"clients": "Clients",
"apps": "Applications",
"messages": "Messages"
},
"npm": {
"enabled": "Enabled",
"disabled": "Disabled",
"total": "Total"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"prowlarr": {
"enableIndexers": "Indexers",
"numberOfGrabs": "Grabs",
"numberOfQueries": "Queries",
"numberOfFailGrabs": "Fail Grabs",
"numberOfFailQueries": "Fail Queries"
},
"transmission": {
"download": "Download",
"upload": "Upload",
"leech": "Leech",
"seed": "Seed"
}
}

View File

@@ -2,20 +2,22 @@ export default function Item({ bookmark }) {
const { hostname } = new URL(bookmark.href);
return (
<li
onClick={() => {
window.open(bookmark.href, "_blank").focus();
}}
key={bookmark.name}
className="mb-3 cursor-pointer flex rounded-md font-medium text-theme-700 hover:text-theme-800 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/50 bg-white/50 hover:bg-theme-300/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<div className="flex-shrink-0 flex items-center justify-center w-11 bg-theme-500/10 dark:bg-theme-900/50 text-theme-700 dark:text-theme-200 text-sm font-medium rounded-l-md">
{bookmark.abbr}
</div>
<div className="flex-1 flex items-center justify-between rounded-r-md ">
<div className="flex-1 grow pl-3 py-2 text-xs">{bookmark.name}</div>
<div className="px-2 py-2 truncate text-theme-500 dark:text-theme-400 opacity-50 text-xs">{hostname}</div>
</div>
<li key={bookmark.name}>
<button
type="button"
onClick={() => window.open(bookmark.href, "_blank").focus()}
className="w-full text-left mb-3 cursor-pointer rounded-md font-medium text-theme-700 hover:text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-black/10 dark:shadow-black/20 bg-white/50 hover:bg-theme-300/10 dark:bg-white/10 dark:hover:bg-white/20"
>
<div className="flex">
<div className="flex-shrink-0 flex items-center justify-center w-11 bg-theme-500/10 dark:bg-theme-900/50 text-theme-700 hover:text-theme-700 dark:text-theme-200 text-sm font-medium rounded-l-md">
{bookmark.abbr}
</div>
<div className="flex-1 flex items-center justify-between rounded-r-md ">
<div className="flex-1 grow pl-3 py-2 text-xs">{bookmark.name}</div>
<div className="px-2 py-2 truncate text-theme-500 dark:text-theme-400 opacity-50 text-xs">{hostname}</div>
</div>
</div>
</button>
</li>
);
}

View File

@@ -2,7 +2,7 @@ import Item from "components/bookmarks/item";
export default function List({ bookmarks }) {
return (
<ul role="list" className="mt-3 flex flex-col">
<ul className="mt-3 flex flex-col">
{bookmarks.map((bookmark) => (
<Item key={bookmark.name} bookmark={bookmark} />
))}

View File

@@ -1,7 +1,7 @@
import { useContext } from "react";
import { useContext, Fragment } from "react";
import { IoColorPalette } from "react-icons/io5";
import { Popover, Transition } from "@headlessui/react";
import { Fragment } from "react";
import classNames from "classnames";
import { ColorContext } from "utils/color-context";
@@ -27,6 +27,7 @@ const colors = [
"pink",
"rose",
"red",
"white",
];
export default function ColorToggle() {
@@ -39,42 +40,38 @@ export default function ColorToggle() {
return (
<div className="w-full self-center">
<Popover className="relative flex items-center">
{({ open }) => (
<>
<Popover.Button className="outline-none">
<IoColorPalette
className="h-5 w-5 text-theme-800 dark:text-theme-200 transition duration-150 ease-in-out"
aria-hidden="true"
/>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute -top-[75px] left-0">
<div className="rounded-md shadow-lg ring-1 ring-black ring-opacity-5">
<div className="relative grid gap-2 p-2 grid-cols-11 shadow-theme-900/10 dark:shadow-theme-900 rounded-md shadow-md">
{colors.map((color) => (
<button role="button" onClick={() => setColor(color)} key={color}>
<div
className={
(active == color ? "border-2" : "border-0") +
` rounded-md w-5 h-5 border-black/50 dark:border-white/50 theme-${color} bg-theme-500`
}
/>
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
<Popover.Button className="outline-none">
<IoColorPalette
className="h-5 w-5 text-theme-800 dark:text-theme-200 transition duration-150 ease-in-out"
aria-hidden="true"
/>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute -top-[75px] left-0">
<div className="rounded-md shadow-lg ring-1 ring-black ring-opacity-5 w-[85vw] sm:w-full">
<div className="relative grid gap-2 p-2 grid-cols-11 bg-white/50 dark:bg-white/10 shadow-black/10 dark:shadow-black/20 rounded-md shadow-md">
{colors.map((color) => (
<button type="button" onClick={() => setColor(color)} key={color}>
<div
className={classNames(
active === color ? "border-2" : "border-0",
`rounded-md w-5 h-5 border-black/50 dark:border-white/50 theme-${color} bg-theme-400`
)}
/>
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
</div>
);

View File

@@ -1,20 +0,0 @@
export default function Greeting() {
const name = process.env.NEXT_PUBLIC_DISPLAY_NAME;
const hour = new Date().getHours();
let day = "day";
if (hour < 12) {
day = "morning";
} else if (hour < 17) {
day = "afternoon";
} else {
day = "evening";
}
return (
<div className="self-end grow text-2xl font-thin text-theme-800 dark:text-theme-200">
Good {day}
</div>
);
}

View File

@@ -1,68 +0,0 @@
import { Fragment, useRef, useState, Children } from "react";
import { Dialog, Transition } from "@headlessui/react";
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
const Modal = ({ Toggle, Content }) => {
const [open, setOpen] = useState(false);
const cancelButtonRef = useRef(null);
return (
<>
<Toggle open={open} setOpen={setOpen} />
<Transition.Root show={open} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={setOpen}
>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-theme-900/90 transition-opacity" />
</Transition.Child>
<div className="fixed z-10 inset-0 overflow-y-auto">
<div className="flex items-center justify-center min-h-full">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative rounded-lg shadow-xl transform transition-all my-8 max-w-lg w-full">
<Content open={open} setOpen={setOpen} />
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
};
const ModalToggle = ({ open, setOpen, children }) => (
<div onClick={() => setOpen(!open)}>{children}</div>
);
const ModalContent = ({ open, setOpen, children }) => (
<div className="body">{children}</div>
);
Modal.Toggle = ModalToggle;
Modal.Content = ModalContent;
export default Modal;

View File

@@ -0,0 +1,17 @@
import { MdRefresh } from "react-icons/md";
export default function Revalidate() {
const revalidate = () => {
fetch("/api/revalidate").then((res) => {
if (res.ok) {
window.location.reload();
}
});
};
return (
<div className="rounded-full flex align-middle self-center mr-3">
<MdRefresh onClick={() => revalidate()} className="text-theme-800 dark:text-theme-200 w-6 h-6 cursor-pointer" />
</div>
);
}

View File

@@ -8,54 +8,77 @@ import Docker from "./widgets/service/docker";
function resolveIcon(icon) {
if (icon.startsWith("http")) {
return `/api/proxy?url=${encodeURIComponent(icon)}`;
} else if (icon.startsWith("/")) {
return icon;
} else {
if (icon.endsWith(".png")) {
return `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${icon}`;
} else {
return `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${icon}.png`;
}
}
if (icon.startsWith("/")) {
return icon;
}
if (icon.endsWith(".png")) {
return `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${icon}`;
}
return `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${icon}.png`;
}
export default function Item({ service }) {
const handleOnClick = () => {
if (service.href && service.href !== "#") {
window.open(service.href, "_blank").focus();
}
};
const hasLink = service.href && service.href !== "#";
return (
<li key={service.name}>
<Disclosure>
<div className={
(service.href && service.href !== "#" ? 'cursor-pointer ' : 'cursor-default ') +
'transition-all h-15 overflow-hidden mb-3 p-1 rounded-md font-medium text-theme-700 hover:text-theme-800 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/40 bg-white/50 hover:bg-theme-300/10 dark:bg-white/5 dark:hover:bg-white/10'
}>
<div className="flex">
{service.icon && (
<div
onClick={() => {
if (service.href && service.href !== "#") {
window.open(service.href, "_blank").focus();
}
}}
className="flex-shrink-0 flex items-center justify-center w-12 "
<div
className={`${
hasLink ? "cursor-pointer " : " "
}transition-all h-15 mb-3 p-1 rounded-md font-medium text-theme-700 hover:text-theme-700/70 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-black/10 dark:shadow-black/20 bg-white/50 hover:bg-theme-300/20 dark:bg-white/10 dark:hover:bg-white/20`}
>
<div className="flex select-none">
{service.icon &&
(hasLink ? (
<button
type="button"
onClick={handleOnClick}
className="flex-shrink-0 flex items-center justify-center w-12 "
>
<Image src={resolveIcon(service.icon)} width={32} height={32} alt="logo" />
</button>
) : (
<div className="flex-shrink-0 flex items-center justify-center w-12 ">
<Image src={resolveIcon(service.icon)} width={32} height={32} alt="logo" />
</div>
))}
{hasLink ? (
<button
type="button"
onClick={handleOnClick}
className="flex-1 flex items-center justify-between rounded-r-md "
>
<Image src={resolveIcon(service.icon)} width={32} height={32} alt="logo" />
<div className="flex-1 px-2 py-2 text-sm text-left">
{service.name}
<p className="text-theme-500 dark:text-theme-400 text-xs font-extralight">{service.description}</p>
</div>
</button>
) : (
<div className="flex-1 flex items-center justify-between rounded-r-md ">
<div className="flex-1 px-2 py-2 text-sm text-left">
{service.name}
<p className="text-theme-500 dark:text-theme-400 text-xs font-extralight">{service.description}</p>
</div>
</div>
)}
<div
onClick={() => {
if (service.href && service.href !== "#") {
window.open(service.href, "_blank").focus();
}
}}
className="flex-1 flex items-center justify-between rounded-r-md "
>
<div className="flex-1 px-2 py-2 text-sm">
{service.name}
<p className="text-theme-500 dark:text-theme-400 text-xs font-extralight">{service.description}</p>
</div>
</div>
{service.container && (
<Disclosure.Button as="div" className="flex-shrink-0 flex items-center justify-center w-12 cursor-pointer">
<Disclosure.Button
as="div"
className="flex-shrink-0 flex items-center justify-center w-12 cursor-pointer"
>
<Status service={service} />
</Disclosure.Button>
)}

View File

@@ -2,7 +2,7 @@ import Item from "components/services/item";
export default function List({ services }) {
return (
<ul role="list" className="mt-3 flex flex-col">
<ul className="mt-3 flex flex-col">
{services.map((service) => (
<Item key={service.name} service={service} />
))}

View File

@@ -1,28 +1,18 @@
import useSWR from "swr";
export default function Status({ service }) {
const { data, error } = useSWR(
`/api/docker/status/${service.container}/${service.server || ""}`
);
const { data, error } = useSWR(`/api/docker/status/${service.container}/${service.server || ""}`);
if (error) {
return (
<div className="w-3 h-3 bg-rose-300 dark:bg-rose-500 rounded-full" />
);
return <div className="w-3 h-3 bg-rose-300 dark:bg-rose-500 rounded-full" />;
}
if (data && data.status === "running") {
return (
<div className="w-3 h-3 bg-emerald-300 dark:bg-emerald-500 rounded-full" />
);
return <div className="w-3 h-3 bg-emerald-300 dark:bg-emerald-500 rounded-full" />;
}
if (data && data.status === "not found") {
return (
<>
<div className="h-2.5 w-2.5 bg-orange-400/50 dark:bg-yellow-200/40 -rotate-45"></div>
</>
);
return <div className="h-2.5 w-2.5 bg-orange-400/50 dark:bg-yellow-200/40 -rotate-45" />;
}
return <div className="w-3 h-3 bg-black/20 dark:bg-white/40 rounded-full" />;

View File

@@ -1,9 +1,14 @@
import { useTranslation } from "react-i18next";
import Sonarr from "./widgets/service/sonarr";
import Radarr from "./widgets/service/radarr";
import Readarr from "./widgets/service/readarr";
import Ombi from "./widgets/service/ombi";
import Portainer from "./widgets/service/portainer";
import Emby from "./widgets/service/emby";
import Nzbget from "./widgets/service/nzbget";
import SABnzbd from "./widgets/service/sabnzbd";
import Transmission from "./widgets/service/transmission";
import Docker from "./widgets/service/docker";
import Pihole from "./widgets/service/pihole";
import Rutorrent from "./widgets/service/rutorrent";
@@ -11,28 +16,41 @@ import Jellyfin from "./widgets/service/jellyfin";
import Speedtest from "./widgets/service/speedtest";
import Traefik from "./widgets/service/traefik";
import Jellyseerr from "./widgets/service/jellyseerr";
import Overseerr from "./widgets/service/overseerr";
import Npm from "./widgets/service/npm";
import Tautulli from "./widgets/service/tautulli";
import CoinMarketCap from "./widgets/service/coinmarketcap";
import Gotify from "./widgets/service/gotify";
import Prowlarr from "./widgets/service/prowlarr";
const widgetMappings = {
docker: Docker,
sonarr: Sonarr,
radarr: Radarr,
readarr: Readarr,
ombi: Ombi,
portainer: Portainer,
emby: Emby,
jellyfin: Jellyfin,
nzbget: Nzbget,
sabnzbd: SABnzbd,
transmission: Transmission,
pihole: Pihole,
rutorrent: Rutorrent,
speedtest: Speedtest,
traefik: Traefik,
jellyseerr: Jellyseerr,
overseerr: Overseerr,
coinmarketcap: CoinMarketCap,
npm: Npm,
tautulli: Tautulli,
gotify: Gotify,
prowlarr: Prowlarr
};
export default function Widget({ service }) {
const { t } = useTranslation("common");
const ServiceWidget = widgetMappings[service.widget.type];
if (ServiceWidget) {
@@ -41,9 +59,7 @@ export default function Widget({ service }) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">
Missing Widget Type: <strong>{service.widget.type}</strong>
</div>
<div className="font-thin text-sm">{t("widget.missing_type", { type: service.widget.type })}</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import getSymbolFromCurrency from "currency-symbol-map";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function CoinMarketCap({ service }) {
const { t } = useTranslation();
const config = service.widget;
const currencyCode = config.currency ?? "USD";
const { symbols } = config;
const { data: statsData, error: statsError } = useSWR(
formatApiUrl(config, `v1/cryptocurrency/quotes/latest?symbol=${symbols.join(",")}&convert=${currencyCode}`)
);
if (!symbols || symbols.length === 0) {
return (
<Widget>
<Block value={t("coinmarketcap.configure")} />
</Widget>
);
}
if (statsError) {
return <Widget error={t("widget.api_error")} />;
}
if (!statsData) {
return (
<Widget>
<Block value={t("coinmarketcap.configure")} />
</Widget>
);
}
const { data } = statsData;
const currencySymbol = getSymbolFromCurrency(currencyCode);
return (
<Widget>
<div className="flex flex-col w-full">
{symbols.map((key) => (
<div
key={data[key].symbol}
className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs"
>
<div className="font-thin pl-2">{data[key].name}</div>
<div className="flex flex-row text-right">
<div className="font-bold mr-2">
{currencySymbol}
{data[key].quote[currencyCode].price.toFixed(2)}
</div>
<div
className={`font-bold w-10 mr-2 ${
data[key].quote[currencyCode].percent_change_1h > 0 ? "text-emerald-300" : "text-rose-300"
}`}
>
{data[key].quote[currencyCode].percent_change_1h.toFixed(2)}%
</div>
</div>
</div>
))}
</div>
</Widget>
);
}

View File

@@ -1,11 +1,14 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { calculateCPUPercent, formatBytes } from "utils/stats-helpers";
import calculateCPUPercent from "utils/stats-helpers";
export default function Docker({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: statusData, error: statusError } = useSWR(
@@ -23,13 +26,13 @@ export default function Docker({ service }) {
);
if (statsError || statusError) {
return <Widget error="Error Fetching Data" />;
return <Widget error={t("widget.api_error")} />;
}
if (statusData && statusData.status !== "running") {
return (
<Widget>
<Block label="Status" value="Offline" />
<Block label={t("widget.status")} value={t("docker.offline")} />
</Widget>
);
}
@@ -37,22 +40,22 @@ export default function Docker({ service }) {
if (!statsData || !statusData) {
return (
<Widget>
<Block label="CPU" />
<Block label="MEM" />
<Block label="RX" />
<Block label="TX" />
<Block label={t("docker.cpu")} />
<Block label={t("docker.mem")} />
<Block label={t("docker.rx")} />
<Block label={t("docker.tx")} />
</Widget>
);
}
return (
<Widget>
<Block label="CPU" value={`${calculateCPUPercent(statsData.stats)}%`} />
<Block label="MEM" value={formatBytes(statsData.stats.memory_stats.usage, 0)} />
<Block label={t("docker.cpu")} value={t("common.percent", { value: calculateCPUPercent(statsData.stats) })} />
<Block label={t("docker.mem")} value={t("common.bytes", { value: statsData.stats.memory_stats.usage })} />
{statsData.stats.networks && (
<>
<Block label="RX" value={formatBytes(statsData.stats.networks.eth0.rx_bytes, 0)} />
<Block label="TX" value={formatBytes(statsData.stats.networks.eth0.tx_bytes, 0)} />
<Block label={t("docker.rx")} value={t("common.bytes", { value: statsData.stats.networks.eth0.rx_bytes })} />
<Block label={t("docker.tx")} value={t("common.bytes", { value: statsData.stats.networks.eth0.tx_bytes })} />
</>
)}
</Widget>

View File

@@ -1,40 +1,213 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import { BsVolumeMuteFill, BsFillPlayFill, BsPauseFill } from "react-icons/bs";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Emby({ service, title = "Emby" }) {
function ticksToTime(ticks) {
const milliseconds = ticks / 10000;
const seconds = Math.floor((milliseconds / 1000) % 60);
const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);
const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);
return { hours, minutes, seconds };
}
function ticksToString(ticks) {
const { hours, minutes, seconds } = ticksToTime(ticks);
const parts = [];
if (hours > 0) {
parts.push(hours);
}
parts.push(minutes);
parts.push(seconds);
return parts.map((part) => part.toString().padStart(2, "0")).join(":");
}
function SingleSessionEntry({ playCommand, session }) {
console.log(session);
const {
NowPlayingItem: { Name, SeriesName, RunTimeTicks },
PlayState: { PositionTicks, IsPaused, IsMuted },
} = session;
const percent = (PositionTicks / RunTimeTicks) * 100;
return (
<>
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div className="text-xs z-10 self-center ml-2">
<span>
{Name}
{SeriesName && ` - ${SeriesName}`}
</span>
</div>
<div className="grow" />
<div className="self-center text-xs flex justify-end mr-1">{IsMuted && <BsVolumeMuteFill />}</div>
</div>
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
style={{
width: `${percent}%`,
}}
/>
<div className="text-xs z-10 self-center ml-1">
{IsPaused && (
<BsFillPlayFill
onClick={() => {
playCommand(session, "Unpause");
}}
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
{!IsPaused && (
<BsPauseFill
onClick={() => {
playCommand(session, "Pause");
}}
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
</div>
<div className="grow " />
<div className="self-center text-xs flex justify-end mr-2">{ticksToString(PositionTicks)}</div>
</div>
</>
);
}
function SessionEntry({ playCommand, session }) {
const {
NowPlayingItem: { Name, SeriesName, RunTimeTicks },
PlayState: { PositionTicks, IsPaused, IsMuted },
} = session;
const percent = (PositionTicks / RunTimeTicks) * 100;
return (
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
style={{
width: `${percent}%`,
}}
/>
<div className="text-xs z-10 self-center ml-1">
{IsPaused && (
<BsFillPlayFill
onClick={() => {
playCommand(session, "Unpause");
}}
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
{!IsPaused && (
<BsPauseFill
onClick={() => {
playCommand(session, "Pause");
}}
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
<span>
{Name}
{SeriesName && ` - ${SeriesName}`}
</span>
</div>
<div className="grow " />
<div className="self-center text-xs flex justify-end mr-1">{IsMuted && <BsVolumeMuteFill />}</div>
<div className="self-center text-xs flex justify-end mr-2">{ticksToString(PositionTicks)}</div>
</div>
);
}
export default function Emby({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: sessionsData, error: sessionsError } = useSWR(formatApiUrl(config, "Sessions"));
const {
data: sessionsData,
error: sessionsError,
mutate: sessionMutate,
} = useSWR(formatApiUrl(config, "Sessions"), {
refreshInterval: 5000,
});
async function handlePlayCommand(session, command) {
const url = formatApiUrl(config, `Sessions/${session.Id}/Playing/${command}`);
await fetch(url, {
method: "POST",
}).then(() => {
sessionMutate();
});
}
if (sessionsError) {
return <Widget error={`${title} API Error`} />;
return <Widget error={t("widget.api_error")} />;
}
if (!sessionsData) {
return (
<Widget>
<Block label="Playing" />
<Block label="Transcoding" />
<Block label="Bitrate" />
</Widget>
<div className="flex flex-col pb-1">
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
</div>
);
}
const playing = sessionsData.filter((session) => session.hasOwnProperty("NowPlayingItem"));
const transcoding = sessionsData.filter(
(session) => session.hasOwnProperty("PlayState") && session.PlayState.PlayMethod === "Transcode"
);
const bitrate = playing.reduce((acc, session) => acc + session.NowPlayingItem.Bitrate, 0);
const playing = sessionsData
.filter((session) => session?.NowPlayingItem)
.sort((a, b) => {
if (a.PlayState.PositionTicks > b.PlayState.PositionTicks) {
return 1;
}
if (a.PlayState.PositionTicks < b.PlayState.PositionTicks) {
return -1;
}
return 0;
});
if (playing.length === 0) {
return (
<div className="flex flex-col pb-1 mx-1">
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">{t("emby.no_active")}</span>
</div>
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
</div>
);
}
if (playing.length === 1) {
const session = playing[0];
return (
<div className="flex flex-col pb-1 mx-1">
<SingleSessionEntry
playCommand={(currentSession, command) => handlePlayCommand(currentSession, command)}
session={session}
/>
</div>
);
}
return (
<Widget>
<Block label="Playing" value={playing.length} />
<Block label="Transcoding" value={transcoding.length} />
<Block label="Bitrate" value={`${Math.round((bitrate / 1024 / 1024) * 100) / 100} Mbps`} />
</Widget>
<div className="flex flex-col pb-1 mx-1">
{playing.map((session) => (
<SessionEntry
key={session.Id}
playCommand={(currentSession, command) => handlePlayCommand(currentSession, command)}
session={session}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Gotify({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: appsData, error: appsError } = useSWR(formatApiUrl(config, `application`));
const { data: messagesData, error: messagesError } = useSWR(formatApiUrl(config, `message`));
const { data: clientsData, error: clientsError } = useSWR(formatApiUrl(config, `client`));
if (appsError || messagesError || clientsError) {
return <Widget error={t("widget.api_error")} />;
}
return (
<Widget>
<Block label={t("gotify.apps")} value={appsData?.length} />
<Block label={t("gotify.clients")} value={clientsData?.length} />
<Block label={t("gotify.messages")} value={messagesData?.messages?.length} />
</Widget>
);
}

View File

@@ -1,6 +1,5 @@
import Emby from "./emby";
// Jellyfin and Emby share the same API, so proxy the Emby widget to Jellyfin.
export default function Jellyfin({ service }) {
return <Emby service={service} title="Jellyfin" />;
return <Emby service={service} />;
}

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
@@ -6,29 +7,31 @@ import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Jellyseerr({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: statsData, error: statsError } = useSWR(formatApiUrl(config, `request/count`));
if (statsError) {
return <Widget error="Jellyseerr API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!statsData) {
return (
<Widget>
<Block label="Pending" />
<Block label="Approved" />
<Block label="Available" />
<Block label={t("jellyseerr.pending")} />
<Block label={t("jellyseerr.approved")} />
<Block label={t("jellyseerr.available")} />
</Widget>
);
}
return (
<Widget>
<Block label="Pending" value={statsData.pending} />
<Block label="Approved" value={statsData.approved} />
<Block label="Available" value={statsData.available} />
<Block label={t("jellyseerr.pending")} value={statsData.pending} />
<Block label={t("jellyseerr.approved")} value={statsData.approved} />
<Block label={t("jellyseerr.available")} value={statsData.available} />
</Widget>
);
}

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
@@ -6,20 +7,22 @@ import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Npm({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: infoData, error: infoError } = useSWR(formatApiUrl(config, "nginx/proxy-hosts"));
if (infoError) {
return <Widget error="NGINX Proxy Manager API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!infoData) {
return (
<Widget>
<Block label="Enabled" />
<Block label="Disabled" />
<Block label="Total" />
<Block label={t("npm.enabled")} />
<Block label={t("npm.disabled")} />
<Block label={t("npm.total")} />
</Widget>
);
}
@@ -30,9 +33,9 @@ export default function Npm({ service }) {
return (
<Widget>
<Block label="Enabled" value={enabled} />
<Block label="Disabled" value={disabled} />
<Block label="Total" value={total} />
<Block label={t("npm.enabled")} value={enabled} />
<Block label={t("npm.disabled")} value={disabled} />
<Block label={t("npm.total")} value={total} />
</Widget>
);
}

View File

@@ -1,35 +1,43 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
import { formatBytes } from "utils/stats-helpers";
export default function Nzbget({ service }) {
const { t } = useTranslation("common");
const config = service.widget;
const { data: statusData, error: statusError } = useSWR(formatApiUrl(config, "status"));
if (statusError) {
return <Widget error="Nzbget API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!statusData) {
return (
<Widget>
<Block label="Rate" />
<Block label="Remaining" />
<Block label="Downloaded" />
<Block label={t("nzbget.rate")} />
<Block label={t("nzbget.remaining")} />
<Block label={t("nzbget.downloaded")} />
</Widget>
);
}
return (
<Widget>
<Block label="Rate" value={`${formatBytes(statusData.DownloadRate)}/s`} />
<Block label="Remaining" value={`${Math.round((statusData.RemainingSizeMB / 1024) * 100) / 100} GB`} />
<Block label="Downloaded" value={`${Math.round((statusData.DownloadedSizeMB / 1024) * 100) / 100} GB`} />
<Block label={t("nzbget.rate")} value={t("common.bitrate", { value: statusData.DownloadRate })} />
<Block
label={t("nzbget.remaining")}
value={t("common.bytes", { value: statusData.RemainingSizeMB * 1024 * 1024 })}
/>
<Block
label={t("nzbget.downloaded")}
value={t("common.bytes", { value: statusData.DownloadedSizeMB * 1024 * 1024 })}
/>
</Widget>
);
}

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
@@ -6,29 +7,31 @@ import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Ombi({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: statsData, error: statsError } = useSWR(formatApiUrl(config, `Request/count`));
if (statsError) {
return <Widget error="Ombi API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!statsData) {
return (
<Widget>
<Block label="Pending" />
<Block label="Approved" />
<Block label="Available" />
<Block label={t("ombi.pending")} />
<Block label={t("ombi.approved")} />
<Block label={t("ombi.available")} />
</Widget>
);
}
return (
<Widget>
<Block label="Pending" value={statsData.pending} />
<Block label="Approved" value={statsData.approved} />
<Block label="Available" value={statsData.available} />
<Block label={t("ombi.pending")} value={statsData.pending} />
<Block label={t("ombi.approved")} value={statsData.approved} />
<Block label={t("ombi.available")} value={statsData.available} />
</Widget>
);
}

View File

@@ -0,0 +1,37 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Overseerr({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: statsData, error: statsError } = useSWR(formatApiUrl(config, `request/count`));
if (statsError) {
return <Widget error={t("widget.api_error")} />;
}
if (!statsData) {
return (
<Widget>
<Block label={t("overseerr.pending")} />
<Block label={t("overseerr.approved")} />
<Block label={t("overseerr.available")} />
</Widget>
);
}
return (
<Widget>
<Block label={t("overseerr.pending")} value={statsData.pending} />
<Block label={t("overseerr.approved")} value={statsData.approved} />
<Block label={t("overseerr.available")} value={statsData.available} />
</Widget>
);
}

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
@@ -6,29 +7,31 @@ import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Pihole({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: piholeData, error: piholeError } = useSWR(formatApiUrl(config, "api.php"));
if (piholeError) {
return <Widget error="PiHole API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!piholeData) {
return (
<Widget>
<Block label="Queries" />
<Block label="Blocked" />
<Block label="Gravity" />
<Block label={t("pihole.queries")} />
<Block label={t("pihole.blocked")} />
<Block label={t("pihole.gravity")} />
</Widget>
);
}
return (
<Widget>
<Block label="Queries" value={piholeData.dns_queries_today.toLocaleString()} />
<Block label="Blocked" value={piholeData.ads_blocked_today.toLocaleString()} />
<Block label="Gravity" value={piholeData.domains_being_blocked.toLocaleString()} />
<Block label={t("pihole.queries")} value={t("common.number", { value: piholeData.dns_queries_today })} />
<Block label={t("pihole.blocked")} value={t("common.number", { value: piholeData.ads_blocked_today })} />
<Block label={t("pihole.gravity")} value={t("common.number", { value: piholeData.domains_being_blocked })} />
</Widget>
);
}

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
@@ -6,26 +7,28 @@ import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Portainer({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: containersData, error: containersError } = useSWR(formatApiUrl(config, `docker/containers/json?all=1`));
if (containersError) {
return <Widget error="Portainer API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!containersData) {
return (
<Widget>
<Block label="Running" />
<Block label="Stopped" />
<Block label="Total" />
<Block label={t("portainer.running")} />
<Block label={t("portainer.stopped")} />
<Block label={t("portainer.total")} />
</Widget>
);
}
if (containersData.error) {
return <Widget error="Portainer API Error" />;
return <Widget error={t("widget.api_error")} />;
}
const running = containersData.filter((c) => c.State === "running").length;
@@ -34,9 +37,9 @@ export default function Portainer({ service }) {
return (
<Widget>
<Block label="Running" value={running} />
<Block label="Stopped" value={stopped} />
<Block label="Total" value={total} />
<Block label={t("portainer.running")} value={running} />
<Block label={t("portainer.stopped")} value={stopped} />
<Block label={t("portainer.total")} value={total} />
</Widget>
);
}

View File

@@ -0,0 +1,55 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Prowlarr({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: indexersData, error: indexersError } = useSWR(formatApiUrl(config, "indexer"));
const { data: grabsData, error: grabsError } = useSWR(formatApiUrl(config, "indexerstats"));
if (indexersError || grabsError) {
return <Widget error={t("widget.api_error")} />;
}
if (!indexersData || !grabsData) {
return (
<Widget>
<Block label={t("prowlarr.enableIndexers")} />
<Block label={t("prowlarr.numberOfGrabs")} />
<Block label={t("prowlarr.numberOfQueries")} />
<Block label={t("prowlarr.numberOfFailGrabs")} />
<Block label={t("prowlarr.numberOfFailQueries")} />
</Widget>
);
}
const indexers = indexersData?.filter((indexer) => indexer.enable === true);
let numberOfGrabs = 0
let numberOfQueries = 0
let numberOfFailedGrabs = 0
let numberOfFailedQueries = 0
grabsData?.indexers?.forEach(element => {
numberOfGrabs += element.numberOfGrabs;
numberOfQueries += element.numberOfQueries;
numberOfFailedGrabs += numberOfFailedGrabs + element.numberOfFailedGrabs;
numberOfFailedQueries += numberOfFailedQueries + element.numberOfFailedQueries;
});
return (
<Widget>
<Block label={t("prowlarr.enableIndexers")} value={indexers.length} />
<Block label={t("prowlarr.numberOfGrabs")} value={numberOfGrabs} />
<Block label={t("prowlarr.numberOfQueries")} value={numberOfQueries} />
<Block label={t("prowlarr.numberOfFailGrabs")} value={numberOfFailedGrabs} />
<Block label={t("prowlarr.numberOfFailQueries")} value={numberOfFailedQueries} />
</Widget>
);
}

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
@@ -6,21 +7,23 @@ import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Radarr({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: moviesData, error: moviesError } = useSWR(formatApiUrl(config, "movie"));
const { data: queuedData, error: queuedError } = useSWR(formatApiUrl(config, "queue/status"));
if (moviesError || queuedError) {
return <Widget error="Radarr API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!moviesData || !queuedData) {
return (
<Widget>
<Block label="Wanted" />
<Block label="Queued" />
<Block label="Movies" />
<Block label={t("radarr.wanted")} />
<Block label={t("radarr.queued")} />
<Block label={t("radarr.movies")} />
</Widget>
);
}
@@ -30,9 +33,9 @@ export default function Radarr({ service }) {
return (
<Widget>
<Block label="Wanted" value={wanted.length} />
<Block label="Queued" value={queuedData.totalCount} />
<Block label="Movies" value={have.length} />
<Block label={t("radarr.wanted")} value={wanted.length} />
<Block label={t("radarr.queued")} value={queuedData.totalCount} />
<Block label={t("radarr.movies")} value={have.length} />
</Widget>
);
}

View File

@@ -0,0 +1,41 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Readarr({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: booksData, error: booksError } = useSWR(formatApiUrl(config, "book"));
const { data: wantedData, error: wantedError } = useSWR(formatApiUrl(config, "wanted/missing"));
const { data: queueData, error: queueError } = useSWR(formatApiUrl(config, "queue/status"));
if (booksError || wantedError || queueError) {
return <Widget error={t("widget.api_error")} />;
}
if (!booksData || !wantedData || !queueData) {
return (
<Widget>
<Block label={t("readarr.wanted")} />
<Block label={t("readarr.queued")} />
<Block label={t("readarr.books")} />
</Widget>
);
}
const have = booksData.filter((book) => book.statistics.bookFileCount > 0);
return (
<Widget>
<Block label={t("readarr.wanted")} value={wantedData.totalRecords} />
<Block label={t("readarr.queued")} value={queueData.totalCount} />
<Block label={t("readarr.books")} value={have.length} />
</Widget>
);
}

View File

@@ -1,45 +1,43 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
import { formatBytes } from "utils/stats-helpers";
export default function Rutorrent({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: statusData, error: statusError } = useSWR(formatApiUrl(config));
if (statusError) {
return <Widget error="Nzbget API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!statusData) {
return (
<Widget>
<Block label="Active" />
<Block label="Upload" />
<Block label="Download" />
<Block label={t("rutorrent.active")} />
<Block label={t("rutorrent.upload")} />
<Block label={t("rutorrent.download")} />
</Widget>
);
}
const upload = statusData.reduce((acc, torrent) => {
return acc + parseInt(torrent["d.get_up_rate"]);
}, 0);
const upload = statusData.reduce((acc, torrent) => acc + parseInt(torrent["d.get_up_rate"], 10), 0);
const download = statusData.reduce((acc, torrent) => {
return acc + parseInt(torrent["d.get_down_rate"]);
}, 0);
const download = statusData.reduce((acc, torrent) => acc + parseInt(torrent["d.get_down_rate"], 10), 0);
const active = statusData.filter((torrent) => torrent["d.get_state"] === "1");
return (
<Widget>
<Block label="Active" value={active.length} />
<Block label="Upload" value={`${formatBytes(upload)}/s`} />
<Block label="Download" value={`${formatBytes(download)}/s`} />
<Block label={t("rutorrent.active")} value={active.length} />
<Block label={t("rutorrent.upload")} value={t("common.bitrate", { value: upload })} />
<Block label={t("rutorrent.download")} value={t("common.bitrate", { value: download })} />
</Widget>
);
}

View File

@@ -0,0 +1,37 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function SABnzbd({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: queueData, error: queueError } = useSWR(formatApiUrl(config, "queue"));
if (queueError) {
return <Widget error={t("widget.api_error")} />;
}
if (!queueData) {
return (
<Widget>
<Block label={t("sabnzbd.rate")} />
<Block label={t("sabnzbd.queue")} />
<Block label={t("sabnzbd.timeleft")} />
</Widget>
);
}
return (
<Widget>
<Block label={t("sabnzbd.rate")} value={`${queueData.queue.speed}B/s`} />
<Block label={t("sabnzbd.queue")} value={queueData.queue.noofslots} />
<Block label={t("sabnzbd.timeleft")} value={queueData.queue.timeleft} />
</Widget>
);
}

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
@@ -6,6 +7,8 @@ import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Sonarr({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: wantedData, error: wantedError } = useSWR(formatApiUrl(config, "wanted/missing"));
@@ -13,24 +16,24 @@ export default function Sonarr({ service }) {
const { data: seriesData, error: seriesError } = useSWR(formatApiUrl(config, "series"));
if (wantedError || queuedError || seriesError) {
return <Widget error="Sonar API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!wantedData || !queuedData || !seriesData) {
return (
<Widget>
<Block label="Wanted" />
<Block label="Queued" />
<Block label="Series" />
<Block label={t("sonarr.wanted")} />
<Block label={t("sonarr.queued")} />
<Block label={t("sonarr.series")} />
</Widget>
);
}
return (
<Widget>
<Block label="Wanted" value={wantedData.totalRecords} />
<Block label="Queued" value={queuedData.totalRecords} />
<Block label="Series" value={seriesData.length} />
<Block label={t("sonarr.wanted")} value={wantedData.totalRecords} />
<Block label={t("sonarr.queued")} value={queuedData.totalRecords} />
<Block label={t("sonarr.series")} value={seriesData.length} />
</Widget>
);
}

View File

@@ -1,35 +1,46 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatBits } from "utils/stats-helpers";
import { formatApiUrl } from "utils/api-helpers";
export default function Speedtest({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: speedtestData, error: speedtestError } = useSWR(formatApiUrl(config, "speedtest/latest"));
if (speedtestError) {
return <Widget error="Speedtest API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!speedtestData) {
return (
<Widget>
<Block label="Download" />
<Block label="Upload" />
<Block label="Ping" />
<Block label={t("speedtest.download")} />
<Block label={t("speedtest.upload")} />
<Block label={t("speedtest.ping")} />
</Widget>
);
}
return (
<Widget>
<Block label="Download" value={`${formatBits(speedtestData.data.download * 1024 * 1024, 0)}ps`} />
<Block label="Upload" value={`${formatBits(speedtestData.data.upload * 1024 * 1024, 0)}ps`} />
<Block label="Ping" value={`${speedtestData.data.ping} ms`} />
<Block
label={t("speedtest.download")}
value={t("common.bitrate", { value: speedtestData.data.download * 1024 * 1024 })}
/>
<Block
label={t("speedtest.upload")}
value={t("common.bitrate", { value: speedtestData.data.upload * 1024 * 1024 })}
/>
<Block
label={t("speedtest.ping")}
value={t("common.ms", { value: speedtestData.data.ping, style: "unit", unit: "millisecond" })}
/>
</Widget>
);
}

View File

@@ -1,37 +1,157 @@
/* eslint-disable camelcase */
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import { BsFillPlayFill, BsPauseFill } from "react-icons/bs";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
function millisecondsToTime(milliseconds) {
const seconds = Math.floor((milliseconds / 1000) % 60);
const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);
const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);
return { hours, minutes, seconds };
}
function millisecondsToString(milliseconds) {
const { hours, minutes, seconds } = millisecondsToTime(milliseconds);
const parts = [];
if (hours > 0) {
parts.push(hours);
}
parts.push(minutes);
parts.push(seconds);
return parts.map((part) => part.toString().padStart(2, "0")).join(":");
}
function SingleSessionEntry({ session }) {
const { full_title, duration, view_offset, progress_percent, state, year, grandparent_year } = session;
return (
<>
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div className="text-xs z-10 self-center ml-2">
<span>{full_title}</span>
</div>
<div className="grow" />
<div className="self-center text-xs flex justify-end mr-2">{year || grandparent_year}</div>
</div>
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
style={{
width: `${progress_percent}%`,
}}
/>
<div className="text-xs z-10 self-center ml-1">
{state === "paused" && (
<BsPauseFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
)}
{state !== "paused" && (
<BsFillPlayFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
)}
</div>
<div className="grow " />
<div className="self-center text-xs flex justify-end mr-2">
{millisecondsToString(view_offset)} / {millisecondsToString(duration)}
</div>
</div>
</>
);
}
function SessionEntry({ session }) {
const { full_title, view_offset, progress_percent, state } = session;
return (
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
style={{
width: `${progress_percent}%`,
}}
/>
<div className="text-xs z-10 self-center ml-1">
{state === "paused" && (
<BsPauseFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
)}
{state !== "paused" && (
<BsFillPlayFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
)}
<span>{full_title}</span>
</div>
<div className="grow " />
<div className="self-center text-xs flex justify-end mr-2">{millisecondsToString(view_offset)}</div>
</div>
);
}
export default function Tautulli({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: statsData, error: statsError } = useSWR(formatApiUrl(config, "get_activity"));
const { data: activityData, error: activityError } = useSWR(formatApiUrl(config, "get_activity"), {
refreshInterval: 5000,
});
if (statsError) {
return <Widget error="Tautulli API Error" />;
if (activityError) {
return <Widget error={t("widget.api_error")} />;
}
if (!statsData) {
if (!activityData) {
return (
<Widget>
<Block label="Playing" />
<Block label="Transcoding" />
<Block label="Bitrate" />
</Widget>
<div className="flex flex-col pb-1 mx-1">
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
</div>
);
}
const data = statsData.response.data;
const playing = activityData.response.data.sessions.sort((a, b) => {
if (a.view_offset > b.view_offset) {
return 1;
}
if (a.view_offset < b.view_offset) {
return -1;
}
return 0;
});
if (playing.length === 0) {
return (
<div className="flex flex-col pb-1 mx-1">
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">{t("tautulli.no_active")}</span>
</div>
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
</div>
);
}
if (playing.length === 1) {
const session = playing[0];
return (
<div className="flex flex-col pb-1 mx-1">
<SingleSessionEntry session={session} />
</div>
);
}
return (
<Widget>
<Block label="Playing" value={data.stream_count} />
<Block label="Transcoding" value={data.stream_count_transcode} />
{/* We divide by 1000 here because thats how Tautulli reports it on its own dashboard */}
<Block label="Bitrate" value={`${Math.round((data.total_bandwidth / 1000) * 100) / 100} Mbps`} />
</Widget>
<div className="flex flex-col pb-1 mx-1">
{playing.map((session) => (
<SessionEntry key={session.Id} session={session} />
))}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
@@ -6,29 +7,31 @@ import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Traefik({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: traefikData, error: traefikError } = useSWR(formatApiUrl(config, "overview"));
if (traefikError) {
return <Widget error="Traefik API Error" />;
return <Widget error={t("widget.api_error")} />;
}
if (!traefikData) {
return (
<Widget>
<Block label="Routers" />
<Block label="Services" />
<Block label="Middleware" />
<Block label={t("traefik.routers")} />
<Block label={t("traefik.services")} />
<Block label={t("traefik.middleware")} />
</Widget>
);
}
return (
<Widget>
<Block label="Routers" value={traefikData.http.routers.total} />
<Block label="Services" value={traefikData.http.services.total} />
<Block label="Middleware" value={traefikData.http.middlewares.total} />
<Block label={t("traefik.routers")} value={traefikData.http.routers.total} />
<Block label={t("traefik.services")} value={traefikData.http.services.total} />
<Block label={t("traefik.middleware")} value={traefikData.http.middlewares.total} />
</Widget>
);
}

View File

@@ -0,0 +1,70 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import Widget from "../widget";
import Block from "../block";
import { formatApiUrl } from "utils/api-helpers";
export default function Transmission({ service }) {
const { t } = useTranslation();
const config = service.widget;
const { data: torrentData, error: torrentError } = useSWR(formatApiUrl(config));
if (torrentError) {
return <Widget error={t("widget.api_error")} />;
}
if (!torrentData) {
return (
<Widget>
<Block label={t("transmission.leech")} />
<Block label={t("transmission.download")} />
<Block label={t("transmission.seed")} />
<Block label={t("transmission.upload")} />
</Widget>
);
}
const { torrents } = torrentData.arguments;
let rateDl = 0;
let rateUl = 0;
let completed = 0;
for (let i = 0; i < torrents.length; i += 1) {
const torrent = torrents[i];
rateDl += torrent.rateDownload;
rateUl += torrent.rateUpload;
if (torrent.percentDone === 1) {
completed += 1;
}
}
const leech = torrents.length - completed;
let unitsDl = "KB/s";
let unitsUl = "KB/s";
rateDl /= 1024;
rateUl /= 1024;
if (rateDl > 1024) {
rateDl /= 1024;
unitsDl = "MB/s";
}
if (rateUl > 1024) {
rateUl /= 1024;
unitsUl = "MB/s";
}
return (
<Widget>
<Block label={t("transmission.leech")} value={leech} />
<Block label={t("transmission.download")} value={`${rateDl.toFixed(2)} ${unitsDl}`} />
<Block label={t("transmission.seed")} value={completed} />
<Block label={t("transmission.upload")} value={`${rateUl.toFixed(2)} ${unitsUl}`} />
</Widget>
);
}

View File

@@ -1,10 +1,5 @@
import { useContext } from "react";
import {
MdDarkMode,
MdLightMode,
MdToggleOff,
MdToggleOn,
} from "react-icons/md";
import { MdDarkMode, MdLightMode, MdToggleOff, MdToggleOn } from "react-icons/md";
import { ThemeContext } from "utils/theme-context";

View File

@@ -1,7 +1,7 @@
import mapIcon from "utils/owm-condition-map";
export default function Icon({ condition, timeOfDay }) {
const Icon = mapIcon(condition, timeOfDay);
const IconComponent = mapIcon(condition, timeOfDay);
return <Icon className="w-10 h-10 text-theme-800 dark:text-theme-200"></Icon>;
return <IconComponent className="w-10 h-10 text-theme-800 dark:text-theme-200" />;
}

View File

@@ -1,20 +1,28 @@
import useSWR from "swr";
import { useState } from "react";
import { BiError } from "react-icons/bi";
import { WiCloudDown } from "react-icons/wi";
import { MdLocationDisabled, MdLocationSearching } from "react-icons/md";
import { useTranslation } from "react-i18next";
import Icon from "./icon";
export default function OpenWeatherMap({ options }) {
const { data, error } = useSWR(`/api/widgets/openweathermap?${new URLSearchParams(options).toString()}`);
function Widget({ options }) {
const { t, i18n } = useTranslation();
if (error || data?.cod == 401) {
const { data, error } = useSWR(
`/api/widgets/openweathermap?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`
);
if (error || data?.cod === 401 || data?.error) {
return (
<div className="flex flex-col">
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">API</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">Error</span>
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
</div>
@@ -23,15 +31,25 @@ export default function OpenWeatherMap({ options }) {
}
if (!data) {
return <div className="flex flex-row items-center"></div>;
return (
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<WiCloudDown className="w-8 h-8 text-theme-800 dark:text-theme-200" />
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.updating")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.wait")}</span>
</div>
</div>
</div>
);
}
if (data.error) {
return <div className="flex flex-row items-center"></div>;
}
const unit = options.units === "metric" ? "celsius" : "fahrenheit";
return (
<div className="flex flex-col justify-center">
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<Icon
@@ -42,13 +60,63 @@ export default function OpenWeatherMap({ options }) {
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">
{options.label && `${options.label}, `}
{data.main.temp.toFixed(1)}&deg;
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{data.weather[0].description.charAt(0).toUpperCase() + data.weather[0].description.slice(1)}
{t("common.number", { value: data.main.temp, style: "unit", unit })}
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{data.weather[0].description}</span>
</div>
</div>
</div>
);
}
export default function OpenWeatherMap({ options }) {
const { t } = useTranslation();
const [location, setLocation] = useState(false);
const [requesting, setRequesting] = useState(false);
if (!location && options.latitude && options.longitude) {
setLocation({ latitude: options.latitude, longitude: options.longitude });
}
const requestLocation = () => {
setRequesting(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({ latitude: position.coords.latitude, longitude: position.coords.longitude });
setRequesting(false);
},
() => {
setRequesting(false);
},
{
enableHighAccuracy: true,
maximumAge: 1000 * 60 * 60 * 3,
timeout: 1000 * 30,
}
);
};
if (!requesting && !location) requestLocation();
if (!location) {
return (
<button type="button" onClick={() => requestLocation()} className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
{requesting ? (
<MdLocationSearching className="w-6 h-6 text-theme-800 dark:text-theme-200 animate-pulse" />
) : (
<MdLocationDisabled className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.current")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.allow")}</span>
</div>
</div>
</button>
);
}
return <Widget options={{ ...location, ...options }} />;
}

View File

@@ -1,19 +1,23 @@
import useSWR from "swr";
import { FiCpu } from "react-icons/fi";
import { BiError } from "react-icons/bi";
import { useTranslation } from "react-i18next";
import UsageBar from "./usage-bar";
export default function Cpu({ expanded }) {
const { t } = useTranslation();
export default function Cpu() {
const { data, error } = useSWR(`/api/widgets/resources?type=cpu`, {
refreshInterval: 1500,
});
if (error || data?.error) {
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">Resources</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">Error</span>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
@@ -21,26 +25,44 @@ export default function Cpu() {
if (!data) {
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">- Usage</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">- Load</span>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
);
}
const percent = data.cpu.usage;
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
<span className="whitespace-pre">{`${Math.round(data.cpu.usage)}%`.padEnd(3, " ")} Usage</span>
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{`${(Math.round(data.cpu.load * 100) / 100).toFixed(1)}`.padEnd(3, " ")} Load
</span>
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">
{t("common.number", {
value: data.cpu.usage,
style: "unit",
unit: "percent",
maximumFractionDigits: 0,
})}
</div>
<div className="pr-1">{t("docker.cpu")}</div>
</div>
{expanded && (
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">
{t("common.number", {
value: data.cpu.load,
maximumFractionDigits: 2,
})}
</div>
<div className="pr-1">{t("resources.load")}</div>
</div>
)}
<UsageBar percent={percent} />
</div>
</div>
);

View File

@@ -1,20 +1,23 @@
import useSWR from "swr";
import { FiHardDrive } from "react-icons/fi";
import { BiError } from "react-icons/bi";
import { formatBytes } from "utils/stats-helpers";
import { useTranslation } from "react-i18next";
import UsageBar from "./usage-bar";
export default function Disk({ options, expanded }) {
const { t } = useTranslation();
export default function Disk({ options }) {
const { data, error } = useSWR(`/api/widgets/resources?type=disk&target=${options.disk}`, {
refreshInterval: 1500,
});
if (error || data?.error) {
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">Resources</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">Error</span>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
@@ -22,26 +25,32 @@ export default function Disk({ options }) {
if (!data) {
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">- Free</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">- Used</span>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
);
}
const percent = Math.round((data.drive.usedGb / data.drive.totalGb) * 100);
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
{formatBytes(data.drive.freeGb * 1024 * 1024 * 1024)} Free
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{formatBytes(data.drive.usedGb * 1024 * 1024 * 1024)} Used
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">{t("common.bytes", { value: data.drive.freeGb * 1024 * 1024 * 1024 })}</div>
<div className="pr-1">{t("resources.free")}</div>
</span>
{expanded && (
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">{t("common.bytes", { value: data.drive.totalGb * 1024 * 1024 * 1024 })}</div>
<div className="pr-1">{t("resources.total")}</div>
</span>
)}
<UsageBar percent={percent} />
</div>
</div>
);

View File

@@ -1,20 +1,23 @@
import useSWR from "swr";
import { FaMemory } from "react-icons/fa";
import { BiError } from "react-icons/bi";
import { formatBytes } from "utils/stats-helpers";
import { useTranslation } from "react-i18next";
import UsageBar from "./usage-bar";
export default function Memory({ expanded }) {
const { t } = useTranslation();
export default function Memory() {
const { data, error } = useSWR(`/api/widgets/resources?type=memory`, {
refreshInterval: 1500,
});
if (error || data?.error) {
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">Resources</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">Error</span>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
@@ -22,26 +25,40 @@ export default function Memory() {
if (!data) {
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">- GB Used</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">- GB Free</span>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
);
}
const percent = Math.round((data.memory.usedMemMb / data.memory.totalMemMb) * 100);
return (
<div className="flex-none flex flex-row items-center justify-center">
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left font-mono">
<span className="text-theme-800 dark:text-theme-200 text-xs">
{formatBytes(data.memory.usedMemMb * 1024 * 1024)} Used
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{formatBytes(data.memory.freeMemMb * 1024 * 1024)} Free
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">
{t("common.bytes", { value: data.memory.freeMemMb * 1024 * 1024, maximumFractionDigits: 0, binary: true })}
</div>
<div className="pr-1">{t("resources.free")}</div>
</span>
{expanded && (
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">
{t("common.bytes", {
value: data.memory.totalMemMb * 1024 * 1024,
maximumFractionDigits: 0,
binary: true,
})}
</div>
<div className="pr-1">{t("resources.total")}</div>
</span>
)}
<UsageBar percent={percent} />
</div>
</div>
);

View File

@@ -3,20 +3,20 @@ import Cpu from "./cpu";
import Memory from "./memory";
export default function Resources({ options }) {
console.log(options);
const { expanded } = options;
return (
<>
<div className="flex flex-col max-w:full basis-1/2 sm:basis-auto self-center">
<div className="flex flex-row space-x-4 self-center">
{options.cpu && <Cpu />}
{options.memory && <Memory />}
{options.disk && <Disk options={options} />}
</div>
{options.label && (
<div className="border-t-2 border-theme-800 dark:border-theme-200 mt-1 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">
{options.label}
</div>
)}
<div className="flex flex-col max-w:full sm:basis-auto self-center m-auto flex-wrap">
<div className="flex flex-row self-center flex-wrap justify-between">
{options.cpu && <Cpu expanded={expanded} />}
{options.memory && <Memory expanded={expanded} />}
{Array.isArray(options.disk)
? options.disk.map((disk) => <Disk key={disk} options={{ disk }} expanded={expanded} />)
: options.disk && <Disk options={options} expanded={expanded} />}
</div>
</>
{options.label && (
<div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
export default function UsageBar({ percent }) {
return (
<div className="mt-0.5 w-full bg-theme-800/30 rounded-full h-1 dark:bg-white/20">
<div
className="bg-theme-800/70 h-1 rounded-full dark:bg-white/50"
style={{
width: `${percent}%`,
}}
/>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FiSearch } from "react-icons/fi";
import { SiDuckduckgo, SiMicrosoftbing, SiGoogle } from "react-icons/si";
@@ -26,11 +27,13 @@ const providers = {
};
export default function Search({ options }) {
const { t } = useTranslation();
const provider = providers[options.provider];
const [query, setQuery] = useState("");
if (!provider) {
return <></>;
return null;
}
function handleSubmit(event) {
@@ -48,21 +51,34 @@ export default function Search({ options }) {
}
return (
<form className="flex-col relative h-8 my-4 min-w-full md:min-w-fit grow" onSubmit={handleSubmit}>
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-theme-200"></div>
<form className="flex-col relative h-8 my-4 min-w-full md:min-w-fit grow first:ml-0 ml-4" onSubmit={handleSubmit}>
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
<input
type="search"
autoFocus
className={`overflow-hidden w-full placeholder-theme-900 text-xs text-theme-900 bg-theme-50 rounded-md border border-theme-300 focus:ring-theme-500 focus:border-theme-500 dark:bg-theme-800 dark:border-theme-600 dark:placeholder-theme-400 dark:text-white dark:focus:ring-theme-500 dark:focus:border-theme-500 h-full`}
placeholder="Search..."
type="text"
className="
overflow-hidden w-full h-full rounded-md
text-xs text-theme-900 dark:text-white
placeholder-theme-900 dark:placeholder-white/80
bg-white/50 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50
focus:border-theme-500 dark:focus:border-white/50
border border-theme-300 dark:border-theme-200/50"
placeholder={t("search.placeholder")}
onChange={(s) => setQuery(s.currentTarget.value)}
required
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
/>
<button
type="submit"
className="text-white absolute right-0.5 bottom-0.5 bg-theme-700 hover:bg-theme-800 border-1 focus:ring-2 focus:ring-theme-300 font-medium rounded-r-md text-sm px-4 py-2 dark:bg-theme-600 dark:hover:bg-theme-700 dark:focus:ring-theme-500"
className="
absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2 border-1
text-white font-medium text-sm
bg-theme-600/40 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50"
>
<provider.icon className="text-theme-800 dark:text-theme-200 w-3 h-3" />
<provider.icon className="text-white w-3 h-3" />
</button>
</form>
);

View File

@@ -1,7 +1,7 @@
import mapIcon from "utils/condition-map";
export default function Icon({ condition, timeOfDay }) {
const Icon = mapIcon(condition, timeOfDay);
const IconComponent = mapIcon(condition, timeOfDay);
return <Icon className="w-10 h-10 text-theme-800 dark:text-theme-200"></Icon>;
return <IconComponent className="w-10 h-10 text-theme-800 dark:text-theme-200" />;
}

View File

@@ -1,21 +1,28 @@
import useSWR from "swr";
import { useState } from "react";
import { BiError } from "react-icons/bi";
import { WiCloudDown } from "react-icons/wi";
import { MdLocationDisabled, MdLocationSearching } from "react-icons/md";
import { useTranslation } from "react-i18next";
import Icon from "./icon";
export default function WeatherApi({ options }) {
console.log(options);
const { data, error } = useSWR(`/api/widgets/weather?${new URLSearchParams(options).toString()}`);
function Widget({ options }) {
const { t, i18n } = useTranslation();
if (error) {
const { data, error } = useSWR(
`/api/widgets/weather?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`
);
if (error || data?.error) {
return (
<div className="flex flex-col">
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">API</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">Error</span>
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
</div>
@@ -24,15 +31,25 @@ export default function WeatherApi({ options }) {
}
if (!data) {
return <div className="flex flex-row items-center justify-end"></div>;
return (
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<WiCloudDown className="w-8 h-8 text-theme-800 dark:text-theme-200" />
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.updating")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.wait")}</span>
</div>
</div>
</div>
);
}
if (data.error) {
return <div className="flex flex-row items-center justify-end"></div>;
}
const unit = options.units === "metric" ? "celsius" : "fahrenheit";
return (
<div className="flex flex-col justify-center">
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<Icon condition={data.current.condition.code} timeOfDay={data.current.is_day ? "day" : "night"} />
@@ -40,7 +57,11 @@ export default function WeatherApi({ options }) {
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">
{options.label && `${options.label}, `}
{options.units === "metric" ? data.current.temp_c : data.current.temp_f}&deg;
{t("common.number", {
value: options.units === "metric" ? data.current.temp_c : data.current.temp_f,
style: "unit",
unit,
})}
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{data.current.condition.text}</span>
</div>
@@ -48,3 +69,55 @@ export default function WeatherApi({ options }) {
</div>
);
}
export default function WeatherApi({ options }) {
const { t } = useTranslation();
const [location, setLocation] = useState(false);
const [requesting, setRequesting] = useState(false);
if (!location && options.latitude && options.longitude) {
setLocation({ latitude: options.latitude, longitude: options.longitude });
}
const requestLocation = () => {
setRequesting(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({ latitude: position.coords.latitude, longitude: position.coords.longitude });
setRequesting(false);
},
() => {
setRequesting(false);
},
{
enableHighAccuracy: true,
maximumAge: 1000 * 60 * 60 * 3,
timeout: 1000 * 30,
}
);
};
if (!requesting && !location) requestLocation();
if (!location) {
return (
<button type="button" onClick={() => requestLocation()} className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
{requesting ? (
<MdLocationSearching className="w-6 h-6 text-theme-800 dark:text-theme-200 animate-pulse" />
) : (
<MdLocationDisabled className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.current")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.allow")}</span>
</div>
</div>
</button>
);
}
return <Widget options={{ ...location, ...options }} />;
}

View File

@@ -1,8 +1,12 @@
/* eslint-disable react/jsx-props-no-spreading */
import { SWRConfig } from "swr";
import "styles/globals.css";
import "styles/weather-icons.css";
import "styles/theme.css";
import "utils/i18n";
function MyApp({ Component, pageProps }) {
return (
<SWRConfig

View File

@@ -11,7 +11,7 @@ export default function Document() {
rel="stylesheet"
/>
</Head>
<body className="w-full h-full bg-theme-50 dark:bg-theme-800 transition duration-150 ease-in-out">
<body className="relative w-full h-full bg-theme-50 dark:bg-theme-800 transition duration-150 ease-in-out">
<Main />
<NextScript />
</body>

View File

@@ -1,6 +1,8 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config";
export default async function handler(req, res) {
@@ -11,17 +13,13 @@ export default async function handler(req, res) {
const bookmarks = yaml.load(fileContents);
// map easy to write YAML objects into easy to consume JS arrays
const bookmarksArray = bookmarks.map((group) => {
return {
name: Object.keys(group)[0],
bookmarks: group[Object.keys(group)[0]].map((entries) => {
return {
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]][0],
};
}),
};
});
const bookmarksArray = bookmarks.map((group) => ({
name: Object.keys(group)[0],
bookmarks: group[Object.keys(group)[0]].map((entries) => ({
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]][0],
})),
}));
res.send(bookmarksArray);
}

View File

@@ -1,4 +1,5 @@
import Docker from "dockerode";
import getDockerArguments from "utils/docker";
export default async function handler(req, res) {
@@ -13,7 +14,7 @@ export default async function handler(req, res) {
}
try {
const docker = new Docker(await getDockerArguments(containerServer));
const docker = new Docker(getDockerArguments(containerServer));
const containers = await docker.listContainers({
all: true,
});
@@ -21,30 +22,30 @@ export default async function handler(req, res) {
// bad docker connections can result in a <Buffer ...> object?
// in any case, this ensures the result is the expected array
if (!Array.isArray(containers)) {
return res.status(500).send({
res.status(500).send({
error: "query failed",
});
return;
}
const containerNames = containers.map((container) => {
return container.Names[0].replace(/^\//, "");
});
const containerNames = containers.map((container) => container.Names[0].replace(/^\//, ""));
const containerExists = containerNames.includes(containerName);
if (!containerExists) {
return res.status(200).send({
res.status(200).send({
error: "not found",
});
return;
}
const container = docker.getContainer(containerName);
const stats = await container.stats({ stream: false });
return res.status(200).json({
stats: stats,
res.status(200).json({
stats,
});
} catch {
return res.status(500).send({
res.status(500).send({
error: "unknown error",
});
}

View File

@@ -1,4 +1,5 @@
import Docker from "dockerode";
import getDockerArguments from "utils/docker";
export default async function handler(req, res) {
@@ -12,7 +13,7 @@ export default async function handler(req, res) {
}
try {
const docker = new Docker(await getDockerArguments(containerServer));
const docker = new Docker(getDockerArguments(containerServer));
const containers = await docker.listContainers({
all: true,
});
@@ -25,9 +26,7 @@ export default async function handler(req, res) {
});
}
const containerNames = containers.map((container) => {
return container.Names[0].replace(/^\//, "");
});
const containerNames = containers.map((container) => container.Names[0].replace(/^\//, ""));
const containerExists = containerNames.includes(containerName);
if (!containerExists) {

View File

@@ -0,0 +1,3 @@
export default function handler(req, res) {
res.send("up");
}

View File

@@ -1,4 +1,5 @@
import https from "https";
import getRawBody from "raw-body";
import { httpRequest, httpsRequest } from "utils/http";
@@ -11,7 +12,8 @@ export const config = {
export default async function handler(req, res) {
const headers = ["X-API-Key", "Authorization"].reduce((obj, key) => {
if (req.headers && req.headers.hasOwnProperty(key.toLowerCase())) {
if (req.headers && Object.prototype.hasOwnProperty.call(req.headers, key.toLowerCase())) {
// eslint-disable-next-line no-param-reassign
obj[key] = req.headers[key.toLowerCase()];
}
return obj;
@@ -29,23 +31,9 @@ export default async function handler(req, res) {
const [status, contentType, data] = await httpsRequest(url, {
agent: httpsAgent,
method: req.method,
headers: headers,
headers,
body:
req.method == "GET" || req.method == "HEAD"
? null
: await getRawBody(req, {
encoding: "utf8",
}),
});
res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
} else {
const [status, contentType, data] = await httpRequest(url, {
method: req.method,
headers: headers,
body:
req.method == "GET" || req.method == "HEAD"
req.method === "GET" || req.method === "HEAD"
? null
: await getRawBody(req, {
encoding: "utf8",
@@ -55,4 +43,17 @@ export default async function handler(req, res) {
res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
}
const [status, contentType, data] = await httpRequest(url, {
method: req.method,
headers,
body:
req.method === "GET" || req.method === "HEAD"
? null
: await getRawBody(req, {
encoding: "utf8",
}),
});
res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
}

View File

@@ -0,0 +1,8 @@
export default async function handler(req, res) {
try {
await res.revalidate("/");
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).send("Error revalidating");
}
}

View File

@@ -1,40 +1,41 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config";
/* eslint-disable no-console */
import { servicesFromConfig, servicesFromDocker, cleanServiceGroups } from "utils/service-helpers";
export default async function handler(req, res) {
checkAndCopyConfig("services.yaml");
let discoveredServices;
let configuredServices;
const servicesYaml = path.join(process.cwd(), "config", "services.yaml");
const fileContents = await fs.readFile(servicesYaml, "utf8");
const services = yaml.load(fileContents);
try {
discoveredServices = cleanServiceGroups(await servicesFromDocker());
} catch {
console.error("Failed to discover services, please check docker.yaml for errors");
discoveredServices = [];
}
// map easy to write YAML objects into easy to consume JS arrays
const servicesArray = services.map((group) => {
return {
name: Object.keys(group)[0],
services: group[Object.keys(group)[0]].map((entries) => {
const { widget, ...service } = entries[Object.keys(entries)[0]];
let res = {
name: Object.keys(entries)[0],
...service,
};
try {
configuredServices = cleanServiceGroups(await servicesFromConfig());
} catch {
console.error("Failed to load services.yaml, please check for errors");
configuredServices = [];
}
if (widget) {
const { type } = widget;
const mergedGroupsNames = [
...new Set([discoveredServices.map((group) => group.name), configuredServices.map((group) => group.name)].flat()),
];
res.widget = {
type: type,
service_group: Object.keys(group)[0],
service_name: Object.keys(entries)[0],
};
}
const mergedGroups = [];
return res;
}),
mergedGroupsNames.forEach((groupName) => {
const discoveredGroup = discoveredServices.find((group) => group.name === groupName) || { services: [] };
const configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] };
const mergedGroup = {
name: groupName,
services: [...discoveredGroup.services, ...configuredGroup.services].filter((service) => service),
};
mergedGroups.push(mergedGroup);
});
res.send(servicesArray);
res.send(mergedGroups);
}

View File

@@ -3,6 +3,7 @@ import credentialedProxyHandler from "utils/proxies/credentialed";
import rutorrentProxyHandler from "utils/proxies/rutorrent";
import nzbgetProxyHandler from "utils/proxies/nzbget";
import npmProxyHandler from "utils/proxies/npm";
import transmissionProxyHandler from "utils/proxies/transmission";
const serviceProxyHandlers = {
// uses query param auth
@@ -11,17 +12,24 @@ const serviceProxyHandlers = {
pihole: genericProxyHandler,
radarr: genericProxyHandler,
sonarr: genericProxyHandler,
readarr: genericProxyHandler,
speedtest: genericProxyHandler,
tautulli: genericProxyHandler,
traefik: genericProxyHandler,
// uses X-API-Key header auth
sabnzbd: genericProxyHandler,
// uses X-API-Key (or similar) header auth
gotify: credentialedProxyHandler,
portainer: credentialedProxyHandler,
jellyseerr: credentialedProxyHandler,
overseerr: credentialedProxyHandler,
ombi: credentialedProxyHandler,
coinmarketcap: credentialedProxyHandler,
prowlarr: credentialedProxyHandler,
// super specific handlers
rutorrent: rutorrentProxyHandler,
nzbget: nzbgetProxyHandler,
npm: npmProxyHandler,
transmission: transmissionProxyHandler,
};
export default async function handler(req, res) {
@@ -33,5 +41,5 @@ export default async function handler(req, res) {
return serviceProxyHandler(req, res);
}
res.status(403).json({ error: "Unkown proxy service type" });
return res.status(403).json({ error: "Unkown proxy service type" });
}

View File

@@ -1,6 +1,8 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config";
export default async function handler(req, res) {
@@ -11,12 +13,10 @@ export default async function handler(req, res) {
const widgets = yaml.load(fileContents);
// map easy to write YAML objects into easy to consume JS arrays
const widgetsArray = widgets.map((group) => {
return {
type: Object.keys(group)[0],
options: { ...group[Object.keys(group)[0]] },
};
});
const widgetsArray = widgets.map((group) => ({
type: Object.keys(group)[0],
options: { ...group[Object.keys(group)[0]] },
}));
res.send(widgetsArray);
}

View File

@@ -2,7 +2,7 @@ import cachedFetch from "utils/cached-fetch";
import { getSettings } from "utils/config";
export default async function handler(req, res) {
const { latitude, longitude, units, provider, cache } = req.query;
const { latitude, longitude, units, provider, cache, lang } = req.query;
let { apiKey } = req.query;
if (!apiKey && !provider) {
@@ -22,7 +22,7 @@ export default async function handler(req, res) {
return res.status(400).json({ error: "Missing API key" });
}
const api_url = `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${apiKey}&units=${units}`;
const apiUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${apiKey}&units=${units}&lang=${lang}`;
res.send(await cachedFetch(api_url, cache));
return res.send(await cachedFetch(apiUrl, cache));
}

View File

@@ -1,26 +1,38 @@
import { cpu, drive, mem, netstat } from "node-os-utils";
import { existsSync } from "fs";
import { cpu, drive, mem } from "node-os-utils";
export default async function handler(req, res) {
const { type, target } = req.query;
if (type == "cpu") {
if (type === "cpu") {
return res.status(200).json({
cpu: {
usage: await cpu.usage(1000),
load: cpu.loadavgTime(5),
},
});
} else if (type == "disk") {
}
if (type === "disk") {
if (!existsSync(target)) {
return res.status(404).json({
error: "Target not found",
});
}
return res.status(200).json({
drive: await drive.info(target || "/"),
});
} else if (type == "memory") {
}
if (type === "memory") {
return res.status(200).json({
memory: await mem.info(),
});
} else {
return res.status(400).json({
error: "invalid type",
});
}
return res.status(400).json({
error: "invalid type",
});
}

View File

@@ -2,7 +2,7 @@ import cachedFetch from "utils/cached-fetch";
import { getSettings } from "utils/config";
export default async function handler(req, res) {
const { latitude, longitude, provider, cache } = req.query;
const { latitude, longitude, provider, cache, lang } = req.query;
let { apiKey } = req.query;
if (!apiKey && !provider) {
@@ -22,7 +22,7 @@ export default async function handler(req, res) {
return res.status(400).json({ error: "Missing API key" });
}
const api_url = `http://api.weatherapi.com/v1/current.json?q=${latitude},${longitude}&key=${apiKey}`;
const apiUrl = `http://api.weatherapi.com/v1/current.json?q=${latitude},${longitude}&key=${apiKey}&lang=${lang}`;
res.send(await cachedFetch(api_url, cache));
return res.send(await cachedFetch(apiUrl, cache));
}

View File

@@ -1,14 +1,15 @@
/* eslint-disable react/no-array-index-key */
import useSWR from "swr";
import Head from "next/head";
import dynamic from "next/dynamic";
import { ThemeProvider } from "utils/theme-context";
import ServicesGroup from "components/services/group";
import BookmarksGroup from "components/bookmarks/group";
import Widget from "components/widget";
import Revalidate from "components/revalidate";
import { getSettings } from "utils/config";
import { ColorProvider } from "utils/color-context";
import Search from "components/widgets/search/search";
import { ThemeProvider } from "utils/theme-context";
const ThemeToggle = dynamic(() => import("components/theme-toggle"), {
ssr: false,
@@ -19,21 +20,36 @@ const ColorToggle = dynamic(() => import("components/color-toggle"), {
});
const rightAlignedWidgets = ["weatherapi", "openweathermap", "weather", "search"];
const expandedWidgets = ["search"];
export default function Home() {
const { data: services, error: servicesError } = useSWR("/api/services");
const { data: bookmarks, error: bookmarksError } = useSWR("/api/bookmarks");
const { data: widgets, error: widgetsError } = useSWR("/api/widgets");
export async function getStaticProps() {
const settings = await getSettings();
return {
props: {
settings,
},
};
}
export default function Home({ settings }) {
const { data: services } = useSWR("/api/services");
const { data: bookmarks } = useSWR("/api/bookmarks");
const { data: widgets } = useSWR("/api/widgets");
const wrappedStyle = {};
if (settings.background) {
wrappedStyle.backgroundImage = `url(${settings.background})`;
wrappedStyle.backgroundSize = "cover";
}
return (
<ColorProvider>
<ThemeProvider>
<Head>
<title>Welcome</title>
<title>{settings.title || "Homepage"}</title>
</Head>
<div className="w-full container m-auto flex flex-col h-screen justify-between">
<div className="flex flex-row flex-wrap space-x-0 sm:space-x-4 m-8 pb-4 mt-10 border-b-2 border-theme-800 dark:border-theme-200 justify-between md:justify-start">
<div className="fixed w-full h-full m-0 p-0" style={wrappedStyle} />
<div className="relative w-full container m-auto flex flex-col h-screen justify-between">
<div className="flex flex-row flex-wrap m-8 pb-4 mt-10 border-b-2 border-theme-800 dark:border-theme-200 justify-between">
{widgets && (
<>
{widgets
@@ -42,7 +58,7 @@ export default function Home() {
<Widget key={i} widget={widget} />
))}
<div className="flex flex-wrap basis-full space-x-0 sm:space-x-4 grow sm:basis-auto justify-between md:justify-end mt-2 md:mt-0">
<div className="ml-4 flex flex-wrap basis-full grow sm:basis-auto justify-between md:justify-end mt-2 md:mt-0">
{widgets
.filter((widget) => rightAlignedWidgets.includes(widget.type))
.map((widget, i) => (
@@ -71,6 +87,7 @@ export default function Home() {
<div className="rounded-full flex p-8 w-full justify-between">
<ColorToggle />
<Revalidate />
<ThemeToggle />
</div>
</div>

View File

@@ -5,11 +5,6 @@
- My First Service:
href: http://localhost/
description: Homepage is awesome
# widget:
# type: npm # npm for NGINX Proxy Manager
# url: http://localhost # no slash at the end
# username: email@example.com # your email
# password: secretpassword # your password
- My Second Group:
- My Second Service:

View File

@@ -5,9 +5,7 @@
cpu: true
memory: true
disk: /
# - search: # Searchbar in widgets area
# provider: custom # Can be google, duckduckgo, bing or custom.
# target: _blank # Can be _blank, _top, _self or _parent.
# customdata:
# url: https://startpage.com/search?q= # Required for custom provider. Remember to add the q param as per your provider.
# abbr: G # Can be omitted. Only the first 2 characters will be considered.
- search:
provider: duckduckgo
target: _blank

View File

@@ -1,3 +1,16 @@
.theme-white {
--color-50: 255 255 255;
--color-100: 255 255 255;
--color-200: 255 255 255;
--color-300: 255 255 255;
--color-400: 255 255 255;
--color-500: 60 60 60;
--color-600: 255 255 255;
--color-700: 40 40 40;
--color-800: 255 255 255;
--color-900: 255 255 255;
}
.theme-slate {
--color-50: 248 250 252;
--color-100: 241 245 249;

View File

@@ -9,19 +9,26 @@ const formats = {
traefik: `{url}/api/{endpoint}`,
portainer: `{url}/api/endpoints/{env}/{endpoint}`,
rutorrent: `{url}/plugins/httprpc/action.php`,
transmission: `{url}/transmission/rpc`,
jellyseerr: `{url}/api/v1/{endpoint}`,
overseerr: `{url}/api/v1/{endpoint}`,
ombi: `{url}/api/v1/{endpoint}`,
npm: `{url}/api/{endpoint}`,
readarr: `{url}/api/v1/{endpoint}?apikey={key}`,
sabnzbd: `{url}/api/?apikey={key}&output=json&mode={endpoint}`,
coinmarketcap: `https://pro-api.coinmarketcap.com/{endpoint}`,
gotify: `{url}/{endpoint}`,
prowlarr: `{url}/api/v1/{endpoint}`,
};
export function formatApiCall(api, args) {
const match = /\{.*?\}/g;
const find = /\{.*?\}/g;
const replace = (match) => {
const key = match.replace(/\{|\}/g, "");
return args[key];
};
return formats[api].replace(match, replace);
return formats[api].replace(find, replace);
}
export function formatApiUrl(widget, endpoint) {

View File

@@ -5,9 +5,9 @@ export default async function cachedFetch(url, duration) {
if (cached) {
return cached;
} else {
const data = await fetch(url).then((res) => res.json());
cache.put(url, data, duration * 1000 * 60);
return data;
}
const data = await fetch(url).then((res) => res.json());
cache.put(url, data, duration * 1000 * 60);
return data;
}

View File

@@ -1,4 +1,4 @@
import { createContext, useState, useEffect } from "react";
import { createContext, useState, useEffect, useMemo } from "react";
let lastColor = false;
@@ -16,7 +16,7 @@ const getInitialColor = () => {
export const ColorContext = createContext();
export const ColorProvider = ({ initialTheme, children }) => {
export function ColorProvider({ initialTheme, children }) {
const [color, setColor] = useState(getInitialColor);
const rawSetColor = (rawColor) => {
@@ -38,5 +38,7 @@ export const ColorProvider = ({ initialTheme, children }) => {
rawSetColor(color);
}, [color]);
return <ColorContext.Provider value={{ color, setColor }}>{children}</ColorContext.Provider>;
};
const value = useMemo(() => ({ color, setColor }), [color]);
return <ColorContext.Provider value={value}>{children}</ColorContext.Provider>;
}

View File

@@ -348,7 +348,9 @@ export default function mapIcon(weatherStatusCode, timeOfDay) {
if (mapping) {
if (timeOfDay === "day") {
return mapping.icon.day;
} else if (timeOfDay === "night") {
}
if (timeOfDay === "night") {
return mapping.icon.night;
}
}

View File

@@ -1,5 +1,7 @@
import { join, path } from "path";
/* eslint-disable no-console */
import { join } from "path";
import { existsSync, copyFile, promises as fs } from "fs";
import yaml from "js-yaml";
export default function checkAndCopyConfig(config) {
@@ -8,7 +10,7 @@ export default function checkAndCopyConfig(config) {
const configSkeleton = join(process.cwd(), "src", "skeleton", config);
copyFile(configSkeleton, configYaml, (err) => {
if (err) {
console.log("error copying config", err);
console.error("error copying config", err);
throw err;
}
console.info("%s was copied to the config folder", config);
@@ -19,7 +21,7 @@ export default function checkAndCopyConfig(config) {
export async function getSettings() {
checkAndCopyConfig("settings.yaml");
const settingsYaml = path.join(process.cwd(), "config", "settings.yaml");
const settingsYaml = join(process.cwd(), "config", "settings.yaml");
const fileContents = await fs.readFile(settingsYaml, "utf8");
return yaml.load(fileContents);
}

View File

@@ -1,30 +1,35 @@
import yaml from "js-yaml";
import path from "path";
import { promises as fs } from "fs";
import { readFileSync } from "fs";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config";
export default async function getDockerArguments(server) {
export default function getDockerArguments(server) {
checkAndCopyConfig("docker.yaml");
const configFile = path.join(process.cwd(), "config", "docker.yaml");
const configData = await fs.readFile(configFile, "utf8");
const configData = readFileSync(configFile, "utf8");
const servers = yaml.load(configData);
if (!server) {
if (process.platform !== "win32" && process.platform !== "darwin") {
return { socketPath: "/var/run/docker.sock" };
} else {
return { host: "127.0.0.1" };
}
} else if (servers[server]) {
return { host: "127.0.0.1" };
}
if (servers[server]) {
if (servers[server].socket) {
return { socketPath: servers[server].socket };
} else if (servers[server].host) {
return { host: servers[server].host, port: servers[server].port || null };
} else {
return servers[server];
}
} else {
return null;
if (servers[server].host) {
return { host: servers[server].host, port: servers[server].port || null };
}
return servers[server];
}
return null;
}

View File

@@ -1,17 +1,18 @@
/* eslint-disable prefer-promise-reject-errors */
import https from "https";
import http from "http";
export function httpsRequest(url, params) {
return new Promise(function (resolve, reject) {
var request = https.request(url, params, function (response) {
var data = [];
return new Promise((resolve, reject) => {
const request = https.request(url, params, (response) => {
const data = [];
response.on("data", (chunk) => {
data.push(chunk);
});
response.on("end", () => {
resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data)]);
resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]);
});
});
@@ -19,21 +20,25 @@ export function httpsRequest(url, params) {
reject([500, error]);
});
if (params.body) {
request.write(params.body);
}
request.end();
});
}
export function httpRequest(url, params) {
return new Promise(function (resolve, reject) {
var request = http.request(url, params, function (response) {
var data = [];
return new Promise((resolve, reject) => {
const request = http.request(url, params, (response) => {
const data = [];
response.on("data", (chunk) => {
data.push(chunk);
});
response.on("end", () => {
resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data)]);
resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]);
});
});
@@ -41,6 +46,10 @@ export function httpRequest(url, params) {
reject([500, error]);
});
if (params.body) {
request.write(params.body);
}
request.end();
});
}
@@ -57,7 +66,6 @@ export function httpProxy(url, params = {}) {
agent: httpsAgent,
...params,
});
} else {
return httpRequest(constructedUrl, params);
}
return httpRequest(constructedUrl, params);
}

50
src/utils/i18n.js Normal file
View File

@@ -0,0 +1,50 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import prettyBytes from "pretty-bytes";
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
ns: ["common"],
// debug: process.env.NODE_ENV === "development",
defaultNS: "common",
nonExplicitSupportedLngs: true,
interpolation: {
escapeValue: false,
},
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
});
i18n.services.formatter.add("bytes", (value, lng, options) =>
prettyBytes(parseFloat(value), { locale: lng, ...options })
);
i18n.services.formatter.add("rate", (value, lng, options) => {
if (value === 0) return "0 Bps";
const bits = options.bits ? value : value / 8;
const k = 1024;
const dm = options.decimals ? options.decimals : 0;
const sizes = ["Bps", "Kbps", "Mbps", "Gbps", "Tbps", "Pbps", "Ebps", "Zbps", "Ybps"];
const i = Math.floor(Math.log(bits) / Math.log(k));
const formatted = new Intl.NumberFormat(lng, { maximumFractionDigits: dm, minimumFractionDigits: dm }).format(
parseFloat(bits / k ** i)
);
return `${formatted} ${sizes[i]}`;
});
i18n.services.formatter.add("percent", (value, lng, options) =>
new Intl.NumberFormat(lng, { style: "percent", ...options }).format(parseFloat(value) / 100.0)
);
export default i18n;

View File

@@ -135,7 +135,7 @@ const conditions = [
night: Icons.WiNightAltShowers,
},
},
{
code: 500,
icon: {
@@ -393,18 +393,17 @@ const conditions = [
night: Icons.WiCloudy,
},
},
];
export default function mapIcon(weatherStatusCode, timeOfDay) {
const mapping = conditions.find(
(condition) => condition.code === weatherStatusCode
);
const mapping = conditions.find((condition) => condition.code === weatherStatusCode);
if (mapping) {
if (timeOfDay === "day") {
return mapping.icon.day;
} else if (timeOfDay === "night") {
}
if (timeOfDay === "night") {
return mapping.icon.night;
}
}

View File

@@ -1,4 +1,4 @@
import { getServiceWidget } from "utils/service-helpers";
import getServiceWidget from "utils/service-helpers";
import { formatApiCall } from "utils/api-helpers";
import { httpProxy } from "utils/http";
@@ -10,15 +10,30 @@ export default async function credentialedProxyHandler(req, res) {
if (widget) {
const url = new URL(formatApiCall(widget.type, { endpoint, ...widget }));
const headers = {
"Content-Type": "application/json",
};
if (widget.type === "coinmarketcap") {
headers["X-CMC_PRO_API_KEY"] = `${widget.key}`;
} else if (widget.type === "gotify") {
headers["X-gotify-Key"] = `${widget.key}`;
} else {
headers["X-API-Key"] = `${widget.key}`;
}
const [status, contentType, data] = await httpProxy(url, {
method: req.method,
withCredentials: true,
credentials: "include",
headers: {
"X-API-Key": `${widget.key}`,
"Content-Type": "application/json",
},
headers,
});
if (status === 204 || status === 304) {
return res.status(status).end();
}
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
}

View File

@@ -1,4 +1,4 @@
import { getServiceWidget } from "utils/service-helpers";
import getServiceWidget from "utils/service-helpers";
import { formatApiCall } from "utils/api-helpers";
import { httpProxy } from "utils/http";
@@ -10,9 +10,16 @@ export default async function genericProxyHandler(req, res) {
if (widget) {
const url = new URL(formatApiCall(widget.type, { endpoint, ...widget }));
const [status, contentType, data] = await httpProxy(url);
const [status, contentType, data] = await httpProxy(url, {
method: req.method,
});
if (contentType) res.setHeader("Content-Type", contentType);
if (status === 204 || status === 304) {
return res.status(status).end();
}
return res.status(status).send(data);
}
}

View File

@@ -1,4 +1,4 @@
import { getServiceWidget } from "utils/service-helpers";
import getServiceWidget from "utils/service-helpers";
import { formatApiCall } from "utils/api-helpers";
export default async function npmProxyHandler(req, res) {
@@ -25,7 +25,7 @@ export default async function npmProxyHandler(req, res) {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + authResponse.token,
Authorization: `Bearer ${authResponse.token}`,
},
}).then((response) => response.json());

View File

@@ -1,5 +1,6 @@
import { JSONRPCClient } from "json-rpc-2.0";
import { getServiceWidget } from "utils/service-helpers";
import getServiceWidget from "utils/service-helpers";
export default async function nzbgetProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
@@ -25,9 +26,9 @@ export default async function nzbgetProxyHandler(req, res) {
if (response.status === 200) {
const jsonRPCResponse = await response.json();
return client.receive(jsonRPCResponse);
} else if (jsonRPCRequest.id !== undefined) {
return Promise.reject(new Error(response.statusText));
}
return Promise.reject(new Error(response.statusText));
})
);

View File

@@ -1,6 +1,6 @@
import RuTorrent from "rutorrent-promise";
import { getServiceWidget } from "utils/service-helpers";
import getServiceWidget from "utils/service-helpers";
export default async function rutorrentProxyHandler(req, res) {
const { group, service } = req.query;

Some files were not shown because too many files have changed in this diff Show More