Compare commits

...

560 Commits

Author SHA1 Message Date
Thomas Camlong
f680c01547 🟣 V0.9.1: Overserr integration and new design ! 2022-08-11 19:05:26 +02:00
ajnart
4f94999b07 🐛 Fix a small bug with the display of images 2022-08-11 17:08:39 +02:00
ajnart
cf89141f82 🐛 Fix a bug with the AppShelf accordion 2022-08-11 10:08:18 +02:00
ajnart
b46bdea72a 🔧 Adjust default config 2022-08-10 18:47:14 +02:00
ajnart
98af9794ec 💄 Very small UI changes 2022-08-10 14:15:02 +02:00
ajnart
3d63226372 💄 Very small UI changes 2022-08-10 14:13:20 +02:00
ajnart
2e8dff346e 💄 Very small UI changes 2022-08-10 14:01:23 +02:00
ajnart
430f3b52e9 🐛 Fixing small bugs 2022-08-10 13:59:46 +02:00
ajnart
901b68732f Add open result to overseerr button 2022-08-09 17:04:19 +02:00
ajnart
d83900e134 🐛 Fix a bug with searching just "!os" in overseerr 2022-08-09 15:06:00 +02:00
ajnart
6f0902d473 🐛 Fix Jellyseerr request 2022-08-09 15:04:39 +02:00
ajnart
a1d3fc66da 🔖 Bumb version to v0.9.1 2022-08-09 13:36:26 +02:00
ajnart
c76ef9643b 🐛 Fix Popover open state 2022-08-09 13:35:59 +02:00
ajnart
67a274804f Add Jellyseerr full support 2022-08-09 13:26:55 +02:00
ajnart
c157c94d95 ✏️ Fix color for MenuItem 2022-08-09 13:23:29 +02:00
ajnart
bd0d5bc663 Make icon Ctrl-clickable
Will open a new tab
2022-08-09 13:23:02 +02:00
ajnart
91d079c5ab 🐛 Fix quick color bug 2022-08-09 13:22:15 +02:00
ajnart
fe8919c6ad 🔖 Upgrade tag to v0.9.0 2022-08-08 16:03:58 +02:00
Thomas Camlong
f792a0df96 Merge branch 'master' into dev 2022-08-08 16:02:25 +02:00
Thomas Camlong
1283b48d6b 🔀 Merge pull request #326 from ajnart/overseerr-integration
 Overseerr integration
2022-08-08 16:00:26 +02:00
ajnart
6adb796b26 🎨 Small styling changes 2022-08-08 15:43:04 +02:00
ajnart
528e899066 🐛 Fix overseerr api key field 2022-08-08 15:17:51 +02:00
ajnart
659222643c 🐳 Revert docker image change 2022-08-08 14:49:06 +02:00
ajnart
20d61c8d2a 📦 Add package and fix bug in DownloadsModule 2022-08-08 14:30:22 +02:00
Thomas Camlong
53e0b098ff 🔀 Merge pull request #322 from ajnart/mantine-v5
⬆️ Upgrading to Mantine v5
2022-08-08 13:52:52 +02:00
ajnart
9fa4836038 ⬆️ Upgrade to Mantine v5.1.0 (from v5.0.2) 2022-08-08 13:52:07 +02:00
ajnart
439874e811 💄 Calendar styling 2022-08-08 13:47:34 +02:00
ajnart
60fc6732b8 📝 Add examples for JSON formats
I could possibly turn these into type declarations with some online parser but at the moment it stays here for developpment purposes
2022-08-08 13:47:15 +02:00
ajnart
772fe7622d 🐛 Fix bug with Downloadmodule width 2022-08-08 13:46:14 +02:00
ajnart
1e69e3a2b0 🐛 Fix onBlurCapture in the Dropdown of overseerr 2022-08-08 13:45:54 +02:00
ajnart
b430e24cdb ✏️ Fix request Modal 2022-08-08 13:45:36 +02:00
ajnart
f9caf6ef26 ⚰️ Remove allowTransparency from dashdot 2022-08-08 13:45:12 +02:00
ajnart
9a53f5d1ee Improve MediaDisplay overseerr 2022-08-08 13:44:58 +02:00
ajnart
04874e69f2 🐛 Fix module wrapper hover bug 2022-08-08 13:44:35 +02:00
ajnart
1741829761 🔥 Remove tryRequest page 2022-08-08 13:44:20 +02:00
ajnart
67f19b5186 💄 Linting 2022-08-07 17:20:59 +02:00
ajnart
68d1068059 ⬆️ Migration to Mantine v5.0 in Popover 2022-08-07 17:20:34 +02:00
ajnart
03dd4b33ac 📦 Add Mantine Modal 2022-08-07 17:19:39 +02:00
ajnart
60ef0fe5d6 🔀 Merge Mantine v5.0 into Overseerr-integaration 2022-08-07 12:25:23 +02:00
ajnart
70814d0bc6 Add Overseerr integration 2022-08-07 12:16:29 +02:00
ajnart
b489c07177 🐛 Fix a bug with mediadisplay 2022-08-07 12:16:15 +02:00
ajnart
a3bc9ab9f4 🏷️ Add type definitions for Movie/Tv/Request 2022-08-07 12:15:35 +02:00
ajnart
40a76593a2 🧪 Add testing page for overseerr request 2022-08-07 12:15:15 +02:00
ajnart
0e3c9e7ba8 🚧 Change query in SearchBar to use new API 2022-08-07 12:14:57 +02:00
ajnart
8abf2af212 Add ModalsProvider to the App 2022-08-07 12:14:37 +02:00
ajnart
13d70cf0fd ♻️ Rework Overseerr API 2022-08-07 12:14:17 +02:00
ajnart
f0bb3f08b0 🏷️ Fix missing types 2022-08-07 12:13:44 +02:00
ajnart
d07b51f67d 📦 Add Consola for logging 2022-08-07 12:13:26 +02:00
ajnart
6dfda07713 Merge branch 'dev' into mantine-v5 2022-08-02 23:03:06 +02:00
ajnart
fd3f58b501 🔧 Dashdot module changes
Fixes #316
2022-08-02 23:00:40 +02:00
ajnart
e4f91a1c00 🔧 Use PasswordInput for credentials 2022-08-02 23:00:38 +02:00
ajnart
f0d1c6daf9 🐳 Change docker image to Linuxserver 2022-08-02 22:59:20 +02:00
Thomas Camlong
33268fda53 ✏️ Fix spelling errors 2022-08-02 22:59:20 +02:00
ajnart
275aa30d45 📦 Bumb to Mantine v5.0.2 2022-08-02 22:56:18 +02:00
ajnart
7c0c986564 💃🏻 Styling and fixing lint errors 2022-08-02 05:22:38 +02:00
ajnart
c4d8fb2e00 🐛 Fix bugs in PingModule 2022-08-02 05:22:02 +02:00
ajnart
33b84b9039 💩 Write shitty code to fix MediaDisplay build 2022-08-02 05:21:30 +02:00
ajnart
498598424b 🔧 Move Calendar module to new popover api 2022-08-02 05:18:07 +02:00
ajnart
762690493a 💃🏻 Styling credits and Menu enabler 2022-08-02 05:17:19 +02:00
ajnart
456f0ff2ee 🐛 Fix a bug with toolstips 2022-08-02 05:16:54 +02:00
ajnart
12c13de1bd 💃🏻 Settings menu styling 2022-08-02 02:21:04 +02:00
ajnart
847e0855d8 💃🏻 Typing, Styling, Formatting 2022-08-02 00:21:51 +02:00
ajnart
4e75605ac0 💃🏻 Typing, Styling, Formatting 2022-08-02 00:21:11 +02:00
ajnart
eff2fc5ac7 🐛 Fix ModuleWrapper Hover state 2022-08-02 00:20:42 +02:00
ajnart
09a8dd7db8 🐛 Fix add or modify service undefined errors 2022-08-02 00:20:04 +02:00
ajnart
318dc83d2d 🐛 Fix Accordion in AppShelf 2022-08-02 00:19:39 +02:00
ajnart
c501cfae76 🐛 Fix keys attribute on ColorSelector 2022-08-01 21:11:37 +02:00
ajnart
8bc74f4e0f 🐛 Fix bug in AppShelfMenu
Menu was not closing on click
2022-08-01 21:11:11 +02:00
ajnart
af001d8dfa 🐛 Fix Hover in ModuleWrapper 2022-08-01 17:53:32 +02:00
ajnart
0e1419cc9a 🐛 Fix configLoader 2022-08-01 17:28:27 +02:00
Thomas Camlong
a7bcc5689a 🔀 Merge pull request #321 from ajnart/ajnart/issue307
 Add caching for icons with an image proxy
2022-08-01 17:14:09 +02:00
ajnart
bc05038427 Add caching for icons with an image proxy
Fixes #307
2022-08-01 17:12:18 +02:00
Thomas Camlong
b4bdf3737a 🔀 Merge pull request #320 from ajnart/ajnart/issue316
🔧 Dashdot module changes
2022-08-01 16:36:50 +02:00
ajnart
1fa2060e2b 🔧 Dashdot module changes
Fixes #316
2022-08-01 16:36:00 +02:00
ajnart
a8c5f07fb2 🚑 Hotfix Docker image with new NextJS version 2022-08-01 14:15:15 +02:00
ajnart
ff5a334f79 🔧 Use PasswordInput for credentials 2022-08-01 14:14:38 +02:00
Thomas Camlong
84fdd705b6 🔀 Merge pull request #317 from ajnart/ajnart/issue240
🐳 Change docker image to Linuxserver
2022-08-01 11:27:27 +02:00
ajnart
818bfad5f4 🐳 Change docker image to Linuxserver 2022-08-01 11:25:53 +02:00
Thomas Camlong
678059b1d3 ✏️ Fix spelling errors 2022-07-28 13:37:17 +02:00
ajnart
9f9566b27c ♻️ Refactor and WIP towards mantine v5 2022-07-26 01:21:04 +02:00
ajnart
d4d9e5cfcb 🚧 Work in progress for Mantine v5 2022-07-26 00:51:55 +02:00
ajnart
7fcdb17d84 ⬆️ Upgrade to Mantine V5.0 and React 18 2022-07-26 00:51:25 +02:00
ajnart
aa990671c1 💚 CI 2022-07-25 00:15:20 +02:00
ajnart
0ce3c4cd83 💚 CI 2022-07-25 00:05:28 +02:00
ajnart
77daffcc4b 💄 Small style changes 2022-07-24 23:54:57 +02:00
ajnart
b04171aa76 Add preview if media is available on Plex 2022-07-24 23:48:48 +02:00
ajnart
a3f5b252b9 🚧 WIP on Overseerr integration 2022-07-24 23:18:01 +02:00
ajnart
1f2d560893 🐛 Fix an old bug with the search bar 2022-07-24 21:54:42 +02:00
ajnart
87c55f264e 🔀 Merge branch 'dev' into overseerr-integration 2022-07-24 21:36:55 +02:00
Thomas Camlong
3fe8a4f7bc 🔀 Merge pull request #303 from manuel-rw/wiki-links-to-doc-links-migration
🚚 migrate wiki links to docusaurus links
2022-07-24 20:27:13 +02:00
ajnart
2cf89a1eb3 💚 Make CI not build the docker image on PRs 2022-07-24 20:22:24 +02:00
ajnart
68d81b97b4 ⬇️ Downgrade NextJS and React
Middleware didn't work in v12.2.3. Hopefully the password protection will work again now.
2022-07-23 22:22:55 +02:00
Manuel
c0917e31ed Merge branch 'dev' into wiki-links-to-doc-links-migration 2022-07-23 13:15:34 +02:00
ajnart
d438faa3d8 🚚 Rename dash. folder to dashdot
Was causing issues on non-unix systems
2022-07-23 13:10:10 +02:00
Manuel Ruwe
9dd5d50034 🚚 migrate wiki links to docusaurus links 2022-07-23 12:40:36 +02:00
Thomas Camlong
1d734633f0 🚑 Merge pull request #301 from ajnart/dev
🚑 Hotfix Docker image with new NextJS version
2022-07-23 00:25:45 +02:00
ajnart
2186756535 🚑 Hotfix Docker image with new NextJS version 2022-07-22 22:27:02 +02:00
Thomas Camlong
702428d24f 🚀 v0.8.2 🐋 Docker fixes and quality of life changes
https://github.com/ajnart/homarr/compare/v0.8.0...v0.8.2
2022-07-22 18:45:13 +02:00
ajnart
c8b0e7013d Improve Calendar module error handling 2022-07-22 18:30:15 +02:00
ajnart
385b4a3b24 🐛 Fix Docker integration actions timeouts 2022-07-22 18:08:32 +02:00
ajnart
5ccdf735ae Hide calendar module sensitive data
Working towards #259
2022-07-22 18:07:36 +02:00
ajnart
81a7789f9c Hide downloads module sensitive data
Working thowards #259
2022-07-22 17:18:33 +02:00
ajnart
a4defd330c 🔥 Remove console.log() statement 2022-07-22 16:21:15 +02:00
ajnart
4628d1d1d7 🚚 Change setCookies to setCookie 2022-07-22 16:20:59 +02:00
ajnart
7b719c2273 🐛 Fix bugs with async events from dockerode 2022-07-22 16:19:56 +02:00
ajnart
a9b840452e ✏️ Fix async data gathering with Dockerode 2022-07-22 16:19:28 +02:00
ajnart
3b0658fee2 Use tryMatchPort 2022-07-22 16:19:07 +02:00
ajnart
b5f1491fbb Add TryMatchPort function and update MatchingImages 2022-07-22 16:18:36 +02:00
ajnart
539903f053 ✏️ Remove resolutions in packagelock 2022-07-22 16:16:09 +02:00
Thomas Camlong
f7aa6338f1 🔀 Merge pull request #296 from RichyHBM/adding-docker-images
Adding docker images to match thanks to @RichyHBM !
2022-07-22 15:18:20 +02:00
Thomas "ajnart" Camlong
f20c2d4472 🔖 Bump version to v0.8.2 2022-07-22 13:20:34 +02:00
Thomas "ajnart" Camlong
d1d13396f8 💄 Linting and prettier 2022-07-22 13:20:02 +02:00
Thomas "ajnart" Camlong
bed08c84de ⬆️ Upgrade layout for new React and NextJS versions 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
c0e1747e09 Make logo text togglable on/off 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
ea8df25620 Add searching feature in docker table 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
cd9e844001 🐛 Fix docker not getting all containers
Turned off containers will not be shown
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
8eac0bed84 Improve login page
Styling and responsiveness
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
d2eb31f510 ⬆️ Upgrade 404 page for NextJS latest 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
ed72ab6ec7 🐛 Fix middleware due to new NextJS version
Fixes #297
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
02d3766d60 ⬆️ Upgrade next.config.js for new NextJS version 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
5b4f166216 📦 Upgrade to React18 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
75ceab0cf1 🐛 Fix fetching images in MatchIcon 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
91181aed13 🔧 Add vscode debug files 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
3234f06a2d 🐛 Make docker container list scrollable
Fixes #295
2022-07-22 13:18:46 +02:00
Thomas Camlong
cac1059c16 Update feature-request.yml 2022-07-22 13:10:47 +02:00
RichyHBM
632376bed5 Additional hotio images 2022-07-21 18:50:27 +01:00
RichyHBM
64a29e7f4c Put qbittorrent in alphabetical order 2022-07-21 18:47:13 +01:00
RichyHBM
c6d8c9b2d8 Add hotio + other high usage images 2022-07-21 18:43:42 +01:00
RichyHBM
6915a1bfaf Add dashdot and linuxserver docker images to image to match list 2022-07-21 18:29:35 +01:00
Thomas "ajnart" Camlong
00751eeca5 Make discord integration a module
This allows for an error message if the docker integration fails to load
2022-07-21 11:43:43 +02:00
Thomas Camlong
715a4bd6c7 Merge pull request #292 from arghyadipchak/master
Fix Dash. compact view storage
2022-07-21 09:08:00 +02:00
Arghyadip Chakraborty
5df2c67c2f Fix Dash. compact view storage 2022-07-21 00:39:08 +05:30
Thomas Camlong
ce0f27bb6e v0.8.0 Docker 🐋 and Dash. ⚙ integrations !
<!-- Small release message -->

-  Add support for lists in module option by @ajnart in https://github.com/ajnart/homarr/pull/280
- 🔨 Fix Readarr default port number by @Moohan in https://github.com/ajnart/homarr/pull/287
-  Add dash. Integration by @MauriceNino in https://github.com/ajnart/homarr/pull/277
-  Add Docker integration by @ajnart in https://github.com/ajnart/homarr/pull/289

- Dash. (Pronounced Dashdot) is another self-hosted service, made by @MauriceNino that provides a simple way to see stats about your PC in a sleek way
- Docker integration provides a simple way to start, stop, restart and delete containers. To get started, simply mount your docker socket by adding `-v /var/run/docker.sock:/var/run/docker.sock` to your Homarr container !

* @Moohan made their first contribution in https://github.com/ajnart/homarr/pull/287
* @MauriceNino made their first contribution in https://github.com/ajnart/homarr/pull/277

**Full Changelog**: https://github.com/ajnart/homarr/compare/v0.7.2...v0.8.0
2022-07-20 16:49:48 +02:00
Thomas Camlong
5c1a171832 🔀 Merge pull request #289 from ajnart/docker-integration
Add Docker integration 🚀
2022-07-20 15:22:40 +02:00
Thomas "ajnart" Camlong
fd8ab2f643 🔀 Backmerge dev with dash. integration 2022-07-20 15:15:07 +02:00
Thomas Camlong
c750eed5ef Merge branch 'dev' into docker-integration 2022-07-20 15:05:16 +02:00
Thomas "ajnart" Camlong
c446bf1a1f 📦 Update cookies-next package 2022-07-20 15:02:09 +02:00
Thomas Camlong
0fdfa55067 🔀 Merge pull request #277 from MauriceNino/feature/276
Add dash. Integration thanks to @MauriceNino !
2022-07-20 14:52:12 +02:00
Thomas "ajnart" Camlong
c313eacefd 🐛 Fix small bug with the network module 2022-07-20 14:47:51 +02:00
Thomas "ajnart" Camlong
649f7521bc 🔒 Add guard for Docker socket 2022-07-20 14:21:11 +02:00
Thomas "ajnart" Camlong
7065b06c82 💄 Format code 2022-07-20 14:09:47 +02:00
Thomas "ajnart" Camlong
c4e01e482e Add simple image name matching 2022-07-20 14:08:56 +02:00
Thomas Camlong
e56c4b6b56 🔀 Merge pull request #287 from Moohan/master
Fix Readarr default port number
2022-07-11 13:54:01 +02:00
James McMahon
ce38163c6d Fix Readarr default port number
Per https://wiki.servarr.com/readarr
2022-07-11 12:21:28 +01:00
Thomas Camlong
0406d6d5ee Add skeleton while the meto module is loading 2022-07-07 07:13:11 +00:00
Thomas Camlong
4b92c52ea8 Add "Add to homarr" feature and move code 2022-07-06 18:08:39 +02:00
Thomas Camlong
be770d282a ⬆️ Upgrade NextJS version 2022-07-06 18:08:03 +02:00
MauriceNino
0bf95483f9 fix: styles for dash. widget 2022-06-30 16:08:39 +02:00
MauriceNino
60b88389a6 fix: remove leftover console.log 2022-06-30 16:08:39 +02:00
MauriceNino
72832a5767 fix: move enabled options to multi-select 2022-06-30 16:08:39 +02:00
MauriceNino
eb0313f551 fix: transform dash. -> dashdot for icon find 2022-06-30 16:08:39 +02:00
MauriceNino
c0ecc3d4c6 fix: types 2022-06-30 16:08:38 +02:00
MauriceNino
da7b478d81 feat: add dash. integration 2022-06-30 16:08:38 +02:00
Thomas Camlong
2702c9a7cf 🔀 Merge pull request #280 from ajnart/ajnart/issue279
 Add support for lists in module option
