Compare commits

..

197 Commits

Author SHA1 Message Date
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
Ben Phelps
cf39395924 add documentation link 2022-09-05 20:20:34 +03:00
Ben Phelps
6061d9ec65 cleanup imports 2022-09-05 20:19:08 +03:00
Ben Phelps
5a8defb478 allow weather apis to use hidden api keys 2022-09-05 20:14:14 +03:00
Alex
a265038bf6 Update Dockerfile 2022-09-05 16:42:10 +01:00
Ben Phelps
08afa0b747 check that content type exists before setting it 2022-09-05 10:08:02 +03:00
97 changed files with 3532 additions and 873 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"]
}
}
}
}

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

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, 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
@@ -79,6 +91,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 +110,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 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,118 @@
{
"widget": {
"missing_type": "Fehlender Widget-Typ: {{type}}",
"api_error": "API-Fehler",
"status": "Status"
},
"search": {
"placeholder": "Suche…"
},
"resources": {
"total": "Gesamt",
"free": "Frei",
"used": "Gebraucht"
},
"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"
}
}

View File

@@ -0,0 +1,129 @@
{
"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"
},
"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"
},
"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"
}
}

View File

@@ -0,0 +1,118 @@
{
"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"
},
"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": "Wanted",
"queued": "Queued",
"books": "Books"
},
"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": "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"
}
}

View File

@@ -0,0 +1,129 @@
{
"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ée"
},
"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": "Active",
"upload": "Téléverser",
"download": "Télécharger"
},
"sonarr": {
"wanted": "Recherchée",
"queued": "En queue",
"series": "Séries"
},
"radarr": {
"wanted": "Recherchée",
"queued": "En queue",
"movies": "Films"
},
"readarr": {
"wanted": "Wanted",
"queued": "Queued",
"books": "Books"
},
"ombi": {
"pending": "En attente",
"approved": "Approuvée",
"available": "Disponible"
},
"jellyseerr": {
"pending": "En attente",
"approved": "Approuvée",
"available": "Disponible"
},
"pihole": {
"queries": "Requêtes",
"blocked": "Bloquée",
"gravity": "La gravité"
},
"speedtest": {
"upload": "Téléversement",
"download": "Téléchargement",
"ping": "Ping-ping"
},
"portainer": {
"running": "Fonctionnement",
"stopped": "Arrêté",
"total": "Total"
},
"traefik": {
"routers": "Routeurs",
"services": "Prestations de service",
"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": "Approuvée",
"available": "Disponible"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"nzbget": {
"remaining": "Remaining",
"downloaded": "Downloaded",
"rate": "Rate"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
}
}

View File

@@ -0,0 +1,118 @@
{
"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"
},
"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"
}
}

View File

@@ -0,0 +1,118 @@
{
"widget": {
"missing_type": "Manglende miniprogramstype: {{type}}",
"api_error": "API-feil",
"status": "Status"
},
"search": {
"placeholder": "Søk …"
},
"resources": {
"total": "Totalt",
"free": "Ledig",
"used": "Brukt"
},
"docker": {
"rx": "Mottatt",
"tx": "Sendt",
"mem": "Minne",
"cpu": "Prosessor",
"offline": "Frakoblet"
},
"emby": {
"playing": "Spiller",
"transcoding": "Transkoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"tautulli": {
"playing": "Spiller",
"transcoding": "Transkoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams"
},
"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": "Pending",
"approved": "Approved",
"available": "Available"
},
"sabnzbd": {
"rate": "Rate",
"queue": "Queue",
"timeleft": "Time Left"
},
"nzbget": {
"rate": "Rate",
"downloaded": "Downloaded",
"remaining": "Remaining"
},
"coinmarketcap": {
"configure": "Configure one or more crypto currencies to track"
},
"gotify": {
"apps": "Applications",
"clients": "Clients",
"messages": "Messages"
}
}

View File

@@ -0,0 +1,118 @@
{
"widget": {
"missing_type": "Missing Widget Type: {{type}}",
"api_error": "API Error",
"status": "Status"
},
"resources": {
"total": "Totaal",
"free": "Vrij",
"used": "Gebruikt"
},
"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"
}
}

View File

@@ -0,0 +1,129 @@
{
"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"
},
"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"
}
}

View File

@@ -0,0 +1,118 @@
{
"widget": {
"missing_type": "Отсутствует тип виджета: {{type}}",
"api_error": "Ошибка API",
"status": "Статус"
},
"search": {
"placeholder": "Поиск…"
},
"resources": {
"total": "Общий",
"free": "Свободно",
"used": "Использовано"
},
"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"
}
}