2022-06-28 22:33:28 +02:00
Thomas Camlong
3bda6c2b76 🔥 Remove the popover TIP when using the searchbar 2022-06-28 19:09:02 +02:00
Thomas Camlong
1a66bfb8be add a <Tip/> component and use it 2022-06-28 19:08:18 +02:00
Thomas Camlong
41be0e6362 🐛 Fix default values for modules
The default value was not set correctly for modules. This has been fixed. It was also fixed in the Weather Module and the Date Module.
2022-06-28 12:12:39 +02:00
Thomas Camlong
e93a3a3b5f Add support for lists in module option
This feature allows a module maker to use a list as the different possible values for a module integration.
2022-06-28 11:27:23 +02:00
Thomas Camlong
9945ef892e 📱 Fix settings pannels height 2022-06-28 11:06:45 +02:00
Thomas Camlong
812de35149 🐛 Fix a bug where download module was always there 2022-06-28 10:34:25 +02:00
Thomas Camlong
035224b02b add start/stop/restart feature on containers 2022-06-27 23:38:54 +02:00
Thomas Camlong
72aba9d8cd 🚧 Work in progress on the Docker integration 2022-06-27 19:25:26 +02:00
ajnart
df7e833b84 🚧 Work in progress on docker integration 2022-06-27 08:03:40 +02:00
Thomas Camlong
aab1492934 🔖 v0.7.2 2022-06-25 17:53:20 +02:00
Thomas Camlong
1ae074db8f 🔥 Remove .docusaurus/ 2022-06-25 17:51:44 +02:00
Thomas Camlong
f21004e944 🔖 v0.7.2
Tag version v0.7.2
2022-06-25 17:47:39 +02:00
Thomas Camlong
7c421cc52f 🔀 Merge pull request #260 from walkxcode/dev
💄 Changes AppShelf category styling
2022-06-25 16:11:38 +02:00
Thomas Camlong
d8e407ab22 🔀 Merge pull request #257 from ajnart/fix-multiple-torrent-client
🐛 Fix itteration on the different types of services
2022-06-25 15:36:59 +02:00
Thomas Camlong
37565284e6 🔀 Merge pull request #271 from ajnart/searchBar
 Adds query placeholder and autoFocus (#267 #268)
2022-06-25 15:36:06 +02:00
Bjorn Lammers
b758df9f44 🔥 Fix indentation because I'm a perfectionist 2022-06-25 15:14:39 +02:00
Bjorn Lammers
a735ae47c5 🙈 Updates .dockerignore 2022-06-25 15:13:00 +02:00
WalkxCode
97d585dc17 Adds query placeholder and autoFocus (#267 #268) 2022-06-25 14:02:53 +02:00
Thomas Camlong
7f3db9add1 🐛 Fix adding a service doesn't fetch 2022-06-24 13:44:43 +02:00
Thomas Camlong
6d6964f086 🔀 Merge pull request #258 from ajnart/ajnart/issue256
🐛 Allow anything in the input for the form.
2022-06-24 13:39:18 +02:00
Thomas Camlong
2a4012f73a 🔀 Merge pull request #263 from ajnart/#261-discord
💬 Adds Discord Button (#261)
2022-06-24 12:44:23 +02:00
Bjorn Lammers
9385315f03 🔀 Merge pull request #265 from jelliuk/patch-1 2022-06-24 12:06:27 +02:00
James
ee824f0b27 Update README.md
Correct the link to Wiki/Integrations
2022-06-24 10:36:43 +01:00
Bjorn Lammers
792af504c7 💬 Adds Discord Button
#261
2022-06-22 13:19:44 +02:00
Bjorn Lammers
cd3c062a24 💄 Changes AppShelf category styling 2022-06-21 19:14:18 +00:00
ajnart
a5f477c19b 🚑 Hotfix to spread torrent pushing 2022-06-21 21:04:21 +02:00
ajnart
85164d79fc 🚑 Hotfix password and usernames 2022-06-21 20:35:40 +02:00
ajnart
7aedc4111f 🚑 Hotfix how the result from the services are awaited 2022-06-21 19:59:25 +02:00
ajnart
d1f89847f5 💄 Small UI fix for mobile 2022-06-21 19:38:32 +02:00
ajnart
57170847a1 🐛 Allow anything in the input for the form.
If it works, it works.
Fixes #256
2022-06-21 19:22:14 +02:00
ajnart
45de715390 🐛 Fix itteration on the different types of services 2022-06-21 19:16:29 +02:00
Thomas Camlong
c29d6f58dd 🔀 Merge pull request #252 from LarveyOfficial/fix-multiple-download-clients
🐛Allow multiple of the same torrent client +1
2022-06-21 16:22:18 +02:00
ajnart
f0bae49830 🚨 Lint and prettier fix 2022-06-21 16:21:40 +02:00
Larvey
c3ceae4dc6 Also fixed Torrent form fields 2022-06-20 17:26:13 -04:00
Larvey
d654fb39e5 🐛Allow multiple of the same torrent client
Allows multiple of the same type of torrent client
2022-06-20 17:10:54 -04:00
Thomas Camlong
7dc205fa66 🚀 v0.7.1 ! Bug fixes and QOL improvements
### Features
*  Changing deluge/qbittorent to use href instead of origin by @VinnyVynce in https://github.com/ajnart/homarr/pull/178
*  Transmission Integration by @ajnart in https://github.com/ajnart/homarr/pull/181
*  Add different URL for API calls by @ajnart in https://github.com/ajnart/homarr/pull/180
* Password / Login Page by @ajnart in https://github.com/ajnart/homarr/pull/179
*  Ability to toggle categories by @ajnart in https://github.com/ajnart/homarr/pull/177
*  Add settings to change title and icons by @Aimsucks in https://github.com/ajnart/homarr/pull/184
*  Color, shade, app opacity, and background customizations by @Aimsucks in https://github.com/ajnart/homarr/pull/188
*  Calendar indication about date and w-e with secondary color by @Darkham42 in https://github.com/ajnart/homarr/pull/193
*  More Information in Torrents Module by @LarveyOfficial in https://github.com/ajnart/homarr/pull/195
*  Could position widgets at left by @Darkham42 in https://github.com/ajnart/homarr/pull/197
*  Configure calendar widget to show Sunday first by @ajnart in https://github.com/ajnart/homarr/pull/224
* Service Addition Overhaul by @LarveyOfficial in https://github.com/ajnart/homarr/pull/229
*  Slidable service span in customizations  by @Aimsucks in https://github.com/ajnart/homarr/pull/222
### Tweaks
🔧 Make credentials non-required for torrents by @ajnart in https://github.com/ajnart/homarr/pull/223
### Bug fixes
* 🐛Fix completed torrents progress color by @LarveyOfficial in https://github.com/ajnart/homarr/pull/227
* 🐛Tiles could be moved accidentally on mobiles by @ajnart in https://github.com/ajnart/homarr/pull/226
* 🐛 Cannot open "New Tab URL" on mobile by @ajnart in https://github.com/ajnart/homarr/pull/225
* 🐛 Fix Calendar Item Duplication by @LarveyOfficial in https://github.com/ajnart/homarr/pull/249
* 🐛Fix URL for Radarr and other services by @LarveyOfficial in https://github.com/ajnart/homarr/pull/250
* 🐛 Fix Calendar not loading content when a service fails by @LarveyOfficial in https://github.com/ajnart/homarr/pull/230
* 🐛 Calendar current day for light theme by @Darkham42 in https://github.com/ajnart/homarr/pull/194
* 🐛 Fix for timezone issues by @LarveyOfficial in https://github.com/ajnart/homarr/pull/186
* 🐛 Fix Sonarr Incorrect Dates by @LarveyOfficial in https://github.com/ajnart/homarr/pull/189
* 🐛 Fix for origin URL not containing path in API request URL by @Aimsucks in https://github.com/ajnart/homarr/pull/221
2022-06-20 22:09:25 +02:00
ajnart
91a249d953 🔖 tag v0.7.1 2022-06-20 22:03:43 +02:00
Thomas Camlong
356afda9c7 Merge pull request #250 from LarveyOfficial/patch-2
🐛Fix URL for Radarr and other services
2022-06-20 20:14:17 +02:00
Larvey
35f02a2296 Fix URL for Radarr and other services 2022-06-20 14:02:33 -04:00
Thomas Camlong
16bcec0deb Merge pull request #249 from LarveyOfficial/patch-1
🐛 Fix Calendar Item Duplication
2022-06-20 19:45:47 +02:00
Larvey
16ec57081b Fix Calendar Item Duplication
- 6fd23cf6a0 changed how items for the calendar are acquired, making it so every series gets updated when a new one gets added, or one gets moved. This causes the entries in the calendar to duplicate due to old code being left in.
2022-06-20 13:32:31 -04:00
ajnart
690f09fcf3 🚑 Hotfix ServiceItems 2022-06-20 10:40:30 +02:00
Thomas Camlong
2f960169bb 🔀 Merge pull request #222 from Aimsucks/app-card-with-slider
 Slidable service span in customizations
2022-06-20 10:29:39 +02:00
ajnart
14a40d9f66 🔧 Tweak values and UI changes 2022-06-20 10:24:22 +02:00
Thomas Camlong
e5abd67f83 🔀 Merge pull request #221 from Aimsucks/issue-215-fix
🐛 Fix for origin URL not containing path in API request URL
2022-06-20 10:22:13 +02:00
Thomas Camlong
399ba7e2fc 🔀 Merge pull request #229 from LarveyOfficial/rework-AddAppShelfItem
Service Addition Overhaul
2022-06-20 10:20:01 +02:00
ajnart
7780ae3d7a ♻️ Re-implement changes 2022-06-20 10:17:49 +02:00
ajnart
80d3f16473 🔧 Tweak UI and change the name of the settings 2022-06-20 10:06:30 +02:00
Larvey
a8c0dfcd0c Fix Capitalization 2022-06-20 10:06:15 +02:00
Larvey
6ee7d6ec8d declutter config file 2022-06-20 10:06:15 +02:00
Larvey
544fae3808 Added Scrollbar 2022-06-20 10:05:55 +02:00
Larvey
4516dde1f4 Reworked AddAppShelfItem
Adds:
- Advanced Options tab
- Changed which ping status codes identify as "Online"
- Change if service opens in new tab or not

Fixes:
- Deluge and Transmission Password requirement
2022-06-20 10:01:36 +02:00
Thomas Camlong
a20c5f8d12 🔀 Merge pull request #223 from ajnart/transmission-login
🔧 Make credentials non-required for torrents
2022-06-20 09:08:10 +02:00
Thomas Camlong
60e5c0d165 🔀 Merge pull request #230 from LarveyOfficial/calendar-multi-content-fix
🐛 Fix Calendar not loading content when a service fails
2022-06-20 09:03:14 +02:00
Larvey
b7bf18250d Fix CalendarModule.tsx
Fix no content showing when 1 of the same service is down.
2022-06-16 21:02:30 -04:00
Thomas Camlong
93256b7a6a 🔀 Merge pull request #224 from ajnart/ajnart/issue217
 Configure calendar widget to show Sunday first #217
2022-06-15 06:52:50 +02:00
Thomas Camlong
47a4437a01 🔀 Merge pull request #225 from ajnart/ajnart/issue202
🐛 Fix cannot open "New Tab URL" on mobile
2022-06-15 06:52:16 +02:00
Thomas Camlong
92470c619e 🔀 Merge pull request #226 from ajnart/ajnart/issue214
🐛Tiles could be moved accidentally on mobiles
2022-06-15 06:51:18 +02:00
Thomas Camlong
7cb3dfbd16 🔀 Merge pull request #227 from LarveyOfficial/fix-progress-color
🐛Fix completed torrents progress color
2022-06-15 06:51:06 +02:00
ajnart
d69e4f41a1 🐛 Fix required username 2022-06-15 06:47:53 +02:00
Larvey
4980254e89 Update DownloadsModule.tsx 2022-06-14 21:14:26 -04:00
ajnart
5133286e04 🐛Tiles could be moved accidentally on mobiles
Fixes #214
2022-06-14 22:45:31 +02:00
ajnart
ca2713a12c 🐛 Cannot open "New Tab URL" on mobile
Fixes #202
2022-06-14 22:11:55 +02:00
ajnart
4981823c37 🔧 Tweak values on the slider 2022-06-14 22:09:30 +02:00
ajnart
5d31e414f0 Configure calendar widget to show Sunday first
Fixes #217
2022-06-14 20:50:11 +02:00
ajnart
8ec2b9d0cd 🔧 Make credentials non-required for torrents
Fixes #201
2022-06-14 20:33:26 +02:00
Aimsucks
bd920dfc86 App card width slider
Added a slider to determine the XL app card width
2022-06-14 17:45:22 +00:00
Aimsucks
b5540a9958 🐛 Origin URL in calendar to includes path
Changed the origin variable in the calendar module to use the entire URL instead of just the origin domain
2022-06-14 17:01:30 +00:00
Thomas Camlong
778988de58 🚀 v0.7.0 : Theming, Password protection, Autocompletion, Transmission, Mobile responsiveness! This is a big upgrade 👀 2022-06-13 10:15:39 +02:00
ajnart
5b1437552d 🔖 Bump version to v0.7.0 2022-06-12 16:58:48 +02:00
WalkxCode
e8a8fbe6ac 💄 Makes the ModuleEnabler grid look better 2022-06-12 16:35:56 +02:00
WalkxCode
5c0a074219 💬 Updates names and placeholders in AddAppShelfItem 2022-06-12 16:35:31 +02:00
WalkxCode
58ec74bb68 🚸 Improves case matching for auto-fill 2022-06-12 16:34:24 +02:00
Thomas Camlong
6ac82bda40 🔀 Merge pull request #197 from Darkham42/dev
feat: could position widgets at left
2022-06-12 15:46:13 +02:00
Thomas Camlong
2c6e86840a 🔀 Merge pull request #195 from LarveyOfficial/patch-3
More Information in Torrents Module
2022-06-12 15:39:32 +02:00
ajnart
df85fc6b7d 🐛 Fix multiple bugs and reformat code 2022-06-12 15:38:14 +02:00
Darkham42
89804dafd1 feat: could position widgets at left 2022-06-12 08:04:20 +02:00
Larvey
98eaee1234 Use humanFileSize 2022-06-12 01:15:38 -04:00
Larvey
433edafddd Remove humanFileSize 2022-06-12 01:14:38 -04:00
Larvey
e39d5741b6 Create humanFileSize.ts 2022-06-12 01:13:51 -04:00
Larvey
21c08cbe63 Change Elements hidden on Mobile 2022-06-12 00:59:12 -04:00
Larvey
ec92a1d89c Removed new Features on Mobile (Temporary) 2022-06-12 00:52:12 -04:00
ajnart
0f2c5dbce2 🔥 Remove keyboard usage to sort items 2022-06-12 06:36:37 +02:00
Larvey
8eae5a908c Merge branch 'dev' into patch-3 2022-06-12 00:25:36 -04:00
Thomas Camlong
a37f0fdee6 🔀 Merge pull request #194 from Darkham42/dev
fix: calendar current day for light theme
2022-06-12 06:16:59 +02:00
Larvey
08799aac18 Fix build issue from pull request #193 2022-06-11 19:16:03 -04:00
Larvey
06531e0fb8 Removed Logging used during development 2022-06-11 19:02:34 -04:00
Larvey
0f56ead24f Fixed Customization Spelling 2022-06-11 19:00:50 -04:00
Larvey
922caa76da More Info in Torrents Module
Added
- ETA
- Torrent Size
- Paused state color in progress bar
2022-06-11 18:59:45 -04:00
Darkham42
0acb1f6b6d refactor: use theme 2022-06-11 23:44:14 +02:00
Darkham42
8d645ca404 fix: calendar current day for light theme 2022-06-11 23:22:58 +02:00
Thomas Camlong
a5c4f30f57 Merge pull request #193 from Darkham42/dev
feat: calendar indication about date and w-e with secondary color
2022-06-11 20:20:37 +02:00
Darkham42
562a05adf5 feat: calendar indication about date and we with secondary color 2022-06-11 20:11:20 +02:00
Thomas Camlong
de77e06b18 🔀 Merge pull request #188 from Aimsucks/more-customizations
Color, shade, app opacity, and background customizations thank you @Aimsucks !
2022-06-11 19:49:46 +02:00
ajnart
03c499d822 🚚 Make the weather module release (from beta) 2022-06-11 19:45:09 +02:00
ajnart
169d08f3b6 🚚 Move selectors to customisation tab 2022-06-11 19:44:11 +02:00
ajnart
437807a9e0 💄 Change module enabler layout 2022-06-11 19:43:01 +02:00
ajnart
4866fd74b5 🚚 Rename tabs in settings 2022-06-11 19:42:36 +02:00
ajnart
426ba69afd Add more transparency areas and fix bugs 2022-06-11 18:37:13 +02:00
Aimsucks
74f87b570d Update src/components/layout/Background.tsx
Co-authored-by: Bjorn Lammers <walkxnl@gmail.com>
2022-06-08 18:09:59 -04:00
Aimsucks
fed5f6df52 📝 Added a placeholder for background 2022-06-08 20:20:24 +00:00
Aimsucks
5cc160473c Revert "📝 Background image placeholder and instructions"
This reverts commit 4833157061.
2022-06-08 20:18:42 +00:00
Aimsucks
4833157061 📝 Background image placeholder and instructions
Updated readme with instructions to mount the /public folder instead of the /public/icons folder, as well as added a placeholder for background image settings
2022-06-08 18:13:41 +00:00
Aimsucks
a0c8518d22 🎨 Made opacity change app background
Made the opacity slider change the individual app background and border rgba values instead of the entire app.
2022-06-08 18:01:16 +00:00
Aimsucks
c0c816d3db Update src/components/Settings/ShadeSelector.tsx
Co-authored-by: Bjorn Lammers <walkxnl@gmail.com>
2022-06-08 13:13:04 -04:00
Aimsucks
ac47de72ee Merge branch 'dev' into more-customizations 2022-06-08 12:48:02 -04:00
ajnart
d631865f71 💄 Small UI qol update
Module download now has a different look and can be toggled on and off
2022-06-08 18:41:22 +02:00
Aimsucks
4ee6562e35 🐛 Fix for favicon not changing
Removed favicon and title from _app.tsx
2022-06-08 16:26:09 +00:00
ajnart
19f80b9b4c 🚑 Hotfix calendar and mobile responsiveness 2022-06-08 16:16:00 +00:00
ajnart
949deacd6d 🔀 Rebase with dev 2022-06-08 16:16:00 +00:00
Larvey
b0f4a91878 Fix Sonarr Incorrect Dates
Due to how Sonarr gives dates, they recommend using UTC time when displaying it as it matches their calendar. Took some digging but it fixed it.
2022-06-08 16:16:00 +00:00
ajnart
68f2e79056 🧱 Try to fix cookies issues 2022-06-08 16:16:00 +00:00
ajnart
43e68e1bbf 🐛 Trying to fix dates 2022-06-08 16:16:00 +00:00
Larvey
5033323b7c Fix for timezone issues 2022-06-08 16:16:00 +00:00
Aimsucks
7519b4a6b2 Added an app opacity slider
Added a slider to change individual app opacity on the AppShelf
2022-06-08 16:03:06 +00:00
Aimsucks
e6eedefec4 Added a shade selector
Added a popover shade selector similar to the color selector, but shows primary and secondary colors to pick the desired Mantine primaryShade
2022-06-08 15:36:54 +00:00
Aimsucks
845d6a3d87 🎨 Made color switcher change Mantine styles
Moved the color switcher's functions to a context provider and made Mantine's styles derived off of that context.
2022-06-08 14:58:32 +00:00
ajnart
f75da289c2 🚑 Hotfix calendar and mobile responsiveness 2022-06-08 09:56:04 +02:00
Thomas Camlong
063a6447c0 🔀 Merge pull request #189 from LarveyOfficial/patch-2
Fix Sonarr Incorrect Dates
2022-06-08 08:11:13 +02:00
ajnart
4dac730412 🔀 Rebase with dev 2022-06-08 08:09:59 +02:00
Larvey
de6e0f645f Fix Sonarr Incorrect Dates
Due to how Sonarr gives dates, they recommend using UTC time when displaying it as it matches their calendar. Took some digging but it fixed it.
2022-06-07 22:05:28 -04:00
Aimsucks
b26ab50c8d 🎨 Changed primary/secondary color to camelCase 2022-06-07 17:48:04 +00:00
Aimsucks
423f8110b9 Added a background image input
Added an input in the advanced options for a background image. Also removed an unused import from my previous commit and changed the margin on the header bar to padding instead.
2022-06-07 17:36:05 +00:00
ajnart
84ae49ed2a 🧱 Try to fix cookies issues 2022-06-07 19:34:58 +02:00
ajnart
fb291c5411 🐛 Trying to fix dates 2022-06-07 19:34:24 +02:00
Aimsucks
901798055b Added primary/secondary color selection
Added two new inputs to the options menu: primary and secondary color selectors.
2022-06-07 16:53:51 +00:00
Thomas Camlong
d32d599098 🔀 Merge pull request #186 from LarveyOfficial/patch-1
Fix for timezone issues
2022-06-07 17:22:57 +02:00
Larvey
76e02cf148 Fix for timezone issues 2022-06-07 11:19:53 -04:00
Thomas Camlong
f19b4675ad 🔀 Merge pull request #184 from Aimsucks/change-title-icons
Add settings to change title and icons
2022-06-07 16:17:50 +02:00
ajnart
4f1640b70a 🐛 Fix a small bug inside the torrent module 2022-06-07 12:41:49 +02:00
Thomas Camlong
c1d17ec8b2 Update src/components/Settings/AdvancedSettings.tsx
Co-authored-by: Bjorn Lammers <walkxnl@gmail.com>
2022-06-07 12:12:23 +02:00
Thomas Camlong
d2f1268520 Merge branch 'dev' into change-title-icons 2022-06-07 10:37:36 +02:00
ajnart
b72afc2270 📦 💄 Upgrade packages and style 2022-06-07 10:36:47 +02:00
ajnart
de0c625f88 🐛 Fixing Deluge integration
Thanks to @scttcper for fixing https://github.com/scttcper/deluge/issues/106 so quickly !
2022-06-07 09:50:04 +02:00
Thomas Camlong
29c9f3ecac 🔥 Remove the Code quality tickboxes
They were annoying (to me at least)
2022-06-07 08:32:39 +02:00
ajnart
a321095daf 💄 Styling the settings 2022-06-07 08:21:03 +02:00
ajnart
ced18da65a 🔥 Remove default values for the Advanced settings 2022-06-07 08:20:19 +02:00
ajnart
1a642ad7b4 🔧 Make the changed values optional 2022-06-07 07:20:44 +02:00
Aimsucks
838f196937 Ability to change title and icons V2!
Results of criticism in pull request #182
2022-06-07 01:35:50 +00:00
Aimsucks
6af5166aa5 Ability to change title and icons 2022-06-07 00:07:56 +00:00
ajnart
7935fb6616 🚑 Hotfix icon matching 2022-06-07 01:04:34 +02:00
ajnart
ed567065b4 ⚰️ Remove dead code 2022-06-07 00:30:42 +02:00
ajnart
06035fb6f0 💄 Very minor fix the the AppShelf UI 2022-06-07 00:15:19 +02:00
ajnart
c1af0a087d 💄 Styling the AppShelf 2022-06-07 00:07:36 +02:00
ajnart
6067c5dfcf 💄 Styling changes for medias and AppShelf 2022-06-06 23:56:33 +02:00
ajnart
bf7b9637f7 🔥 Remove CPU module 2022-06-06 23:56:08 +02:00
ajnart
c552104413 🔥 Remove CPU module 2022-06-06 23:55:49 +02:00
ajnart
6fd23cf6a0 Add support for multiple Arr services
In the calendar, you can now have 2 separate Sonarr or Radarr instances
2022-06-06 23:40:45 +02:00
ajnart
e2f59383d6 🚑 Small UI hotfixes 2022-06-06 23:27:55 +02:00
ajnart
8b92135a80 💄 Make responsiveness better for mobile
Posters aren't huge on mobile anymore, yay
2022-06-06 22:32:57 +02:00
ajnart
aef4a30512 🚑 Hotfix position of the downloads module 2022-06-06 21:45:45 +02:00
Thomas Camlong
ace8bd75e7 🔀 Merge pull request #177 from ajnart/ajnart/issue150
 Ability to toggle categories
2022-06-06 21:40:02 +02:00
Thomas Camlong
2e461b4e7a 🔀 Merge pull request #179 from ajnart/ajnart/issue174
Password / Login Page
2022-06-06 21:39:47 +02:00
Thomas Camlong
3f87e939c9 🔀Merge pull request #180 from ajnart/ajnart/issue163
 Add different URL for API calls
2022-06-06 21:39:38 +02:00
Thomas Camlong
1d9dfc5102 🔀 Merge pull request #181 from ajnart/ajnart/issue147
Transmission Integration
2022-06-06 21:39:26 +02:00
ajnart
80a94d3778 FR: Transmission Integration
Fixes #147
2022-06-06 21:38:50 +02:00
ajnart
39d66faf4e Add autocomplete to Search Module
Suggestions when searching with the search bar Fixes #12
2022-06-06 20:02:42 +02:00
ajnart
c50e11c75b 🐛 Fix celcius to farenheit 2022-06-06 19:12:59 +02:00
ajnart
9a3ebb56cb 🎨 Quality of life : Use debouncedValue 2022-06-06 18:44:02 +02:00
ajnart
1d1495453a 🩹 Add default values for the categories to be opened by default 2022-06-06 18:31:42 +02:00
ajnart
26cfc485c2 Ability to toggle categories
Fixes #150
2022-06-06 18:31:40 +02:00
ajnart
83b4da282a Password / Login Page
Fixes #174
2022-06-06 18:30:14 +02:00
ajnart
ea972effb4 Add different URL for API calls
Fixes #163
2022-06-06 18:29:02 +02:00
ajnart
9686761c3d Merge branch 'master' into dev 2022-06-06 18:28:34 +02:00
ajnart
13a5a4a263 Revert CI changes 2022-06-06 18:28:26 +02:00
ajnart
339919cfff Add keyboard navigation (kind of)
Fixes #165
2022-06-06 17:39:18 +02:00
ajnart
2594a7caa5 💄 Disable item selection on mobile
Fixed Disable text selection in iOS (touch devices) #166
2022-06-06 17:37:42 +02:00
ajnart
2966be4fc4 Add support for multiple same service in Calendar
Fixes Calendar Support for Multiple Sonarr / Radarr #176
2022-06-06 15:34:33 +02:00
ajnart
5e21a7df9c 🐛 Fix bug in ping module
Module would not ping on the first activation / deactivation
2022-06-06 15:33:25 +02:00
Thomas Camlong
64eb00f2ee 🔀 Changing deluge/qbittorent to use href instead of origin
Thank you @VinnyVynce
2022-06-06 15:21:16 +02:00
ajnart
00928ae709 📱 Make the design way more responsive for mobile 2022-06-06 15:20:46 +02:00
ajnart
bbb912479b 🪝 AdduseSetSafeInterval hook 2022-06-06 15:02:41 +02:00
VinnyVynce
5b16589360 Change urls for href instead of origin 2022-06-06 07:07:35 -04:00
Thomas Camlong
39674fc769 Update docker_dev.yml 2022-06-06 12:22:32 +02:00
Thomas Camlong
e718fd6b80 v0.6.0 Categories and current download graphs ! 🥳 2022-06-03 14:14:47 +02:00
ajnart
bdaf70f26b 🚑 Hotfix errors 2022-06-03 14:11:24 +02:00
Thomas Camlong
44a7df5ae0 Merge pull request #170 from ajnart/system-info
System info
2022-06-03 13:56:45 +02:00
ajnart
25fa376c2d Update display for current CPU 2022-06-03 13:56:15 +02:00
Thomas "ajnart" Camlong
de3792fb6b 🐛 Fixing a bug related to imports 2022-06-03 13:56:14 +02:00
ajnart
64b1679b03 🐛 Fixing bugs in system info 2022-06-03 13:56:14 +02:00
ajnart
8da0b38662 Working on system info 2022-06-03 13:56:13 +02:00
ajnart
13fd1a9fc0 System-info WIP 2022-06-03 13:55:43 +02:00
Thomas Camlong
04c1b41015 Merge pull request #169 from ajnart/qol
🚸 Improve UX and QoL
2022-06-03 13:55:29 +02:00
Thomas Camlong
6a32b80098 📝 Update demo link 2022-06-02 17:51:21 +02:00
WalkxCode
759e02f74a 🔥 Remove modified files from pull request" 2022-06-01 20:00:21 +02:00
WalkxCode
5758019923 🚸 Improve UX and QoL 2022-06-01 19:53:57 +02:00
Thomas "ajnart" Camlong
cad160010d Make proxied requests for calendar 2022-06-01 16:19:32 +02:00
Thomas "ajnart" Camlong
56b6347824 🚧 Trying to improve calendar module 2022-06-01 15:32:29 +02:00
ajnart
c258003ec5 🐛 Fixed a bug in the Lidarr image display 2022-05-30 21:43:33 +02:00
ajnart
5ac5098a2a 💄 Small UI changes
Changed the color to use one of mantine's
2022-05-30 09:20:16 +02:00
ajnart
3c96053b7f Add a ScrollArea to the Downloads module 2022-05-30 09:19:49 +02:00
ajnart
1de20d1583 Avancement on Overseerr integration 2022-05-29 21:39:57 +02:00
ajnart
596db5fefc ⬆️ Upgrade dependencies 2022-05-29 19:09:12 +02:00
ajnart
7ee56bd6ed add default overseer image display 2022-05-29 19:06:29 +02:00
Thomas Camlong
67a89ba61a Merge pull request #167 from ajnart/build-optimization
Build optimization
2022-05-29 19:04:19 +02:00
ajnart
4c0a3ce48c 💚 Update CI 2022-05-29 18:57:03 +02:00
ajnart
2d2f9d8d19 🏗️ Update Yarn install 2022-05-29 18:55:22 +02:00
ajnart
0a7f98dd80 🏗️ Change packageManager 2022-05-29 18:47:12 +02:00
ajnart
5b4d302c17 ⬆️ Upgrade dependencies 2022-05-29 18:43:31 +02:00
ajnart
31d23852f7 Migrate from tabler-icons-react to @tabler/icons 2022-05-29 18:42:58 +02:00
ajnart
a9e8db5018 🔖 Bumb version to v0.6.0 2022-05-29 15:34:31 +02:00
ajnart
f3d1767daf 🚨 Linting 2022-05-29 15:33:44 +02:00
ajnart
b229aacba5 Add download module to the AppShelf 2022-05-29 15:32:39 +02:00
ajnart
174ed140ae Add total downloads module 2022-05-29 15:31:25 +02:00
ajnart
62635bffe9 💄 Style DownloadsModule (torrents) 2022-05-29 15:31:04 +02:00
ajnart
63e6efab1f Add TotalDownloadsModule to module exports 2022-05-29 15:30:50 +02:00
ajnart
ad1af0e07d 🧱 Move components in infrastructure 2022-05-29 15:30:23 +02:00
ajnart
cfd9eb94b5 💄 Improve boxShadows of menus
Makes them look better
2022-05-29 15:30:03 +02:00
ajnart
c6762281ef 📦 Add nivo for charts 💹 2022-05-29 15:29:11 +02:00
ajnart
7d09a0064a 🔥 Delete Download module from index 2022-05-29 15:28:45 +02:00
ajnart
2d6b9522c5 Make Calendar module fetch way more data 2022-05-29 11:25:53 +02:00
ajnart
1a2e752281 Add categories! 2022-05-29 10:45:49 +02:00
ajnart
c7c76ee22b 💄 Styling backgrounds of widgets 2022-05-29 09:11:46 +02:00
ajnart
0457c91ede 🚸 Improve add service autofill capabilities 2022-05-27 13:14:09 +02:00
ajnart
1a420c3b8b 🚑 Hotfix deluge torrent progress 2022-05-27 09:45:51 +02:00
Thomas Camlong
c993d32dd3 v0.5.2 : Torrents module : Deluge and qBittorrent integrations ! 🥳
v0.5.2 adds a bunch of QOL changes and the long awaited Deluge and qBittorrent integrations. More feature to come with the torrent module soon.
2022-05-26 21:29:48 +02:00
ajnart
1f66d64f24 Add deluge integration
Fixes #122
2022-05-26 21:08:16 +02:00
ajnart
54ce138475 Add deluge password saving 2022-05-26 21:07:01 +02:00
ajnart
6173c20616 🏷️ Update types for AppShelfMenu 2022-05-26 21:06:44 +02:00
ajnart
e3d22c6d3a 🏷️ Add deluge types 2022-05-26 21:06:17 +02:00
ajnart
fd44fbb208 📦 Add Deluge package for future deluge integration 2022-05-26 21:06:04 +02:00
ajnart
3dc0208a73 💄 Styling changes 2022-05-26 20:07:54 +02:00
ajnart
b6fcabc270 🐛 Fix footer display issues 2022-05-26 20:07:22 +02:00
ajnart
ee2e36bdfa Rename the download module 2022-05-26 19:45:18 +02:00
ajnart
6bc16a51f1 🔖 Bump version to v0.5.2 2022-05-26 19:16:33 +02:00
ajnart
b0c92c9951 💄 Style DownloadModule 2022-05-26 19:14:36 +02:00
ajnart
72fddda411 Make the Footer update a notification 2022-05-26 19:14:19 +02:00
ajnart
949379e6e6 🎨 Change default config 2022-05-26 18:35:28 +02:00
Thomas Camlong
17736fc432 Update README.md 2022-05-26 18:29:24 +02:00
ajnart
da31832a1e 💫 Add download module on the main page 2022-05-26 18:19:32 +02:00
ajnart
3a358a229d Add Download Module!
Shows current downloads from qbittorrent at the moment, transmission coming soon 😉
2022-05-26 18:19:12 +02:00
ajnart
a6875abfe3 💬 Update API to support getting downloads 2022-05-26 18:18:30 +02:00
ajnart
2aad3d3eb0 Add support for qBittorrent in AddAppShelfItem 2022-05-26 18:17:59 +02:00
ajnart
8e2d347ab5 🏗️ Rework moduleWrapper architecture 2022-05-26 18:16:57 +02:00
ajnart
8b055bc3b6 💄 Style the notifications to the bottom right 2022-05-26 18:16:24 +02:00
ajnart
54a68f1d74 🏷️ Update types to support qBittorrent login 2022-05-26 18:16:00 +02:00
ajnart
2fabd1908d 📦 Add @ctrl/qbittorrent as a dependency 2022-05-26 18:15:42 +02:00
ajnart
789e0510ea 🐛 Fix a bug with strings as module settings 2022-05-26 18:15:00 +02:00
ajnart
2c16075413 💄 WeatherModule styling 2022-05-26 18:13:51 +02:00
ajnart
96f58288ac 💄 DateModule styling 2022-05-26 18:13:35 +02:00
ajnart
d4168dcdf4 💄 Styling and UI changes 2022-05-26 18:13:23 +02:00
Thomas Camlong
c044da2b55 Update Crowdin configuration file 2022-05-26 00:44:24 +02:00
ajnart
1ec8f1db19 🚑 Critical hotfix for various bugs 2022-05-26 00:10:48 +02:00
ajnart
c725559e9b 🔥 Remove Crowdin 2022-05-25 18:32:55 +02:00
ajnart
044c3fdf4c Merge branch 'dev' of github.com:/ajnart/homarr into dev 2022-05-25 18:26:17 +02:00
ajnart
4026d0b6be 💄 Update poster styling 2022-05-25 18:26:13 +02:00
Thomas Camlong
151e37c282 Update Crowdin configuration file 2022-05-25 18:13:29 +02:00
Thomas Camlong
0a476f648a v0.5.1 : Readarr and Lidarr integrations !
### New Features
-  Lidarr and Readarr integrations
-  Add a way to delete a config via the API
-  Add a way to save a config and delete it
-  Add a key bind to open settings (CTRL + L)

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

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

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

### Other Changes
- 🧑‍💻 Added strings as an option type for modules
- 🏗️ Make the max notifications to 4
2022-05-25 13:17:54 +02:00
ajnart
3f2aa50f85 Totally rework how the media previews work! 2022-05-25 13:13:36 +02:00
ajnart
fbaaa389c2 🐛 Fix Readarr date match 2022-05-25 13:13:17 +02:00
ajnart
af83695d81 🏗️ Make the max notifications to 4 2022-05-25 13:12:12 +02:00
ajnart
2cb6781a94 Lidarr and Readarr integrations 2022-05-25 10:50:57 +02:00
ajnart
4f68f7e395 Add a keybind to open settings, CTRL + L 2022-05-24 23:02:27 +02:00
ajnart
6a14937112 Merge branch 'dev' of github.com:/ajnart/homarr into dev 2022-05-24 22:55:50 +02:00
ajnart
9eef4988e7 Add a way to save a config and delete it 2022-05-24 22:55:47 +02:00
ajnart
3855673787 Add a way to delete a config via the API 2022-05-24 22:55:28 +02:00
ajnart
a89b0746ba 💄 Make the settings menu a drawer instead 2022-05-24 22:55:10 +02:00
Bjorn Lammers
09dd5d7907 🔖 Update version to v0.5.1 2022-05-24 21:51:08 +02:00
Bjorn Lammers
f029483f1e 🔖 Update version to v0.5.1 2022-05-24 21:50:57 +02:00
Bjorn Lammers
364055b9b6 📝 Oopsie doopsie hehe 2022-05-24 21:48:06 +02:00
Thomas Camlong
8775ad249c Merge pull request #152 from ajnart/docs
📝 (README): Updates documentation & Move to Wiki
2022-05-24 21:39:50 +02:00
Bjorn Lammers
3249d766b3 📝 Add "Read the Wiki" 2022-05-24 21:39:34 +02:00
Bjorn Lammers
fd65dc8943 📝 Fix some bugs 2022-05-24 21:26:10 +02:00
WalkxCode
fd73c7f70d 📝 (README): Updates documentation & Move to Wiki 2022-05-24 21:21:20 +02:00
ajnart
4984866fb3 🚨 Linting and add icons
Adds future support for self hosted icons
2022-05-24 20:15:07 +02:00
ajnart
4ae4b224c7 🐛 Fixing issues with weahter module 2022-05-24 20:14:26 +02:00
ajnart
802f7fd6c7 🧑‍💻 Added strings as an option type for modules 2022-05-24 20:14:07 +02:00
ajnart
bbb35b236f 💄 Change the way the footer is displayed 2022-05-24 20:13:01 +02:00
Bjorn Lammers
2eb3b18499 Merge pull request #144 from ajnart/dev
v0.5.0 : Quality of life and dev experience
2022-05-23 22:24:24 +02:00
Bjorn Lammers
553be7da33 Merge pull request #144 from ajnart/dev
v0.5.0 : Quality of life and dev experience
2022-05-23 22:22:08 +02:00
Bjorn Lammers
260b850e1a 📝 Add star mention 2022-05-23 21:49:03 +02:00
Bjorn Lammers
726a4fddd3 🔥Remove fixed issues 2022-05-23 21:09:48 +02:00
Bjorn Lammers
318c094f27 📝 Update Docs to match new release 2022-05-23 18:30:11 +02:00
Thomas Camlong
6e0d3807e4 Merge pull request #142 from ajnart/dnd
Drag and drop ! (v0.5.0)
2022-05-23 17:02:18 +02:00
ajnart
10e9dc06dd ⚰️ Remove dead code 2022-05-23 16:52:43 +02:00
ajnart
e84687e5fc 🔖 Version v0.5.0 2022-05-23 14:44:01 +02:00
Thomas Camlong
361d41065c Merge branch 'dev' into dnd 2022-05-23 14:39:17 +02:00
ajnart
4c0fbc0b42 ⚰️ Remove dead code 2022-05-23 14:38:39 +02:00
ajnart
ef8e380956 🔥 Remove some other default configuration files 2022-05-23 14:34:17 +02:00
ajnart
5db28b1607 🚨 Fix storybook compilation 2022-05-23 14:23:05 +02:00
ajnart
dbfd4cf050 🐛 Fix search module default queryUrl 2022-05-23 12:38:10 +02:00
ajnart
ffd298a2b6 🐛 Fix line clamping in media display 2022-05-23 12:37:36 +02:00
ajnart
9b1b5906e7 ⬆️ Upgrade and remove dependencies 2022-05-23 11:48:25 +02:00
Thomas Camlong
19bd14c63d Merge branch 'dev' into dnd 2022-05-23 11:24:31 +02:00
ajnart
b69343af56 Introduce DND in main app shelf! 2022-05-23 11:20:08 +02:00
ajnart
94ee90eebb ⚰️ Remove dead code 2022-05-23 11:19:40 +02:00
ajnart
72b3097ad1 ⚰️ Remove dead code 2022-05-23 11:19:26 +02:00
Thomas Camlong
225f910fe8 Merge pull request #139 from ajnart/New-Config-Format
 Add new config format
2022-05-23 10:48:46 +02:00
ajnart
10d9ffc740 🚨 Fix compilation for types 2022-05-23 10:44:31 +02:00
ajnart
4202d25d62 📦 Add type definitions for UUID 2022-05-23 10:26:17 +02:00
ajnart
6a905e1b49 🚨 Lint code and prettier 2022-05-23 10:24:54 +02:00
ajnart
72e08f484f 🚑 Use different type of UUID 2022-05-23 10:23:10 +02:00
ajnart
64dbb9c025 Add drag and drop, fixes #88 2022-05-23 00:04:14 +02:00
ajnart
af2e0235bf Add new config format
Should be WAAAAY easier to work with modules now
2022-05-22 20:42:10 +02:00
ajnart
bf85818f8b 🐛 Fix #133 2022-05-22 20:40:10 +02:00
ajnart
1840713179 Basic drag and drop 2022-05-21 10:32:54 +02:00
ajnart
b11bffb7cf 🐛 Exclude stories from tsconfig 2022-05-21 10:32:35 +02:00
ajnart
bfb26a9402 🚑 Fix API url for services 2022-05-21 01:26:55 +02:00
ajnart
c3b11be2d0 🚑 Fix UUID by using crypto 2022-05-21 01:26:24 +02:00
ajnart
ecfb89de40 🏷️ Fix types
Fixed the apiKey field for a service
2022-05-21 01:02:45 +02:00
ajnart
e1eab70f93 Match config with URL typed
Homarr will now match a config with the URL used or return a 404 if not found
2022-05-21 01:01:20 +02:00
ajnart
adb341c0fa Add default icon, fix URL parsing
Fixes #121 and Fixes #132
2022-05-21 00:54:36 +02:00
ajnart
25ccdffeb9 Make logo clickable 2022-05-21 00:52:55 +02:00
ajnart
b98d399a9c Change 404 message 2022-05-21 00:52:39 +02:00
ajnart
f36e7b8abb Made service name clickable
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-20 23:03:42 +02:00
Thomas "ajnart" Camlong
667322d14e Use ID instead of only names 2022-05-20 22:34:36 +02:00
Thomas "ajnart" Camlong
9b440c0da3 🚧 Add basic BASE_URL and PORT env utilisation #76 2022-05-19 02:05:23 +02:00
Thomas Camlong
2586733a98 v0.4.0
Add Weather and Ping module
2022-05-18 23:22:14 +02:00
ajnart
7bc779b296 ⚰️ Remove dead code
Used to test the weather module
2022-05-18 23:13:32 +02:00
ajnart
6064dcb6a6 💄 Footer styling 2022-05-18 23:12:52 +02:00
ajnart
7c7b0cc970 💫 Add animations to the AppShelf 2022-05-18 23:12:34 +02:00
ajnart
c182397dd9 💫 Add animations to the PingModule 2022-05-18 23:11:58 +02:00
ajnart
dc5ee3bdf3 Add animations to the AppShelf 2022-05-18 22:51:12 +02:00
ajnart
c8e1295a4b Improve date module am/pm 2022-05-18 22:50:53 +02:00
ajnart
331c55240b Added Freedom units setting 2022-05-18 22:50:33 +02:00
Thomas Camlong
65037f9b56 Add Weather module (beta)
Shows the current weather !
2022-05-18 22:17:58 +02:00
Bjorn L
39853d79ce 🔧 Change versions to v0.4.0 2022-05-18 22:15:03 +02:00
Bjorn L
8530550347 🔧 Change versions to v0.4.0 2022-05-18 22:14:27 +02:00
Thomas Camlong
ba8e9ef63c Merge branch 'dev' into weather-module 2022-05-18 22:14:01 +02:00
ajnart
119f2d7e51 Add a proceudally generated options manager
This allows for options in settings generated based on their name in module config. Very important change 🧙
2022-05-18 22:11:37 +02:00
ajnart
b0be26300e 💄 Update AppShell menu and item styling
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-18 22:10:31 +02:00
ajnart
0400188ea7 🚚 Move the update indicator to the Footer
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-18 22:09:13 +02:00
ajnart
879581224a 🔥 Remove update indicator from settings
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-18 22:08:09 +02:00
ajnart
7e5602c881 🚨 Update eslint config
Co-authored-by: Bjorn L. <walkxnl@gmail.com>
2022-05-18 22:07:28 +02:00
Bjorn L
4870ea3e40 📝 Adds Docs for the Weather Module 2022-05-18 16:58:06 +02:00
Bjorn L
61c55acd50 📝 Adds Request Icons section 2022-05-18 16:55:48 +02:00
Thomas Camlong
c45421d27e Merge branch 'dev' into weather-module 2022-05-18 10:24:16 +02:00
Thomas "ajnart" Camlong
b396d2604f 🚑 Critical hotfix : Compilation failed 2022-05-18 10:23:18 +02:00
Thomas "ajnart" Camlong
28b6dcd1db 📦 Update deps 2022-05-18 10:10:42 +02:00
Thomas "ajnart" Camlong
1dd74ea7da 🐛 Try to fix module compilation 2022-05-17 22:59:02 +02:00
Thomas "ajnart" Camlong
64923b03d9 🎨 Fix architecture for CI 2022-05-17 22:59:02 +02:00
Thomas "ajnart" Camlong
2ba9d517a8 Improve weather module 2022-05-17 22:59:02 +02:00
Aj - Thomas
471a9f7407 Update page title 2022-05-17 22:59:02 +02:00
Aj - Thomas
bdf871b476 💄 � Update weather module styling 2022-05-17 22:59:02 +02:00
Aj - Thomas
ab860eeea1 � Weather module improvements 2022-05-17 22:59:02 +02:00
Thomas "ajnart" Camlong
50d760f3b8 Prepare for v0.3.2 2022-05-17 21:24:10 +02:00
Thomas "ajnart" Camlong
73d06e15fb Update tests for storybook 2022-05-17 21:04:19 +02:00
Thomas "ajnart" Camlong
49d57024b9 Advancement on the weather widget 2022-05-17 21:04:19 +02:00
Thomas "ajnart" Camlong
31deb5010f 💄 Improve styling of modules 2022-05-17 21:04:18 +02:00
Thomas "ajnart" Camlong
e86eb7798f 🚧 Set up the structure for the weather module 2022-05-17 21:04:16 +02:00
Thomas Camlong
2896423766 Add ping service module
 Add ping service module resolves #78
2022-05-17 20:59:44 +02:00
Thomas "ajnart" Camlong
696d0c582d 🐛 Clear the search input on search
Resolves #125
2022-05-17 20:58:55 +02:00
ajnart
e94cae620a Rever b7e8c51b29
Does not work. Apparently
2022-05-17 04:19:59 +02:00
ajnart
c9c6f2b0c9 Add ping service module
Resolves #78
2022-05-17 04:02:14 +02:00
ajnart
b8fe799ac6 ⚰️ Remove dead code for the settings
I turned the settings into a module in 4cb8539143
2022-05-17 02:07:38 +02:00
ajnart
4cb8539143 Make the search bar a module
Resolves #118
2022-05-17 02:04:44 +02:00
ajnart
16b86870c4 🏗️ Fix small bug in code arch, forgot the key 2022-05-17 02:03:52 +02:00
ajnart
d4ce2a3ed6 🏷️ Update types for the SearchBar 2022-05-17 01:52:43 +02:00
ajnart
a474f3e4ee 🥅 Add 404 to catch errors
Reduce the ammount of visible errors by adding a 404 page.
2022-05-17 01:44:26 +02:00
ajnart
9a49fbb747 💄 Update AppShelf UI 2022-05-17 01:43:40 +02:00
ajnart
e3d47d78e0 🐛 Add a delay before opening search results
Resolves #115
2022-05-17 01:23:19 +02:00
ajnart
d62189f086 💄 Remove version from logo and add it in footer
resolves #116
2022-05-17 01:01:26 +02:00
ajnart
bb1b3d7d9a Merge branch 'dev' of github.com:/ajnart/homarr into dev 2022-05-17 00:55:44 +02:00
ajnart
13aeeefb22 🐛 Fix AddAppShelfItem image fit not properly set
Resolves #117
2022-05-17 00:55:24 +02:00
ajnart
8cdc9c3e29 🎨 Use user prefered theme 2022-05-17 00:42:27 +02:00
ajnart
3e31a4d38e 💄 Better style events in the calendar 2022-05-17 00:42:27 +02:00
ajnart
0cb3db6b89 📦 Upgrade package 2022-05-17 00:42:27 +02:00
ajnart
b7e8c51b29 🎨 Use user prefered theme 2022-05-17 00:19:41 +02:00
ajnart
e60db9f57a 💄 Better style events in the calendar 2022-05-17 00:19:24 +02:00
ajnart
2c707e86aa 📦 Upgrade package 2022-05-17 00:18:22 +02:00
Thomas Camlong
5c6541e1a7 🚀 Patch v0.3.1
Patch v0.3.1
2022-05-16 23:50:54 +02:00
Bjorn L
da81783c8e 🧑‍💻 Adds release note template 2022-05-16 23:11:48 +02:00
Chris
6a90a124b3 Update docker.yml 2022-05-16 23:03:16 +02:00
Chris
bd6edbbec6 Reverting changes from c593334
Changing to back to how it was before c593334
2022-05-16 23:03:16 +02:00
Chris
53ab06f97e Update docker_dev.yml 2022-05-16 23:03:16 +02:00
Chris
6904018585 temp edit 2 2022-05-16 23:03:16 +02:00
Chris
8c14b3ccf9 temp edit to test workflow 2022-05-16 23:03:16 +02:00
Chris
8557820e6e Update docker_dev.yml 2022-05-16 23:03:16 +02:00
Walkx
3782499da5 💚 Update .github/workflows/docker_dev.yml
Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com>
2022-05-16 23:03:16 +02:00
Walkx
97ca45964a 💚 Update .github/workflows/docker_dev.yml
Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com>
2022-05-16 23:03:16 +02:00
Walkx
7fa464b38f 💚 Update .github/workflows/docker.yml
Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com>
2022-05-16 23:03:16 +02:00
Chris
fe5fa99b4a 💚 Fixed CI fail on PR from fork
Fixed CI failing when a PR is made from a fork due to user from fork not having the permission packages: write. CI will now check if it has write perms before push built docker image.
2022-05-16 23:03:16 +02:00
Chris
9bf8b337f6 test CI fix 4 2022-05-16 23:03:16 +02:00
Chris
06caa2ca5e test CI fix 3 2022-05-16 23:03:16 +02:00
Chris
1145ee39b6 test CI fix 2 2022-05-16 23:03:16 +02:00
Chris
68111616fe test CI fix 2022-05-16 23:03:16 +02:00
Chris
7662c11bb5 💚 CI won't push to docker on PRs from forks
If not from fork it still will. Since that's how it was set by ajnart.
2022-05-16 23:03:16 +02:00
Chris
1aaa575480 💚 Stop running CI when ignored files are updated
CI will now not run when some files are updated that are not related to building.
2022-05-16 23:03:16 +02:00
Chris
3529e46b11 🔥 Remove unneeded lines
Removed adduser and addgroup since they aren't needed.
2022-05-16 23:03:16 +02:00
ajnart
006b1a61bf 💄 Update styling of AppShelf 2022-05-16 23:03:16 +02:00
ajnart
f5eb36ff00 💄 Update styling of AppShelf 2022-05-16 23:03:16 +02:00
Aj - Thomas
a97c9b0c0f 💄 Update styling 2022-05-16 23:03:16 +02:00
Aj - Thomas
08daeb87bc � Header styling 2022-05-16 23:03:16 +02:00
Aj - Thomas
ebc7ba9684 � Update search bar styling 2022-05-16 23:03:16 +02:00
Aj - Thomas
8392dcef20 � Module styling 2022-05-16 23:03:16 +02:00
Aj - Thomas
20a37b678f Update header styling 2022-05-16 23:03:16 +02:00
Aj - Thomas
d3bd894c2a 🙈 Add configs to gitingore 2022-05-16 23:03:16 +02:00
Aj - Thomas
e75ff14975 🔥 Remove search bar from index 2022-05-16 23:03:16 +02:00
Aj - Thomas
ab1e2a32a0 🔥 Remove Navbar 2022-05-16 23:03:16 +02:00
Aj - Thomas
22cd5c8b93 Add search bar in top bar 2022-05-16 23:03:16 +02:00
Aj - Thomas
5c8b1c4fc4 Add all components in Aside bar 2022-05-16 23:03:16 +02:00
Aj - Thomas
a71b50e33f 📄 Documenting keybinds for theme switch 2022-05-16 23:03:16 +02:00
Aj - Thomas
d4c1148025 💄 Style the modal for adding a service 2022-05-16 23:03:16 +02:00
Aj - Thomas
0d11244506 ♻ Rework the search bar 2022-05-16 23:03:16 +02:00
Aj - Thomas
e786b1e44f ✏ Fix typos 2022-05-16 23:03:16 +02:00
Aj - Thomas
509873db55 💄 App shell styling
The modal now looks a little bit better
2022-05-16 23:03:16 +02:00
Aj - Thomas
c5178ee288 💄 Styling and responsiveness
Co-authored-by: Walkx <walkxnl@gmail.com>
2022-05-16 23:03:16 +02:00
Walkx
4045628166 🔥 Remove this random href 2022-05-16 23:03:16 +02:00
Thomas Camlong
f8b2d64a26 📝 Updates documentation
Thanks to @walkxcode
2022-05-16 22:53:36 +02:00
Walkx
62ba99f6cd 📝 Updates ToC and adds Back to Top link 2022-05-16 16:59:08 +02:00
Walkx
2fad4d06bd 📝 Adds Known Issues 2022-05-16 16:58:20 +02:00
Walkx
c9e58e17da 📝 Update Docs 2022-05-16 16:17:41 +02:00
Walkx
8e03719a51 📝 Update README.md
Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com>
2022-05-16 16:15:04 +02:00
Walkx
c4df55060b 📝 Update Docs 2022-05-16 15:43:58 +02:00
Walkx
47c636e810 📝 Updates documentation 2022-05-16 01:30:35 +02:00
Walkx
38d18fc433 🚀Change demo page link 2022-05-15 22:45:55 +02:00
140 changed files with 21070 additions and 14366 deletions

View File

@@ -2,5 +2,8 @@ Dockerfile
.dockerignore .dockerignore
node_modules node_modules
npm-debug.log npm-debug.log
README.md *.md
.git .git
.github
LICENSE
docs/

View File

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

View File

@@ -22,13 +22,3 @@ body:
- High (App breaking feature) - High (App breaking feature)
validations: validations:
required: true required: true
- type: checkboxes
id: idiot-check
attributes:
label: Please tick the boxes
description: Before submitting, please ensure that
options:
- label: You've read the [docs](https://github.com/ajnart/homarr#readme)
required: true
- label: You've checked for [duplicate issues](https://github.com/ajnart/homarr/issues)
required: true

View File

@@ -14,10 +14,3 @@
### Screenshot _(if applicable)_ ### Screenshot _(if applicable)_
> If you've introduced any significant UI changes, please include a screenshot. > If you've introduced any significant UI changes, please include a screenshot.
### Code Quality Checklist _(Please complete)_
- [ ] All changes are backwards compatible
- [ ] There are no (new) build warnings or errors
- [ ] _(If a new config option is added)_ Attribute is outlined in the schema and documented
- [ ] _(If a new dependency is added)_ Package is essential, and has been checked out for security or performance
- [ ] Bumps version, if new feature added

27
.github/release-note.md vendored Normal file
View File

@@ -0,0 +1,27 @@
## 🦞 Homarr [v0.0.0](https://github.com/ajnart/homarr/compare/v0.0.0...v0.0.0) (2022-01-01)
<!-- Small release message -->
<!-- Bigger announcement marked in bold -->
### Upgrade Steps
*Upgrading without a mounted config? Make sure to download your config from the settings first! You can add it back later by drag and dropping it into your browser.*
* `docker pull ghcr.io/ajnart/homarr:latest`
* `docker stop [container_id]`
* `docker rm [container_id]`
* `docker run --name homarr -p 7575:7575 -v /data/docker/homarr:/app/data/configs -d ghcr.io/ajnart/homarr:latest`
* *(or use our [docker_compose.yml](https://github.com/ajnart/homarr#-installation))*
### Breaking Changes
### New Features
### Bug Fixes
### UI Changes
### GitHub Changes
### Other Changes
_**Special thanks to our contributors: @ajnart, @c00ldude1oo, @walkxcode, and of course all people using our project.**_

View File

@@ -1,11 +1,17 @@
name: Master docker CI name: Master CI
# Workflow to build and publish docker image # This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on: on:
push: push:
branches: [master] branches: [master]
paths-ignore:
- '.github/**'
- '**.md'
tags: tags:
- v* - v*
workflow_dispatch: workflow_dispatch:
env: env:
@@ -18,72 +24,46 @@ jobs:
# Push image to GitHub Packages. # Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/ # See also https://docs.docker.com/docker-hub/builds/
yarn_install_and_build: yarn_install_and_build:
# Will run yarn install && yarn build
runs-on: ubuntu-latest
steps:
- name: Setup
uses: actions/setup-node@v3
- name: Checkout
uses: actions/checkout@v3
- name: Get yarn cache directory path
# to help speed up build times
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn cache
# to help speed up build times
uses: actions/cache@v3
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Nextjs cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --frozen-lockfile
- run: yarn build
- name: Cache build output
# to copy needed files to docker build job
uses: actions/cache@v2
id: restore-build
with:
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
docker_image_build_and_push:
needs: [yarn_install_and_build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write
contents: read contents: read
steps: steps:
- name: Setup
uses: actions/setup-node@v3
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- uses: actions/cache@v2
id: restore-build - name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- uses: actions/cache@v3
id: yarn-cache
with: with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Restore NextJS cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: | path: |
./next.config.js ${{ github.workspace }}/.next/cache
./pages/ # Generate a new cache whenever packages or source files change.
./public/ key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
./.next/static/ # If source files changed but packages didn't, rebuild from a prior cache.
./.next/standalone/ restore-keys: |
./packages.json ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
key: ${{ github.sha }}
- run: yarn install --immutable
- run: yarn build
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
@@ -94,10 +74,13 @@ jobs:
tags: | tags: |
type=raw,value=latest type=raw,value=latest
type=pep440,pattern={{version}} type=pep440,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -110,6 +93,8 @@ jobs:
with: with:
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -6,12 +6,18 @@ name: Development CI
on: on:
push: push:
branches: [dev] branches: [dev]
paths-ignore:
- '.github/**'
- '**.md'
pull_request: pull_request:
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tags: tag:
requierd: true required: true
description: 'Tags to deploy to' description: 'Tag to deploy to'
env: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty
@@ -23,69 +29,48 @@ jobs:
# Push image to GitHub Packages. # Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/ # See also https://docs.docker.com/docker-hub/builds/
yarn_install_and_build: yarn_install_and_build:
runs-on: ubuntu-latest
steps:
- name: Setup
uses: actions/setup-node@v3
- name: Checkout
uses: actions/checkout@v3
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn cache
uses: actions/cache@v3
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Nextjs cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --frozen-lockfile
- run: yarn build
- name: Cache build output
uses: actions/cache@v2
id: restore-build
with:
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
docker_image_build_and_push:
needs: [yarn_install_and_build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write
contents: read contents: read
steps: steps:
- name: Setup
uses: actions/setup-node@v3
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- uses: actions/cache@v2
id: restore-build - name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- uses: actions/cache@v3
id: yarn-cache
with: with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Restore NextJS cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: | path: |
./next.config.js ${{ github.workspace }}/.next/cache
./pages/ # Generate a new cache whenever packages or source files change.
./public/ key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
./.next/static/ # If source files changed but packages didn't, rebuild from a prior cache.
./.next/standalone/ restore-keys: |
./packages.json ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
key: ${{ github.sha }}
- run: yarn install --immutable
- run: yarn build
- name: Docker meta - name: Docker meta
if: github.event_name != 'pull_request'
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
@@ -94,12 +79,17 @@ jobs:
# generate Docker tags based on the following events/attributes # generate Docker tags based on the following events/attributes
tags: | tags: |
type=ref,event=pr type=ref,event=pr
tpye=raw,value=dev,priority=1 type=raw,value=${{ github.event.inputs.tag }}, prefix=test-,enable=${{ github.event.inputs.tag != '' }}
tpye=raw,value=dev,priority=1,enable=${{ github.event.inputs.tag == '' }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Login to GHCR - name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
@@ -107,10 +97,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
with: with:
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
context: . context: .
push: true push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

13
.gitignore vendored
View File

@@ -35,4 +35,15 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
# storybook # storybook
storybook-static storybook-static
data/configs
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
# Yarn v2
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@@ -1,34 +0,0 @@
module.exports = {
stories: ['../src/components/**/*.story.mdx', '../src/components/**/*.story.*'],
addons: [
'storybook-dark-mode',
'@storybook/addon-links',
'@storybook/addon-essentials',
{
name: 'storybook-addon-turbo-build',
options: { optimizationLevel: 2 },
},
],
typescript: {
check: false,
reactDocgen: false,
},
framework: '@storybook/react',
features: { emotionAlias: false },
webpackFinal: async (config, { configType }) => {
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
// 'PRODUCTION' is used when building the static version of storybook.
// https://github.com/polkadot-js/extension/issues/621#issuecomment-759341776
// framer-motion uses the .mjs notation and we need to include it so that webpack will
// transpile it for us correctly (enables using a CJS module inside an ESM).
config.module.rules.push({
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto',
});
// Return the altered config
return config;
},
};

View File

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

28
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "yarn dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "yarn dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

786
.yarn/releases/yarn-3.2.1.cjs vendored Normal file

File diff suppressed because one or more lines are too long

5
.yarnrc Normal file
View File

@@ -0,0 +1,5 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
yarn-path ".yarn/releases/yarn-1.22.19.cjs"

3
.yarnrc.yml Normal file
View File

@@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.2.1.cjs

View File

@@ -1,19 +1,21 @@
FROM node:16-alpine FROM node:16-alpine
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY /next.config.js ./ COPY next.config.js ./
COPY /public ./public COPY public ./public
COPY /package.json ./package.json COPY package.json ./package.json
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY /.next/standalone ./ COPY .next/standalone ./
COPY /.next/static ./.next/static COPY .next/static ./.next/static
EXPOSE 7575 EXPOSE 7575
ENV PORT 7575 ENV PORT 7575
VOLUME /app/data/configs
CMD ["node", "server.js"] CMD ["node", "server.js"]

204
README.md
View File

@@ -1,84 +1,142 @@
<h3 align="center">Homarr</h3>
<br/> <!-- Project Title -->
<h1 align="center">Homarr</h1>
<!-- Badges -->
<p align="center"> <p align="center">
<a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml"> <img src="https://img.shields.io/github/stars/ajnart/homarr?label=%E2%AD%90%20Stars&style=flat-square?branch=master&kill_cache=1%22">
<img title="Docker CI Status" src="https://github.com/ajnart/homarr/actions/workflows/docker.yml/badge.svg" alt="CI Status"></a> <a href="https://github.com/ajnart/homarr/releases/latest">
<a href="https://github.com/ajnart/homarr/releases/latest"> <img alt="Latest Release (Semver)" src="https://img.shields.io/github/v/release/ajnart/homarr?label=%F0%9F%9A%80%20Release">
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/ajnart/homarr"></a> </a>
<a href="https://github.com/ajnart/homarr/pkgs/container/homarr"> <a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml">
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/ajnart/homarr?label=Downloads%20"></a> <img title="Docker CI Status" src="https://github.com/ajnart/homarr/actions/workflows/docker.yml/badge.svg" alt="CI Status">
</p> </a>
<p align="center"> <a href="https://discord.gg/aCsmEV5RgA">
<a href=""> <img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield">
<img align="end" width=600 src="https://user-images.githubusercontent.com/49837342/168315259-b778c816-10fe-44db-bd25-3eea6f31b233.png" /> </a>
<a/>
</p>
<p align = "center">
A homepage for <i>your</i> server.
<br/>
<a href = "https://github.com/ajnart/homarr/deployments/activity_log?environment=Production" > <strong> Demo ↗️ </strong> </a> • <a href = "#-installation" > <strong> Install ➡️ </strong> </a>
<br />
<br />
<i>Join the discord!</i>
<br />
<a href = "https://discord.gg/aCsmEV5RgA" > <img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield" > </a>
<br/>
<br/>
</p> </p>
# 📃 Table of Contents <!-- Links -->
- [📃 Table of Contents](#-table-of-contents) <p align="center">
- [🚀 Getting Started](#-getting-started) <i>Join the discord! — Don't forget to star the repo if you are enjoying the project!</i>
- [ About](#-about) </p>
- [🐛 Known Issues](#-known-issues) <p align="center">
- [⚡ Installation](#-installation) <a href="https://homarr.ajnart.fr/"><strong> Demo ↗️ </strong></a> • <a href="https://homarr.vercel.app/docs/quick-start/"><strong> Install ➡️ </strong></a> • <a href="https://homarr.vercel.app/docs/about"><strong> Read the Docs 📄 </strong></a>
- [Deploying from Docker Image 🐳](#deploying-from-docker-image-) </p>
- [Building from Source 🛠️](#building-from-source-)
- [💖 Contributing](#-contributing)
<!-- Getting Started --> ---
# 🚀 Getting Started
## About <!-- Homarr Description -->
<img align="right" width=250 src="public/imgs/logo-color.svg" />
Homarr is a simple and lightweight homepage for your server, that helps you easily access all of your services in one place. Homarr is a simple and lightweight homepage for your server, that helps you easily access all of your services in one place.
**[⤴️ Back to Top](#-table-of-contents)**
## ⚡ Installation It integrates with the services you use to display information on the homepage (E.g. Show upcoming Sonarr/Radarr releases).
### Deploying from Docker Image 🐳 For a full list of integrations, [head over to our documentation](https://homarr.vercel.app/docs/advanced-features/integrations).
If you have any questions about Homarr or want to share information with us, please go to one of the following places:
- [Github Discussions](https://github.com/ajnart/homarr/discussions)
- [Discord Server](https://discord.gg/aCsmEV5RgA)
*Before you file an [issue](https://github.com/ajnart/homarr/issues/new/choose), make sure you have the read [known issues](#-known-issues) section.*
**For more information, [read the documentation!](https://homarr.vercel.app/docs/about)**
<details>
<summary><b>Table of Contents</b></summary>
<p>
- [✨ Features](#-features)
- [👀 Preview](#-preview)
- [💥 Known Issues](#-known-issues)
- [🚀 Installation](#-installation)
- [🐳 Deploying from Docker Image](#-deploying-from-docker-image)
- [🛠️ Building from Source](#-building-from-source)
- [💖 Contributing](#-contributing)
- [📜 License](#-license)
</p>
</details>
---
## ✨ Features
- Integrates with services you use.
- Search the web directly from your homepage.
- Real-time status indicator for every service.
- Automatically finds icons while you type the name of a service.
- Widgets that can display all types of information.
- Easy deployment with Docker.
- Very light-weight and fast.
- Free and Open-Source.
- And more...
**[⤴️ Back to Top](#homarr)**
---
## 👀 Preview
<img alt="Homarr Preview" align="center" width="100%" src="https://user-images.githubusercontent.com/71191962/169860380-856634fb-4f41-47cb-ba54-6a9e7b3b9c81.gif" />
**[⤴️ Back to Top](#homarr)**
---
## 💥 Known Issues
- Posters on the Calendar get blocked by adblockers. (IMDb posters)
**[⤴️ Back to Top](#homarr)**
---
## 🚀 Installation
### 🐳 Deploying from Docker Image
> Supported architectures: x86-64, ARM, ARM64 > Supported architectures: x86-64, ARM, ARM64
_Requirements_: _Requirements_:
- [Docker](https://docs.docker.com/get-docker/) - [Docker](https://docs.docker.com/get-docker/)
**Standard Docker Install** **Standard Docker Install**
```sh ```bash
docker run --name homarr -p 7575:7575 -v /data/docker/homarr:/app/data/configs -d ghcr.io/ajnart/homarr:latest docker run \
--name homarr \
--restart unless-stopped \
-p 7575:7575 \
-v ./homarr/configs:/app/data/configs \
-v ./homarr/icons:/app/public/icons \
-d ghcr.io/ajnart/homarr:latest
``` ```
**Docker Compose** **Docker Compose**
```yml ```yml
--- ---
version: '3' version: '3'
#--------------------------------------------------------------------------------------------# #---------------------------------------------------------------------#
# Homarr - A homepage for your server. # # Homarr - A homepage for your server. #
#--------------------------------------------------------------------------------------------# #---------------------------------------------------------------------#
services: services:
homarr: homarr:
container_name: homarr container_name: homarr
image: ghcr.io/ajnart/homarr:latest image: ghcr.io/ajnart/homarr:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /data/docker/homarr:/app/data/configs - ./homarr/configs:/app/data/configs
- ./homarr/icons:/app/public/icons
ports: ports:
- '7575:7575' - '7575:7575'
``` ```
***Getting EACCESS errors in the logs? Try running `sudo chmod 775 /directory-you-mounted-to`!*** ```sh
docker compose up -d
```
### Building from Source 🛠️ *Getting EACCESS errors in the logs? Try running `sudo chmod 777 /directory-you-mounted-to`!*
**[⤴️ Back to Top](#homarr)**
### 🛠️ Building from Source
_Requirements_: _Requirements_:
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
@@ -93,9 +151,51 @@ _Requirements_:
- Start the NextJS web server: ``yarn start`` - Start the NextJS web server: ``yarn start``
- *Note: If you want to update the code in real time, launch with ``yarn dev``* - *Note: If you want to update the code in real time, launch with ``yarn dev``*
# 💖 Contributing **[⤴️ Back to Top](#homarr)**
---
## 💖 Contributing
**Please read our [Contribution Guidelines](/CONTRIBUTING.md)** **Please read our [Contribution Guidelines](/CONTRIBUTING.md)**
All contributions are highly appreciated. All contributions are highly appreciated.
**[⤴️ Back to Top](#-table-of-contents)** **[⤴️ Back to Top](#homarr)**
---
## 📜 License
Homarr is Licensed under [MIT](https://en.wikipedia.org/wiki/MIT_License)
```txt
Copyright © 2022 Thomas "ajnart" Camlong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**[⤴️ Back to Top](#homarr)**
---
<p align="center">
<i>Thank you for visiting! <b>For more information <a href="https://homarr.vercel.app/docs/about">read the documentation!</a></b></i>
<br/>
<br/>
</p>

3
crowdin.yml Normal file
View File

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

View File

@@ -1,12 +0,0 @@
{
"name": "config",
"services": [],
"settings": {
"searchBar": true,
"searchUrl": "https://duckduckgo.com/?q=",
"enabledModules": [
"Date",
"Calendar"
]
}
}

View File

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

View File

@@ -2,15 +2,19 @@
"name": "default", "name": "default",
"services": [ "services": [
{ {
"type": "Other",
"name": "example", "name": "example",
"id": "09c45847-8afc-4c1a-9697-f03192de948a",
"type": "Other",
"icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif", "icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
} }
], ],
"settings": { "settings": {
"searchBar": true, "searchUrl": "https://google.com/search?q="
"searchUrl": "https://bing.com/search?q=", },
"enabledModules": [] "modules": {
"Search Bar": {
"enabled": true
}
} }
} }

View File

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

View File

@@ -1,13 +1,16 @@
const { env } = require('process');
const withBundleAnalyzer = require('@next/bundle-analyzer')({ const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true', enabled: process.env.ANALYZE === 'true',
}); });
module.exports = withBundleAnalyzer({ module.exports = withBundleAnalyzer({
reactStrictMode: true, images: {
eslint: { domains: ['cdn.jsdelivr.net'],
ignoreDuringBuilds: true,
}, },
reactStrictMode: false,
experimental: { experimental: {
outputStandalone: true, outputStandalone: true,
}, },
output: 'standalone',
}); });

View File

@@ -1,87 +1,91 @@
{ {
"name": "homarr", "name": "homarr",
"version": "0.3.0", "version": "0.9.1",
"private": "false",
"description": "Homarr - A homepage for your server.", "description": "Homarr - A homepage for your server.",
"license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ajnart/homarr" "url": "https://github.com/ajnart/homarr"
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
"start": "next start --port 7575", "start": "next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"export": "next build && next export", "export": "next build && next export",
"lint": "next lint", "lint": "next lint",
"jest": "jest", "jest": "jest",
"jest:watch": "jest --watch", "jest:watch": "jest --watch",
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"", "prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"", "prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest", "test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
"storybook": "start-storybook -p 7001", "ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
"storybook:build": "build-storybook", },
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write" "dependencies": {
}, "@ctrl/deluge": "^4.1.0",
"dependencies": { "@ctrl/qbittorrent": "^4.1.0",
"@mantine/core": "^4.2.4", "@ctrl/shared-torrent": "^4.1.1",
"@mantine/dates": "^4.2.4", "@ctrl/transmission": "^4.1.1",
"@mantine/dropzone": "^4.2.4", "@dnd-kit/core": "^6.0.5",
"@mantine/form": "^4.2.4", "@dnd-kit/sortable": "^7.0.1",
"@mantine/hooks": "^4.2.4", "@dnd-kit/utilities": "^3.2.0",
"@mantine/modals": "^4.2.4", "@emotion/react": "^11.10.0",
"@mantine/next": "^4.2.4", "@emotion/server": "^11.10.0",
"@mantine/notifications": "^4.2.4", "@mantine/carousel": "^5.1.0",
"@mantine/prism": "^4.2.4", "@mantine/core": "^5.1.0",
"@mantine/rte": "^4.2.4", "@mantine/dates": "^5.1.0",
"@mantine/spotlight": "^4.2.4", "@mantine/dropzone": "^5.1.0",
"@modulz/radix-icons": "^4.0.0", "@mantine/form": "^5.1.0",
"axios": "^0.27.2", "@mantine/hooks": "^5.1.0",
"cookies-next": "^2.0.4", "@mantine/modals": "^5.1.0",
"dayjs": "^1.11.2", "@mantine/next": "^5.1.0",
"framer-motion": "^6.3.1", "@mantine/notifications": "^5.1.0",
"js-file-download": "^0.4.12", "@mantine/prism": "^5.0.0",
"next": "12.1.5-canary.4", "@nivo/core": "^0.79.0",
"prism-react-renderer": "^1.3.1", "@nivo/line": "^0.79.1",
"react": "18.0.0", "@tabler/icons": "^1.78.0",
"react-dom": "18.0.0", "add": "^2.0.6",
"tabler-icons-react": "^1.46.0" "axios": "^0.27.2",
}, "consola": "^2.15.3",
"devDependencies": { "cookies-next": "^2.1.1",
"@babel/core": "^7.17.8", "dayjs": "^1.11.4",
"@next/bundle-analyzer": "^12.1.4", "dockerode": "^3.3.2",
"@next/eslint-plugin-next": "^12.1.4", "embla-carousel-react": "^7.0.0",
"@storybook/addon-essentials": "^6.4.22", "framer-motion": "^6.5.1",
"@storybook/addon-links": "^6.4.22", "js-file-download": "^0.4.12",
"@storybook/react": "^6.4.22", "next": "12.1.6",
"@testing-library/dom": "^8.12.0", "prism-react-renderer": "^1.3.5",
"@testing-library/jest-dom": "^5.16.3", "react": "^18.2.0",
"@testing-library/react": "^13.0.0", "react-dom": "^18.2.0",
"@testing-library/user-event": "^14.0.4", "sharp": "^0.30.7",
"@types/jest": "^27.4.1", "systeminformation": "^5.12.1",
"@types/node": "^17.0.23", "uuid": "^8.3.2",
"@types/react": "17.0.43", "yarn": "^1.22.19"
"@typescript-eslint/eslint-plugin": "^5.16.0", },
"@typescript-eslint/parser": "^5.16.0", "devDependencies": {
"babel-loader": "^8.2.4", "@next/bundle-analyzer": "^12.1.4",
"eslint": "^8.11.0", "@next/eslint-plugin-next": "^12.1.4",
"eslint-config-airbnb": "19.0.4", "@types/dockerode": "^3.3.9",
"eslint-config-airbnb-typescript": "^16.1.4", "@types/node": "17.0.1",
"eslint-config-mantine": "1.1.0", "@types/react": "17.0.1",
"eslint-plugin-import": "^2.25.4", "@types/uuid": "^8.3.4",
"eslint-plugin-jest": "^26.1.3", "@typescript-eslint/eslint-plugin": "^5.30.7",
"eslint-plugin-jsx-a11y": "^6.5.1", "@typescript-eslint/parser": "^5.30.7",
"eslint-plugin-react": "^7.29.4", "eslint": "^8.20.0",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-config-airbnb": "^19.0.4",
"eslint-plugin-storybook": "^0.5.11", "eslint-config-airbnb-typescript": "^17.0.0",
"eslint-plugin-testing-library": "^5.2.0", "eslint-config-mantine": "^2.0.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-import": "^2.26.0",
"jest": "^27.5.1", "eslint-plugin-jest": "^26.6.0",
"prettier": "^2.6.2", "eslint-plugin-jsx-a11y": "^6.6.1",
"storybook-addon-turbo-build": "^1.1.0", "eslint-plugin-react": "^7.30.1",
"storybook-dark-mode": "^1.0.9", "eslint-plugin-react-hooks": "^4.6.0",
"ts-jest": "^27.1.4", "eslint-plugin-testing-library": "^5.5.1",
"typescript": "4.6.3" "eslint-plugin-unused-imports": "^2.0.0",
} "jest": "^28.1.3",
"prettier": "^2.7.1",
"typescript": "^4.7.4"
},
"packageManager": "yarn@3.2.1"
} }

0
public/icons/.gitkeep Normal file
View File

View File

@@ -1,25 +1,30 @@
import { import {
Modal, ActionIcon,
Anchor,
Button,
Center, Center,
Group, Group,
TextInput,
Image, Image,
Button,
Select,
AspectRatio,
Text,
Card,
LoadingOverlay, LoadingOverlay,
ActionIcon, Modal,
MultiSelect,
PasswordInput,
Select,
Stack,
Switch,
Tabs,
TextInput,
Title,
Tooltip, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { motion } from 'framer-motion'; import { IconApps } from '@tabler/icons';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Apps } from 'tabler-icons-react'; import { v4 as uuidv4 } from 'uuid';
import { useDebouncedValue } from '@mantine/hooks';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { ServiceTypeList } from '../../tools/types'; import { tryMatchPort, ServiceTypeList, StatusCodes } from '../../tools/types';
import { AppShelfItemWrapper } from './AppShelfItemWrapper'; import Tip from '../layout/Tip';
export function AddItemShelfButton(props: any) { export function AddItemShelfButton(props: any) {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
@@ -28,143 +33,158 @@ export function AddItemShelfButton(props: any) {
<Modal <Modal
size="xl" size="xl"
radius="md" radius="md"
title={<Title order={3}>Add service</Title>}
opened={props.opened || opened} opened={props.opened || opened}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
title="Add a service"
> >
<AddAppShelfItemForm setOpened={setOpened} /> <AddAppShelfItemForm setOpened={setOpened} />
</Modal> </Modal>
<ActionIcon <Tooltip withinPortal label="Add a service">
variant="default" <ActionIcon
radius="md" variant="default"
size="xl" radius="md"
color="blue" size="xl"
style={props.style} color="blue"
onClick={() => setOpened(true)} style={props.style}
> onClick={() => setOpened(true)}
<Tooltip label="Add a service"> >
<Apps /> <IconApps />
</Tooltip> </ActionIcon>
</ActionIcon> </Tooltip>
</> </>
); );
} }
export default function AddItemShelfItem(props: any) { function MatchIcon(name: string | undefined, form: any) {
const [opened, setOpened] = useState(false); if (name === undefined || name === '') return null;
return (
<>
<Modal
size="xl"
radius="md"
opened={props.opened || opened}
onClose={() => setOpened(false)}
title="Add a service"
>
<AddAppShelfItemForm setOpened={setOpened} />
</Modal>
<AppShelfItemWrapper>
<Card.Section>
<Group position="center" mx="lg">
<Text
// TODO: #1 Remove this hack to get the text to be centered.
ml={15}
style={{
alignSelf: 'center',
alignContent: 'center',
alignItems: 'center',
justifyContent: 'center',
justifyItems: 'center',
}}
mt="sm"
weight={500}
>
Add a service
</Text>
</Group>
</Card.Section>
<Card.Section>
<AspectRatio ratio={5 / 3} m="xl">
<motion.i
whileHover={{
cursor: 'pointer',
scale: 1.1,
}}
>
<Apps style={{ cursor: 'pointer' }} onClick={() => setOpened(true)} size={60} />
</motion.i>
</AspectRatio>
</Card.Section>
</AppShelfItemWrapper>
</>
);
}
function MatchIcon(name: string, form: any) {
fetch( fetch(
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name `https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.toLowerCase()}.png` .toLowerCase()
) .replace(/^dash\.$/, 'dashdot')}.png`
.then((res) => { ).then((res) => {
if (res.status === 200) { if (res.ok) {
form.setFieldValue('icon', res.url); form.setFieldValue('icon', res.url);
} }
}) });
.catch(() => {
// Do nothing
});
return false; return false;
} }
function MatchService(name: string, form: any) {
const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase());
if (service) {
form.setFieldValue('type', service);
}
}
const DEFAULT_ICON = '/favicon.png';
export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) { export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) {
const { setOpened } = props; const { setOpened } = props;
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const [isLoading, setLoading] = useState(false); const [isLoading, setLoading] = useState(false);
// Extract all the categories from the services in config
const InitialCategories = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
const [categories, setCategories] = useState<string[]>(InitialCategories);
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
id: props.id ?? uuidv4(),
type: props.type ?? 'Other', type: props.type ?? 'Other',
category: props.category ?? null,
name: props.name ?? '', name: props.name ?? '',
icon: props.icon ?? '', icon: props.icon ?? DEFAULT_ICON,
url: props.url ?? '', url: props.url ?? '',
apiKey: props.apiKey ?? (undefined as unknown as string), apiKey: props.apiKey ?? undefined,
username: props.username ?? undefined,
password: props.password ?? undefined,
openedUrl: props.openedUrl ?? undefined,
status: props.status ?? ['200'],
newTab: props.newTab ?? true,
}, },
validate: { validate: {
apiKey: () => null, apiKey: () => null,
// Validate icon with a regex // Validate icon with a regex
icon: (value: string) => { icon: (value: string) =>
if (!value.match(/^https?:\/\/.+\.(png|jpg|jpeg|gif)$/)) { // Disable matching to allow any values
return 'Please enter a valid icon URL'; null,
// Validate url with a regex http/https
url: (value: string) => {
try {
const _isValid = new URL(value);
} catch (e) {
return 'Please enter a valid URL';
} }
return null; return null;
}, },
// Validate url with a regex http/https status: (value: string[]) => {
url: (value: string) => { if (!value.length) {
if (!value.match(/^https?:\/\/.+\/$/)) { return 'Please select a status code';
return 'Please enter a valid URL (that ends with a /)';
} }
return null; return null;
}, },
}, },
}); });
const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
useEffect(() => {
if (
form.values.name !== debounced ||
form.values.icon !== DEFAULT_ICON ||
form.values.type !== 'Other'
) {
return;
}
MatchIcon(form.values.name, form);
MatchService(form.values.name, form);
tryMatchPort(form.values.name, form);
}, [debounced]);
// Try to set const hostname to new URL(form.values.url).hostname)
// If it fails, set it to the form.values.url
let hostname = form.values.url;
try {
hostname = new URL(form.values.url).origin;
} catch (e) {
// Do nothing
}
return ( return (
<> <>
<Center> <Center mb="lg">
<Image height={120} width={120} src={form.values.icon} alt="Placeholder" withPlaceholder /> <Image
height={120}
width={120}
fit="contain"
src={form.values.icon}
alt="Placeholder"
withPlaceholder
/>
</Center> </Center>
<form <form
onSubmit={form.onSubmit(() => { onSubmit={form.onSubmit(() => {
const newForm = { ...form.values };
if (newForm.newTab === true) newForm.newTab = undefined;
if (newForm.category === null) newForm.category = undefined;
if (newForm.status.length === 1 && newForm.status[0] === '200') {
delete newForm.status;
}
// If service already exists, update it. // If service already exists, update it.
if (config.services && config.services.find((s) => s.name === form.values.name)) { if (config.services && config.services.find((s) => s.id === newForm.id)) {
setConfig({ setConfig({
...config, ...config,
// replace the found item by matching ID
services: config.services.map((s) => { services: config.services.map((s) => {
if (s.name === form.values.name) { if (s.id === newForm.id) {
return { return {
...form.values, ...newForm,
}; };
} }
return s; return s;
@@ -173,65 +193,182 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
} else { } else {
setConfig({ setConfig({
...config, ...config,
services: [...config.services, form.values], services: [...config.services, newForm],
}); });
} }
setOpened(false); setOpened(false);
form.reset(); form.reset();
})} })}
> >
<Group direction="column" grow> <Tabs defaultValue="Options">
<TextInput <Tabs.List grow>
required <Tabs.Tab value="Options">Options</Tabs.Tab>
label="Service name" <Tabs.Tab value="Advanced Options">Advanced options</Tabs.Tab>
placeholder="Plex" </Tabs.List>
value={form.values.name} <Tabs.Panel value="Options">
onChange={(event) => { <Stack>
form.setFieldValue('name', event.currentTarget.value); <TextInput
const match = MatchIcon(event.currentTarget.value, form); required
if (match) { label="Service name"
form.setFieldValue('icon', match); placeholder="Plex"
} {...form.getInputProps('name')}
}} />
error={form.errors.name && 'Invalid icon url'} <TextInput
/> required
label="Icon URL"
<TextInput placeholder={DEFAULT_ICON}
required {...form.getInputProps('icon')}
label="Icon url" />
placeholder="https://i.gifer.com/ANPC.gif" <TextInput
{...form.getInputProps('icon')} required
/> label="Service URL"
<TextInput placeholder="http://localhost:7575"
required {...form.getInputProps('url')}
label="Service url" />
placeholder="http://localhost:8989" <TextInput
{...form.getInputProps('url')} label="On Click URL"
/> placeholder="http://sonarr.example.com"
<Select {...form.getInputProps('openedUrl')}
label="Select the type of service (used for API calls)" />
defaultValue="Other" <Select
placeholder="Pick one" label="Service type"
required defaultValue="Other"
searchable placeholder="Pick one"
data={ServiceTypeList} required
{...form.getInputProps('type')} searchable
/> data={ServiceTypeList}
<LoadingOverlay visible={isLoading} /> {...form.getInputProps('type')}
{(form.values.type === 'Sonarr' || form.values.type === 'Radarr') && ( />
<TextInput <Select
required label="Category"
label="API key" data={categories}
placeholder="Your API key" placeholder="Select a category or create a new one"
value={form.values.apiKey} nothingFound="Nothing found"
onChange={(event) => { searchable
form.setFieldValue('apiKey', event.currentTarget.value); clearable
}} creatable
error={form.errors.apiKey && 'Invalid API key'} onCreate={(query) => {
/> const item = { value: query, label: query };
)} setCategories([...InitialCategories, query]);
</Group> return item;
}}
getCreateLabel={(query) => `+ Create "${query}"`}
{...form.getInputProps('category')}
/>
<LoadingOverlay visible={isLoading} />
{(form.values.type === 'Sonarr' ||
form.values.type === 'Radarr' ||
form.values.type === 'Lidarr' ||
form.values.type === 'Overseerr' ||
form.values.type === 'Jellyseerr' ||
form.values.type === 'Readarr') && (
<>
<TextInput
required
label="API key"
placeholder="Your API key"
value={form.values.apiKey}
onChange={(event) => {
form.setFieldValue('apiKey', event.currentTarget.value);
}}
error={form.errors.apiKey && 'Invalid API key'}
/>
<Tip>
Get your API key{' '}
<Anchor
target="_blank"
weight="bold"
style={{ fontStyle: 'inherit', fontSize: 'inherit' }}
href={`${hostname}/settings/general`}
>
here.
</Anchor>
</Tip>
</>
)}
{form.values.type === 'qBittorrent' && (
<>
<TextInput
required
label="Username"
placeholder="admin"
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={form.errors.username && 'Invalid username'}
/>
<PasswordInput
required
label="Password"
placeholder="adminadmin"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
{form.values.type === 'Deluge' && (
<>
<PasswordInput
label="Password"
placeholder="password"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
{form.values.type === 'Transmission' && (
<>
<TextInput
label="Username"
placeholder="admin"
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={form.errors.username && 'Invalid username'}
/>
<PasswordInput
label="Password"
placeholder="adminadmin"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="Advanced Options">
<Stack>
<MultiSelect
required
label="HTTP Status Codes"
data={StatusCodes}
placeholder="Select valid status codes"
clearButtonLabel="Clear selection"
nothingFound="Nothing found"
defaultValue={['200']}
clearable
searchable
{...form.getInputProps('status')}
/>
<Switch
label="Open service in new tab"
defaultChecked={form.values.newTab}
{...form.getInputProps('newTab')}
/>
</Stack>
</Tabs.Panel>
</Tabs>
<Group grow position="center" mt="xl"> <Group grow position="center" mt="xl">
<Button type="submit">{props.message ?? 'Add service'}</Button> <Button type="submit">{props.message ?? 'Add service'}</Button>
</Group> </Group>

View File

@@ -1,26 +0,0 @@
import { SimpleGrid } from '@mantine/core';
import AppShelf, { AppShelfItem } from './AppShelf';
export default {
title: 'Item Shelf',
component: AppShelf,
args: {
service: {
name: 'qBittorrent',
url: 'http://',
icon: 'https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/qBittorrent/icon.png',
type: 'qBittorrent',
apiKey: '',
},
},
};
export const Default = (args: any) => <AppShelf {...args} />;
export const One = (args: any) => <AppShelfItem {...args} />;
export const Ten = (args: any) => (
<SimpleGrid>
{Array.from(Array(10)).map((_, i) => (
<AppShelfItem {...args} key={i} />
))}
</SimpleGrid>
);

View File

@@ -1,104 +1,188 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion } from 'framer-motion'; import { Accordion, Grid, Paper, Stack, useMantineColorScheme } from '@mantine/core';
import { Text, AspectRatio, SimpleGrid, Card, Image, useMantineTheme } from '@mantine/core'; import {
closestCenter,
DndContext,
DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
import { useLocalStorage } from '@mantine/hooks';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import AppShelfMenu from './AppShelfMenu';
const AppShelf = () => { import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
const { config } = useConfig(); import { ModuleMenu, ModuleWrapper } from '../../modules/moduleWrapper';
import { DownloadsModule } from '../../modules';
import DownloadComponent from '../../modules/downloads/DownloadsModule';
const AppShelf = (props: any) => {
const { config, setConfig } = useConfig();
// Extract all the categories from the services in config
const categoryList = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
const [toggledCategories, setToggledCategories] = useLocalStorage({
key: 'app-shelf-toggled',
// This is a bit of a hack to toggle the categories on the first load, return a string[] of the categories
defaultValue: categoryList,
});
const [activeId, setActiveId] = useState(null);
const { colorScheme } = useMantineColorScheme();
const sensors = useSensors(
useSensor(TouchSensor, {
activationConstraint: {
delay: 500,
tolerance: 5,
},
}),
useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
delay: 500,
tolerance: 5,
},
})
);
function handleDragStart(event: any) {
const { active } = event;
setActiveId(active.id);
}
function handleDragEnd(event: any) {
const { active, over } = event;
if (active.id !== over.id) {
const newConfig = { ...config };
const activeIndex = newConfig.services.findIndex((e) => e.id === active.id);
const overIndex = newConfig.services.findIndex((e) => e.id === over.id);
newConfig.services = arrayMove(newConfig.services, activeIndex, overIndex);
setConfig(newConfig);
}
setActiveId(null);
}
const getItems = (filter?: string) => {
// If filter is not set, return all the services without a category or a null category
let filtered = config.services;
if (!filter) {
filtered = config.services.filter((e) => !e.category || e.category === null);
}
if (filter) {
filtered = config.services.filter((e) => e.category === filter);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={config.services}>
<Grid gutter="xl" align="center">
{filtered.map((service) => (
<Grid.Col
key={service.id}
span={6}
xl={config.settings.appCardWidth || 2}
xs={4}
sm={3}
md={3}
>
<SortableAppShelfItem service={service} key={service.id} id={service.id} />
</Grid.Col>
))}
</Grid>
</SortableContext>
<DragOverlay
style={{
// Add a shadow to the drag overlay
boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)',
}}
>
{activeId ? (
<AppShelfItem service={config.services.find((e) => e.id === activeId)} id={activeId} />
) : null}
</DragOverlay>
</DndContext>
);
};
if (categoryList.length > 0) {
const noCategory = config.services.filter(
(e) => e.category === undefined || e.category === null
);
const downloadEnabled = config.modules?.[DownloadsModule.title]?.enabled ?? false;
// Create an item with 0: true, 1: true, 2: true... For each category
return (
// TODO: Style accordion so that the bar is transparent to the user settings
<Stack>
<Accordion
variant="separated"
radius="lg"
order={2}
multiple
value={toggledCategories}
onChange={(state) => {
setToggledCategories([...state]);
}}
>
{categoryList.map((category, idx) => (
<Accordion.Item key={category} value={idx.toString()}>
<Accordion.Control>{category}</Accordion.Control>
<Accordion.Panel>{getItems(category)}</Accordion.Panel>
</Accordion.Item>
))}
{/* Return the item for all services without category */}
{noCategory && noCategory.length > 0 ? (
<Accordion.Item key="Other" value="Other">
<Accordion.Control>Other</Accordion.Control>
<Accordion.Panel>{getItems()}</Accordion.Panel>
</Accordion.Item>
) : null}
{downloadEnabled ? (
<Accordion.Item key="Downloads" value="Your downloads">
<Accordion.Control>Your downloads</Accordion.Control>
<Accordion.Panel>
<Paper
p="lg"
radius="lg"
style={{
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
${(config.settings.appOpacity || 100) / 100}`,
borderColor: `rgba(${
colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'
} \
${(config.settings.appOpacity || 100) / 100}`,
}}
>
<ModuleMenu module={DownloadsModule} />
<DownloadComponent />
</Paper>
</Accordion.Panel>
</Accordion.Item>
) : null}
</Accordion>
</Stack>
);
}
return ( return (
<SimpleGrid <Stack>
cols={7} {getItems()}
spacing="xl" <ModuleWrapper mt="xl" module={DownloadsModule} />
breakpoints={[ </Stack>
{ maxWidth: 2400, cols: 6, spacing: 'xl' },
{ maxWidth: 1800, cols: 5, spacing: 'xl' },
{ maxWidth: 1500, cols: 4, spacing: 'lg' },
{ maxWidth: 800, cols: 3, spacing: 'md' },
{ maxWidth: 400, cols: 3, spacing: 'sm' },
{ maxWidth: 400, cols: 2, spacing: 'sm' },
]}
>
{config.services.map((service) => (
<AppShelfItem key={service.name} service={service} />
))}
</SimpleGrid>
); );
}; };
export function AppShelfItem(props: any) {
const { service }: { service: serviceItem } = props;
const [hovering, setHovering] = useState(false);
const theme = useMantineTheme();
return (
<motion.div
key={service.name}
onHoverStart={() => {
setHovering(true);
}}
onHoverEnd={() => {
setHovering(false);
}}
>
<Card
style={{
boxShadow: hovering ? '0px 0px 3px rgba(0, 0, 0, 0.5)' : '0px 0px 1px rgba(0, 0, 0, 0.5)',
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
}}
radius="md"
>
<Card.Section>
<Text mt="sm" align="center" lineClamp={1} weight={500}>
{service.name}
</Text>
<motion.div
style={{
position: 'absolute',
top: 5,
right: 5,
alignSelf: 'flex-end',
}}
animate={{
opacity: hovering ? 1 : 0,
}}
>
<AppShelfMenu service={service} />
</motion.div>
</Card.Section>
<Card.Section>
<AspectRatio
ratio={3 / 5}
m="xl"
style={{
width: 150,
height: 90,
}}
>
<motion.i
whileHover={{
cursor: 'pointer',
scale: 1.1,
}}
>
<Image
style={{
maxWidth: 80,
}}
fit="contain"
onClick={() => {
window.open(service.url);
}}
src={service.icon}
/>
</motion.i>
</AspectRatio>
</Card.Section>
</Card>
</motion.div>
);
}
export default AppShelf; export default AppShelf;

View File

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

View File

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

View File

@@ -1,67 +1,78 @@
import { Menu, Modal, Text } from '@mantine/core'; import { ActionIcon, Menu, Modal, Text, useMantineTheme } from '@mantine/core';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { useState } from 'react'; import { useState } from 'react';
import { Check, Edit, Trash } from 'tabler-icons-react'; import { IconCheck as Check, IconEdit as Edit, IconMenu, IconTrash as Trash } from '@tabler/icons';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { AddAppShelfItemForm } from './AddAppShelfItem'; import { AddAppShelfItemForm } from './AddAppShelfItem';
import { useColorTheme } from '../../tools/color';
export default function AppShelfMenu(props: any) { export default function AppShelfMenu(props: any) {
const { service } = props; const { service }: { service: serviceItem } = props;
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const { secondaryColor } = useColorTheme();
const theme = useMantineTheme();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
return ( return (
<> <>
<Modal <Modal
size="xl" size="xl"
radius="lg" radius="md"
opened={props.opened || opened} opened={props.opened || opened}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
title="Modify a service" title="Modify a service"
> >
<AddAppShelfItemForm <AddAppShelfItemForm setOpened={setOpened} {...service} message="Save service" />
setOpened={setOpened}
name={service.name}
type={service.type}
url={service.url}
icon={service.icon}
apiKey={service.apiKey}
message="Save service"
/>
</Modal> </Modal>
<Menu position="right"> <Menu
<Menu.Label>Settings</Menu.Label> withinPortal
<Menu.Item width={150}
color="primary" shadow="xl"
icon={<Edit size={14} />} withArrow
// TODO: #2 Add the ability to edit the service. radius="md"
onClick={() => setOpened(true)} position="right"
> styles={{
Edit dropdown: {
</Menu.Item> // Add shadow and elevation to the body
<Menu.Label>Danger zone</Menu.Label> boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
<Menu.Item },
color="red" }}
onClick={(e: any) => { >
setConfig({ <Menu.Target>
...config, <ActionIcon style={{}}>
services: config.services.filter((s) => s.name !== service.name), <IconMenu />
}); </ActionIcon>
showNotification({ </Menu.Target>
autoClose: 5000, <Menu.Dropdown>
title: ( <Menu.Label>Settings</Menu.Label>
<Text> <Menu.Item color={secondaryColor} icon={<Edit />} onClick={() => setOpened(true)}>
Service <b>{service.name}</b> removed successfully Edit
</Text> </Menu.Item>
), <Menu.Label>Danger zone</Menu.Label>
color: 'green', <Menu.Item
icon: <Check />, color="red"
message: undefined, onClick={(e: any) => {
}); setConfig({
}} ...config,
icon={<Trash size={14} />} services: config.services.filter((s) => s.id !== service.id),
> });
Delete showNotification({
</Menu.Item> autoClose: 5000,
title: (
<Text>
Service <b>{service.name}</b> removed successfully!
</Text>
),
color: 'green',
icon: <Check />,
message: undefined,
});
}}
icon={<Trash />}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu> </Menu>
</> </>
); );

View File

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

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { createStyles, Switch, Group, useMantineColorScheme } from '@mantine/core'; import { createStyles, Switch, Group, useMantineColorScheme, Kbd } from '@mantine/core';
import { Sun, MoonStars } from 'tabler-icons-react'; import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons';
import { useConfig } from '../../tools/state';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
root: { root: {
@@ -29,6 +30,7 @@ const useStyles = createStyles((theme) => ({
})); }));
export function ColorSchemeSwitch() { export function ColorSchemeSwitch() {
const { config } = useConfig();
const { colorScheme, toggleColorScheme } = useMantineColorScheme(); const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();
@@ -40,6 +42,9 @@ export function ColorSchemeSwitch() {
<Switch checked={colorScheme === 'dark'} onChange={() => toggleColorScheme()} size="md" /> <Switch checked={colorScheme === 'dark'} onChange={() => toggleColorScheme()} size="md" />
</div> </div>
Switch to {colorScheme === 'dark' ? 'light' : 'dark'} mode Switch to {colorScheme === 'dark' ? 'light' : 'dark'} mode
<Group spacing={2}>
<Kbd>Ctrl</Kbd>+<Kbd>J</Kbd>
</Group>
</Group> </Group>
); );
} }

View File

@@ -1,5 +1,5 @@
import { Box, useMantineColorScheme } from '@mantine/core'; import { Box, useMantineColorScheme } from '@mantine/core';
import { Sun, MoonStars } from 'tabler-icons-react'; import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
export function ColorSchemeToggle() { export function ColorSchemeToggle() {

View File

@@ -1,32 +1,37 @@
import { Center, Loader, Select, Tooltip } from '@mantine/core'; import { Center, Loader, Select, Tooltip } from '@mantine/core';
import { setCookies } from 'cookies-next'; import { setCookie } from 'cookies-next';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
export default function ConfigChanger() { export default function ConfigChanger() {
const { config, loadConfig, setConfig, getConfigs } = useConfig(); const { config, loadConfig, setConfig, getConfigs } = useConfig();
const [configList, setConfigList] = useState([] as string[]); const [configList, setConfigList] = useState<string[]>([]);
const [value, setValue] = useState(config.name);
useEffect(() => { useEffect(() => {
getConfigs().then((configs) => setConfigList(configs)); getConfigs().then((configs) => setConfigList(configs));
// setConfig(initialConfig);
}, [config]); }, [config]);
// If configlist is empty, return a loading indicator // If configlist is empty, return a loading indicator
if (configList.length === 0) { if (configList.length === 0) {
return ( return (
<Center> <Tooltip label={"Loading your configs. This doesn't load in vercel."}>
<Tooltip label={"Loading your configs. This doesn't load in vercel."}> <Center>
<Loader /> <Loader />
</Tooltip> </Center>
</Center> </Tooltip>
); );
} }
// return <Select data={[{ value: '1', label: '1' },]} onChange={(e) => console.log(e)} value="1" />;
return ( return (
<Select <Select
defaultValue={config.name}
label="Config loader" label="Config loader"
value={value}
defaultValue={config.name}
onChange={(e) => { onChange={(e) => {
loadConfig(e ?? 'default'); loadConfig(e ?? 'default');
setCookies('config-name', e ?? 'default', { maxAge: 60 * 60 * 24 * 30 }); setCookie('config-name', e ?? 'default', {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
}} }}
data={ data={
// If config list is empty, return the current config // If config list is empty, return the current config

View File

@@ -1,61 +1,18 @@
import { Group, Text, useMantineTheme, MantineTheme } from '@mantine/core'; import { Group, Text, useMantineTheme } from '@mantine/core';
import { Upload, Photo, X, Icon as TablerIcon, Check } from 'tabler-icons-react'; import { IconX as X, IconCheck as Check, IconX, IconPhoto, IconUpload } from '@tabler/icons';
import { DropzoneStatus, FullScreenDropzone } from '@mantine/dropzone';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { useRef } from 'react'; import { setCookie } from 'cookies-next';
import { useRouter } from 'next/router'; import { Dropzone } from '@mantine/dropzone';
import { setCookies } from 'cookies-next';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { Config } from '../../tools/types'; import { Config } from '../../tools/types';
import { migrateToIdConfig } from '../../tools/migrate';
function getIconColor(status: DropzoneStatus, theme: MantineTheme) {
return status.accepted
? theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]
: status.rejected
? theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.colors.gray[7];
}
function ImageUploadIcon({
status,
...props
}: React.ComponentProps<TablerIcon> & { status: DropzoneStatus }) {
if (status.accepted) {
return <Upload {...props} />;
}
if (status.rejected) {
return <X {...props} />;
}
return <Photo {...props} />;
}
export const dropzoneChildren = (status: DropzoneStatus, theme: MantineTheme) => (
<Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}>
<ImageUploadIcon status={status} style={{ color: getIconColor(status, theme) }} size={80} />
<div>
<Text size="xl" inline>
Drag images here or click to select files
</Text>
<Text size="sm" color="dimmed" inline mt={7}>
Attach as many files as you like, each file should not exceed 5mb
</Text>
</div>
</Group>
);
export default function LoadConfigComponent(props: any) { export default function LoadConfigComponent(props: any) {
const { setConfig } = useConfig(); const { setConfig } = useConfig();
const theme = useMantineTheme(); const theme = useMantineTheme();
const router = useRouter();
const openRef = useRef<() => void>();
return ( return (
<FullScreenDropzone <Dropzone.FullScreen
onDrop={(files) => { onDrop={(files) => {
files[0].text().then((e) => { files[0].text().then((e) => {
try { try {
@@ -83,13 +40,41 @@ export default function LoadConfigComponent(props: any) {
icon: <Check />, icon: <Check />,
message: undefined, message: undefined,
}); });
setCookies('config-name', newConfig.name, { maxAge: 60 * 60 * 24 * 30 }); setCookie('config-name', newConfig.name, {
setConfig(newConfig); maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
const migratedConfig = migrateToIdConfig(newConfig);
setConfig(migratedConfig);
}); });
}} }}
accept={['application/json']} accept={['application/json']}
> >
{(status) => dropzoneChildren(status, theme)} <Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}>
</FullScreenDropzone> <Dropzone.Accept>
<Text size="xl" inline>
<IconUpload
size={50}
stroke={1.5}
color={theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]}
/>
Drag files here to upload a config. Support for JSON only.
</Text>
</Dropzone.Accept>
<Dropzone.Reject>
<Text size="xl" inline>
<IconX
size={50}
stroke={1.5}
color={theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]}
/>
This file format is not supported. Please only upload JSON.
</Text>
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={50} stroke={1.5} />
</Dropzone.Idle>
</Group>
</Dropzone.FullScreen>
); );
} }

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
import SearchBar from './SearchBar';
export default {
title: 'Search bar',
config: {
searchBar: false,
},
};
export const Default = (args: any) => <SearchBar {...args} />;

View File

@@ -1,90 +0,0 @@
import { TextInput, Text, Popover, Box } from '@mantine/core';
import { useForm } from '@mantine/hooks';
import { useState } from 'react';
import { Search, BrandYoutube, Download } from 'tabler-icons-react';
import { useConfig } from '../../tools/state';
export default function SearchBar(props: any) {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
const [icon, setIcon] = useState(<Search />);
const querryUrl = config.settings.searchUrl || 'https://www.google.com/search?q=';
const form = useForm({
initialValues: {
querry: '',
},
});
if (config.settings.searchBar === false) {
return null;
}
return (
<Box
mb={"xl"}
style={{
width: '100%',
}}
>
<form
onChange={() => {
// If querry contains !yt or !t add "Searching on YouTube" or "Searching torrent"
const querry = form.values.querry.trim();
const isYoutube = querry.startsWith('!yt');
const isTorrent = querry.startsWith('!t');
if (isYoutube) {
setIcon(<BrandYoutube size={22} />);
} else if (isTorrent) {
setIcon(<Download size={22} />);
} else {
setIcon(<Search size={22} />);
}
}}
onSubmit={form.onSubmit((values) => {
// Find if querry is prefixed by !yt or !t
const querry = values.querry.trim();
const isYoutube = querry.startsWith('!yt');
const isTorrent = querry.startsWith('!t');
if (isYoutube) {
window.open(`https://www.youtube.com/results?search_query=${querry.substring(3)}`);
} else if (isTorrent) {
window.open(`https://bitsearch.to/search?q=${querry.substring(3)}`);
} else {
window.open(`${querryUrl}${values.querry}`);
}
})}
>
<Popover
opened={opened}
style={{
width: '100%',
}}
position="bottom"
placement="start"
withArrow
trapFocus={false}
transition="pop-top-left"
onFocusCapture={() => setOpened(true)}
onBlurCapture={() => setOpened(false)}
target={
<TextInput
variant="filled"
color="blue"
icon={icon}
radius="md"
size="md"
placeholder="Search the web"
{...props}
{...form.getInputProps('querry')}
/>
}
>
<Text>
tip: Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on YouTube or for a Torrent respectively.
</Text>
</Popover>
</form>
</Box>
);
}

View File

@@ -0,0 +1,65 @@
import { TextInput, Button, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useConfig } from '../../tools/state';
import { ColorSelector } from './ColorSelector';
import { OpacitySelector } from './OpacitySelector';
import { AppCardWidthSelector } from './AppCardWidthSelector';
import { ShadeSelector } from './ShadeSelector';
export default function TitleChanger() {
const { config, setConfig } = useConfig();
const form = useForm({
initialValues: {
title: config.settings.title,
logo: config.settings.logo,
favicon: config.settings.favicon,
background: config.settings.background,
},
});
const saveChanges = (values: {
title?: string;
logo?: string;
favicon?: string;
background?: string;
}) => {
setConfig({
...config,
settings: {
...config.settings,
title: values.title,
logo: values.logo,
favicon: values.favicon,
background: values.background,
},
});
};
return (
<Stack mb="md" mr="sm" mt="xs">
<form onSubmit={form.onSubmit((values) => saveChanges(values))}>
<Stack>
<TextInput label="Page title" placeholder="Homarr 🦞" {...form.getInputProps('title')} />
<TextInput label="Logo" placeholder="/img/logo.png" {...form.getInputProps('logo')} />
<TextInput
label="Favicon"
placeholder="/favicon.png"
{...form.getInputProps('favicon')}
/>
<TextInput
label="Background"
placeholder="/img/background.png"
{...form.getInputProps('background')}
/>
<Button type="submit">Save</Button>
</Stack>
</form>
<ColorSelector type="primary" />
<ColorSelector type="secondary" />
<ShadeSelector />
<OpacitySelector />
<AppCardWidthSelector />
</Stack>
);
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Text, Slider, Stack } from '@mantine/core';
import { useConfig } from '../../tools/state';
export function AppCardWidthSelector() {
const { config, setConfig } = useConfig();
const setappCardWidth = (appCardWidth: number) => {
setConfig({
...config,
settings: {
...config.settings,
appCardWidth,
},
});
};
return (
<Stack spacing="xs">
<Text>App Width</Text>
<Slider
label={null}
defaultValue={config.settings.appCardWidth}
step={0.2}
min={0.8}
max={2}
styles={{ markLabel: { fontSize: 'xx-small' } }}
onChange={(value) => setappCardWidth(value)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { ColorSwatch, Grid, Group, Popover, Text, useMantineTheme } from '@mantine/core';
import { useConfig } from '../../tools/state';
import { useColorTheme } from '../../tools/color';
interface ColorControlProps {
type: string;
}
export function ColorSelector({ type }: ColorControlProps) {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
const { primaryColor, secondaryColor, setPrimaryColor, setSecondaryColor } = useColorTheme();
const theme = useMantineTheme();
const colors = Object.keys(theme.colors).map((color) => ({
swatch: theme.colors[color][6],
color,
}));
const configColor = type === 'primary' ? primaryColor : secondaryColor;
const setConfigColor = (color: string) => {
if (type === 'primary') {
setPrimaryColor(color);
setConfig({
...config,
settings: {
...config.settings,
primaryColor: color,
},
});
} else {
setSecondaryColor(color);
setConfig({
...config,
settings: {
...config.settings,
secondaryColor: color,
},
});
}
};
const swatches = colors.map(({ color, swatch }) => (
<Grid.Col span={2} key={color}>
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigColor(color)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
return (
<Group>
<Popover
width={250}
withinPortal
opened={opened}
onClose={() => setOpened(false)}
position="left"
withArrow
>
<Popover.Target>
<ColorSwatch
component="button"
type="button"
color={theme.colors[configColor][6]}
onClick={() => setOpened((o) => !o)}
size={22}
style={{ cursor: 'pointer' }}
/>
</Popover.Target>
<Popover.Dropdown>
<Grid gutter="lg" columns={14}>
{swatches}
</Grid>
</Popover.Dropdown>
</Popover>
<Text>{type[0].toUpperCase() + type.slice(1)} color</Text>
</Group>
);
}

View File

@@ -0,0 +1,86 @@
import { Text, SegmentedControl, TextInput, Stack } from '@mantine/core';
import { useState } from 'react';
import { useConfig } from '../../tools/state';
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
import { WidgetsPositionSwitch } from '../WidgetsPositionSwitch/WidgetsPositionSwitch';
import ConfigChanger from '../Config/ConfigChanger';
import SaveConfigComponent from '../Config/SaveConfig';
import ModuleEnabler from './ModuleEnabler';
import Tip from '../layout/Tip';
export default function CommonSettings(args: any) {
const { config, setConfig } = useConfig();
const matches = [
{ label: 'Google', value: 'https://google.com/search?q=' },
{ label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' },
{ label: 'Bing', value: 'https://bing.com/search?q=' },
{ label: 'Custom', value: 'Custom' },
];
const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl);
const [searchUrl, setSearchUrl] = useState(
matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom'
);
return (
<Stack mb="md" mr="sm">
<Stack spacing={0} mt="xs">
<Text>Search engine</Text>
<Tip>
Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on YouTube or
for a Torrent respectively.
</Tip>
<SegmentedControl
fullWidth
mb="sm"
title="Search engine"
value={
// Match config.settings.searchUrl with a key in the matches array
searchUrl
}
onChange={
// Set config.settings.searchUrl to the value of the selected item
(e) => {
setSearchUrl(e);
setConfig({
...config,
settings: {
...config.settings,
searchUrl: e,
},
});
}
}
data={matches}
/>
{searchUrl === 'Custom' && (
<>
<Tip>%s can be used as a placeholder for the query.</Tip>
<TextInput
label="Query URL"
placeholder="Custom query URL"
value={customSearchUrl}
onChange={(event) => {
setCustomSearchUrl(event.currentTarget.value);
setConfig({
...config,
settings: {
...config.settings,
searchUrl: event.currentTarget.value,
},
});
}}
/>
</>
)}
</Stack>
<ColorSchemeSwitch />
<WidgetsPositionSwitch />
<ModuleEnabler />
<ConfigChanger />
<SaveConfigComponent />
<Tip>Upload your config file by dragging and dropping it onto the page!</Tip>
</Stack>
);
}

View File

@@ -0,0 +1,44 @@
import { Group, ActionIcon, Anchor, Text } from '@mantine/core';
import { IconBrandDiscord, IconBrandGithub } from '@tabler/icons';
import { CURRENT_VERSION } from '../../../data/constants';
export default function Credits(props: any) {
return (
<Group position="center" mt="xs">
<Group spacing={0}>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<IconBrandGithub size={18} />
</ActionIcon>
<Text
style={{
position: 'relative',
fontSize: '0.90rem',
color: 'gray',
}}
>
{CURRENT_VERSION}
</Text>
</Group>
<Group spacing={1}>
<Text
style={{
fontSize: '0.90rem',
textAlign: 'center',
color: 'gray',
}}
>
Made with by @
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
ajnart
</Anchor>
</Text>
<ActionIcon<'a'> component="a" href="https://discord.gg/aCsmEV5RgA" size="lg">
<IconBrandDiscord size={18} />
</ActionIcon>
</Group>
</Group>
);
}

View File

@@ -1,41 +1,35 @@
import { Group, Switch } from '@mantine/core'; import { Checkbox, SimpleGrid, Stack, Title } from '@mantine/core';
import * as Modules from '../modules'; import * as Modules from '../../modules';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
export default function ModuleEnabler(props: any) { export default function ModuleEnabler(props: any) {
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const modules = Object.values(Modules).map((module) => module); const modules = Object.values(Modules).map((module) => module);
const enabledModules = config.settings.enabledModules ?? [];
modules.filter((module) => enabledModules.includes(module.title));
return ( return (
<Group direction="column"> <Stack>
{modules.map((module) => ( <Title order={4}>Module enabler</Title>
<Switch <SimpleGrid cols={3} spacing="xs">
key={module.title} {modules.map((module) => (
size="md" <Checkbox
checked={enabledModules.includes(module.title)} key={module.title}
label={`Enable ${module.title} module`} size="md"
onChange={(e) => { checked={config.modules?.[module.title]?.enabled ?? false}
if (e.currentTarget.checked) { label={`${module.title}`}
onChange={(e) => {
setConfig({ setConfig({
...config, ...config,
settings: { modules: {
...config.settings, ...config.modules,
enabledModules: [...enabledModules, module.title], [module.title]: {
...config.modules?.[module.title],
enabled: e.currentTarget.checked,
},
}, },
}); });
} else { }}
setConfig({ />
...config, ))}
settings: { </SimpleGrid>
...config.settings, </Stack>
enabledModules: enabledModules.filter((m) => m !== module.title),
},
});
}
}}
/>
))}
</Group>
); );
} }

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Text, Slider, Stack } from '@mantine/core';
import { useConfig } from '../../tools/state';
export function OpacitySelector() {
const { config, setConfig } = useConfig();
const MARKS = [
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 30, label: '30' },
{ value: 40, label: '40' },
{ value: 50, label: '50' },
{ value: 60, label: '60' },
{ value: 70, label: '70' },
{ value: 80, label: '80' },
{ value: 90, label: '90' },
{ value: 100, label: '100' },
];
const setConfigOpacity = (opacity: number) => {
setConfig({
...config,
settings: {
...config.settings,
appOpacity: opacity,
},
});
};
return (
<Stack spacing="xs">
<Text>App Opacity</Text>
<Slider
defaultValue={config.settings.appOpacity || 100}
step={10}
min={10}
marks={MARKS}
styles={{ markLabel: { fontSize: 'xx-small' } }}
onChange={(value) => setConfigOpacity(value)}
/>
</Stack>
);
}

View File

@@ -1,10 +0,0 @@
import { SettingsMenuButton } from './SettingsMenu';
export default {
title: ' menu',
args: {
opened: false,
},
};
export const Default = (args: any) => <SettingsMenuButton {...args} />;

View File

@@ -1,173 +1,61 @@
import { import { ActionIcon, Title, Tooltip, Drawer, Tabs, ScrollArea } from '@mantine/core';
ActionIcon, import { useHotkeys } from '@mantine/hooks';
Group, import { useState } from 'react';
Modal, import { IconSettings } from '@tabler/icons';
Switch, import AdvancedSettings from './AdvancedSettings';
Title, import CommonSettings from './CommonSettings';
Text, import Credits from './Credits';
Tooltip,
SegmentedControl,
Indicator,
Alert,
TextInput,
} from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { useEffect, useState } from 'react';
import { AlertCircle, Settings as SettingsIcon } from 'tabler-icons-react';
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
import { useConfig } from '../../tools/state';
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
import ConfigChanger from '../Config/ConfigChanger';
import SaveConfigComponent from '../Config/SaveConfig';
import ModuleEnabler from './ModuleEnabler';
function SettingsMenu(props: any) { function SettingsMenu(props: any) {
const { config, setConfig } = useConfig();
const colorScheme = useColorScheme();
const { current, latest } = props;
const matches = [
{ label: 'Google', value: 'https://google.com/search?q=' },
{ label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' },
{ label: 'Bing', value: 'https://bing.com/search?q=' },
{ label: 'Custom', value: 'Custom' },
];
const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl);
const [searchUrl, setSearchUrl] = useState(
matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom'
);
return ( return (
<Group direction="column" grow> <Tabs defaultValue="Common">
<Alert <Tabs.List grow>
icon={<AlertCircle size={16} />} <Tabs.Tab value="Common">Common</Tabs.Tab>
title="Update available" <Tabs.Tab value="Customizations">Customizations</Tabs.Tab>
radius="lg" </Tabs.List>
hidden={current === latest} <Tabs.Panel data-autofocus value="Common">
> <ScrollArea style={{ height: '78vh' }} offsetScrollbars>
Version {latest} is available. Current: {current} <CommonSettings />
</Alert> </ScrollArea>
<Group grow direction="column" spacing={0}> </Tabs.Panel>
<Text>Search engine</Text> <Tabs.Panel value="Customizations">
<SegmentedControl <ScrollArea style={{ height: '78vh' }} offsetScrollbars>
fullWidth <AdvancedSettings />
title="Search engine" </ScrollArea>
value={ </Tabs.Panel>
// Match config.settings.searchUrl with a key in the matches array </Tabs>
searchUrl
}
onChange={
// Set config.settings.searchUrl to the value of the selected item
(e) => {
setSearchUrl(e);
setConfig({
...config,
settings: {
...config.settings,
searchUrl: e,
},
});
}
}
data={matches}
/>
{searchUrl === 'Custom' && (
<TextInput
label="Querry URL"
placeholder="Custom querry url"
value={customSearchUrl}
onChange={(event) => {
setCustomSearchUrl(event.currentTarget.value);
setConfig({
...config,
settings: {
...config.settings,
searchUrl: event.currentTarget.value,
},
});
}}
/>
)}
</Group>
<Group direction="column">
<Switch
size="md"
onChange={(e) =>
setConfig({
...config,
settings: {
...config.settings,
searchBar: e.currentTarget.checked,
},
})
}
checked={config.settings.searchBar}
label="Enable search bar"
/>
</Group>
<ModuleEnabler />
<ColorSchemeSwitch />
<ConfigChanger />
<SaveConfigComponent />
<Text
style={{
alignSelf: 'center',
fontSize: '0.75rem',
textAlign: 'center',
color: '#a0aec0',
}}
>
tip: You can upload your config file by dragging and dropping it onto the page
</Text>
</Group>
); );
} }
export function SettingsMenuButton(props: any) { export function SettingsMenuButton(props: any) {
const [update, setUpdate] = useState(false); useHotkeys([['ctrl+L', () => setOpened(!opened)]]);
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [latestVersion, setLatestVersion] = useState(CURRENT_VERSION);
useEffect(() => {
// Fetch Data here when component first mounted
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
res.json().then((data) => {
setLatestVersion(data.tag_name);
if (data.tag_name !== CURRENT_VERSION) {
setUpdate(true);
}
});
});
}, []);
return ( return (
<> <>
<Modal <Drawer
size="md" size="xl"
title={<Title order={3}>Settings</Title>} padding="lg"
position="right"
title={<Title order={5}>Settings</Title>}
opened={props.opened || opened} opened={props.opened || opened}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
> >
<SettingsMenu current={CURRENT_VERSION} latest={latestVersion} /> <SettingsMenu />
</Modal> <Credits />
<ActionIcon </Drawer>
variant="default" <Tooltip label="Settings">
radius="md" <ActionIcon
size="xl" variant="default"
color="blue" radius="md"
style={props.style} size="xl"
onClick={() => setOpened(true)} color="blue"
> style={props.style}
<Tooltip label="Settings"> onClick={() => setOpened(true)}
<Indicator >
size={12} <IconSettings />
disabled={CURRENT_VERSION === latestVersion} </ActionIcon>
offset={-3} </Tooltip>
position="top-end"
>
<SettingsIcon />
</Indicator>
</Tooltip>
</ActionIcon>
</> </>
); );
} }

View File

@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import {
ColorSwatch,
Group,
Popover,
Text,
useMantineTheme,
MantineTheme,
Stack,
Grid,
} from '@mantine/core';
import { useConfig } from '../../tools/state';
import { useColorTheme } from '../../tools/color';
export function ShadeSelector() {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
const { primaryColor, secondaryColor, primaryShade, setPrimaryShade } = useColorTheme();
const theme = useMantineTheme();
const primaryShades = theme.colors[primaryColor].map((s, i) => ({
swatch: theme.colors[primaryColor][i],
shade: i as MantineTheme['primaryShade'],
}));
const secondaryShades = theme.colors[secondaryColor].map((s, i) => ({
swatch: theme.colors[secondaryColor][i],
shade: i as MantineTheme['primaryShade'],
}));
const setConfigShade = (shade: MantineTheme['primaryShade']) => {
setPrimaryShade(shade);
setConfig({
...config,
settings: {
...config.settings,
primaryShade: shade,
},
});
};
const primarySwatches = primaryShades.map(({ swatch, shade }) => (
<Grid.Col span={1} key={Number(shade)}>
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigShade(shade)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
const secondarySwatches = secondaryShades.map(({ swatch, shade }) => (
<Grid.Col span={1} key={Number(shade)}>
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigShade(shade)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
return (
<Group>
<Popover
width={350}
withinPortal
opened={opened}
onClose={() => setOpened(false)}
position="left"
withArrow
>
<Popover.Target>
<ColorSwatch
component="button"
type="button"
color={theme.colors[primaryColor][Number(primaryShade)]}
onClick={() => setOpened((o) => !o)}
size={22}
style={{ display: 'block', cursor: 'pointer' }}
/>
</Popover.Target>
<Popover.Dropdown>
<Stack spacing="xs">
<Grid gutter="lg" columns={10}>
{primarySwatches}
{secondarySwatches}
</Grid>
</Stack>
</Popover.Dropdown>
</Popover>
<Text>Shade</Text>
</Group>
);
}

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { createStyles, Switch, Group } from '@mantine/core';
import { useConfig } from '../../tools/state';
const useStyles = createStyles((theme) => ({
root: {
position: 'relative',
'& *': {
cursor: 'pointer',
},
},
icon: {
pointerEvents: 'none',
position: 'absolute',
zIndex: 1,
top: 3,
},
iconLight: {
left: 4,
color: theme.white,
},
iconDark: {
right: 4,
color: theme.colors.gray[6],
},
}));
export function WidgetsPositionSwitch() {
const { config, setConfig } = useConfig();
const { classes, cx } = useStyles();
const defaultPosition = config?.settings?.widgetPosition || 'right';
const [widgetPosition, setWidgetPosition] = useState(defaultPosition);
const toggleWidgetPosition = () => {
const position = widgetPosition === 'right' ? 'left' : 'right';
setWidgetPosition(position);
setConfig({
...config,
settings: {
...config.settings,
widgetPosition: position,
},
});
};
return (
<Group>
<div className={classes.root}>
<Switch
checked={widgetPosition === 'left'}
onChange={() => toggleWidgetPosition()}
size="md"
/>
</div>
Position widgets on left
</Group>
);
}

View File

@@ -1,22 +1,36 @@
import { Aside as MantineAside, Group } from '@mantine/core'; import { Aside as MantineAside, createStyles } from '@mantine/core';
import { CalendarModule } from '../modules/calendar/CalendarModule'; import Widgets from './Widgets';
import ModuleWrapper from '../modules/moduleWrapper';
export default function Aside() { const useStyles = createStyles((theme) => ({
hide: {
[theme.fn.smallerThan('xs')]: {
display: 'none',
},
},
burger: {
[theme.fn.largerThan('sm')]: {
display: 'none',
},
},
}));
export default function Aside(props: any) {
const { classes, cx } = useStyles();
return ( return (
<MantineAside <MantineAside
hiddenBreakpoint="md" pr="md"
hiddenBreakpoint="sm"
hidden hidden
className={cx(classes.hide)}
style={{ style={{
border: 'none', border: 'none',
background: 'none',
}} }}
width={{ width={{
base: 'auto', base: 'auto',
}} }}
> >
<Group mt="sm" direction="column"> <Widgets />
<ModuleWrapper module={CalendarModule} />
</Group>
</MantineAside> </MantineAside>
); );
} }

View File

@@ -0,0 +1,20 @@
import { Global } from '@mantine/core';
import { useConfig } from '../../tools/state';
export function Background() {
const { config } = useConfig();
return (
<Global
styles={{
body: {
minHeight: '100vh',
backgroundImage: `url('${config.settings.background}')` || '',
backgroundPosition: 'center center',
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
},
}}
/>
);
}

View File

@@ -1,13 +1,8 @@
import React from 'react'; import React, { useEffect } from 'react';
import { import { createStyles, Footer as FooterComponent } from '@mantine/core';
createStyles, import { showNotification } from '@mantine/notifications';
Anchor, import { IconAlertCircle as AlertCircle } from '@tabler/icons';
Text, import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
Group,
ActionIcon,
Footer as FooterComponent,
} from '@mantine/core';
import { BrandGithub } from 'tabler-icons-react';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
footer: { footer: {
@@ -40,54 +35,40 @@ interface FooterCenteredProps {
} }
export function Footer({ links }: FooterCenteredProps) { export function Footer({ links }: FooterCenteredProps) {
const { classes } = useStyles(); useEffect(() => {
const items = links.map((link) => ( // Fetch Data here when component first mounted
<Anchor<'a'> fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
color="dimmed" res.json().then((data) => {
key={link.label} if (data.tag_name > CURRENT_VERSION) {
href={link.link} showNotification({
sx={{ lineHeight: 1 }} color: 'yellow',
onClick={(event) => event.preventDefault()} autoClose: false,
size="sm" title: 'New version available',
> icon: <AlertCircle />,
{link.label} message: `Version ${data.tag_name} is available, update now!`,
</Anchor> });
)); } else if (data.tag_name < CURRENT_VERSION) {
showNotification({
color: 'orange',
autoClose: 5000,
title: 'You are using a development version',
icon: <AlertCircle />,
message: 'This version of Homarr is still in development! Bugs are expected 🐛',
});
}
});
});
}, []);
return ( return (
<FooterComponent height="auto" style={{ border: 'none' }}> <FooterComponent
<Group height="auto"
sx={{ style={{
position: 'fixed', background: 'none',
bottom: 0, border: 'none',
right: 15, clear: 'both',
}} }}
direction="row" children={undefined}
align="center" />
mb={15}
>
<Group className={classes.links}>{items}</Group>
<Group spacing="xs" position="right" noWrap>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<BrandGithub size={18} />
</ActionIcon>
</Group>
<Text
style={{
fontSize: '0.90rem',
textAlign: 'center',
color: '#a0aec0',
}}
>
Made with by @
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
ajnart
</Anchor>
</Text>
</Group>
</FooterComponent>
); );
} }

View File

@@ -1,117 +1,42 @@
import React from 'react'; import { Box, createStyles, Group, Header as Head } from '@mantine/core';
import { createStyles, Header as Head, Group, Drawer, Center } from '@mantine/core';
import { useBooleanToggle } from '@mantine/hooks';
import { NextLink } from '@mantine/next';
import { Logo } from './Logo';
import CalendarComponent from '../modules/calendar/CalendarModule';
import { SettingsMenuButton } from '../Settings/SettingsMenu';
import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem'; import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem';
import DockerMenuButton from '../../modules/docker/DockerModule';
import SearchBar from '../../modules/search/SearchModule';
import { SettingsMenuButton } from '../Settings/SettingsMenu';
import { Logo } from './Logo';
const HEADER_HEIGHT = 60; const HEADER_HEIGHT = 60;
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
root: { hide: {
position: 'relative', [theme.fn.smallerThan('xs')]: {
zIndex: 1,
},
dropdown: {
position: 'absolute',
top: HEADER_HEIGHT,
left: 0,
right: 0,
zIndex: 0,
borderTopRightRadius: 0,
borderTopLeftRadius: 0,
borderTopWidth: 0,
overflow: 'hidden',
[theme.fn.largerThan('md')]: {
display: 'none', display: 'none',
}, },
}, },
header: {
display: 'flex',
height: '100%',
},
links: {
[theme.fn.smallerThan('md')]: {
display: 'none',
},
},
burger: { burger: {
[theme.fn.largerThan('md')]: { [theme.fn.largerThan('sm')]: {
display: 'none', display: 'none',
}, },
}, },
link: {
display: 'block',
lineHeight: 1,
padding: '8px 12px',
borderRadius: theme.radius.sm,
textDecoration: 'none',
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.gray[7],
fontSize: theme.fontSizes.sm,
fontWeight: 500,
'&:hover': {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
},
[theme.fn.smallerThan('sm')]: {
borderRadius: 0,
padding: theme.spacing.md,
},
},
linkActive: {
'&, &:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][9], 0.25)
: theme.colors[theme.primaryColor][0],
color: theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 3 : 7],
},
},
})); }));
interface HeaderResponsiveProps { export function Header(props: any) {
links: { link: string; label: string }[];
}
export function Header({ links }: HeaderResponsiveProps) {
const [opened, toggleOpened] = useBooleanToggle(false);
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();
return ( return (
<Head height={HEADER_HEIGHT}> <Head height="auto">
<Group direction="row" align="center" position="apart" className={classes.header} mx="xl"> <Group p="xs" position="apart">
<NextLink style={{ textDecoration: 'none' }} href="/"> <Box className={classes.hide}>
<Logo style={{ fontSize: 22 }} /> <Logo style={{ fontSize: 22 }} />
</NextLink> </Box>
<Group> <Group noWrap>
<SearchBar />
<DockerMenuButton />
<SettingsMenuButton /> <SettingsMenuButton />
<AddItemShelfButton /> <AddItemShelfButton />
</Group> </Group>
</Group> </Group>
<Drawer
opened={opened}
overlayOpacity={0.55}
overlayBlur={3}
onClose={() => toggleOpened()}
position="right"
>
{opened ?? (
<Center>
<CalendarComponent />
</Center>
)}
</Drawer>
</Head> </Head>
); );
} }

View File

@@ -0,0 +1,14 @@
import React from 'react';
import Head from 'next/head';
import { useConfig } from '../../tools/state';
export function HeaderConfig(props: any) {
const { config } = useConfig();
return (
<Head>
<title>{config.settings.title || 'Homarr 🦞'}</title>
<link rel="shortcut icon" href={config.settings.favicon || '/favicon.svg'} />
</Head>
);
}

View File

@@ -1,36 +1,39 @@
import { AppShell, Center, createStyles } from '@mantine/core'; import { AppShell, createStyles } from '@mantine/core';
import { Header } from './Header'; import { Header } from './Header';
import { Footer } from './Footer'; import { Footer } from './Footer';
import Aside from './Aside'; import Aside from './Aside';
import Navbar from './Navbar'; import Navbar from './Navbar';
import { HeaderConfig } from './HeaderConfig';
import { Background } from './Background';
import { useConfig } from '../../tools/state';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
main: { main: {},
[theme.fn.largerThan('md')]: {
maxWidth: 1500,
},
},
})); }));
export default function Layout({ children, style }: any) { export default function Layout({ children, style }: any) {
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();
const { config } = useConfig();
const widgetPosition = config?.settings?.widgetPosition === 'left';
return ( return (
<AppShell <AppShell
navbar={<Navbar />} fixed={false}
aside={<Aside />} header={<Header />}
header={<Header links={[]} />} navbar={widgetPosition ? <Navbar /> : undefined}
aside={widgetPosition ? undefined : <Aside />}
footer={<Footer links={[]} />} footer={<Footer links={[]} />}
> >
<Center> <HeaderConfig />
<main <Background />
className={cx(classes.main)} <main
style={{ className={cx(classes.main)}
...style, style={{
}} ...style,
> }}
{children} >
</main> {children}
</Center> </main>
</AppShell> </AppShell>
); );
} }

View File

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

View File

@@ -1,22 +1,37 @@
import { Group, Navbar as MantineNavbar } from '@mantine/core'; import { createStyles, Navbar as MantineNavbar } from '@mantine/core';
import { DateModule } from '../modules/date/DateModule'; import Widgets from './Widgets';
import ModuleWrapper from '../modules/moduleWrapper';
const useStyles = createStyles((theme) => ({
hide: {
[theme.fn.smallerThan('xs')]: {
display: 'none',
},
},
burger: {
[theme.fn.largerThan('sm')]: {
display: 'none',
},
},
}));
export default function Navbar() { export default function Navbar() {
const { classes, cx } = useStyles();
return ( return (
<MantineNavbar <MantineNavbar
hiddenBreakpoint="lg" pl="md"
hiddenBreakpoint="sm"
hidden hidden
className={cx(classes.hide)}
style={{ style={{
border: 'none', border: 'none',
background: 'none',
}} }}
width={{ width={{
base: 'auto', base: 'auto',
}} }}
> >
<Group mt="sm" direction="column"> <Widgets />
<ModuleWrapper module={DateModule} />
</Group>
</MantineNavbar> </MantineNavbar>
); );
} }

View File

@@ -0,0 +1,19 @@
import { Text } from '@mantine/core';
interface TipProps {
children: React.ReactNode;
}
export default function Tip(props: TipProps) {
return (
<Text
style={{
fontSize: '0.75rem',
color: 'gray',
marginBottom: '0.5rem',
}}
>
Tip: {props.children}
</Text>
);
}

View File

@@ -0,0 +1,16 @@
import { Stack } from '@mantine/core';
import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../../modules';
import { DashdotModule } from '../../modules/dashdot';
import { ModuleWrapper } from '../../modules/moduleWrapper';
export default function Widgets(props: any) {
return (
<Stack my="sm" style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={DashdotModule} />
</Stack>
);
}

View File

@@ -1,7 +0,0 @@
import CalendarComponent from './CalendarModule';
export default {
title: 'Calendar component',
};
export const Default = (args: any) => <CalendarComponent {...args} />;

View File

@@ -1,152 +0,0 @@
/* eslint-disable react/no-children-prop */
import { Popover, Box, ScrollArea, Divider, Indicator } from '@mantine/core';
import React, { useEffect, useState } from 'react';
import { Calendar } from '@mantine/dates';
import { showNotification } from '@mantine/notifications';
import { Calendar as CalendarIcon, Check } from 'tabler-icons-react';
import { RadarrMediaDisplay, SonarrMediaDisplay } from './MediaDisplay';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
export const CalendarModule: IModule = {
title: 'Calendar',
description:
'A calendar module for displaying upcoming releases. It interacts with the Sonarr and Radarr API.',
icon: CalendarIcon,
component: CalendarComponent,
};
export default function CalendarComponent(props: any) {
const { config } = useConfig();
const [sonarrMedias, setSonarrMedias] = useState([] as any);
const [radarrMedias, setRadarrMedias] = useState([] as any);
useEffect(() => {
// Filter only sonarr and radarr services
const filtered = config.services.filter(
(service) => service.type === 'Sonarr' || service.type === 'Radarr'
);
// Get the url and apiKey for all Sonarr and Radarr services
const sonarrService = filtered.filter((service) => service.type === 'Sonarr').at(0);
const radarrService = filtered.filter((service) => service.type === 'Radarr').at(0);
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
if (sonarrService && sonarrService.apiKey) {
fetch(
`${sonarrService?.url}api/calendar?apikey=${sonarrService?.apiKey}&end=${nextMonth}`
).then((response) => {
response.ok &&
response.json().then((data) => {
setSonarrMedias(data);
showNotification({
title: 'Sonarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
});
}
if (radarrService && radarrService.apiKey) {
fetch(
`${radarrService?.url}api/v3/calendar?apikey=${radarrService?.apiKey}&end=${nextMonth}`
).then((response) => {
response.ok &&
response.json().then((data) => {
setRadarrMedias(data);
showNotification({
title: 'Radarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
});
}
}, [config.services]);
if (sonarrMedias === undefined && radarrMedias === undefined) {
return <Calendar />;
}
return (
<Calendar
onChange={(day: any) => {}}
renderDay={(renderdate) => (
<DayComponent
renderdate={renderdate}
sonarrmedias={sonarrMedias}
radarrmedias={radarrMedias}
/>
)}
/>
);
}
function DayComponent(props: any) {
const {
renderdate,
sonarrmedias,
radarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: [] } = props;
const [opened, setOpened] = useState(false);
const day = renderdate.getDate();
// Itterate over the medias and filter the ones that are on the same day
const sonarrFiltered = sonarrmedias.filter((media: any) => {
const date = new Date(media.airDate);
// Return true if the date is renerdate without counting hours and minutes
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
});
const radarrFiltered = radarrmedias.filter((media: any) => {
const date = new Date(media.inCinemas);
// Return true if the date is renerdate without counting hours and minutes
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
});
if (sonarrFiltered.length === 0 && radarrFiltered.length === 0) {
return <div>{day}</div>;
}
return (
<Box
onClick={() => {
setOpened(true);
}}
>
{radarrFiltered.length > 0 && <Indicator size={7} color="yellow" children={null} />}
{sonarrFiltered.length > 0 && <Indicator size={7} offset={8} color="blue" children={null} />}
<Popover
position="left"
radius="lg"
shadow="xl"
transition="pop"
width={700}
onClose={() => setOpened(false)}
opened={opened}
// TODO: Fix this !! WTF ?
target={` ${day}`}
>
<ScrollArea style={{ height: 400 }}>
{sonarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<SonarrMediaDisplay media={media} />
{index < sonarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment>
))}
{radarrFiltered.length > 0 && sonarrFiltered.length > 0 && (
<Divider variant="dashed" my="xl" />
)}
{radarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<RadarrMediaDisplay media={media} />
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment>
))}
</ScrollArea>
</Popover>
</Box>
);
}

View File

@@ -1,67 +0,0 @@
import { RadarrMediaDisplay } from './MediaDisplay';
export default {
title: 'Media display component',
args: {
media: {
title: 'Doctor Strange in the Multiverse of Madness',
originalTitle: 'Doctor Strange in the Multiverse of Madness',
originalLanguage: {
id: 1,
name: 'English',
},
secondaryYearSourceId: 0,
sortTitle: 'doctor strange in multiverse madness',
sizeOnDisk: 0,
status: 'announced',
overview:
'Doctor Strange, with the help of mystical allies both old and new, traverses the mind-bending and dangerous alternate realities of the Multiverse to confront a mysterious new adversary.',
inCinemas: '2022-05-04T00:00:00Z',
images: [
{
coverType: 'poster',
url: 'https://image.tmdb.org/t/p/original/wRnbWt44nKjsFPrqSmwYki5vZtF.jpg',
},
{
coverType: 'fanart',
url: 'https://image.tmdb.org/t/p/original/ndCSoasjIZAMMDIuMxuGnNWu4DU.jpg',
},
],
website: 'https://www.marvel.com/movies/doctor-strange-in-the-multiverse-of-madness',
year: 2022,
hasFile: false,
youTubeTrailerId: 'aWzlQ2N6qqg',
studio: 'Marvel Studios',
path: '/config/Doctor Strange in the Multiverse of Madness (2022)',
qualityProfileId: 1,
monitored: true,
minimumAvailability: 'announced',
isAvailable: true,
folderName: '/config/Doctor Strange in the Multiverse of Madness (2022)',
runtime: 126,
cleanTitle: 'doctorstrangeinmultiversemadness',
imdbId: 'tt9419884',
tmdbId: 453395,
titleSlug: '453395',
certification: 'PG-13',
genres: ['Fantasy', 'Action', 'Adventure'],
tags: [],
added: '2022-04-29T20:52:33Z',
ratings: {
tmdb: {
votes: 0,
value: 0,
type: 'user',
},
},
collection: {
name: 'Doctor Strange Collection',
tmdbId: 618529,
images: [],
},
id: 1,
},
},
};
export const Default = (args: any) => <RadarrMediaDisplay {...args} />;

View File

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

View File

@@ -1,7 +0,0 @@
import DateComponent from './DateModule';
export default {
title: 'Date module',
};
export const Default = (args: any) => <DateComponent {...args} />;

View File

@@ -1,41 +0,0 @@
import { Group, Text, Title } from '@mantine/core';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import { Clock } from 'tabler-icons-react';
import { IModule } from '../modules';
export const DateModule: IModule = {
title: 'Date',
description: 'Show the current time and date in a card',
icon: Clock,
component: DateComponent,
};
export default function DateComponent(props: any) {
const [date, setDate] = useState(new Date());
const hours = date.getHours();
const minutes = date.getMinutes();
// Change date on minute change
// Note: Using 10 000ms instead of 1000ms to chill a little :)
useEffect(() => {
setInterval(() => {
setDate(new Date());
}, 10000);
}, []);
return (
<Group p="sm" direction="column">
<Title>
{hours < 10 ? `0${hours}` : hours}:{minutes < 10 ? `0${minutes}` : minutes}
</Title>
<Text size="xl">
{
// Use dayjs to format the date
// https://day.js.org/en/getting-started/installation/
dayjs(date).format('dddd, MMMM D')
}
</Text>
</Group>
);
}

View File

@@ -1,2 +0,0 @@
export * from './date';
export * from './calendar';

View File

@@ -1,29 +0,0 @@
import { Card, useMantineTheme } from '@mantine/core';
import { useConfig } from '../../tools/state';
import { IModule } from './modules';
export default function ModuleWrapper(props: any) {
const { module }: { module: IModule } = props;
const { config } = useConfig();
const enabledModules = config.settings.enabledModules ?? [];
// Remove 'Module' from enabled modules titles
const isShown = enabledModules.includes(module.title);
const theme = useMantineTheme();
if (!isShown) {
return null;
}
return (
<Card
hidden={!isShown}
mx="sm"
radius="lg"
shadow="sm"
style={{
// Make background color of the card depend on the theme
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : 'white',
}}
>
<module.component />
</Card>
);
}

View File

@@ -1,11 +1,24 @@
// This interface is to be used in all the modules of the project // This interface is to be used in all the modules of the project
// Each module should have its own interface and call the following function: // Each module should have its own interface and call the following function:
// TODO: Add a function to register a module // TODO: Add a function to register a module
import { TablerIcon } from '@tabler/icons';
// Note: Maybe use context to keep track of the modules // Note: Maybe use context to keep track of the modules
export interface IModule { export interface IModule {
title: string; title: string;
description: string; description: string;
icon: React.ReactNode; icon: TablerIcon;
component: React.ComponentType; component: React.ComponentType;
props?: any; options?: Option;
}
interface Option {
[x: string]: OptionValues;
}
export interface OptionValues {
name: string;
value: boolean | string | string[];
options?: string[];
} }

View File

@@ -0,0 +1,320 @@
/* eslint-disable react/no-children-prop */
import {
Box,
Divider,
Indicator,
Popover,
ScrollArea,
createStyles,
useMantineTheme,
} from '@mantine/core';
import React, { useEffect, useState } from 'react';
import { Calendar } from '@mantine/dates';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
import axios from 'axios';
import { useDisclosure } from '@mantine/hooks';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import {
SonarrMediaDisplay,
RadarrMediaDisplay,
LidarrMediaDisplay,
ReadarrMediaDisplay,
} from '../common';
import { serviceItem } from '../../tools/types';
import { useColorTheme } from '../../tools/color';
export const CalendarModule: IModule = {
title: 'Calendar',
description:
'A calendar module for displaying upcoming releases. It interacts with the Sonarr and Radarr API.',
icon: CalendarIcon,
component: CalendarComponent,
options: {
sundaystart: {
name: 'Start the week on Sunday',
value: false,
},
},
};
export default function CalendarComponent(props: any) {
const { config } = useConfig();
const theme = useMantineTheme();
const { secondaryColor } = useColorTheme();
const useStyles = createStyles((theme) => ({
weekend: {
color: `${secondaryColor} !important`,
},
}));
const [sonarrMedias, setSonarrMedias] = useState([] as any);
const [lidarrMedias, setLidarrMedias] = useState([] as any);
const [radarrMedias, setRadarrMedias] = useState([] as any);
const [readarrMedias, setReadarrMedias] = useState([] as any);
const sonarrServices = config.services.filter((service) => service.type === 'Sonarr');
const radarrServices = config.services.filter((service) => service.type === 'Radarr');
const lidarrServices = config.services.filter((service) => service.type === 'Lidarr');
const readarrServices = config.services.filter((service) => service.type === 'Readarr');
const today = new Date();
const { classes, cx } = useStyles();
function getMedias(service: serviceItem | undefined, type: string) {
if (!service || !service.apiKey) {
return Promise.resolve({ data: [] });
}
return axios.post(`/api/modules/calendar?type=${type}`, { id: service.id });
}
useEffect(() => {
// Create each Sonarr service and get the medias
const currentSonarrMedias: any[] = [];
Promise.all(
sonarrServices.map((service) =>
getMedias(service, 'sonarr')
.then((res) => {
currentSonarrMedias.push(...res.data);
})
.catch(() => {
currentSonarrMedias.push([]);
})
)
).then(() => {
setSonarrMedias(currentSonarrMedias);
});
const currentRadarrMedias: any[] = [];
Promise.all(
radarrServices.map((service) =>
getMedias(service, 'radarr')
.then((res) => {
currentRadarrMedias.push(...res.data);
})
.catch(() => {
currentRadarrMedias.push([]);
})
)
).then(() => {
setRadarrMedias(currentRadarrMedias);
});
const currentLidarrMedias: any[] = [];
Promise.all(
lidarrServices.map((service) =>
getMedias(service, 'lidarr')
.then((res) => {
currentLidarrMedias.push(...res.data);
})
.catch(() => {
currentLidarrMedias.push([]);
})
)
).then(() => {
setLidarrMedias(currentLidarrMedias);
});
const currentReadarrMedias: any[] = [];
Promise.all(
readarrServices.map((service) =>
getMedias(service, 'readarr')
.then((res) => {
currentReadarrMedias.push(...res.data);
})
.catch(() => {
currentReadarrMedias.push([]);
})
)
).then(() => {
setReadarrMedias(currentReadarrMedias);
});
}, [config.services]);
const weekStartsAtSunday =
(config?.modules?.[CalendarModule.title]?.options?.sundaystart?.value as boolean) ?? false;
return (
<Calendar
firstDayOfWeek={weekStartsAtSunday ? 'sunday' : 'monday'}
onChange={(day: any) => {}}
dayStyle={(date) =>
date.getDay() === today.getDay() && date.getDate() === today.getDate()
? {
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0],
}
: {}
}
styles={{
calendarHeader: {
marginRight: 15,
marginLeft: 15,
},
}}
allowLevelChange={false}
dayClassName={(date, modifiers) => cx({ [classes.weekend]: modifiers.weekend })}
renderDay={(renderdate) => (
<DayComponent
renderdate={renderdate}
sonarrmedias={sonarrMedias}
radarrmedias={radarrMedias}
lidarrmedias={lidarrMedias}
readarrmedias={readarrMedias}
/>
)}
/>
);
}
function DayComponent(props: any) {
const {
renderdate,
sonarrmedias,
radarrmedias,
lidarrmedias,
readarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
props;
const [opened, { close, open }] = useDisclosure(false);
const day = renderdate.getDate();
const readarrFiltered = readarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
return date.toDateString() === renderdate.toDateString();
});
const lidarrFiltered = lidarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
return date.toDateString() === renderdate.toDateString();
});
const sonarrFiltered = sonarrmedias.filter((media: any) => {
const date = new Date(media.airDateUtc);
return date.toDateString() === renderdate.toDateString();
});
const radarrFiltered = radarrmedias.filter((media: any) => {
const date = new Date(media.inCinemas);
return date.toDateString() === renderdate.toDateString();
});
const totalFiltered = [
...readarrFiltered,
...lidarrFiltered,
...sonarrFiltered,
...radarrFiltered,
];
if (totalFiltered.length === 0) {
return <div>{day}</div>;
}
return (
<Popover
position="bottom"
withArrow
withinPortal
radius="lg"
shadow="sm"
transition="pop"
onClose={close}
opened={opened}
>
<Popover.Target>
<Box onClick={open}>
{readarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
left: 8,
}}
color="red"
children={null}
/>
)}
{radarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
left: 8,
}}
color="yellow"
children={null}
/>
)}
{sonarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
right: 8,
}}
color="blue"
children={null}
/>
)}
{lidarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
right: 8,
}}
color="green"
children={undefined}
/>
)}
<div>{day}</div>
</Box>
</Popover.Target>
<Popover.Dropdown>
<ScrollArea
offsetScrollbars
scrollbarSize={5}
style={{
height:
totalFiltered.slice(0, 2).length > 1 ? totalFiltered.slice(0, 2).length * 150 : 200,
width: 400,
}}
>
{sonarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<SonarrMediaDisplay media={media} />
{index < sonarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{radarrFiltered.length > 0 && sonarrFiltered.length > 0 && (
<Divider variant="dashed" size="sm" my="xl" />
)}
{radarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<RadarrMediaDisplay media={media} />
{index < radarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{sonarrFiltered.length > 0 && lidarrFiltered.length > 0 && (
<Divider variant="dashed" size="sm" my="xl" />
)}
{lidarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<LidarrMediaDisplay media={media} />
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{lidarrFiltered.length > 0 && readarrFiltered.length > 0 && (
<Divider variant="dashed" size="sm" my="xl" />
)}
{readarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<ReadarrMediaDisplay media={media} />
{index < readarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
</ScrollArea>
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -0,0 +1,260 @@
import { Badge, Button, Group, Image, Stack, Text, Title } from '@mantine/core';
import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons';
import { useState } from 'react';
import { useColorTheme } from '../../tools/color';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { RequestModal } from '../overseerr/RequestModal';
import { Result } from '../overseerr/SearchResult';
export interface IMedia {
overview: string;
imdbId?: any;
tmdbId?: any;
artist?: string;
title?: string;
type: 'movie' | 'tvshow' | 'book' | 'music' | 'overseer';
episodetitle?: string;
voteAverage?: string;
poster?: string;
genres: string[];
seasonNumber?: number;
plexUrl?: string;
episodeNumber?: number;
[key: string]: any;
}
export function OverseerrMediaDisplay(props: any) {
const { media }: { media: Result } = props;
const { config } = useConfig();
const service = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
);
return (
<MediaDisplay
media={{
...media,
genres: [],
overview: media.overview ?? '',
title: media.title ?? media.name ?? media.originalName,
poster: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${media.posterPath}`,
seasonNumber: media.mediaInfo?.seasons.length,
episodetitle: media.title,
plexUrl: media.mediaInfo?.plexUrl ?? media.mediaInfo?.mediaUrl,
voteAverage: media.voteAverage?.toString(),
overseerrResult: media,
overseerrId: `${service?.openedUrl ? service?.openedUrl : service?.url}/${
media.mediaType
}/${media.id}`,
type: 'overseer',
}}
/>
);
}
export function ReadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const readarr = config.services.find((service: serviceItem) => service.type === 'Readarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!readarr) {
return null;
}
const baseUrl = readarr.openedUrl
? new URL(readarr.openedUrl).origin
: new URL(readarr.url).origin;
// Remove '/' from the end of the lidarr url
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
...media,
title: media.title,
poster: fullLink,
artist: media.authorTitle,
overview: `new book release by ${media.authorTitle}`,
genres: media.genres ?? [],
voteAverage: media.ratings.value.toString(),
type: 'book',
}}
/>
);
}
export function LidarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const lidarr = config.services.find((service: serviceItem) => service.type === 'Lidarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!lidarr) {
return null;
}
const baseUrl = lidarr.openedUrl ? new URL(lidarr.openedUrl).origin : new URL(lidarr.url).origin;
// Remove '/' from the end of the lidarr url
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
type: 'music',
title: media.title,
poster: fullLink,
artist: media.artist.artistName,
overview: media.overview,
genres: media.genres,
}}
/>
);
}
export function RadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
return (
<MediaDisplay
media={{
...media,
title: media.title ?? media.originalTitle,
overview: media.overview ?? '',
genres: media.genres ?? [],
poster: media.images.find((image: any) => image.coverType === 'poster')?.url,
voteAverage: media.ratings.tmdb.value.toString(),
imdbId: media.imdbId,
type: 'movie',
}}
/>
);
}
export function SonarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
const poster = media.series.images.find((image: any) => image.coverType === 'poster');
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
...media,
genres: media.series.genres ?? [],
overview: media.overview ?? media.series.overview ?? '',
title: media.series.title,
poster: poster ? poster.url : undefined,
episodeNumber: media.episodeNumber,
seasonNumber: media.seasonNumber,
episodetitle: media.title,
imdbId: media.series.imdbId,
voteAverage: media.series.ratings.value.toString(),
type: 'tvshow',
}}
/>
);
}
export function MediaDisplay({ media }: { media: IMedia }) {
const [opened, setOpened] = useState(false);
const { secondaryColor } = useColorTheme();
return (
<Group mr="xs" align="stretch" noWrap style={{ maxHeight: 200 }}>
<Image src={media.poster} height={200} width={150} radius="md" fit="cover" />
<Stack justify="space-around">
<Stack spacing="sm">
<Text lineClamp={2}>
<Title order={5}>{media.title}</Title>
</Text>
<Group spacing="xs">
{media.type === 'tvshow' && (
<Badge variant="dot" size="xs" radius="md" color="blue">
s{media.seasonNumber}e{media.episodeNumber} - {media.episodetitle}
</Badge>
)}
{media.type === 'music' && (
<Badge variant="dot" size="xs" radius="md" color="green">
{media.artist}
</Badge>
)}
{media.type === 'movie' && (
<Badge variant="dot" size="xs" radius="md" color="orange">
Radarr
</Badge>
)}
{media.type === 'book' && (
<Badge variant="dot" size="xs" radius="md" color="red">
Readarr
</Badge>
)}
{media.genres.slice(0, 2).map((genre) => (
<Badge size="xs" radius="md" key={genre}>
{genre}
</Badge>
))}
</Group>
<Text color="dimmed" size="xs" lineClamp={4}>
{media.overview}
</Text>
</Stack>
<Group noWrap>
{media.plexUrl && (
<Button
component="a"
target="_blank"
variant="outline"
href={media.plexUrl}
size="sm"
rightIcon={<IconPlayerPlay size={15} />}
>
Play
</Button>
)}
{media.imdbId && (
<Button
component="a"
target="_blank"
href={`https://www.imdb.com/title/${media.imdbId}`}
variant="outline"
size="sm"
rightIcon={<IconExternalLink size={15} />}
>
IMDb
</Button>
)}
{media.overseerrId && (
<Button
component="a"
target="_blank"
href={media.overseerrId}
variant="outline"
size="sm"
rightIcon={<IconExternalLink size={15} />}
>
TMDb
</Button>
)}
{media.type === 'overseer' && !media.overseerrResult?.mediaInfo?.mediaAddedAt && (
<>
<RequestModal
base={media.overseerrResult as Result}
opened={opened}
setOpened={setOpened}
/>
<Button
onClick={() => setOpened(true)}
color={secondaryColor}
size="sm"
rightIcon={<IconDownload size={15} />}
>
Request
</Button>
</>
)}
</Group>
</Stack>
</Group>
);
}

View File

@@ -0,0 +1,57 @@
{
"title": "Mika in Real Life",
"authorTitle": "jean, emiko Mika in Real Life",
"seriesTitle": "",
"disambiguation": "",
"authorId": 1,
"foreignBookId": "93584169",
"titleSlug": "93584169",
"monitored": true,
"anyEditionOk": false,
"ratings": {
"votes": 149,
"value": 4.15,
"popularity": 618.35
},
"releaseDate": "2022-08-09T00:00:00Z",
"pageCount": 384,
"genres": [
"fiction",
"romance",
"contemporary",
"adult",
"adult-fiction",
"chick-lit",
"womens-fiction",
"asian-literature",
"family",
"lgbt"
],
"images": [
{
"url": "/MediaCover/Books/1/cover.jpg?lastWrite=637899714580000000",
"coverType": "cover",
"extension": ".jpg"
}
],
"links": [
{
"url": "https://www.goodreads.com/work/editions/93584169",
"name": "Goodreads Editions"
},
{
"url": "https://www.goodreads.com/book/show/59430548-mika-in-real-life",
"name": "Goodreads Book"
}
],
"statistics": {
"bookFileCount": 0,
"bookCount": 0,
"totalBookCount": 1,
"sizeOnDisk": 0,
"percentOfBooks": 0
},
"added": "2022-08-07T20:48:09Z",
"grabbed": false,
"id": 1
}

View File

@@ -0,0 +1,70 @@
{
"title": "The Tunnel to Summer, the Exit of Goodbyes",
"originalTitle": "夏へのトンネル、さよならの出口",
"originalLanguage": {
"id": 8,
"name": "Japanese"
},
"alternateTitles": [
{
"sourceType": "tmdb",
"movieId": 1,
"title": "Natsu e no Tunnel, Sayonara no Deguchi",
"sourceId": 0,
"votes": 0,
"voteCount": 0,
"language": {
"id": 1,
"name": "English"
},
"id": 1
}
],
"secondaryYearSourceId": 0,
"sortTitle": "tunnel to summer exit goodbyes",
"sizeOnDisk": 0,
"status": "announced",
"overview": "Tono Kaoru heard a rumor: The laws of space and time mean nothing to the Urashima Tunnel. If you find it, walk through and you'll find your heart's desire on the other side...in exchange for years of your own life. On the night Kaoru just so happens to find himself standing in front of a tunnel that looks suspiciously like the one the rumor describes, he finds himself thinking of Karen, the sister he lost in an accident five years ago. To Kaoru's surprise, he's been followed by the new transfer student Anzu Hanaki, who promises to help him experiment with the mysterious tunnel--but what does she want from Kaoru in exchange? And what will he have left to give, after the tunnel's done with him?",
"inCinemas": "2022-09-09T00:00:00Z",
"images": [
{
"coverType": "poster",
"url": "https://image.tmdb.org/t/p/original/3x5gc6dHsfNqZryipu159IALEPH.jpg"
},
{
"coverType": "fanart",
"url": "https://image.tmdb.org/t/p/original/zO3QSYs858SqiapafD7iJp17KVD.jpg"
}
],
"website": "https://natsuton.com/",
"year": 2022,
"hasFile": false,
"youTubeTrailerId": "",
"studio": "Pony Canyon",
"path": "/data/Library/Movies/The Tunnel to Summer, the Exit of Goodbyes (2022)",
"qualityProfileId": 4,
"monitored": true,
"minimumAvailability": "announced",
"isAvailable": true,
"folderName": "/data/Library/Movies/The Tunnel to Summer, the Exit of Goodbyes (2022)",
"runtime": 0,
"cleanTitle": "thetunneltosummerexitgoodbyes",
"imdbId": "tt17382524",
"tmdbId": 916192,
"titleSlug": "916192",
"genres": [
"Animation",
"Drama",
"Mystery"
],
"tags": [],
"added": "2022-07-05T07:50:42Z",
"ratings": {
"tmdb": {
"votes": 0,
"value": 0,
"type": "user"
}
},
"id": 1
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,409 @@
{
"page": 1,
"totalPages": 2,
"totalResults": 21,
"results": [
{
"id": 66025,
"firstAirDate": "2016-06-14",
"genreIds": [
80,
18
],
"mediaType": "tv",
"name": "Animal Kingdom",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "Animal Kingdom",
"overview": "Un jeune homme de dix-sept ans emménage avec la famille Cody après le décès de sa mère, une fratrie baignant dans la criminalité gouvernée d'une main de maître par la matriarche, Smurf.",
"popularity": 75.653,
"voteAverage": 7.7,
"voteCount": 318,
"backdropPath": "/eQJwfyMqSra10ck8HOoiCrbQR32.jpg",
"posterPath": "/rzvdKrnSRKPFI0pgqMQknDPpRC9.jpg",
"mediaInfo": {
"downloadStatus": [],
"downloadStatus4k": [],
"id": 217,
"mediaType": "tv",
"tmdbId": 66025,
"tvdbId": 304262,
"imdbId": null,
"status": 3,
"status4k": 1,
"createdAt": "2022-08-08T11:06:20.000Z",
"updatedAt": "2022-08-08T11:06:23.000Z",
"lastSeasonChange": "2022-08-08T11:06:20.000Z",
"mediaAddedAt": null,
"serviceId": 0,
"serviceId4k": null,
"externalServiceId": 56,
"externalServiceId4k": null,
"externalServiceSlug": "animal-kingdom-2016",
"externalServiceSlug4k": null,
"ratingKey": null,
"ratingKey4k": null,
"seasons": [],
"serviceUrl": "http://sonarr:8989/series/animal-kingdom-2016"
}
},
{
"id": 44629,
"mediaType": "movie",
"adult": false,
"genreIds": [
18,
53,
80,
9648
],
"originalLanguage": "en",
"originalTitle": "Animal Kingdom",
"overview": "Une rue anonyme dans la banlieue de Melbourne. Cest là que vit la famille Cody. Profession: criminels. Lirruption parmi eux de Joshua, un neveu éloigné, offre à la police le moyen de les infiltrer. Il ne reste plus à Joshua quà choisir son camp...",
"popularity": 11.839,
"releaseDate": "2010-06-03",
"title": "Animal Kingdom",
"video": false,
"voteAverage": 6.8,
"voteCount": 643,
"backdropPath": "/dxOv6K3LNbZfQaGDyx7Tp94Koy.jpg",
"posterPath": "/qrVjc5JcaujL58SMMW9lqrp3bBX.jpg"
},
{
"id": 95731,
"firstAirDate": "2020-09-25",
"genreIds": [
99
],
"mediaType": "tv",
"name": "Au cœur de Disney's Animal Kingdom",
"originCountry": [],
"originalLanguage": "en",
"originalName": "Magic of Disney's Animal Kingdom",
"overview": "Au cœur dAnimal Kingdom narrée par Josh Gad, une célébrité parmi les fans de Disney, nous emmène en coulisses découvrir la magie de deux des animations animalières les plus visitées au monde : le parc à thème de Disney, Animal Kingdom, et The Seas with Nemo & Friends à Epcot. Les spectateurs sapprochent au plus près de créatures parmi les plus rares et les plus belles de la planète et rencontrent les experts en soins animaliers qui ont tissé des liens stupéfiants avec les 5 000 et plus animaux du parc. Chacun des huit épisodes plonge au cœur de lendroit le plus magique sur Terre, dévoilant les multiples facettes de sa conception et de sa gestion.",
"popularity": 3.367,
"voteAverage": 8,
"voteCount": 4,
"backdropPath": "/gMTMnd54VVAbGiodBqMTGCjM3b2.jpg",
"posterPath": "/gvNTeRAfu4KN3dD5HUO4Nbnri07.jpg"
},
{
"id": 120862,
"mediaType": "movie",
"adult": false,
"genreIds": [
35,
18,
10749
],
"originalLanguage": "en",
"originalTitle": "The Animal Kingdom",
"overview": "Tom Collier, jeune éditeur, a entretenu une liaison passionnée et intellectuelle avec une dessinatrice, Daisy Sage. Celle-ci ayant mis un terme à leur relation, il a fait la connaissance de Cecilia, qu'il a rapidement décidé d'épouser. Alors que les fiançailles sont annoncées, Daisy, toujours amoureuse, fait son retour, mais trop tard. Le mariage a lieu. Sous l'influence de Cecilia, Tom Collier, qui était un éditeur intègre et exigeant, fait de plus en plus de concessions commerciales. Daisy, elle demeure fidèle à elle-même. Tom Collier, se retrouve a évoluer, par amour pour sa femme, dans un milieu de conventions bourgeoises qui ne l'intéressent pas.",
"popularity": 2.102,
"releaseDate": "1932-12-28",
"title": "The Animal Kingdom",
"video": false,
"voteAverage": 6.3,
"voteCount": 13,
"backdropPath": "/5P1Hx46wvCVx9D9yT8M5rdUIHZB.jpg",
"posterPath": "/3sLWwNvS77xynAGLkbiHVXlO3UH.jpg"
},
{
"id": 311015,
"mediaType": "movie",
"adult": false,
"genreIds": [
99
],
"originalLanguage": "en",
"originalTitle": "Disney Parks: Disney's Animal Kingdom",
"overview": "",
"popularity": 1.208,
"releaseDate": "2010-01-01",
"title": "Disney Parks: Disney's Animal Kingdom",
"video": true,
"voteAverage": 9,
"voteCount": 2,
"backdropPath": null,
"posterPath": "/93OEKY5vnKqGFbOyHtUAdcEz8NV.jpg"
},
{
"id": 291774,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "Kenya 3D: Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2013-03-08",
"title": "Kenya 3D: Animal Kingdom",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 640253,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "it",
"originalTitle": "Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2016-11-12",
"title": "Animal Kingdom",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": "/vJFK5cCcIh4X4op0oeK5iY2ibPv.jpg"
},
{
"id": 507434,
"mediaType": "movie",
"adult": false,
"genreIds": [
27
],
"originalLanguage": "en",
"originalTitle": "Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2017-02-25",
"title": "Animal Kingdom",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": "/8QxSJRLLw2m8ymrFsC2xJ26yd1n.jpg",
"posterPath": "/s77Q92boNGgkT2J5se3gwq5N8Xp.jpg"
},
{
"id": 775877,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "Disney's Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2004-05-12",
"title": "Disney's Animal Kingdom",
"video": true,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 318575,
"mediaType": "movie",
"adult": false,
"genreIds": [
99
],
"originalLanguage": "en",
"originalTitle": "Nature: Love in the Animal Kingdom",
"overview": "",
"popularity": 0.655,
"releaseDate": "2013-11-06",
"title": "Nature: Love in the Animal Kingdom",
"video": true,
"voteAverage": 9.5,
"voteCount": 2,
"backdropPath": "/vx2dfrXPTn0dKoyIqCEgrGvzwkd.jpg",
"posterPath": "/1fd53UCxtLAItNI5jMtVetFuw6v.jpg"
},
{
"id": 743266,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "Animal Kingdom: Great Are Thy Works",
"overview": "",
"popularity": 0.6,
"releaseDate": "1993-01-01",
"title": "Animal Kingdom: Great Are Thy Works",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": "/vjnsGLvymjG7dAIbjwzgFCdbhl6.jpg"
},
{
"id": 828152,
"mediaType": "movie",
"adult": false,
"genreIds": [
99
],
"originalLanguage": "en",
"originalTitle": "Disney's Animal Kingdom: Alive with Magic",
"overview": "",
"popularity": 0.6,
"releaseDate": "2017-06-27",
"title": "Disney's Animal Kingdom: Alive with Magic",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": "/amzVT8T9Ju3KLCDnBq4Rhf3LO8j.jpg"
},
{
"id": 280391,
"mediaType": "movie",
"adult": false,
"genreIds": [
12,
35,
16
],
"originalLanguage": "fr",
"originalTitle": "Pourquoi j'ai pas mangé mon père",
"overview": "Lhistoire trépidante dÉdouard, fils aîné du roi des simiens, qui, considéré à sa naissance comme trop malingre, est rejeté par sa tribu. Il grandit loin deux, auprès de son ami Ian, et, incroyablement ingénieux, il découvre le feu, la chasse, lhabitat moderne, lamour et même… lespoir. Généreux, il veut tout partager, révolutionne lordre établi, et mène son peuple avec éclat et humour vers la véritable humanité… celle où on ne mange pas son père.",
"popularity": 12.971,
"releaseDate": "2015-04-08",
"title": "Pourquoi j'ai pas mangé mon père",
"video": false,
"voteAverage": 5.3,
"voteCount": 303,
"backdropPath": "/msDLrSt7Ozpe6oOg4XJrsQJd2IE.jpg",
"posterPath": "/efpzs2g1uRNcP8wPbIKSRPPH0aC.jpg"
},
{
"id": 775559,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "A New species of Theme Park: Disneys Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "1998-04-14",
"title": "A New species of Theme Park: Disneys Animal Kingdom",
"video": true,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 775831,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "Disney Animal Kingdom Villas: A Village Comes to Life",
"overview": "",
"popularity": 0.6,
"releaseDate": "2007-06-14",
"title": "Disney Animal Kingdom Villas: A Village Comes to Life",
"video": true,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 432906,
"mediaType": "movie",
"adult": false,
"genreIds": [
99
],
"originalLanguage": "en",
"originalTitle": "Out in Nature: Homosexual Behaviour in the Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2001-09-07",
"title": "Out in Nature: Homosexual Behaviour in the Animal Kingdom",
"video": false,
"voteAverage": 6.8,
"voteCount": 4,
"backdropPath": null,
"posterPath": "/jjxhR9ZxZ3vhauK8IDR6wIBlCLI.jpg"
},
{
"id": 128887,
"mediaType": "movie",
"adult": false,
"genreIds": [
16,
35
],
"originalLanguage": "ja",
"originalTitle": "クレヨンしんちゃん オタケべ!カスカベ野生王国",
"overview": "",
"popularity": 5.365,
"releaseDate": "2009-04-18",
"title": "クレヨンしんちゃん オタケべ!カスカベ野生王国",
"video": false,
"voteAverage": 8.5,
"voteCount": 10,
"backdropPath": "/azvwXB25Wvbx2Cou3Th7lbnjrqP.jpg",
"posterPath": "/h7LipCtdCyBOKR1By5wSP2Ufy3c.jpg"
},
{
"id": 579733,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "no",
"originalTitle": "Dyreriket",
"overview": "",
"popularity": 0.6,
"releaseDate": "2018-05-01",
"title": "Dyreriket",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 111612,
"firstAirDate": "2018-10-12",
"genreIds": [
10764
],
"mediaType": "tv",
"name": "坂上どうぶつ王国",
"originCountry": [
"JP"
],
"originalLanguage": "ja",
"originalName": "坂上どうぶつ王国",
"overview": "",
"popularity": 1.186,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": "/op8bK5R76L9QpwcVTnYG7nKXKsU.jpg",
"posterPath": "/2VPq9RYaDohOT8YqTibKZMMT2Ue.jpg"
},
{
"id": 156216,
"firstAirDate": "2022-01-17",
"genreIds": [
16
],
"mediaType": "tv",
"name": "动物王国的故事",
"originCountry": [
"CN"
],
"originalLanguage": "zh",
"originalName": "动物王国的故事",
"overview": "",
"popularity": 0.6,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": "/uxIJQnjzIQn2MGHk17nNhoIEkxU.jpg",
"posterPath": "/v90bqYZRUT30n22DdwahmW18LFn.jpg"
}
]
}

View File

@@ -0,0 +1,832 @@
{
"title": "Celebrate",
"disambiguation": "",
"overview": "",
"artistId": 9,
"foreignAlbumId": "bfedab35-92b7-449b-adf0-875439ec9a85",
"monitored": true,
"anyReleaseOk": true,
"profileId": 1,
"duration": 1818062,
"albumType": "Album",
"secondaryTypes": [],
"mediumCount": 1,
"ratings": {
"votes": 1,
"value": 10
},
"releaseDate": "2022-07-27T00:00:00Z",
"releases": [
{
"id": 202,
"albumId": 32,
"foreignReleaseId": "22bd49a1-f858-427d-94ee-1788b54fb508",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "ONCE JAPAN限定盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 203,
"albumId": 32,
"foreignReleaseId": "52c73f5f-4f91-451b-96d1-3ac3ef9371ee",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "初回限定盤B",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 204,
"albumId": 32,
"foreignReleaseId": "5745040b-a5fa-4dae-ad31-0bce9d501e23",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "JEONGYEON盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 205,
"albumId": 32,
"foreignReleaseId": "006f9135-454b-4182-a057-47d1b002a282",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "NAYEON盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 206,
"albumId": 32,
"foreignReleaseId": "eeacd54b-a2bd-48f8-8d7c-3ab55b68f17c",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 81,
"media": [
{
"mediumNumber": 1,
"mediumName": "NAYEON盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 2,
"mediumName": "JEONGYEON盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 3,
"mediumName": "MOMO盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 4,
"mediumName": "SANA盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 5,
"mediumName": "JIHYO盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 6,
"mediumName": "MINA盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 7,
"mediumName": "DAHYUN盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 8,
"mediumName": "CHAEYOUNG盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 9,
"mediumName": "TZUYU盤",
"mediumFormat": "CD"
}
],
"mediumCount": 9,
"disambiguation": "5th Anniversary Collection BOX",
"country": [
"Japan"
],
"label": [
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan"
],
"format": "9xCD",
"monitored": false
},
{
"id": 207,
"albumId": 32,
"foreignReleaseId": "8ddd43f0-859e-4cff-be7c-daf6806cc035",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "JIHYO盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 208,
"albumId": 32,
"foreignReleaseId": "ad8e0553-97de-499b-8010-85bd02c62859",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "TZUYU盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 209,
"albumId": 32,
"foreignReleaseId": "276bf831-8cae-49a0-bc50-479869d401ac",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "MOMO盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 210,
"albumId": 32,
"foreignReleaseId": "3d201058-deb0-4159-a82f-d9076a608036",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "MINA盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 211,
"albumId": 32,
"foreignReleaseId": "e1fbf96d-f83e-478c-be7d-f0f6dd5305d1",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "DAHYUN盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 212,
"albumId": 32,
"foreignReleaseId": "769a7006-763b-4cd8-8d1f-d389d52ec002",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "CHAEYOUNG盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 213,
"albumId": 32,
"foreignReleaseId": "42e74581-0ef3-4db9-8a20-ba8a3daa1cf0",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "初回限定盤A",
"country": [
"Japan"
],
"label": [
"Warner Music Japan",
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 214,
"albumId": 32,
"foreignReleaseId": "81bdf07f-61ad-4436-bfae-63cd1d9e700c",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "通常盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 215,
"albumId": 32,
"foreignReleaseId": "273b3ba1-88e8-4653-a542-c8b0489c1772",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "SANA盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 216,
"albumId": 32,
"foreignReleaseId": "2442df5f-4090-452c-be7f-5885dffee8e2",
"title": "Celebrate",
"status": "Official",
"duration": 1818062,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "Digital Media"
}
],
"mediumCount": 1,
"disambiguation": "",
"country": [
"Algeria",
"Angola",
"Anguilla",
"Antigua and Barbuda",
"Argentina",
"Armenia",
"Australia",
"Austria",
"Azerbaijan",
"Bahamas",
"Bahrain",
"Barbados",
"Belgium",
"Belize",
"Benin",
"Bermuda",
"Bhutan",
"Bolivia",
"Bosnia and Herzegovina",
"Botswana",
"Brazil",
"Brunei",
"Bulgaria",
"Burkina Faso",
"Cambodia",
"Cameroon",
"Canada",
"Cape Verde",
"Cayman Islands",
"Chad",
"Chile",
"China",
"Colombia",
"Congo",
"Costa Rica",
"Côte d'Ivoire",
"Croatia",
"Cyprus",
"Czech Republic",
"Denmark",
"Dominica",
"Dominican Republic",
"Ecuador",
"Egypt",
"El Salvador",
"Estonia",
"Fiji",
"Finland",
"France",
"Gabon",
"Gambia",
"Georgia",
"Germany",
"Ghana",
"Greece",
"Grenada",
"Guatemala",
"Guinea-Bissau",
"Guyana",
"Honduras",
"Hong Kong",
"Hungary",
"Iceland",
"India",
"Indonesia",
"Iraq",
"Ireland",
"Israel",
"Italy",
"Jamaica",
"Japan",
"Jordan",
"Kazakhstan",
"Kenya",
"Kuwait",
"Kyrgyzstan",
"Laos",
"Latvia",
"Lebanon",
"Liberia",
"Libya",
"Lithuania",
"Luxembourg",
"Macao",
"North Macedonia",
"Madagascar",
"Malawi",
"Malaysia",
"Maldives",
"Mali",
"Malta",
"Mauritania",
"Mauritius",
"Mexico",
"Federated States of Micronesia",
"Moldova",
"Mongolia",
"Montserrat",
"Morocco",
"Mozambique",
"Myanmar",
"Namibia",
"Nepal",
"Netherlands",
"New Zealand",
"Nicaragua",
"Niger",
"Nigeria",
"Norway",
"Oman",
"Panama",
"Papua New Guinea",
"Paraguay",
"Peru",
"Philippines",
"Poland",
"Portugal",
"Qatar",
"Romania",
"Rwanda",
"Saint Kitts and Nevis",
"Saint Lucia",
"Saint Vincent and The Grenadines",
"Saudi Arabia",
"Senegal",
"Seychelles",
"Sierra Leone",
"Singapore",
"Slovakia",
"Slovenia",
"Solomon Islands",
"South Africa",
"Spain",
"Sri Lanka",
"Suriname",
"Eswatini",
"Sweden",
"Switzerland",
"Taiwan",
"Tajikistan",
"Tanzania",
"Thailand",
"Tonga",
"Trinidad and Tobago",
"Tunisia",
"Turkey",
"Turkmenistan",
"Turks and Caicos Islands",
"Uganda",
"Ukraine",
"United Arab Emirates",
"United Kingdom",
"United States",
"Uruguay",
"Uzbekistan",
"Vanuatu",
"Venezuela",
"Vietnam",
"British Virgin Islands",
"Yemen",
"Democratic Republic of the Congo",
"Zambia",
"Zimbabwe",
"Montenegro",
"Serbia",
"Kosovo"
],
"label": [
"Warner Music Japan"
],
"format": "Digital Media",
"monitored": true
}
],
"genres": [],
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "Digital Media"
}
],
"artist": {
"artistMetadataId": 14,
"status": "continuing",
"ended": false,
"artistName": "TWICE",
"foreignArtistId": "8da127cc-c432-418f-b356-ef36210d82ac",
"tadbId": 0,
"discogsId": 0,
"overview": "Twice (Korean: 트와이스; RR: Teuwaiseu; Japanese: トゥワイス, Hepburn: To~uwaisu; commonly stylized in all caps as TWICE) is a South Korean girl group formed by JYP Entertainment. The group is composed of nine members: Nayeon, Jeongyeon, Momo, Sana, Jihyo, Mina, Dahyun, Chaeyoung, and Tzuyu. Twice was formed under the television program Sixteen (2015) and debuted on October 20, 2015, with the extended play (EP) The Story Begins.\nTwice rose to domestic fame in 2016 with their single \"Cheer Up\", which charted at number one on the Gaon Digital Chart, became the best-performing single of the year, and won \"Song of the Year\" at the Melon Music Awards and Mnet Asian Music Awards. Their next single, \"TT\", from their third EP Twicecoaster: Lane 1, topped the Gaon charts for four consecutive weeks. The EP was the highest selling Korean girl group album of 2016. Within 19 months after debut, Twice had already sold over 1.2 million units of their four EPs and special album. As of December 2020, the group has sold over 10 million albums cumulatively in South Korea and Japan, becoming the highest-selling K-Pop girl group of all time.The group debuted in Japan on June 28, 2017, under Warner Music Japan, with the release of a compilation album titled #Twice. The album charted at number 2 on the Oricon Albums Chart with the highest first-week album sales by a K-pop artist in Japan in two years. It was followed by the release of Twice's first original Japanese maxi single titled \"One More Time\" in October. Twice became the first Korean girl group to earn a platinum certification from the Recording Industry Association of Japan (RIAJ) for both an album and CD single in the same year. Twice ranked third in the Top Artist category of Billboard Japan's 2017 Year-end Rankings, and in 2019, they became the first Korean girl group to embark on a Japanese dome tour.\nTwice is the first female Korean act to simultaneously top both Billboard's World Albums and World Digital Song Sales charts with the release of their first studio album Twicetagram and its lead single \"Likey\" in 2017. With the release of their single \"Feel Special\" in 2019, Twice became the third female Korean act to chart into the Canadian Hot 100. After signing with Republic Records for American promotions as part of a partnership with JYP Entertainment, the group has charted into the US Billboard 200 with More & More and Eyes Wide Open in 2020 and Taste of Love and Formula of Love: O+T=<3 in 2021. Their first official English-language single, \"The Feels\", became their first song to enter the US Billboard Hot 100 and the UK Singles Chart, peaking at the 83rd and 80th positions of the charts, respectively. They have been dubbed the next \"Nation's Girl Group\", and their point choreography—including for \"Cheer Up\" (2016), \"TT\" (2016), \"Signal\" (2017), and \"What Is Love?\" (2018)—became dance crazes and viral memes imitated by many celebrities.",
"artistType": "Group",
"disambiguation": "South Korean girl group",
"links": [
{
"url": "https://www.generasia.com/wiki/Twice",
"name": "generasia"
},
{
"url": "http://twice.jype.com/",
"name": "jype"
},
{
"url": "https://twitter.com/JYPETWICE",
"name": "twitter"
},
{
"url": "https://www.facebook.com/JYPETWICE",
"name": "facebook"
},
{
"url": "https://www.instagram.com/twicetagram/",
"name": "instagram"
},
{
"url": "https://www.wikidata.org/wiki/Q20645861",
"name": "wikidata"
},
{
"url": "http://fans.jype.com/twice",
"name": "jype"
},
{
"url": "https://commons.wikimedia.org/wiki/File:Twice_performing_at_SAC_2016_02_(cropped).jpg",
"name": "wikimedia"
},
{
"url": "https://www.discogs.com/artist/4786543",
"name": "discogs"
},
{
"url": "https://www.last.fm/music/%ED%8A%B8%EC%99%80%EC%9D%B4%EC%8A%A4",
"name": "last"
},
{
"url": "https://www.last.fm/music/TWICE",
"name": "last"
},
{
"url": "https://commons.wikimedia.org/wiki/File:160507_Twice_guerrilla_concert.jpg",
"name": "wikimedia"
},
{
"url": "https://open.spotify.com/artist/7n2Ycct7Beij7Dj7meI4X0",
"name": "spotify"
},
{
"url": "http://www.twicejapan.com/",
"name": "twicejapan"
},
{
"url": "https://www.instagram.com/jypetwice_japan/",
"name": "instagram"
},
{
"url": "https://twitter.com/JYPETWICE_JAPAN",
"name": "twitter"
},
{
"url": "https://itunes.apple.com/jp/artist/id1203816887",
"name": "apple"
},
{
"url": "https://commons.wikimedia.org/wiki/File:(TV10)_%EC%97%AC%EC%9E%90%EC%B9%9C%EA%B5%AC%C2%B7%ED%8A%B8%EC%99%80%EC%9D%B4%EC%8A%A4%C2%B7%EB%B8%94%EB%9E%99%ED%95%91%ED%81%AC,_%EB%A0%88%EB%93%9C%EC%B9%B4%ED%8E%AB_%EA%B0%81%EC%96%91%EA%B0%81%EC%83%89_%ED%8C%A8%EC%85%98_%EC%97%B4%EC%A0%84_(2017_%EA%B3%A8%EB%93%A0%EB%94%94%EC%8A%A4%ED%81%AC_%EB%A0%88%EB%93%9C%EC%B9%B4%ED%8E%AB)_2m19s.jpg",
"name": "wikimedia"
},
{
"url": "https://itunes.apple.com/us/artist/id1203816887",
"name": "apple"
},
{
"url": "http://viaf.org/viaf/178150468353504172529",
"name": "viaf"
},
{
"url": "https://www.deezer.com/artist/161553",
"name": "deezer"
},
{
"url": "https://imvdb.com/n/twice",
"name": "imvdb"
},
{
"url": "https://listen.tidal.com/artist/3577941",
"name": "tidal"
},
{
"url": "https://www.youtube.com/TWICE",
"name": "youtube"
},
{
"url": "https://www.youtube.com/twicejapan_official",
"name": "youtube"
},
{
"url": "https://music.apple.com/mx/artist/1203816887",
"name": "apple"
},
{
"url": "https://www.imdb.com/name/nm9652049/",
"name": "imdb"
},
{
"url": "https://www.tiktok.com/@twice_tiktok_officialjp",
"name": "tiktok"
},
{
"url": "https://music.youtube.com/channel/UCAq0pFGa2w9SjxOq0ZxKVIw",
"name": "youtube"
}
],
"images": [
{
"url": "http://assets.fanart.tv/fanart/music/8da127cc-c432-418f-b356-ef36210d82ac/musicbanner/twice-58fb678fb1219.jpg",
"coverType": "banner",
"extension": ".jpg"
},
{
"url": "http://assets.fanart.tv/fanart/music/8da127cc-c432-418f-b356-ef36210d82ac/artistbackground/twice-619421e3c57cc.jpg",
"coverType": "fanart",
"extension": ".jpg"
},
{
"url": "http://assets.fanart.tv/fanart/music/8da127cc-c432-418f-b356-ef36210d82ac/hdmusiclogo/twice-58d833d0a608a.png",
"coverType": "logo",
"extension": ".png"
},
{
"url": "http://assets.fanart.tv/fanart/music/8da127cc-c432-418f-b356-ef36210d82ac/artistthumb/twice-58fb69c0c2b00.jpg",
"coverType": "poster",
"extension": ".jpg"
}
],
"path": "/data/Library/Music/TWICE",
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": [
"Dance",
"Electronica",
"K-Pop",
"Pop",
"R&B"
],
"cleanName": "twice",
"sortName": "twice",
"tags": [],
"added": "2022-07-30T19:32:06Z",
"ratings": {
"votes": 4,
"value": 9.5
},
"statistics": {
"albumCount": 0,
"trackFileCount": 0,
"trackCount": 0,
"totalTrackCount": 0,
"sizeOnDisk": 0,
"percentOfTracks": 0
},
"id": 9
},
"images": [
{
"url": "/MediaCover/Albums/32/cover.jpg?lastWrite=637927379160000000",
"coverType": "cover",
"extension": ".jpg",
"remoteUrl": "https://imagecache.lidarr.audio/v1/caa/22bd49a1-f858-427d-94ee-1788b54fb508/32961181216-1200.jpg"
}
],
"links": [],
"statistics": {
"trackFileCount": 9,
"trackCount": 9,
"totalTrackCount": 9,
"sizeOnDisk": 74968875,
"percentOfTracks": 100
},
"grabbed": false,
"id": 32
}

View File

@@ -0,0 +1,47 @@
{
"id": 634649,
"mediaType": "movie",
"adult": false,
"genreIds": [
28,
12,
878
],
"originalLanguage": "en",
"originalTitle": "Spider-Man: No Way Home",
"overview": "Après les événements liés à l'affrontement avec Mysterio, l'identité secrète de Spider-Man a été révélée. Il est poursuivi par le gouvernement américain, qui l'accuse du meurtre de Mysterio, et traqué par les médias. Cet événement a également des conséquences terribles sur la vie de sa petite-amie M.J. et de son meilleur ami Ned. Désemparé, Peter Parker demande alors de l'aide au docteur Strange. Ce dernier lance un sort pour que tout le monde oublie que Peter est Spider-Man. Mais les choses ne se passent pas comme prévu, et cette action altère la stabilité de l'espace-temps. Cela ouvre le « multivers », un concept terrifiant dont ils ne savent quasiment rien...",
"popularity": 1643.549,
"releaseDate": "2021-12-15",
"title": "Spider-Man: No Way Home",
"video": false,
"voteAverage": 8,
"voteCount": 14510,
"backdropPath": "/ocUp7DJBIc8VJgLEw1prcyK1dYv.jpg",
"posterPath": "/3SyG7dq2q0ollxJ4pSsrqcfRmVj.jpg",
"mediaInfo": {
"downloadStatus": [],
"downloadStatus4k": [],
"id": 91,
"mediaType": "movie",
"tmdbId": 634649,
"tvdbId": null,
"imdbId": null,
"status": 5,
"status4k": 1,
"createdAt": "2021-11-15T15:15:57.000Z",
"updatedAt": "2022-08-01T08:40:19.000Z",
"lastSeasonChange": "2021-11-15T15:15:57.000Z",
"mediaAddedAt": "2021-12-23T12:04:39.000Z",
"serviceId": 0,
"serviceId4k": null,
"externalServiceId": 89,
"externalServiceId4k": null,
"externalServiceSlug": "634649",
"externalServiceSlug4k": null,
"ratingKey": "823",
"ratingKey4k": null,
"seasons": [],
"plexUrl": "https://app.plex.tv/desktop#!/server/719240db84d0795f30baa1c7283588fea536bb21/details?key=%2Flibrary%2Fmetadata%2F823",
"serviceUrl": "http://radarr:7878/movie/634649"
}
}

View File

@@ -0,0 +1,490 @@
{
"page": 1,
"totalPages": 43,
"totalResults": 847,
"results": [
{
"id": 66732,
"firstAirDate": "2016-07-15",
"genreIds": [
18,
10765,
9648
],
"mediaType": "tv",
"name": "Stranger Things",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "Stranger Things",
"overview": "Quand un jeune garçon disparaît, une petite ville découvre une affaire mystérieuse, des expériences secrètes, des forces surnaturelles terrifiantes... et une fillette.",
"popularity": 1750.831,
"voteAverage": 8.6,
"voteCount": 12763,
"backdropPath": "/56v2KjBlU4XaOv9rVYEQypROD7P.jpg",
"posterPath": "/r2w5UNf2mO2Mdl4q6HopuBms6XM.jpg",
"mediaInfo": {
"downloadStatus": [],
"downloadStatus4k": [],
"id": 202,
"mediaType": "tv",
"tmdbId": 66732,
"tvdbId": 305288,
"imdbId": null,
"status": 4,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-02T02:30:09.000Z",
"lastSeasonChange": "2022-08-01T08:55:00.000Z",
"mediaAddedAt": "2022-08-01T08:49:00.000Z",
"serviceId": 0,
"serviceId4k": null,
"externalServiceId": 42,
"externalServiceId4k": null,
"externalServiceSlug": "stranger-things",
"externalServiceSlug4k": null,
"ratingKey": "2012",
"ratingKey4k": null,
"seasons": [
{
"id": 166,
"seasonNumber": 1,
"status": 3,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-02T02:30:09.000Z"
},
{
"id": 167,
"seasonNumber": 2,
"status": 3,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-02T02:30:09.000Z"
},
{
"id": 168,
"seasonNumber": 3,
"status": 3,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-02T02:30:09.000Z"
},
{
"id": 169,
"seasonNumber": 4,
"status": 5,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-01T08:55:00.000Z"
}
],
"plexUrl": "https://app.plex.tv/desktop#!/server/719240db84d0795f30baa1c7283588fea536bb21/details?key=%2Flibrary%2Fmetadata%2F2012",
"serviceUrl": "http://sonarr:8989/series/stranger-things"
}
},
{
"id": 74851,
"firstAirDate": "2017-10-27",
"genreIds": [
10767
],
"mediaType": "tv",
"name": "Beyond Stranger Things",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "Beyond Stranger Things",
"overview": "Les secrets de l'univers de \"Stranger Things 2\" sont révélés tandis que comédiens et artistes invités évoquent les derniers épisodes avec Jim Rash. Attention, spoilers !",
"popularity": 72.277,
"voteAverage": 7.5,
"voteCount": 74,
"backdropPath": "/qevaCqIekzc7Bp5f2kGAi92kO39.jpg",
"posterPath": "/rHCFO8RJ3Hg6a8KjWAsvAsa38hp.jpg"
},
{
"id": 182026,
"mediaType": "movie",
"adult": false,
"genreIds": [
18
],
"originalLanguage": "en",
"originalTitle": "Stranger Things",
"overview": "",
"popularity": 76.465,
"releaseDate": "2013-04-05",
"title": "Stranger Things",
"video": false,
"voteAverage": 8.6,
"voteCount": 51,
"backdropPath": null,
"posterPath": "/4TKdguyacjYrC1Hnbi3PjSP8r3M.jpg"
},
{
"id": 1865,
"mediaType": "movie",
"adult": false,
"genreIds": [
12,
28,
14
],
"originalLanguage": "en",
"originalTitle": "Pirates of the Caribbean: On Stranger Tides",
"overview": "Dans cette histoire pleine daction, où vérité, trahison, jeunesse éternelle et mort forment un cocktail explosif, le capitaine Jack Sparrow retrouve une femme quil a connu autrefois. Leurs liens sontils faits damour ou, cette femme nestelle quune aventurière sans scrupules qui cherche à lutiliser pour découvrir la légendaire Fontaine de Jouvence? Lorsquelle loblige à embarquer à bord du Queen Annes Revenge, le bateau du terrible pirate BarbeNoire, Jack ne sait plus ce quil doit craindre le plus : Le redoutable maître du bateau ou cette femme surgit de son passé…",
"popularity": 251.27,
"releaseDate": "2011-05-14",
"title": "Pirates des Caraïbes : La Fontaine de jouvence",
"video": false,
"voteAverage": 6.5,
"voteCount": 12180,
"backdropPath": "/uzIGtyS6bbnJzGsPL93WCF1FWm8.jpg",
"posterPath": "/5JjjjGg24IGRXIQtaZkPU59acjV.jpg"
},
{
"id": 96608,
"firstAirDate": "2020-01-30",
"genreIds": [
9648,
80
],
"mediaType": "tv",
"name": "Intimidation",
"originCountry": [
"GB"
],
"originalLanguage": "en",
"originalName": "The Stranger",
"overview": "Adam Price mène une vie idyllique : il a un bon travail, deux fils merveilleux et son mariage semble sans faille. Mais son bonheur va soudainement voler en éclats lorsque « The Stranger » dévoile un secret choquant au sujet de sa femme.",
"popularity": 15.11,
"voteAverage": 7.4,
"voteCount": 283,
"backdropPath": "/97pA0UjBqqgcZFbREQL3U1BQDgX.jpg",
"posterPath": "/y9mX3A3O4SxffDIAlK8Li8AL8BD.jpg"
},
{
"id": 7183,
"mediaType": "movie",
"adult": false,
"genreIds": [
53
],
"originalLanguage": "en",
"originalTitle": "Perfect Stranger",
"overview": "Rowena est une journaliste d'investigation. Lorsqu'elle découvre que Harrison Hill, le très puissant publicitaire, est peut-être lié au meurtre de son amie, elle décide de mener son enquête. Pour se faire et l'approcher, elle va endosser deux identités, celle de Katherine, une intérimaire employée de sa société et Veronica, une jeune femme avec laquelle Hill flirte sur internet.",
"popularity": 20.863,
"releaseDate": "2007-04-11",
"title": "Dangereuse séduction",
"video": false,
"voteAverage": 5.8,
"voteCount": 756,
"backdropPath": "/sG7flxRI3ujV5t2scYpbmREVQbv.jpg",
"posterPath": "/jpQoXiLjTN8uqU9Ym9TMaz2D9aS.jpg"
},
{
"id": 99282,
"firstAirDate": "2020-04-13",
"genreIds": [
18,
9648,
80
],
"mediaType": "tv",
"name": "The Stranger",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "The Stranger",
"overview": "Un jeune conducteur sans scrupule prend un mystérieux passager d'Hollywood Hills. Sur 12 heures, les deux hommes naviguent dans les bas-fonds sordides de Los Angeles...",
"popularity": 7.158,
"voteAverage": 7.5,
"voteCount": 25,
"backdropPath": "/g8n6jB5Mkn6FUGQ5MbqEMIHrZba.jpg",
"posterPath": "/4KrCPwB6yNBR8Chg5quigrrUFCD.jpg"
},
{
"id": 10053,
"mediaType": "movie",
"adult": false,
"genreIds": [
27,
9648
],
"originalLanguage": "en",
"originalTitle": "When a Stranger Calls",
"overview": "Alors qu'elle garde des enfants, une étudiante est terrorisée par un homme qui la harcèle au téléphone en lui demandant si elle a bien vérifié que tout était normal avec les petits dont elle a la charge. Les policiers qu'elle a appelés finissent par localiser les appels et l'informent que ceux-ci proviennent de la maison où elle se trouve...",
"popularity": 18.475,
"releaseDate": "2006-02-03",
"title": "Terreur sur la Ligne",
"video": false,
"voteAverage": 5.7,
"voteCount": 873,
"backdropPath": "/lF3ojoSmCZgrh9nyy2lOxoWL7KD.jpg",
"posterPath": "/xva4IuEfaT6c8tZLpNK2LKCtNGf.jpg"
},
{
"id": 291151,
"mediaType": "movie",
"adult": false,
"genreIds": [
53,
27,
9648
],
"originalLanguage": "en",
"originalTitle": "The Stranger",
"overview": "Un homme mystérieux à la recherche de sa femme arrive dans une petite ville du Canada, sa présence va quelque peu bouleverser l'apparente tranquillité qui y règne.",
"popularity": 7.932,
"releaseDate": "2014-06-12",
"title": "The Stranger",
"video": false,
"voteAverage": 4.7,
"voteCount": 77,
"backdropPath": "/plTx6iHNbLxNXKL4swZxl4RVT2w.jpg",
"posterPath": "/8YjSy1vG4yuuatgdAU1NbitA52F.jpg"
},
{
"id": 1262,
"mediaType": "movie",
"adult": false,
"genreIds": [
35,
18,
14,
10749
],
"originalLanguage": "en",
"originalTitle": "Stranger Than Fiction",
"overview": "Un beau matin, Harold Crick, un obscur fonctionnaire du fisc, entend soudain une voix de femme qui se met à commenter tout ce qu'il vit, y compris ses pensées les plus intimes. Pour Harold, c'est un cauchemar qui dérègle sa vie parfaitement agencée, mais cela devient encore plus grave lorsque la voix annonce qu'il va bientôt mourir...Harold découvre que cette voix est celle d'une romancière, Karen Eiffel, qui s'efforce désespérément d'écrire la fin de ce qui pourrait être son meilleur livre. Il ne lui reste plus qu'à trouver comment tuer son personnage principal : Harold ! Elle ignore que celui-ci existe, qu'il entend ses mots et connaît le sort qu'elle lui réserve...Pour s'en sortir vivant, Harold doit changer son destin. Sa seule chance est de devenir un personnage de comédie, puisque ceux-ci ne sont jamais tués...",
"popularity": 12.475,
"releaseDate": "2006-09-09",
"title": "L'Incroyable Destin de Harold Crick",
"video": false,
"voteAverage": 7.3,
"voteCount": 1875,
"backdropPath": "/d9eONXYtCmQnPWw61w9pNMGlSzK.jpg",
"posterPath": "/hZpCDBXmKqDBBonBKGAcZ95Qmvi.jpg"
},
{
"id": 87692,
"firstAirDate": "2019-04-06",
"genreIds": [
16,
35,
10765
],
"mediaType": "tv",
"name": "Chou Kadou Girl",
"originCountry": [
"JP"
],
"originalLanguage": "ja",
"originalName": "超可動ガール⅙ AMAZING STRANGER",
"overview": "Haruto est un otaku qui ne s'intéresse pas aux (vraies) filles en 3D ! Un jour, sa dernière acquisition, une figurine de son héroïne préférée Nona, se met à bouger toute seule. Ainsi commence la drôle de vie conjugale entre un otaku et un robot...",
"popularity": 11.422,
"voteAverage": 6.4,
"voteCount": 5,
"backdropPath": "/yl4Ltag61cTv0XtwbwMpvzxt7ov.jpg",
"posterPath": "/pPxakEs1TP6JhclPceGxHBoE8Ey.jpg"
},
{
"id": 455108,
"mediaType": "movie",
"adult": false,
"genreIds": [
9648,
18,
27,
36
],
"originalLanguage": "en",
"originalTitle": "The Little Stranger",
"overview": "Fils dune modeste domestique, le docteur Faraday sest construit une existence tranquille et respectable en devenant médecin de campagne. En 1947, lors dun été particulièrement long et chaud, il est appelé au chevet dune patiente à Hundreds Hall, où sa mère fut employée autrefois. Le domaine, qui appartient depuis plus de deux siècles à la famille Ayres, est aujourdhui en piteux état, et ses habitants la mère, son fils et sa fille sont hantés par quelque chose de bien plus effrayant encore que le déclin de leurs finances. Faraday ne simagine pas à quel point le destin de cette famille et le sien sont liés, ni ce que cela a de terrifiant…",
"popularity": 12.538,
"releaseDate": "2018-08-30",
"title": "The Little Stranger",
"video": false,
"voteAverage": 5.7,
"voteCount": 216,
"backdropPath": "/eyrUZ6jvg1Qy3jUz5YH8U4UkFLP.jpg",
"posterPath": "/qm1KJU9coK2voDIFD6AUvSgVG56.jpg"
},
{
"id": 38166,
"mediaType": "movie",
"adult": false,
"genreIds": [
28,
18,
53,
9648
],
"originalLanguage": "en",
"originalTitle": "The Stranger",
"overview": "Un agent du F.B.I poursuit le témoin matériel d'une enquête classée secret défense.",
"popularity": 6.555,
"releaseDate": "2010-06-01",
"title": "The Stranger",
"video": false,
"voteAverage": 4.9,
"voteCount": 57,
"backdropPath": "/kjFC8S6y9wKiRXRpOPwQQu6e9cJ.jpg",
"posterPath": "/fXg4MXYruDKrssFmfzKlf2TINJb.jpg"
},
{
"id": 20246,
"mediaType": "movie",
"adult": false,
"genreIds": [
80,
18,
9648,
53
],
"originalLanguage": "en",
"originalTitle": "The Stranger",
"overview": "L'inspecteur Wilson, de la commission contre les crimes de guerre, décide de relâcher un ancien chef de camp d'extermination nazi, dans l'espoir qu'il le conduira jusqu'à son supérieur, Franz Kindler. L'Allemand, qui circule sous un nom d'emprunt, se rend dans la petite ville de Harper. L'inspecteur le suit. Se sachant surveillé, l'ex-détenu attire le policier dans le gymnase de l'école. Là, il l'assomme et se précipite dans la maison voisine, qui n'est autre que celle de Franz Kindler, aujourd'hui professeur dans ce collège, pour le prévenir de l'arrivée de la police…",
"popularity": 7.449,
"releaseDate": "1946-07-02",
"title": "Le Criminel",
"video": false,
"voteAverage": 7.3,
"voteCount": 449,
"backdropPath": "/eewSm2QKPMueCM3ix5r3aE5eIur.jpg",
"posterPath": "/ee3F8CvNMSJZvYiwW2DKSvU9rQj.jpg"
},
{
"id": 469,
"mediaType": "movie",
"adult": false,
"genreIds": [
35,
18
],
"originalLanguage": "en",
"originalTitle": "Stranger Than Paradise",
"overview": "Eva, 16 ans, quitte la Hongrie et retrouve son cousin Willie, installé depuis 10 ans aux États-Unis. Inadaptés à cette terre de désillusions, ils partent de Miami découvrir le paradis de la Floride, royaume du jeu et dernier espoir d'un exil douloureux.",
"popularity": 9.713,
"releaseDate": "1984-10-01",
"title": "Stranger Than Paradise",
"video": false,
"voteAverage": 7.2,
"voteCount": 394,
"backdropPath": "/tAEV7htL9Yi0hMHtxlv2VAm9Rbe.jpg",
"posterPath": "/fxlMexOi2D64ugS07Sv2hJZYM3R.jpg"
},
{
"id": 45964,
"mediaType": "movie",
"adult": false,
"genreIds": [
27,
53,
18,
80,
9648
],
"originalLanguage": "en",
"originalTitle": "When a Stranger Calls",
"overview": "Au cours dune nuit où elle garde les enfants dun couple marié, une baby-sitter se fait harceler au téléphone par un inconnu qui lui pose systématiquement la même question : « êtes-vous allée voir les enfants ? ». De plus en plus inquiète à mesure que les appels se succèdent, la jeune femme décide de contacter la police.",
"popularity": 9.731,
"releaseDate": "1979-10-26",
"title": "Terreur sur la ligne",
"video": false,
"voteAverage": 6.2,
"voteCount": 178,
"backdropPath": "/3dK12SaczU7Tf8btq7K2F5HQg6F.jpg",
"posterPath": "/x4d8XUXbWLjiro51iQ2qiFhT6t4.jpg"
},
{
"id": 105024,
"firstAirDate": "2020-06-24",
"genreIds": [
35,
18
],
"mediaType": "tv",
"name": "Hello, Stranger",
"originCountry": [
"PH"
],
"originalLanguage": "tl",
"originalName": "Hello, Stranger",
"overview": "",
"popularity": 3.554,
"voteAverage": 7.3,
"voteCount": 3,
"backdropPath": "/8uXYX9F92gc0RlVlTEYVrze83fo.jpg",
"posterPath": "/uu8yWT64FP0W39whxIcs2aMv1Wb.jpg"
},
{
"id": 618352,
"mediaType": "movie",
"adult": false,
"genreIds": [
16,
28,
27,
14
],
"originalLanguage": "en",
"originalTitle": "DC Showcase: The Phantom Stranger",
"overview": "L'histoire se situe dans les années 1970, quand une jeune femme du nom de Jess et ses amis se rendent à une soirée dans un vieux manoir qui appartient à un certain Seth, les choses tournent au vinaigre, le Phantom Stranger arrivera pour leur porter secours.",
"popularity": 8.072,
"releaseDate": "2020-02-25",
"title": "DC Showcase: The Phantom Stranger",
"video": false,
"voteAverage": 7.5,
"voteCount": 49,
"backdropPath": "/vQkGZ0u9E8PgBbjg8vo61KHxQDc.jpg",
"posterPath": "/tqcL1YEiGUKsW1Ofka59m4MIKr1.jpg"
},
{
"id": 413852,
"mediaType": "movie",
"adult": false,
"genreIds": [
18,
9648,
53,
878
],
"originalLanguage": "en",
"originalTitle": "Welcome the Stranger",
"overview": "Alice arrive inopinément chez son frère, Ethan, en espérant se réconcilier avec lui. D'étranges visions et le retour de la petite amie d'Ethan perturbent son projet...",
"popularity": 5.994,
"releaseDate": "2018-03-20",
"title": "Welcome the Stranger",
"video": false,
"voteAverage": 5,
"voteCount": 33,
"backdropPath": "/51aiE8fEXchmbLIyX7Smm3zJavV.jpg",
"posterPath": "/fZch4FhfexA18gUUQjHXKnLmkjh.jpg"
},
{
"id": 41670,
"mediaType": "movie",
"adult": false,
"genreIds": [
18,
10749,
80
],
"originalLanguage": "en",
"originalTitle": "A Stranger Among Us",
"overview": "Chargée d'enquêter sur un meurtre au sein de la communauté hassidique de la ville de New-York, la détective Emily Eden parvient à se faire accepter au sein de cette secte si hermétique.",
"popularity": 7.016,
"releaseDate": "1992-07-17",
"title": "Une étrangère parmi nous",
"video": false,
"voteAverage": 5.7,
"voteCount": 71,
"backdropPath": "/hL0hkMFGWgOvC0P4le6gzRzwa62.jpg",
"posterPath": "/rvk00cSV6cGWQQIppEPYLnDebQ1.jpg"
}
]
}

View File

@@ -0,0 +1,110 @@
{
"seriesId": 37,
"episodeFileId": 7387,
"seasonNumber": 1,
"episodeNumber": 4,
"title": "Part IV",
"airDate": "2022-06-08",
"airDateUtc": "2022-06-08T07:00:00Z",
"overview": "Obi-Wan Kenobi plots a daring mission into enemy territory.",
"episodeFile": {
"seriesId": 37,
"seasonNumber": 1,
"relativePath": "Season 1/Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi[rartv].mkv",
"path": "/tv/Obi-Wan Kenobi/Season 1/Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi[rartv].mkv",
"size": 1893191174,
"dateAdded": "2022-06-08T07:32:27.158296Z",
"sceneName": "Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi[rartv]",
"quality": {
"quality": {
"id": 3,
"name": "WEBDL-1080p",
"source": "web",
"resolution": 1080
},
"revision": {
"version": 1,
"real": 0,
"isRepack": false
}
},
"language": {
"id": 1,
"name": "English"
},
"mediaInfo": {
"audioChannels": 5.1,
"audioCodec": "EAC3 Atmos",
"videoCodec": "h264"
},
"originalFilePath": "Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi[rarbg]/Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi.mkv",
"qualityCutoffNotMet": false,
"id": 7387
},
"hasFile": true,
"monitored": true,
"unverifiedSceneNumbering": false,
"series": {
"title": "Obi-Wan Kenobi",
"sortTitle": "obiwan kenobi",
"seasonCount": 1,
"status": "ended",
"overview": "During the reign of the Empire, Obi-Wan Kenobi embarks on a crucial mission.",
"network": "Disney+",
"airTime": "03:00",
"images": [
{
"coverType": "banner",
"url": "https://artworks.thetvdb.com/banners/v4/series/393199/banners/6290d38b8c283.jpg"
},
{
"coverType": "poster",
"url": "https://artworks.thetvdb.com/banners/v4/series/393199/posters/629668351aca3.jpg"
},
{
"coverType": "fanart",
"url": "https://artworks.thetvdb.com/banners/v4/series/393199/backgrounds/62912a0fe623d.jpg"
}
],
"seasons": [
{
"seasonNumber": 1,
"monitored": true
}
],
"year": 2022,
"path": "/tv/Obi-Wan Kenobi",
"profileId": 1,
"languageProfileId": 1,
"seasonFolder": true,
"monitored": true,
"useSceneNumbering": false,
"runtime": 39,
"tvdbId": 393199,
"tvRageId": 0,
"tvMazeId": 52260,
"firstAired": "2022-05-27T00:00:00Z",
"lastInfoSync": "2022-07-22T03:36:34.392414Z",
"seriesType": "standard",
"cleanTitle": "obiwankenobi",
"imdbId": "tt8466564",
"titleSlug": "obi-wan-kenobi",
"certification": "TV-14",
"genres": [
"Action",
"Adventure",
"Fantasy",
"Mini-Series",
"Science Fiction"
],
"tags": [],
"added": "2022-05-03T20:22:10.47688Z",
"ratings": {
"votes": 0,
"value": 0
},
"qualityProfileId": 1,
"id": 37
},
"id": 1407
}

View File

@@ -0,0 +1 @@
export * from './MediaDisplay';

View File

@@ -0,0 +1,246 @@
import { createStyles, useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { IModule } from '../ModuleTypes';
const asModule = <T extends IModule>(t: T) => t;
export const DashdotModule = asModule({
title: 'Dash.',
description: 'A module for displaying the graphs of your running Dash. instance.',
icon: CalendarIcon,
component: DashdotComponent,
options: {
cpuMultiView: {
name: 'CPU Multi-Core View',
value: false,
},
storageMultiView: {
name: 'Storage Multi-Drive View',
value: false,
},
useCompactView: {
name: 'Use Compact View',
value: false,
},
graphs: {
name: 'Graphs',
value: ['CPU', 'RAM', 'Storage', 'Network'],
options: ['CPU', 'RAM', 'Storage', 'Network', 'GPU'],
},
url: {
name: 'Dash. URL',
value: '',
},
},
});
const useStyles = createStyles((theme, _params) => ({
heading: {
marginTop: 0,
marginBottom: 10,
},
table: {
display: 'table',
},
tableRow: {
display: 'table-row',
},
tableLabel: {
display: 'table-cell',
paddingRight: 10,
},
tableValue: {
display: 'table-cell',
whiteSpace: 'pre-wrap',
paddingBottom: 5,
},
graphsContainer: {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
rowGap: 10,
columnGap: 10,
},
iframe: {
flex: '1 0 auto',
maxWidth: '100%',
height: '140px',
borderRadius: theme.radius.lg,
},
}));
const bpsPrettyPrint = (bits?: number) =>
!bits
? '-'
: bits > 1000 * 1000 * 1000
? `${(bits / 1000 / 1000 / 1000).toFixed(1)} Gb/s`
: bits > 1000 * 1000
? `${(bits / 1000 / 1000).toFixed(1)} Mb/s`
: bits > 1000
? `${(bits / 1000).toFixed(1)} Kb/s`
: `${bits.toFixed(1)} b/s`;
const bytePrettyPrint = (byte: number): string =>
byte > 1024 * 1024 * 1024
? `${(byte / 1024 / 1024 / 1024).toFixed(1)} GiB`
: byte > 1024 * 1024
? `${(byte / 1024 / 1024).toFixed(1)} MiB`
: byte > 1024
? `${(byte / 1024).toFixed(1)} KiB`
: `${byte.toFixed(1)} B`;
const useJson = (targetUrl: string, url: string) => {
const [data, setData] = useState<any | undefined>();
const doRequest = async () => {
try {
const resp = await axios.get(`/api/modules/dashdot?url=${url}&base=${targetUrl}`);
setData(resp.data);
// eslint-disable-next-line no-empty
} catch (e) {}
};
useEffect(() => {
if (targetUrl) {
doRequest();
}
}, [targetUrl]);
return data;
};
export function DashdotComponent() {
const { config } = useConfig();
const theme = useMantineTheme();
const { classes } = useStyles();
const { colorScheme } = useMantineColorScheme();
const dashConfig = config.modules?.[DashdotModule.title]
.options as typeof DashdotModule['options'];
const isCompact = dashConfig?.useCompactView?.value ?? false;
const dashdotService: serviceItem | undefined = config.services.filter(
(service) => service.type === 'Dash.'
)[0];
const dashdotUrl = dashdotService?.url ?? dashConfig?.url?.value ?? '';
const enabledGraphs = dashConfig?.graphs?.value ?? ['CPU', 'RAM', 'Storage', 'Network'];
const cpuEnabled = enabledGraphs.includes('CPU');
const storageEnabled = enabledGraphs.includes('Storage');
const ramEnabled = enabledGraphs.includes('RAM');
const networkEnabled = enabledGraphs.includes('Network');
const gpuEnabled = enabledGraphs.includes('GPU');
const info = useJson(dashdotUrl, '/info');
const storageLoad = useJson(dashdotUrl, '/load/storage');
const totalUsed =
(storageLoad?.layout as any[])?.reduce((acc, curr) => (curr.load ?? 0) + acc, 0) ?? 0;
const totalSize =
(info?.storage?.layout as any[])?.reduce((acc, curr) => (curr.size ?? 0) + acc, 0) ?? 0;
const graphs = [
{
name: 'CPU',
enabled: cpuEnabled,
params: {
multiView: dashConfig?.cpuMultiView?.value ?? false,
},
},
{
name: 'Storage',
enabled: storageEnabled && !isCompact,
params: {
multiView: dashConfig?.storageMultiView?.value ?? false,
},
},
{
name: 'RAM',
enabled: ramEnabled,
},
{
name: 'Network',
enabled: networkEnabled,
spanTwo: true,
},
{
name: 'GPU',
enabled: gpuEnabled,
spanTwo: true,
},
].filter((g) => g.enabled);
if (dashdotUrl === '') {
return (
<div>
<h2 className={classes.heading}>Dash.</h2>
<p>
No dash. service found. Please add one to your Homarr dashboard or set a dashdot URL in
the module options
</p>
</div>
);
}
return (
<div>
<h2 className={classes.heading}>Dash.</h2>
{!info ? (
<p>Cannot acquire information from dash. - are you running the latest version?</p>
) : (
<div className={classes.graphsContainer}>
<div className={classes.table}>
{storageEnabled && isCompact && (
<div className={classes.tableRow}>
<p className={classes.tableLabel}>Storage:</p>
<p className={classes.tableValue}>
{((100 * totalUsed) / (totalSize || 1)).toFixed(1)}%{'\n'}
{bytePrettyPrint(totalUsed)} / {bytePrettyPrint(totalSize)}
</p>
</div>
)}
{networkEnabled && (
<div className={classes.tableRow}>
<p className={classes.tableLabel}>Network:</p>
<p className={classes.tableValue}>
{bpsPrettyPrint(info?.network?.speedUp)} Up{'\n'}
{bpsPrettyPrint(info?.network?.speedDown)} Down
</p>
</div>
)}
</div>
{graphs.map((graph) => (
<iframe
className={classes.iframe}
style={
isCompact
? {
width: graph.spanTwo ? '100%' : 'calc(50% - 5px)',
}
: undefined
}
key={graph.name}
title={graph.name}
src={`${dashdotUrl}?singleGraphMode=true&graph=${graph.name.toLowerCase()}&theme=${colorScheme}&surface=${(colorScheme ===
'dark'
? theme.colors.dark[7]
: theme.colors.gray[0]
).substring(1)}${isCompact ? '&gap=10' : '&gap=5'}&innerRadius=${theme.radius.lg}${
graph.params
? `&${Object.entries(graph.params)
.map(([key, value]) => `${key}=${value.toString()}`)
.join('&')}`
: ''
}`}
frameBorder="0"
/>
))}
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,42 @@
import { Group, Text, Title } from '@mantine/core';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import { IconClock as Clock } from '@tabler/icons';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
export const DateModule: IModule = {
title: 'Date',
description: 'Show the current time and date in a card',
icon: Clock,
component: DateComponent,
options: {
full: {
name: 'Display full time (24-hour)',
value: true,
},
},
};
export default function DateComponent(props: any) {
const [date, setDate] = useState(new Date());
const setSafeInterval = useSetSafeInterval();
const { config } = useConfig();
const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? true;
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
// Change date on minute change
// Note: Using 10 000ms instead of 1000ms to chill a little :)
useEffect(() => {
setSafeInterval(() => {
setDate(new Date());
}, 1000 * 60);
}, []);
return (
<Group p="sm" spacing="xs">
<Title>{dayjs(date).format(formatString)}</Title>
<Text size="lg">{dayjs(date).format('dddd, MMMM D')}</Text>
</Group>
);
}

View File

@@ -0,0 +1,164 @@
import { Button, Group, Modal, Title } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
IconCheck,
IconPlayerPlay,
IconPlayerStop,
IconPlus,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from '@tabler/icons';
import axios from 'axios';
import Dockerode from 'dockerode';
import { tryMatchService } from '../../tools/addToHomarr';
import { AddAppShelfItemForm } from '../../components/AppShelf/AddAppShelfItem';
import { useState } from 'react';
function sendDockerCommand(
action: string,
containerId: string,
containerName: string,
reload: () => void
) {
showNotification({
id: containerId,
loading: true,
title: `${action}ing container ${containerName}`,
message: undefined,
autoClose: false,
disallowClose: true,
});
axios
.get(`/api/docker/container/${containerId}?action=${action}`)
.then((res) => {
updateNotification({
id: containerId,
title: `Container ${containerName} ${action}ed`,
message: `Your container was successfully ${action}ed`,
icon: <IconCheck />,
autoClose: 2000,
});
})
.catch((err) => {
updateNotification({
id: containerId,
color: 'red',
title: 'There was an error',
message: err.response.data.reason,
autoClose: 2000,
});
})
.finally(() => {
reload();
});
}
export interface ContainerActionBarProps {
selected: Dockerode.ContainerInfo[];
reload: () => void;
}
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
const [opened, setOpened] = useState<boolean>(false);
return (
<Group>
<Modal
size="xl"
radius="md"
opened={opened}
onClose={() => setOpened(false)}
title="Add service"
>
<AddAppShelfItemForm
setOpened={setOpened}
{...tryMatchService(selected.at(0))}
message="Add service to homarr"
/>
</Modal>
<Button
leftIcon={<IconRotateClockwise />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('restart', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="orange"
radius="md"
>
Restart
</Button>
<Button
leftIcon={<IconPlayerStop />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('stop', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="red"
radius="md"
>
Stop
</Button>
<Button
leftIcon={<IconPlayerPlay />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('start', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="green"
radius="md"
>
Start
</Button>
<Button leftIcon={<IconRefresh />} onClick={() => reload()} variant="light" radius="md">
Refresh data
</Button>
<Button
leftIcon={<IconPlus />}
color="indigo"
variant="light"
radius="md"
onClick={() => {
if (selected.length !== 1) {
showNotification({
autoClose: 5000,
title: <Title order={5}>Please only add one service at a time!</Title>,
color: 'red',
message: undefined,
});
} else {
setOpened(true);
}
}}
>
Add to Homarr
</Button>
<Button
leftIcon={<IconTrash />}
color="red"
variant="light"
radius="md"
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('remove', container.Id, container.Names[0].substring(1), reload)
)
)
}
>
Remove
</Button>
</Group>
);
}

View File

@@ -0,0 +1,49 @@
import { Badge, BadgeVariant, MantineSize } from '@mantine/core';
import Dockerode from 'dockerode';
export interface ContainerStateProps {
state: Dockerode.ContainerInfo['State'];
}
export default function ContainerState(props: ContainerStateProps) {
const { state } = props;
const options: {
size: MantineSize;
radius: MantineSize;
variant: BadgeVariant;
} = {
size: 'md',
radius: 'md',
variant: 'outline',
};
switch (state) {
case 'running': {
return (
<Badge color="green" {...options}>
Running
</Badge>
);
}
case 'created': {
return (
<Badge color="cyan" {...options}>
Created
</Badge>
);
}
case 'exited': {
return (
<Badge color="red" {...options}>
Stopped
</Badge>
);
}
default: {
return (
<Badge color="purple" {...options}>
Unknown
</Badge>
);
}
}
}

View File

@@ -0,0 +1,83 @@
import { ActionIcon, Drawer, Group, LoadingOverlay, Text, Tooltip } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import Docker from 'dockerode';
import { IconBrandDocker, IconX } from '@tabler/icons';
import { showNotification } from '@mantine/notifications';
import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
export const DockerModule: IModule = {
title: 'Docker',
description: 'Allows you to easily manage your torrents',
icon: IconBrandDocker,
component: DockerMenuButton,
};
export default function DockerMenuButton(props: any) {
const [opened, setOpened] = useState(false);
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const { config } = useConfig();
const moduleEnabled = config.modules?.[DockerModule.title]?.enabled ?? false;
useEffect(() => {
reload();
}, [config.modules]);
function reload() {
if (!moduleEnabled) {
return;
}
setTimeout(() => {
axios
.get('/api/docker/containers')
.then((res) => {
setContainers(res.data);
setSelection([]);
})
.catch(() =>
// Send an Error notification
showNotification({
autoClose: 1500,
title: <Text>Docker integration failed</Text>,
color: 'red',
icon: <IconX />,
message: 'Did you forget to mount the docker socket ?',
})
);
}, 300);
}
const exists = config.modules?.[DockerModule.title]?.enabled ?? false;
if (!exists) {
return null;
}
// Check if the user has at least one container
if (containers.length < 1) return null;
return (
<>
<Drawer
opened={opened}
onClose={() => setOpened(false)}
padding="xl"
size="full"
title={<ContainerActionBar selected={selection} reload={reload} />}
>
<DockerTable containers={containers} selection={selection} setSelection={setSelection} />
</Drawer>
<Tooltip label="Docker">
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
onClick={() => setOpened(true)}
>
<IconBrandDocker />
</ActionIcon>
</Tooltip>
</>
);
}

View File

@@ -0,0 +1,124 @@
import { Table, Checkbox, Group, Badge, createStyles, ScrollArea, TextInput } from '@mantine/core';
import { IconSearch } from '@tabler/icons';
import Dockerode from 'dockerode';
import { useEffect, useState } from 'react';
import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({
rowSelected: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
: theme.colors[theme.primaryColor][0],
},
}));
export default function DockerTable({
containers,
selection,
setSelection,
}: {
setSelection: any;
containers: Dockerode.ContainerInfo[];
selection: Dockerode.ContainerInfo[];
}) {
const [usedContainers, setContainers] = useState<Dockerode.ContainerInfo[]>(containers);
const { classes, cx } = useStyles();
const [search, setSearch] = useState('');
useEffect(() => {
setContainers(containers);
}, [containers]);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.currentTarget;
setSearch(value);
setContainers(filterContainers(containers, value));
};
function filterContainers(data: Dockerode.ContainerInfo[], search: string) {
const query = search.toLowerCase().trim();
return data.filter((item) =>
item.Names.some((name) => name.toLowerCase().includes(query) || item.Image.includes(query))
);
}
const toggleRow = (container: Dockerode.ContainerInfo) =>
setSelection((current: Dockerode.ContainerInfo[]) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current: any) =>
current.length === usedContainers.length ? [] : usedContainers.map((c) => c)
);
const rows = usedContainers.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.Id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.Names[0].replace('/', '')}</td>
<td>{element.Image}</td>
<td>
<Group>
{element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
// Remove duplicates with filter function
.filter(
(port, index, self) =>
index === self.findIndex((t) => t.PrivatePort === port.PrivatePort)
)
.slice(-3)
.map((port) => (
<Badge key={port.PrivatePort} variant="outline">
{port.PrivatePort}:{port.PublicPort}
</Badge>
))}
{element.Ports.length > 3 && (
<Badge variant="filled">{element.Ports.length - 3} more</Badge>
)}
</Group>
</td>
<td>
<ContainerState state={element.State} />
</td>
</tr>
);
});
return (
<ScrollArea style={{ height: '80vh' }}>
<TextInput
placeholder="Search by container or image name"
mt="md"
icon={<IconSearch size={14} />}
value={search}
onChange={handleSearchChange}
/>
<Table captionSide="bottom" highlightOnHover sx={{ minWidth: 800 }} verticalSpacing="sm">
<thead>
<tr>
<th style={{ width: 40 }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === usedContainers.length}
indeterminate={selection.length > 0 && selection.length !== usedContainers.length}
transitionDuration={0}
/>
</th>
<th>Name</th>
<th>Image</th>
<th>Ports</th>
<th>State</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
);
}

View File

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

View File

@@ -0,0 +1,203 @@
import {
Table,
Text,
Tooltip,
Title,
Group,
Progress,
Skeleton,
ScrollArea,
Center,
} from '@mantine/core';
import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { useViewportSize } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IModule } from '../ModuleTypes';
import { useConfig } from '../../tools/state';
import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
import { humanFileSize } from '../../tools/humanFileSize';
export const DownloadsModule: IModule = {
title: 'Torrent',
description: 'Show the current download speed of supported services',
icon: Download,
component: DownloadComponent,
options: {
hidecomplete: {
name: 'Hide completed torrents',
value: false,
},
},
};
export default function DownloadComponent() {
const { config } = useConfig();
const { height, width } = useViewportSize();
const downloadServices =
config.services.filter(
(service) =>
service.type === 'qBittorrent' ||
service.type === 'Transmission' ||
service.type === 'Deluge'
) ?? [];
const hideComplete: boolean =
(config?.modules?.[DownloadsModule.title]?.options?.hidecomplete?.value as boolean) ?? false;
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
const setSafeInterval = useSetSafeInterval();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
if (downloadServices.length === 0) return;
const interval = setInterval(() => {
// Send one request with each download service inside
axios
.post('/api/modules/downloads')
.then((response) => {
setTorrents(response.data);
setIsLoading(false);
})
.catch((error) => {
setTorrents([]);
// eslint-disable-next-line no-console
console.error('Error while fetching torrents', error.response.data);
setIsLoading(false);
showNotification({
title: 'Error fetching torrents',
autoClose: 1000,
disallowClose: true,
id: 'fail-torrent-downloads-module',
color: 'red',
message:
'Please check your config for any potential errors, check the console for more info',
});
clearInterval(interval);
});
}, 5000);
}, []);
if (downloadServices.length === 0) {
return (
<Group>
<Title order={3}>No supported download clients found!</Title>
<Group>
<Text>Add a download service to view your current downloads</Text>
<AddItemShelfButton />
</Group>
</Group>
);
}
if (isLoading) {
return (
<>
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
</>
);
}
const DEVICE_WIDTH = 576;
const ths = (
<tr>
<th>Name</th>
<th>Size</th>
{width > 576 ? <th>Down</th> : ''}
{width > 576 ? <th>Up</th> : ''}
<th>ETA</th>
<th>Progress</th>
</tr>
);
// Convert Seconds to readable format.
function calculateETA(givenSeconds: number) {
// If its superior than one day return > 1 day
if (givenSeconds > 86400) {
return '> 1 day';
}
// Transform the givenSeconds into a readable format. e.g. 1h 2m 3s
const hours = Math.floor(givenSeconds / 3600);
const minutes = Math.floor((givenSeconds % 3600) / 60);
const seconds = Math.floor(givenSeconds % 60);
// Only show hours if it's greater than 0.
const hoursString = hours > 0 ? `${hours}h ` : '';
const minutesString = minutes > 0 ? `${minutes}m ` : '';
const secondsString = seconds > 0 ? `${seconds}s` : '';
return `${hoursString}${minutesString}${secondsString}`;
}
// Loop over qBittorrent torrents merging with deluge torrents
const rows = torrents
.filter((torrent) => !(torrent.progress === 1 && hideComplete))
.map((torrent) => {
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
const size = torrent.totalSelected;
return (
<tr key={torrent.id}>
<td>
<Tooltip position="top" label={torrent.name}>
<Text
style={{
maxWidth: '30vw',
}}
size="xs"
lineClamp={1}
>
{torrent.name}
</Text>
</Tooltip>
</td>
<td>
<Text size="xs">{humanFileSize(size)}</Text>
</td>
{width > 576 ? (
<td>
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td>
) : (
''
)}
{width > 576 ? (
<td>
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td>
) : (
''
)}
<td>
<Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text>
</td>
<td>
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
<Progress
radius="lg"
color={
torrent.progress === 1 ? 'green' : torrent.state === 'paused' ? 'yellow' : 'blue'
}
value={torrent.progress * 100}
size="lg"
/>
</td>
</tr>
);
});
return (
<ScrollArea mt="xl" sx={{ height: 300, width: '100%' }}>
{rows.length > 0 ? (
<Table highlightOnHover>
<thead>{ths}</thead>
<tbody>{rows}</tbody>
</Table>
) : (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>No torrents found</Title>
</Center>
)}
</ScrollArea>
);
}

View File

@@ -0,0 +1,191 @@
import { Text, Title, Group, useMantineTheme, Box, Card, ColorSwatch, Stack } from '@mantine/core';
import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { linearGradientDef } from '@nivo/core';
import { Datum, ResponsiveLine } from '@nivo/line';
import { useListState } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem';
import { useConfig } from '../../tools/state';
import { humanFileSize } from '../../tools/humanFileSize';
import { IModule } from '../ModuleTypes';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
export const TotalDownloadsModule: IModule = {
title: 'Download Speed',
description: 'Show the current download speed of supported services',
icon: Download,
component: TotalDownloadsComponent,
};
interface torrentHistory {
x: number;
up: number;
down: number;
}
export default function TotalDownloadsComponent() {
const setSafeInterval = useSetSafeInterval();
const { config } = useConfig();
const downloadServices =
config.services.filter(
(service) =>
service.type === 'qBittorrent' ||
service.type === 'Transmission' ||
service.type === 'Deluge'
) ?? [];
const [torrentHistory, torrentHistoryHandlers] = useListState<torrentHistory>([]);
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
const totalDownloadSpeed = torrents.reduce((acc, torrent) => acc + torrent.downloadSpeed, 0);
const totalUploadSpeed = torrents.reduce((acc, torrent) => acc + torrent.uploadSpeed, 0);
useEffect(() => {
if (downloadServices.length === 0) return;
const interval = setSafeInterval(() => {
// Send one request with each download service inside
axios
.post('/api/modules/downloads')
.then((response) => {
setTorrents(response.data);
})
.catch((error) => {
setTorrents([]);
// eslint-disable-next-line no-console
console.error('Error while fetching torrents', error.response.data);
showNotification({
title: 'Torrent speed module failed to fetch torrents',
autoClose: 1000,
disallowClose: true,
id: 'fail-torrent-speed-module',
color: 'red',
message:
'Error fetching torrents, please check your config for any potential errors, check the console for more info',
});
clearInterval(interval);
});
}, 1000);
}, [config.services]);
useEffect(() => {
torrentHistoryHandlers.append({
x: Date.now(),
down: totalDownloadSpeed,
up: totalUploadSpeed,
});
}, [totalDownloadSpeed, totalUploadSpeed]);
if (downloadServices.length === 0) {
return (
<Group>
<Title order={4}>No supported download clients found!</Title>
<div>
<AddItemShelfButton
style={{
float: 'inline-end',
}}
/>
Add a download service to view your current downloads
</div>
</Group>
);
}
const theme = useMantineTheme();
// Load the last 10 values from the history
const history = torrentHistory.slice(-10);
const chartDataUp = history.map((load, i) => ({
x: load.x,
y: load.up,
})) as Datum[];
const chartDataDown = history.map((load, i) => ({
x: load.x,
y: load.down,
})) as Datum[];
return (
<Stack>
<Title order={4}>Current download speed</Title>
<Stack>
<Group>
<ColorSwatch size={12} color={theme.colors.green[5]} />
<Text>Download: {humanFileSize(totalDownloadSpeed)}/s</Text>
</Group>
<Group>
<ColorSwatch size={12} color={theme.colors.blue[5]} />
<Text>Upload: {humanFileSize(totalUploadSpeed)}/s</Text>
</Group>
</Stack>
<Box
style={{
height: 200,
width: '100%',
}}
>
<ResponsiveLine
isInteractive
enableSlices="x"
sliceTooltip={({ slice }) => {
const Download = slice.points[0].data.y as number;
const Upload = slice.points[1].data.y as number;
// Get the number of seconds since the last update.
const seconds = (Date.now() - (slice.points[0].data.x as number)) / 1000;
// Round to the nearest second.
const roundedSeconds = Math.round(seconds);
return (
<Card p="sm" radius="md" withBorder>
<Text size="md">{roundedSeconds} seconds ago</Text>
<Card.Section p="sm">
<Stack>
<Group>
<ColorSwatch size={10} color={theme.colors.green[5]} />
<Text size="md">Download: {humanFileSize(Download)}</Text>
</Group>
<Group>
<ColorSwatch size={10} color={theme.colors.blue[5]} />
<Text size="md">Upload: {humanFileSize(Upload)}</Text>
</Group>
</Stack>
</Card.Section>
</Card>
);
}}
data={[
{
id: 'downloads',
data: chartDataUp,
},
{
id: 'uploads',
data: chartDataDown,
},
]}
curve="monotoneX"
yFormat=" >-.2f"
axisTop={null}
axisRight={null}
enablePoints={false}
animate={false}
enableGridX={false}
enableGridY={false}
enableArea
defs={[
linearGradientDef('gradientA', [
{ offset: 0, color: 'inherit' },
{ offset: 100, color: 'inherit', opacity: 0 },
]),
]}
fill={[{ match: '*', id: 'gradientA' }]}
colors={[
// Blue
theme.colors.blue[5],
// Green
theme.colors.green[5],
]}
/>
</Box>
</Stack>
);
}

View File

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

9
src/modules/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export * from './calendar';
export * from './dashdot';
export * from './date';
export * from './downloads';
export * from './ping';
export * from './search';
export * from './weather';
export * from './docker';
export * from './overseerr';

View File

@@ -0,0 +1,229 @@
import {
ActionIcon,
Button,
Card,
Group,
Menu,
MultiSelect,
Switch,
TextInput,
useMantineColorScheme,
} from '@mantine/core';
import { IconAdjustments } from '@tabler/icons';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { useConfig } from '../tools/state';
import { IModule } from './ModuleTypes';
function getItems(module: IModule) {
const { config, setConfig } = useConfig();
const items: JSX.Element[] = [];
if (module.options) {
const keys = Object.keys(module.options);
const values = Object.values(module.options);
// Get the value and the name of the option
const types = values.map((v) => typeof v.value);
// Loop over all the types with a for each loop
types.forEach((type, index) => {
const optionName = `${module.title}.${keys[index]}`;
const moduleInConfig = config.modules?.[module.title];
if (type === 'object') {
items.push(
<MultiSelect
label={module.options?.[keys[index]].name}
data={module.options?.[keys[index]].options ?? []}
defaultValue={
(moduleInConfig?.options?.[keys[index]]?.value as string[]) ??
(values[index].value as string[]) ??
[]
}
searchable
onChange={(value) => {
setConfig({
...config,
modules: {
...config.modules,
[module.title]: {
...moduleInConfig,
options: {
...moduleInConfig?.options,
[keys[index]]: {
...moduleInConfig?.options?.[keys[index]],
value,
},
},
},
},
});
}}
/>
);
}
if (type === 'string') {
items.push(
<form
onSubmit={(e) => {
e.preventDefault();
setConfig({
...config,
modules: {
...config.modules,
[module.title]: {
...config.modules[module.title],
options: {
...config.modules[module.title].options,
[keys[index]]: {
...config.modules[module.title].options?.[keys[index]],
value: (e.target as any)[0].value,
},
},
},
},
});
}}
>
<Group noWrap align="end">
<TextInput
key={optionName}
id={optionName}
name={optionName}
label={values[index].name}
defaultValue={
(moduleInConfig?.options?.[keys[index]]?.value as string) ??
(values[index].value as string) ??
''
}
onChange={(e) => {}}
/>
<Button type="submit">Save</Button>
</Group>
</form>
);
}
// TODO: Add support for other types
if (type === 'boolean') {
items.push(
<Switch
defaultChecked={
// Set default checked to the value of the option if it exists
(moduleInConfig?.options?.[keys[index]]?.value as boolean) ??
(values[index].value as boolean) ??
false
}
key={keys[index]}
onClick={(e) => {
setConfig({
...config,
modules: {
...config.modules,
[module.title]: {
...config.modules[module.title],
options: {
...config.modules[module.title].options,
[keys[index]]: {
...config.modules[module.title].options?.[keys[index]],
value: e.currentTarget.checked,
},
},
},
},
});
}}
label={values[index].name}
/>
);
}
});
}
return items;
}
export function ModuleWrapper(props: any) {
const { module }: { module: IModule } = props;
const { colorScheme } = useMantineColorScheme();
const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules[module.title]?.enabled ?? false;
//TODO: fix the hover problem
const [hovering, setHovering] = useState(false);
if (!isShown) {
return null;
}
return (
<Card
{...props}
key={module.title}
hidden={!isShown}
withBorder
radius="lg"
shadow="sm"
style={{
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
${(config.settings.appOpacity || 100) / 100}`,
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
${(config.settings.appOpacity || 100) / 100}`,
}}
>
<motion.div
onHoverStart={() => {
setHovering(true);
}}
onHoverEnd={() => {
setHovering(false);
}}
>
<ModuleMenu module={module} hovered={hovering} />
<module.component />
</motion.div>
</Card>
);
}
export function ModuleMenu(props: any) {
const { module, styles, hovered } = props;
const items: JSX.Element[] = getItems(module);
return (
<>
{module.options && (
<Menu
key={module.title}
withinPortal
width="lg"
shadow="xl"
withArrow
closeOnItemClick={false}
radius="md"
position="left"
>
<Menu.Target>
<motion.div
style={{
position: 'absolute',
top: 15,
right: 15,
alignSelf: 'flex-end',
}}
animate={{
opacity: hovered === true ? 1 : 0,
}}
>
<ActionIcon>
<IconAdjustments />
</ActionIcon>
</motion.div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Settings</Menu.Label>
{items.map((item) => (
<Menu.Item key={item.key}>{item}</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
</>
);
}

248
src/modules/overseerr/Movie.d.ts vendored Normal file
View File

@@ -0,0 +1,248 @@
export interface MovieResult {
id: number;
adult: boolean;
budget: number;
genres: Genre[];
relatedVideos: RelatedVideo[];
originalLanguage: string;
originalTitle: string;
popularity: number;
productionCompanies: ProductionCompany[];
productionCountries: ProductionCountry[];
releaseDate: Date;
releases: Releases;
revenue: number;
spokenLanguages: SpokenLanguage[];
status: string;
title: string;
video: boolean;
voteAverage: number;
voteCount: number;
backdropPath: string;
homepage: string;
imdbId: string;
overview: string;
posterPath: string;
runtime: number;
tagline: string;
credits: Credits;
collection: Collection;
externalIds: ExternalIDS;
mediaInfo: Media;
watchProviders: WatchProvider[];
}
export interface Collection {
id: number;
name: string;
posterPath: string;
backdropPath: string;
}
export interface Credits {
cast: Cast[];
crew: Crew[];
}
export interface Cast {
castId: number;
character: string;
creditId: string;
id: number;
name: string;
order: number;
gender: number;
profilePath: null | string;
}
export interface Crew {
creditId: string;
department: Department;
id: number;
job: string;
name: string;
gender: number;
profilePath: null | string;
}
export enum Department {
Art = 'Art',
Camera = 'Camera',
CostumeMakeUp = 'Costume & Make-Up',
Crew = 'Crew',
Directing = 'Directing',
Editing = 'Editing',
Production = 'Production',
Sound = 'Sound',
VisualEffects = 'Visual Effects',
Writing = 'Writing',
}
export interface ExternalIDS {
facebookId: string;
imdbId: string;
instagramId: string;
twitterId: string;
}
export interface Genre {
id: number;
name: string;
}
export interface Request {
id: number;
status: number;
createdAt: Date;
updatedAt: Date;
type: string;
is4k: boolean;
serverId: number;
profileId: number;
rootFolder: string;
languageProfileId: null;
tags: any[];
media: Media;
requestedBy: EdBy;
modifiedBy: EdBy;
seasons: any[];
seasonCount: number;
}
export interface Media {
downloadStatus: any[];
downloadStatus4k: any[];
id: number;
mediaType: string;
tmdbId: number;
tvdbId: null;
imdbId: null;
status: number;
status4k: number;
createdAt: Date;
updatedAt: Date;
lastSeasonChange: Date;
mediaAddedAt: Date;
serviceId: number;
serviceId4k: null;
externalServiceId: number;
externalServiceId4k: null;
externalServiceSlug: string;
externalServiceSlug4k: null;
ratingKey: string;
ratingKey4k: null;
requests?: Request[];
issues?: any[];
seasons: any[];
plexUrl: string;
serviceUrl: string;
}
export interface EdBy {
permissions: number;
id: number;
email: string;
plexUsername: string;
username: string;
recoveryLinkExpirationDate: null;
userType: number;
avatar: string;
movieQuotaLimit: null;
movieQuotaDays: null;
tvQuotaLimit: null;
tvQuotaDays: null;
createdAt: Date;
updatedAt: Date;
settings: Settings;
requestCount: number;
displayName: string;
}
export interface Settings {
id: number;
locale: string;
region: string;
originalLanguage: null;
pgpKey: null;
discordId: string;
pushbulletAccessToken: null;
pushoverApplicationToken: null;
pushoverUserKey: null;
telegramChatId: null;
telegramSendSilently: null;
notificationTypes: NotificationTypes;
}
export interface NotificationTypes {
discord: number;
email: number;
webpush: number;
}
export interface ProductionCompany {
id: number;
name: string;
originCountry?: string;
logoPath: string;
displayPriority?: number;
}
export interface ProductionCountry {
iso_3166_1: string;
name: string;
}
export interface RelatedVideo {
site: string;
key: string;
name: string;
size: number;
type: string;
url: string;
}
export interface Releases {
results: Result[];
}
export interface Result {
iso_3166_1: string;
release_dates: ReleaseDate[];
}
export interface ReleaseDate {
certification: string;
iso_639_1: ISO639_1 | null;
note: Note;
release_date: Date;
type: number;
}
export enum ISO639_1 {
CS = 'cs',
Empty = '',
}
export enum Note {
Empty = '',
HBOMax = 'HBO Max',
LosAngelesCalifornia = 'Los Angeles, California',
Starz = 'STARZ',
The4KUHDBluRayDVD = '4K UHD, Blu-ray & DVD',
TheMoreFunStuffVersion = 'The More Fun Stuff Version',
Tvod = 'TVOD',
VOD = 'VOD',
}
export interface SpokenLanguage {
english_name: string;
iso_639_1: string;
name: string;
}
export interface WatchProvider {
iso_3166_1: string;
link: string;
buy: ProductionCompany[];
flatrate: ProductionCompany[];
}

View File

@@ -0,0 +1,14 @@
import { IconEyeglass } from '@tabler/icons';
import { OverseerrMediaDisplay } from '../common';
import { IModule } from '../ModuleTypes';
export const OverseerrModule: IModule = {
title: 'Overseerr',
description: 'Allows you to search and add media from Overseerr/Jellyseerr',
icon: IconEyeglass,
component: OverseerrMediaDisplay,
};
export interface OverseerSearchProps {
query: string;
}

View File

@@ -0,0 +1,240 @@
import { Alert, Button, Checkbox, createStyles, Group, Modal, Stack, Table } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconAlertCircle, IconCheck, IconDownload } from '@tabler/icons';
import axios from 'axios';
import Consola from 'consola';
import { useState } from 'react';
import { useColorTheme } from '../../tools/color';
import { MovieResult } from './Movie.d';
import { MediaType, Result } from './SearchResult.d';
import { TvShowResult, TvShowResultSeason } from './TvShow.d';
interface RequestModalProps {
base: Result;
opened: boolean;
setOpened: (opened: boolean) => void;
}
const useStyles = createStyles((theme) => ({
rowSelected: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
: theme.colors[theme.primaryColor][0],
},
}));
export function RequestModal({ base, opened, setOpened }: RequestModalProps) {
const [result, setResult] = useState<MovieResult | TvShowResult>();
const { secondaryColor } = useColorTheme();
function getResults(base: Result) {
axios.get(`/api/modules/overseerr/${base.id}?type=${base.mediaType}`).then((res) => {
setResult(res.data);
});
}
if (opened && !result) {
getResults(base);
}
if (!result || !opened) {
return null;
}
return base.mediaType === 'movie' ? (
<MovieRequestModal result={result as MovieResult} opened={opened} setOpened={setOpened} />
) : (
<TvRequestModal result={result as TvShowResult} opened={opened} setOpened={setOpened} />
);
}
export function MovieRequestModal({
result,
opened,
setOpened,
}: {
result: MovieResult;
opened: boolean;
setOpened: (opened: boolean) => void;
}) {
const { secondaryColor } = useColorTheme();
return (
<Modal
onClose={() => setOpened(false)}
radius="lg"
size="lg"
trapFocus
zIndex={150}
withinPortal
opened={opened}
title={
<Group>
<IconDownload />
Ask for {result.title}
</Group>
}
>
<Stack>
<Alert
icon={<IconAlertCircle size={16} />}
title="Using API key"
color={secondaryColor}
radius="md"
variant="filled"
>
This request will be automatically approved
</Alert>
<Group>
<Button variant="outline" color="gray" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
variant="outline"
onClick={() => {
askForMedia(MediaType.Movie, result.id, result.title, []);
}}
>
Request
</Button>
</Group>
</Stack>
</Modal>
);
}
export function TvRequestModal({
result,
opened,
setOpened,
}: {
result: TvShowResult;
opened: boolean;
setOpened: (opened: boolean) => void;
}) {
const [selection, setSelection] = useState<TvShowResultSeason[]>(result.seasons);
const { classes, cx } = useStyles();
const toggleRow = (container: TvShowResultSeason) =>
setSelection((current: TvShowResultSeason[]) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current: any) =>
current.length === result.seasons.length ? [] : result.seasons.map((c) => c)
);
const rows = result.seasons.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
key={element.id}
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.name}</td>
<td>{element.episodeCount}</td>
</tr>
);
});
const { secondaryColor } = useColorTheme();
return (
<Modal
onClose={() => setOpened(false)}
radius="lg"
size="lg"
opened={opened}
title={
<Group>
<IconDownload />
Ask for {result.name ?? result.originalName ?? 'a TV show'}
</Group>
}
>
<Stack>
<Alert
icon={<IconAlertCircle size={16} />}
title="Using API key"
color={secondaryColor}
radius="md"
variant="filled"
>
This request will be automatically approved
</Alert>
<Table captionSide="bottom" highlightOnHover>
<caption>Tick the seasons that you want to be downloaded</caption>
<thead>
<tr>
<th>
<Checkbox
onChange={toggleAll}
checked={selection.length === result.seasons.length}
indeterminate={selection.length > 0 && selection.length !== result.seasons.length}
transitionDuration={0}
/>
</th>
<th>Season</th>
<th>Number of episodes</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<Group>
<Button variant="outline" color="gray" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
variant="outline"
disabled={selection.length === 0}
onClick={() => {
askForMedia(
MediaType.Tv,
result.id,
result.name,
selection.map((s) => s.seasonNumber)
);
}}
>
Request
</Button>
</Group>
</Stack>
</Modal>
);
}
function askForMedia(type: MediaType, id: number, name: string, seasons?: number[]) {
Consola.info(`Requesting ${type} ${id} ${name}`);
showNotification({
title: 'Request',
id: id.toString(),
message: `Requesting media ${name}`,
color: 'orange',
loading: true,
autoClose: false,
disallowClose: true,
icon: <IconAlertCircle />,
});
axios
.post(`/api/modules/overseerr/${id}`, { type, seasons })
.then(() => {
updateNotification({
id: id.toString(),
title: '',
color: 'green',
message: ` ${name} requested`,
icon: <IconCheck />,
autoClose: 2000,
});
})
.catch((err) => {
updateNotification({
id: id.toString(),
color: 'red',
title: 'There was an error',
message: err.message,
autoClose: 2000,
});
});
}

66
src/modules/overseerr/SearchResult.d.ts vendored Normal file
View File

@@ -0,0 +1,66 @@
export interface SearchResult {
page: number;
totalPages: number;
totalResults: number;
results: Result[];
}
export interface Result {
id: number;
mediaType: MediaType;
adult?: boolean;
genreIds: number[];
originalLanguage: OriginalLanguage;
originalTitle?: string;
overview: string;
popularity: number;
releaseDate?: Date;
title?: string;
video?: boolean;
voteAverage: number;
voteCount: number;
backdropPath: null | string;
posterPath: string;
mediaInfo?: MediaInfo;
firstAirDate?: Date;
name?: string;
originCountry?: string[];
originalName?: string;
}
export interface MediaInfo {
downloadStatus: any[];
downloadStatus4k: any[];
id: number;
mediaType: MediaType;
tmdbId: number;
tvdbId: null;
imdbId: null;
status: number;
status4k: number;
createdAt: Date;
updatedAt: Date;
lastSeasonChange: Date;
mediaAddedAt: Date;
serviceId: number;
serviceId4k: null;
externalServiceId: number;
externalServiceId4k: null;
externalServiceSlug: string;
externalServiceSlug4k: null;
ratingKey: string;
ratingKey4k: null;
seasons: any[];
plexUrl: string;
serviceUrl: string;
mediaUrl?: string;
}
export enum MediaType {
Movie = 'movie',
Tv = 'tv',
}
export enum OriginalLanguage {
En = 'en',
}

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