View File

@@ -0,0 +1,118 @@
{
"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"
},
"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"
}
}

View File

@@ -0,0 +1,118 @@
{
"widget": {
"missing_type": "缺少小部件类型:{{type}}",
"api_error": "API错误",
"status": "地位"
},
"search": {
"placeholder": "搜索…"
},
"resources": {
"total": "全部的",
"free": "自由的",
"used": "用过的"
},
"docker": {
"rx": "rx",
"tx": "TX",
"mem": "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": "ping"
},
"portainer": {
"running": "跑步",
"stopped": "停了下来",
"total": "全部的"
},
"traefik": {
"routers": "路由器",
"services": "服务",
"middleware": "中间件"
},
"npm": {
"enabled": "已启用",
"disabled": "禁用",
"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"
}
}

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 backdrop-blur-md"
>
<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 backdrop-blur-md`}
>
<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,13 @@
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 Docker from "./widgets/service/docker";
import Pihole from "./widgets/service/pihole";
import Rutorrent from "./widgets/service/rutorrent";
@@ -11,13 +15,17 @@ 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";
const widgetMappings = {
docker: Docker,
sonarr: Sonarr,
radarr: Radarr,
readarr: Readarr,
ombi: Ombi,
portainer: Portainer,
emby: Emby,
@@ -28,11 +36,17 @@ const widgetMappings = {
speedtest: Speedtest,
traefik: Traefik,
jellyseerr: Jellyseerr,
overseerr: Overseerr,
coinmarketcap: CoinMarketCap,
npm: Npm,
tautulli: Tautulli,
gotify: Gotify,
sabnzbd: SABnzbd
};
export default function Widget({ service }) {
const { t } = useTranslation("common");
const ServiceWidget = widgetMappings[service.widget.type];
if (ServiceWidget) {
@@ -41,9 +55,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

@@ -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}bps`} />
<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" && (
<BsFillPlayFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
)}
{state !== "paused" && (
<BsPauseFill 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" && (
<BsFillPlayFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
)}
{state !== "paused" && (
<BsPauseFill 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

@@ -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,22 +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 }) {
function Widget({ options }) {
const { t, i18n } = useTranslation();
const { data, error } = useSWR(
`/api/widgets/openweathermap?lat=${options.latitude}&lon=${options.longitude}&apiKey=${options.apiKey}&duration=${options.cache}&units=${options.units}`
`/api/widgets/openweathermap?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`
);
if (error || data?.cod == 401) {
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>
@@ -25,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
@@ -44,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() {
const { t } = useTranslation();
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,26 @@ 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 font-mono min-w-[80px]">
<div className="text-theme-800 dark:text-theme-200 text-xs">
{t("common.number", { value: data.cpu.usage, style: "unit", unit: "percent", maximumFractionDigits: 0 })}{" "}
{t("docker.cpu")}
</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 }) {
const { t } = useTranslation();
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,28 @@ 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 group">
<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
<div className="flex flex-col ml-3 text-left min-w-[80px]">
<span className="text-theme-800 dark:text-theme-200 text-xs group-hover:hidden">
{t("common.bytes", { value: data.drive.freeGb * 1024 * 1024 * 1024 })} {t("resources.free")}
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{formatBytes(data.drive.usedGb * 1024 * 1024 * 1024)} Used
<span className="text-theme-800 dark:text-theme-200 text-xs hidden group-hover:block">
{t("common.bytes", { value: data.drive.totalGb * 1024 * 1024 * 1024 })} {t("resources.total")}
</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() {
const { t } = useTranslation();
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,28 @@ 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 group">
<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
<div className="flex flex-col ml-3 text-left min-w-[80px]">
<span className="text-theme-800 dark:text-theme-200 text-xs group-hover:hidden">
{t("common.bytes", { value: data.memory.freeMemMb * 1024 * 1024 })} {t("resources.free")}
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">
{formatBytes(data.memory.freeMemMb * 1024 * 1024)} Free
<span className="text-theme-800 dark:text-theme-200 text-xs hidden group-hover:block">
{t("common.bytes", { value: data.memory.usedMemMb * 1024 * 1024 })} {t("resources.used")}
</span>
<UsageBar percent={percent} />
</div>
</div>
);

View File

@@ -4,19 +4,17 @@ import Memory from "./memory";
export default function Resources({ 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 />}
{options.memory && <Memory />}
{Array.isArray(options.disk)
? options.disk.map((disk) => <Disk key={disk} options={{ disk }} />)
: options.disk && <Disk options={options} />}
</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 backdrop-blur-md">
<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,35 @@ 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
backdrop-blur-md"
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,22 +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 }) {
function Widget({ options }) {
const { t, i18n } = useTranslation();
const { data, error } = useSWR(
`/api/widgets/weather?lat=${options.latitude}&lon=${options.longitude}&apiKey=${options.apiKey}&duration=${options.cache}`
`/api/widgets/weather?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`
);
if (error) {
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>
@@ -25,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"} />
@@ -41,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>
@@ -49,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

@@ -11,13 +11,18 @@ 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,
// super specific handlers
rutorrent: rutorrentProxyHandler,
nzbget: nzbgetProxyHandler,
@@ -33,5 +38,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

@@ -1,9 +1,28 @@
import cachedFetch from "utils/cached-fetch";
import { getSettings } from "utils/config";
export default async function handler(req, res) {
const { lat, lon, apiKey, duration, units } = req.query;
const { latitude, longitude, units, provider, cache, lang } = req.query;
let { apiKey } = req.query;
const api_url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=${units}`;
if (!apiKey && !provider) {
return res.status(400).json({ error: "Missing API key or provider" });
}
res.send(await cachedFetch(api_url, duration));
if (!apiKey && provider !== "openweathermap") {
return res.status(400).json({ error: "Invalid provider for endpoint" });
}
if (!apiKey && provider) {
const settings = await getSettings();
apiKey = settings?.providers?.openweathermap;
}
if (!apiKey) {
return res.status(400).json({ error: "Missing API key" });
}
const apiUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${apiKey}&units=${units}&lang=${lang}`;
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

@@ -1,9 +1,28 @@
import cachedFetch from "utils/cached-fetch";
import { getSettings } from "utils/config";
export default async function handler(req, res) {
const { lat, lon, apiKey, duration } = req.query;
const { latitude, longitude, provider, cache, lang } = req.query;
let { apiKey } = req.query;
const api_url = `http://api.weatherapi.com/v1/current.json?q=${lat},${lon}&key=${apiKey}`;
if (!apiKey && !provider) {
return res.status(400).json({ error: "Missing API key or provider" });
}
res.send(await cachedFetch(api_url, duration));
if (!apiKey && provider !== "weatherapi") {
return res.status(400).json({ error: "Invalid provider for endpoint" });
}
if (!apiKey && provider) {
const settings = await getSettings();
apiKey = settings?.providers?.weatherapi;
}
if (!apiKey) {
return res.status(400).json({ error: "Missing API key" });
}
const apiUrl = `http://api.weatherapi.com/v1/current.json?q=${latitude},${longitude}&key=${apiKey}&lang=${lang}`;
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

@@ -0,0 +1,6 @@
# For configuration options and examples, please see:
# https://github.com/benphelps/homepage/wiki/Settings
providers:
openweathermap: openweathermapapikey
weatherapi: weatherapiapikey

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

@@ -10,18 +10,23 @@ const formats = {
portainer: `{url}/api/endpoints/{env}/{endpoint}`,
rutorrent: `{url}/plugins/httprpc/action.php`,
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}`,
};
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,8 @@
/* eslint-disable no-console */
import { join } from "path";
import { existsSync, copyFile } from "fs";
import { existsSync, copyFile, promises as fs } from "fs";
import yaml from "js-yaml";
export default function checkAndCopyConfig(config) {
const configYaml = join(process.cwd(), "config", config);
@@ -7,10 +10,18 @@ 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);
});
}
}
export async function getSettings() {
checkAndCopyConfig("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,10 +1,11 @@
/* 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);
@@ -24,9 +25,9 @@ export function httpsRequest(url, params) {
}
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);
@@ -57,7 +58,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,16 +10,31 @@ 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,
});
res.setHeader("Content-Type", contentType);
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();
}
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";
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;

View File

@@ -1,26 +1,147 @@
import { promises as fs } from "fs";
import path from "path";
import yaml from "js-yaml";
export async function getServiceWidget(group, service) {
import yaml from "js-yaml";
import Docker from "dockerode";
import * as shvl from "shvl";
import checkAndCopyConfig from "utils/config";
import getDockerArguments from "utils/docker";
export async function servicesFromConfig() {
checkAndCopyConfig("services.yaml");
const servicesYaml = path.join(process.cwd(), "config", "services.yaml");
const fileContents = await fs.readFile(servicesYaml, "utf8");
const services = yaml.load(fileContents);
if (!services) {
return [];
}
// 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) => {
return {
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]],
};
}),
};
const servicesArray = services.map((servicesGroup) => ({
name: Object.keys(servicesGroup)[0],
services: servicesGroup[Object.keys(servicesGroup)[0]].map((entries) => ({
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]],
})),
}));
return servicesArray;
}
export async function servicesFromDocker() {
checkAndCopyConfig("docker.yaml");
const dockerYaml = path.join(process.cwd(), "config", "docker.yaml");
const dockerFileContents = await fs.readFile(dockerYaml, "utf8");
const servers = yaml.load(dockerFileContents);
if (!servers) {
return [];
}
const serviceServers = await Promise.all(
Object.keys(servers).map(async (serverName) => {
const docker = new Docker(getDockerArguments(serverName));
const containers = await docker.listContainers({
all: true,
});
// 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 [];
}
const discovered = containers.map((container) => {
let constructedService = null;
Object.keys(container.Labels).forEach((label) => {
if (label.startsWith("homepage")) {
if (!constructedService) {
constructedService = {
container: container.Names[0].replace(/^\//, ""),
server: serverName,
};
}
shvl.set(constructedService, label.replace("homepage.", ""), container.Labels[label]);
}
});
return constructedService;
});
return { server: serverName, services: discovered.filter((filteredService) => filteredService) };
})
);
const mappedServiceGroups = [];
serviceServers.forEach((server) => {
server.services.forEach((serverService) => {
let serverGroup = mappedServiceGroups.find((searchedGroup) => searchedGroup.name === serverService.group);
if (!serverGroup) {
mappedServiceGroups.push({
name: serverService.group,
services: [],
});
serverGroup = mappedServiceGroups[mappedServiceGroups.length - 1];
}
const { name: serviceName, group: serverServiceGroup, ...pushedService } = serverService;
const result = {
name: serviceName,
...pushedService,
};
serverGroup.services.push(result);
});
});
const serviceGroup = servicesArray.find((g) => g.name === group);
return mappedServiceGroups;
}
export function cleanServiceGroups(groups) {
return groups.map((serviceGroup) => ({
name: serviceGroup.name,
services: serviceGroup.services.map((service) => {
const cleanedService = { ...service };
if (cleanedService.widget) {
// whitelisted set of keys to pass to the frontend
const {
type, // all widgets
server, // docker widget
container,
currency, // coinmarketcap widget
symbols,
} = cleanedService.widget;
cleanedService.widget = {
type,
currency,
symbols,
service_name: service.name,
service_group: serviceGroup.name,
};
if (type === "docker") {
cleanedService.widget.server = server;
cleanedService.widget.container = container;
}
}
return cleanedService;
}),
}));
}
export default async function getServiceWidget(group, service) {
const configuredServices = await servicesFromConfig();
const serviceGroup = configuredServices.find((g) => g.name === group);
if (serviceGroup) {
const serviceEntry = serviceGroup.services.find((s) => s.name === service);
if (serviceEntry) {
@@ -29,5 +150,16 @@ export async function getServiceWidget(group, service) {
}
}
const discoveredServices = await servicesFromDocker();
const dockerServiceGroup = discoveredServices.find((g) => g.name === group);
if (dockerServiceGroup) {
const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service);
if (dockerServiceEntry) {
const { widget } = dockerServiceEntry;
return widget;
}
}
return false;
}

View File

@@ -1,4 +1,4 @@
export function calculateCPUPercent(stats) {
export default function calculateCPUPercent(stats) {
let cpuPercent = 0.0;
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
@@ -9,27 +9,3 @@ export function calculateCPUPercent(stats) {
return Math.round(cpuPercent * 10) / 10;
}
export function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat(bytes / Math.pow(k, i)).toFixed(dm) + " " + sizes[i];
}
export function formatBits(bytes, decimals = 2) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["B", "Kb", "Mb", "Gb", "Tb", "Pb", "Eb", "Zb", "Yb"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat(bytes / Math.pow(k, i)).toFixed(dm) + " " + sizes[i];
}

View File

@@ -1,4 +1,4 @@
import { createContext, useState, useEffect } from "react";
import { createContext, useState, useEffect, useMemo } from "react";
const getInitialTheme = () => {
if (typeof window !== "undefined" && window.localStorage) {
@@ -18,7 +18,7 @@ const getInitialTheme = () => {
export const ThemeContext = createContext();
export const ThemeProvider = ({ initialTheme, children }) => {
export function ThemeProvider({ initialTheme, children }) {
const [theme, setTheme] = useState(getInitialTheme);
const rawSetTheme = (rawTheme) => {
@@ -39,5 +39,7 @@ export const ThemeProvider = ({ initialTheme, children }) => {
rawSetTheme(theme);
}, [theme]);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
};
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}